Skip to content

Instantly share code, notes, and snippets.

@joshuabowers
Created January 10, 2024 00:34
Show Gist options
  • Save joshuabowers/a7bd94dc7fa972767cd00989f15d4672 to your computer and use it in GitHub Desktop.
Save joshuabowers/a7bd94dc7fa972767cd00989f15d4672 to your computer and use it in GitHub Desktop.
typefn: 006 - Generics

It is often desireable, in software development, to develop code which exhibits two orthogonal, yet mutually beneficial, properties: type safety and reusability. The former property relates to the set of operations that are valid to be performed on data adhering to specific known types; break the contracts defined by the type, and the system breaks. The latter concern—reusability—relates to the capability to modularize code in such a fashion that it can be used in multiple different contexts with only a modicum of alteration. The ideal is to minimize repetitious boilerplate and maintenance headaches while still ensuring operations performed on data are semantically valid.

Enter generics: a feature of many modern languages which provide the utility of an abstract type for reusability, but which provide mechanisms to enforce type checking. A generic is, itself, a type, but unlike non-generic types, it has a few special properties attached to it.

First, generic types are swapped out with an actual type at the point of invocation. That is, a function written against a generic type will, when called elsewhere in code, replace the generic type with the type information provided at point of call. When TypeScript performs type checking of the invocation, it will ascertain the correctness of the substituted type, rather than the generic. This will—usually—catch type-related semantic errors.

Second, generics provide an abstraction: as they are not tied to any one specific type at the point of function definition and are only realized at point of use, they offer to general use of a type like any (or, say, object), while affording type checking later on.

Third, generics may be optionally constrained at the point of definition, limiting the scope of data their associated functions may be applicable to. A generic so constrained would cause TypeScript to flag an error if an incompatible type is used at point of call. Somewhat paradoxically, while constraints limit the applicability of use of a generic function, they open up the set of operations that can be performed on the generic data safely. That is, a constrained generic has more information about its capabilities, allowing code written around it to more fully use the type.

Some potential use cases for generics include using predefined utility types—built-in to TypeScript—to automate and clean some definitions (e.g., using Readonly<> to make all fields on a type readonly); using a custom generic to define type relationships of other types; and using constrained generics to write code that can be reused in multiple type-safe contexts.

New types—including new generic types—can be defined in terms of other types, including through use of generics. Through careful use of both generic constraints and conditional typing, which will be covered in another article, it is possible to refine the scope of a newly defined type, often to define relationships with other types.

When calling a generic function, TypeScript will attempt to infer the appropriate type contextually; this may sometimes fail outright, or revert to a slightly less appropriate type, depending upon circumstance. It is not always necessary to specify the exact type being bound to the generic type, but doing so can offer a fine-grained degree of control to ensure type correctness. Even so, whether by inference or specification, a substituted type can allow functionality downstream of the generic to correctly allow type safe behavior.

Generics are incredibly useful for writing code which exhibits both flexibility and type correctness. They will be revisited frequently in this series.

// Three use cases to highlight:
// 1. Built-in utility types, such as Readonly, can be used to define
// other types. Here, we see both the use of a build-in to define a type, but
// also the use of allowing part of that definition to be constrained. The
// `kind` field, here, must always be something that behaves like a string, and
// defaults to that type.
type Shape<Kind extends string = string> = Readonly<{kind: Kind}>;
// 2. Abstracting type relationships
// `DefinedShape` defines a generic type which does two things: establishes that
// a type built from it is a `Shape`, and that it has the fields that are
// provided. These fields are further constrained to be readonly.
// `Circle` and `Square`, therefore, can both be used wherever `Shape` can
// be used, each have a readonly `kind` field, and the respectively have a
// readonly `radius` and `side` field.
type DefinedShape<Kind extends string, Fields extends {}> = Shape<Kind>
& Readonly<Fields>
type Circle = DefinedShape<'circle', {radius: number}>
type Square = DefinedShape<'square', {side: number}>
// 3. Abstracting functions
// First, define a couple of types to simplify the function definition.
// Note that `DescribeFn` is a generic which is constrained to only work
// on types which conform to `Shape`.
type Description = [string, number];
type DescribeFn<S extends Shape> = (shape: S) => Description
// This function takes an array of a single type of shape and a callback
// to convert a standard entry within that array to a tuple, and uses that
// information to generate a string based representation of the collection.
// Because `S` is constrained to `Shape`, any instance of that type can use
// what is known about `Shape`, namely its `kind`.
const describeShapes =
<S extends Shape>(shapes: S[], describe: DescribeFn<S>) => (
`${shapes[0].kind}:\n${
shapes.map(describe).map(t => `(${t[0]}: ${t[1]})`).join(',\n')
}`
);
// Define a couple of circles and squares to test the above function. The
// next article in the series will cover ways to make this sort of formulation
// easier.
const circles: Circle[] = [
{kind: 'circle', radius: 3},
{kind: 'circle', radius: 7}
];
const squares: Square[] = [
{kind: 'square', side: 2},
{kind: 'square', side: 9}
]
// Use the above function for two differently typed contexts. Note that in
// neither case is an explicit type asserted on the `describeShapes` invocation;
// TypeScript will correctly infer that `<Circle>` is implied in the first call,
// and `<Square> is correctly implied in the second. Furthermore, note that
// the callback function is correctly constrained to the call-specific type:
// in the first case, the `radius` field can be safely accessed as `c` is
// inferred to be `Circle`, and similarly for `s` and `side`.
const describedCircles = describeShapes(circles, c => ['radius', c.radius])
const describedSquares = describeShapes(squares, s => ['side', s.side])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment