Generics in TypeScript
Use generic type parameters to preserve relationships between inputs and outputs without giving up inference.
How can we type a function so that it accepts different types without forgetting which type a particular call receives?
The answer is generics. Generics let us describe a relationship between types, allowing functions to process inputs and produce outputs of varying shapes while preserving the information that connects them.
Our first generic
The starting point for us will be to describe the identity function:
const identity = value => value;
TypeScript gives us a few options for typing this. We can use unknown, any, or a generic type parameter. The runtime behavior of identity stays the same, but the information TypeScript has about its result changes.
Returning unknown
We can accept an unknown value and return it. At runtime, passing in a string still gives us that string back. But the return type tells TypeScript only that the result is unknown.
const identity = (value: unknown): unknown => value;
const result = identity('thomas');
console.log(result);
console.log(result.toUpperCase()); // Intentional type error: result is unknown.Ctrl+Enter or ⌘Enter runs the code. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
The last line intentionally produces a type error: the compiler won’t allow us to call .toUpperCase() on a value whose type is unknown. We could check whether the result is a string, but the caller already supplied a string. We have lost that relationship in the signature.
Returning any
Using any makes the error disappear, but it also allows an operation that does not make sense for the supplied value:
const identity = (value: any): any => value;
const result = identity('thomas');
console.log(result.toUpperCase());
console.log(result.toFixed(2)); // Type-checks, but throws when Run is pressed.Ctrl+Enter or ⌘Enter runs the code. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
This code type-checks, but once it’s executed, the call to .toFixed(2) fails because the string "thomas" does not have that number method. any has removed the check that would have caught the mistake.
Preserving the type
How can we retain the input type without discarding it or turning off checking? This is where a type parameter helps:
const identity = <T>(value: T): T => value;
The above code block expresses the following:
<T>declares a type parameter for the function.value: Tgives the input that type.: Tgives the output the same type.
For identity, we want the result to preserve the input’s type. Using T for both the parameter and return type describes that relationship. TypeScript can infer T from the argument, so we usually don’t need to specify it ourselves.
const identity = <T>(value: T): T => value;
const result = identity('thomas');
const year = identity(2019);
console.log(result.toUpperCase());
console.log(year.toFixed(0));
// console.log(result.toFixed(2));Ctrl+Enter or ⌘Enter runs the code. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
TypeScript now knows that result has the methods available on a string, and year has the methods available on a number. Now we’re finally getting somewhere!
Uncomment the last line to try .toFixed(2) on the string and see the compiler catch the mistake.
A practical example: the first item
Typing identity makes the relationship between an input and its output easy to see. Let’s extend that idea to a function that retrieves the first item from an array while preserving its type.
The first function selects the first item from an array. In its signature, T describes the array’s element type. The input is readonly T[], and the result is T | undefined because the array might be empty.
const first = <T>(items: readonly T[]): T | undefined => items[0];
const titles = ['Generics', 'Curried Functions'];
const posts = [
{ title: 'Generics', draft: true },
{ title: 'Curried Functions', draft: true }
];
const firstTitle = first(titles);
const firstPost = first(posts);
console.log(firstTitle?.toUpperCase());
console.log(firstPost?.title);
console.log(first([]));
// Uncomment to see TypeScript reject a number method on a post title:
// console.log(firstPost?.title.toFixed(2));Ctrl+Enter or ⌘Enter runs the code. Tab indents inside the editor; press Ctrl+M (macOS: Ctrl+Shift+M) to make Tab move focus instead. Reset is undoable with Ctrl+Z or ⌘Z.
When we pass in an array of strings, the result is a string or undefined. When we pass in an array of post objects, the result retains the title and draft fields, or it is undefined.
We can use one implementation for both without forfeiting any information about what a particular array contains. Adding another field to the post objects makes that field available on firstPost.
If you extend the code block with an assignment that has first take in an array of numbers, TypeScript will inform you of the return type being number | undefined, and any attempt to call .toUpperCase() on this assignment fails loudly.
Generic reflection
And thus concludes our exploration of generics in TypeScript. To recap, we learned how to strongly type identity so that TypeScript preserves the input value’s type, and then extended this idea to the first function so it preserves an array’s element type. In both cases, generics let us write one function that works across many types without forgetting which type a particular call receives.
Small, reusable functions that keep their types intact are what functional programming is built from, and with generics in hand, we’ve taken our first steps toward strongly typed functional programming in TypeScript!