Skip to content

Instantly share code, notes, and snippets.

@joshuabowers
Created January 12, 2024 01:17
Show Gist options
  • Save joshuabowers/4c69ec0c738b66ce0327ae624c3b5909 to your computer and use it in GitHub Desktop.
Save joshuabowers/4c69ec0c738b66ce0327ae624c3b5909 to your computer and use it in GitHub Desktop.
typefn: 008 - Generic Tupled Rest Parameters

In Type Guards, two ancillary example functions were presented which purported to simplify the creation of typed, structured objects. (I.e., circle and square, for creating, respectively, Circle and Square objects.) These objects were previously encoded by hand in Generics, so having functions to automate parts of the process is a step up.

However, consider two points interrelated to the functions provided in Type Guards: first, a degree of repetitious code is shared between the two; second, behavior common to the two that might be capable of being abstracted and encapsulated, wasn't. Now, when dealing with just two very basic functions as presented in those examples, neither of these two points is terribly pressing.

For larger, vastly more complex systems, however, a greater degree of uniformity is certainly desirable. Uniformity, control over behavior, encapsulation of logic and data; as has been previously established in the discussion on Higher Order Functions, HOF's provide an excellent context to afford these desired outcomes. When in need of abstraction, wrap it in another layer of functions.

Circles are noticeably not, importantly, squares. Which suggests that a constructor function for circles might look vaguely similar to the similar function for squares—should one squint—but is unlikely to be exactly like it. What if one were interested in rectilinear boxes instead of squares, where two parameters for width and height were used instead of side length? Circles could be abstracted to ellipses, but this sort of generalization isn't always going to scale correctly to encompass all desired types.

One could fashion the closure functions to accept an arbitrary parameter list, using rest params, (e.g.: (...params: any[]) => Circle, say), but this is unsatisfying: firstly, any number of parameters would be considered by TypeScript to be valid, and secondly, none of the parameters is type safe. Ideally, we would have a fixed number of type strict parameters to force TypeScript to error out on bad uses.

Enter generic tupled rest parameters: these are specialty generic types which, when applied to a rest parameter, cause TypeScript to assign both cardinality and type strictness constraints to an invocations parameter list. As can be see in the first set of examples in this articles source, this allows for an interesting degree of fluidity. To form one of these constructs, have a generic type extend any[], then use that as the type of the rest params.

As TypeScript treats tuples as fixed cardinality arrays, and will automatically infer types for generic types in functions where applicable, not specifying an explicit type for the generic will cause a per-invocation inference for the tuple. This is a step in the right direction, but lacks consistency: if any given invocation has variable cardinality, this new admixture solves nothing.

But, what happens when a generic tupled rest parameter is set in the context of a HOF, but used in the context of a closure? Well, for the duration of an invocation, the generic type is realized and fixed, meaning it would be permanent for downstream closures. So, defining such a tuple on a HOF and using on a closure would yield a fixed, type strict parameter list!

As the rest parameters are only used at the point of invoking the closure functions, it becomes necessary to actually bind their generic type to an explicit tuple at the point of creating the closure. This explicitness can be slightly cumbersome, but yields great dividends, as can be seen in the final set of examples provided with this discussion.

Beware of a point of caution: TypeScript will automatically generate names for the parameters based off of whatever name is used for the rest parameter. In VS Code, for example, IntelliSense will flag these parameter names whenever highlighting a function with the cursor or upon writing an invocation. Other tooling is likely to behave similarly. For the examples provided here, these names are generated as params_0, params_1, etc. This naming behavior is fixed an not readily alterable. As a consequence, invocations of closure functions will be slightly more esoteric, contextually, than a hand-crafted function might be.

As a final note: generic tupled rest parameters are restricted to neither basic system literal types, nor to a single uniform type for the whole list. It is possible to use these to bind to any user-defined type of arbitrary complexity, as well as to bind any arbitrary set of differently typed parameters in fixed positions.

Generic tupled rest parameters provide an interesting tool for injecting cardinality and type strictness into situations where situational flexibility is required. Consider their use when looking for abstracted encapsulations in the future.

// Generic Tupled Rest Parameters are specified by having a generic type
// extend from `any[]`, then using that type as the type of a rest
// parameter. When TypeScript encounters such a formulation, it will create
// invocation-specific strict type-bindings for each argument, binding a
// new identifier for each parameter.
// In this example, anArrayType is `[number, string, boolean]`; in the
// invocation of `asArray`, the three parameters are bound to
// `params_0`, `params_1`, and `params_2`
const asArray = <Params extends any[]>(...params: Params) => params
const anArray = asArray(4, 'buttercup', false)
type anArrayType = typeof anArray
// However, notice that `asArray` doesn't have a fixed set of arguments:
// if it is invoked with a different set of arguments, the typing of the
// invocation (and in this example, the result) is entirely different.
// Here, `anotherArrayType` becomes `[string, number]`
const anotherArray = asArray('tulip', 42)
type anotherArrayType = typeof anotherArray
// For the next example, consider the following types, where `Shape`
// defines an abstraction and `Circle` and `Box` realize it.
type Shape = {kind: string}
type Circle = {kind: 'circle', radius: number}
type Box = {kind: 'box', width: number, height: number}
// An upcoming function will need to convert a tupled array to an object.
// This type isn't strictly necessary, but can clean up some tool reporting.
type ConvertFn<Params extends any[], Fields extends {}> =
(...params: Params) => Fields
// `shape` acts as a super-constructor for derived types. That is, a derived
// type creates its constructor function by invoking `shape`, a HOF, which
// is responsible for generating the appropriate closure. This allows both
// a standardization of behavior and the abstraction and addition of behavior
// common across related sub-types.
// NB: `Params` is the generic tupled rest parameter, which is fixed on the
// invocation of `shape`; for each invocation of `shape`, this will differ;
// however, `Params` becomes static within a closure, so a derived type will
// effectively have a fixed parameter list.
// NB2: `S` must satisfy the type restrictions of both `Shape` (as above, in
// this example, only having a `kind` string field) and whatever additional
// fields are specified in `Fields`. This is necessary to perform the cast
// of the closure's return value to the appropriate type cleanly.
const shape = <
Params extends any[],
Fields extends {},
S extends Shape & Fields
>(kind: string, convert: ConvertFn<Params, Fields>) =>
(...params: Params) => ({kind, ...convert(...params)}) as S;
// Define a pair of derived type constructor functions. Unlike the
// first two examples at the start, here the generic tupled rest parameters
// are explicitly defined. Note that this is done by using a tupled-type
// notation. (E.g.: `box` uses `[number, number]`)
// NB: all of this is type safe: the conversion functions, despite not having
// explicit type hints, nevertheless conform to the type specification provided
// via the generics.
const circle = shape<[number], {radius: number}, Circle>(
'circle', (radius) => ({radius})
)
const box = shape<[number, number], {width: number, height: number}, Box>(
'box', (width, height) => ({width, height})
)
// Use the type-specific constructors above to create a list of mixed
// typed-objects.
// NB: `circle` explicitly has an enforced parameter list of a single argument;
// calling it with no arguments or more than 1 would result in a transpilation
// error, as an incorrect number of arguments had been supplied. This is true
// for `box` as well: in this case, it expects explicitly two arguments, so
// fewer or more would be erroneous.
const shapes: Shape[] = [
circle(15),
box(5, 10),
box(7, 2)
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment