TypeScript Thursday - S01E01 - Generics
Welcome to TypeScript Thursday, a series of articles covering TypeScript's type system. Today's episode is about generic types.
Introduction
Generics are one of the most useful features of TypeScript, allowing you to build powerful abstractions that maintain type safety throughout your program. Even if you've never built a generic type yourself, you most likely have benefitted from them when using everyday TypeScript features, such as Array.forEach
.
const a: number[] = [1, 2, 3];
const b: string[] = ['a', 'b', 'c'];
a.forEach((value) => {
console.log(value);
// ^? number
});
b.forEach((value) => {
console.log(value);
// ^? string
});
In the snippet above, the value
parameter in the forEach
callback correctly and automatically gets typed according to the array we're iterating on.
If you work with promises you may have already seen the generic Promise
type:
const p1 = Promise.resolve(42);
// ^? Promise<number>
const p2 = Promise.resolve('foo');
// ^? Promise<string>
And if you use React you may have used the FC
type to define a component:
import React from 'react';
type Props = { label: string }
const Button: React.FC<Props> = ({ label }) => {
// ^? string
return <button>{label}</button>;
}
Let's explore how the above examples and others work, and build some of our own.
Generic types
Before going into the subject of the matter, let's take a moment to think about plain old functions. A function has an input (the arguments it receives) and an output (the return value).
// input output
// V V
const id = (value: any): any => value;
In the snippet above, we have a simple function that receives a value and returns it. Input. Output.
Generic types are like functions, but for types. Input goes in. Output comes out. The only difference is that we're working with types and not concrete values.
// generic param
// V
type Identity<T> = T;
// ^
// type output
The above is the simplest generic type we can write. It is equivalent to a function that returns its only argument, commonly known as an identity function. Functions use parentheses ()
to define their arguments, generic types use angled brackets <>
instead.
A note about the name
T
: it's just a convention, you can use any name you want. Other conventions includeU
(when you have a second generic parameter),R
(for return),V
(for value),K
(for key),P
(for property) , as well as using more descriptive names and prefixing them withT
e.g.TValue
.
When we call a function we can pass arguments to it. Similarly, when we instantiate a generic type we can pass type parameters to it by using the same angled brackets <>
syntax:
type Foo = Identity<string>; // string
type Bar = Identity<number>; // number
type Baz = Identity<Foo>; // string
Another way to think about generics is through the lens of a box.
type Box = {
content: any;
}
We can put anything in that Box
, but when we unpack it we lose the type of its content:
const banana: string = 'banana';
const box: Box = { content: banana };
console.log(box.content);
// ^? any
Using generics we can create a Box
type that lets us unpack it safely:
type Box<T> = {
content: T;
}
const banana: string = 'banana';
const box: Box<string> = { content: banana };
console.log(box.content);
// ^? string
You can use the generic type parameters as many times as you want inside the type, and you can even have multiple parameters.
type Result<V, E> = {
value?: V;
error?: E;
}
const result: Result<string, Error> = { value: 'foobar' };
When instantiating a generic type we must specify all the parameters, but we can also have default parameters, just like regular functions:
type Result<V, E = Error> = {
value?: V;
error?: E;
}
// E is Error
// V
type Bar = Result<string>;
Generic functions and type inference
We can declare a generic function in a similar way to a generic type, by putting the generic type parameters before the opening parentheses:
const id = <T>(value: T): T => value;
function id<T>(value: T): T { return value; }
When calling a generic function you can instantiate the type parameters, just like with generic types, or you can let TypeScript infer them by looking at the arguments we're passing into the function.
function forEach<T>(array: T[], callback: (value: T) => void) {}
// inferred from here
// VVV
forEach([1, 2, 3], (value) => {});
// ^? number
This can work for both parameters and return values:
function map<T, U>(array: T[], callback: (value: T) => U): U[] {}
// ^ ^
// | |
// | +------------------------------+
// +--------------------+ |
// | |
// V V
// +----> `number` `string` ---------+
// | V V |
const strings = map([1, 2, 3], (value) => `${value}`); // |
// ^? string[] <-----------------------------------------+
In the example above, TypeScript has to infer 2 generic types T
and U
. T
is used for the array
and callback
arguments, while U
is used for the return value of the callback. When looking at the function call, TypeScript can infer T
as being number
and U
as being string
.
We can always manually specify the generic parameters by using the same angled brackets <>
syntax:
// explicit instantiation
// VV VV
map<number, string>([1, 2, 3], value => `${value}`)
Manually specifying the generic type parameters can be useful to catch mistakes early on in the code e.g. when returning values from a generic function, as opposed to when you use them later in the program.
// Type 'number' is not assignable to type 'string'.(2322)
// VVV
map<number, string>([1, 2, 3], value => value)
Sometimes you may want to avoid generic inference because it produces unexpected results.
function f<T>(value: T, getDefault: () => T) {}
In the example above, TypeScript has to infer a single generic type that's used both in an argument and in a return value. So which one should take priority? In the examples below the order of the arguments decides the priority:
function f1<T>(value: T, getDefault: () => T) {}
function f2<T>(getDefault: () => T, value: T) {}
// Both of these produce errors
f1(1, () => 'a'); // T is inferred as `number`
f2(() => 'a', 1); // T is inferred as `string`
The example below shows a stranger case. The types that we pass into the function have an inheritance relationship, so TypeScript chooses the best common subtype when inferring the generic parameter.
class Animal { walk() {} }
class Dog extends Animal { woof() {} }
// T is inferred as `Animal` in both cases.
f1(new Dog(), () => new Animal());
f2(() => new Animal(), new Dog());
There is a long-standing feature request to add a way to prevent TypeScript from considering certain function parameters for the inference process, for cases like the one above, and others: microsoft/TypeScript#14829.
Most of the time you can let TypeScript automatically infer generic type parameters and not worry about it, but it's good to know that you can always apply more control if you need it.
Unwrapping generics
We can use type inference to "unwrap" a generic type:
type Box<T> = { content: T }
const box = <T>(content: T): Box<T> => ({ content });
const unbox = <T>(box: Box<T>): T => box.content;
unbox(box(42)); // number
unbox(box('foo')); // string
// How far can we go?!
unbox(box(unbox(box(42)))); // number
Generic classes
Classes, like functions, can have generic parameters too, and can use them in their constructors and methods:
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
}
Just like functions infer the type parameters when calling them, classes infer them during instantiation:
new Box(42).getValue() // number
// You can also manually instantiate the type parameters.
new Box<string>('foo').getValue() // string
Generic React components
Building a React component with generic types is similar to functions (or classes if you use class components).
type DropdownProps<T> = {
options: T[];
value?: T;
onChange: (value: T) => void;
}
function Dropdown <T>(props: DropdownProps<T>) { /* ... */ }
<Dropdown
value={1}
options={[1, 2, 3]}
onChange={(option) => {}}
// ^? number
/>
You can even manually specify the generic type parameters when rendering a React component with JSX:
<Dropdown<number> {...props} />
The only gotcha with React is that you have to use a function
declaration, because arrow functions will generate a syntax error. To understand why, consider the following:
const Content = <T>hello</T>;
Assuming that T
is a custom React component, the above is valid TypeScript code that assigns a JSX expression to a variable. Now take the following:
const Component = <T>(props: { data: T }) => {
return <span>{props.data}</span>;
}
TypeScript will interpret the second example in the same way as the first one, treating <T>
as an opening JSX element. Because it doesn't find a closing </T>
, it compiles with an error:
// JSX element 'T' has no corresponding closing tag.(17008)
If you really want to use an arrow function, you can add a generic constraint (read more about them below) to disambiguate the syntax:
const Component = <T extends unknown>(props: { data: T }) => {
return <span>{props.data}</span>;
}
Generic constraints
Generic type parameters behave like the special type unknown
, meaning you can't make any assumptions about their shape. Let's say you want to write a function that takes an array of objects, filters the ones that have the property foo
equal to 'bar'
, and keeps the type of the objects when returning the new array. We already know we can use generic types for the last part, but how do we implement the first?
const filter = <T>(values: T[]) => {
// Property 'foo' does not exist on type 'T'.(2339)
// VVV
return values.filter(value => value.foo === 'bar');
}
If we try to access any property on a generic type, TypeScript will tell us that it can't guarantee the property will be there, as the caller can instantiate the generic type with anything.
// We can choose anything for the generic parameters,
// in this case numbers without a `foo` property.
filter<number>([1, 2, 3])
To make sure the property exists, we can specify a type constraint — a requirement that any instantiation of the generic type has to meet. In our case, we want the type to be an object with a foo
property. The object could have other properties, but we don't need to know about them in our implementation, we only need to preserve the type when returning it.
// the constraint
// VVV
const filter = <T extends { foo: string }>(values: T[]) => {
// Now we can safely access `foo`.
return values.filter(value => value.foo === 'bar');
}
The T extends U
syntax represents an is-a
relationship — T
can be assigned to the type U
. Since TypeScript is a structural type system, it means that the shape of T
includes the shape of U
. For objects, it means T
has all the keys in U
.
const values = filter([{ foo: 'bar', other: true }];
// ^? { foo: string, other: boolean }[]
Our objects in the example above meet the constraint, and the function preserves the extra keys in the return type.
We can even use the other type parameters when declaring a constraint, and let TypeScript infer everything from the call:
type DropdownProps<Value, Option> = {
options: Option[];
value?: Value;
onChange: (option: Option) => void;
}
const Dropdown = <
Value,
Option extends { value: Value, label: string }
>(
props: DropdownProps<Value, Option>
) => { /* ... */ }
<Dropdown
value={1} // Value infers `number` from here
options={[
{ value: 1, label: 'One', foo: 'bar' },
// ^
// |
// +------------+
{ value: 2, label: 'Two', foo: 'baz' }, // |
]} // |
onChange={(option) => { // |
// ^? Option |
console.log(option.foo); // |
// ^? string <---------------+
}}
/>
Assignability
function foo<T>(data: T): T {
// 'T' could be instantiated with an arbitrary type which
// could be unrelated to '{ bar: number; }'.(2322)
return { bar: 42 };
}
The error above says that we can't assume that T
is an object with bar: number
property, because we're allowed to do this:
// This MUST return `string`.
foo<string>('foobar')
What happens if we add an explicit constraint?
function foo<T extends { bar: number }>(data: T): T {
// '{ bar: number; }' is assignable to the constraint of type 'T',
// but 'T' could be instantiated with a different subtype
// of constraint '{ bar: number; }'.(2322)
return { bar: 42 };
}
Even with the constraint, we can still do this:
// This MUST return `{ bar: number; other: boolean }`.
foo<{ bar: number; other: boolean }>({ bar: 42, other: false })
The bottom line is that you can't return a specific type from a function that has a generic return type.
Moreover, when comparing generic functions TypeScript will try to unify the type parameters to determine if they're compatible.
let a = <T>(data: T): T => data;
let b = <U>(data: U): U => data;
a = b;
In the above examples, both generic type parameters are unconstrained so they're compatible with one another.
let a = <T>(data: T): T => data;
let b = <U extends number>(data: U): U => data;
// Types of parameters 'data' and 'data' are incompatible.
// Type 'T' is not assignable to type 'number'.(2322)
a = b;
// This is fine.
b = a;
If we add a constraint to one of them, then we can't assign an unconstrained type parameter to it. This is similar to assigning unknown
to number
, which is also not allowed. Assigning number
to unknown
is allowed.
let a = <T, U>(x: T, y: U): [T, U] => ([x, y]);
let b = <S>(x: S, y: S): [S, S] => ([x, y]);
// Types of parameters 'y' and 'y' are incompatible.
// Type 'U' is not assignable to type 'T'.
// 'T' could be instantiated with an arbitrary type which could be unrelated to 'U'.(2322)
a = b;
In the example above the second function uses a single generic type parameter S
for both its arguments. TypeScript compares that with the first function which uses 2 type parameters, 1 for each argument. It decides they're not compatible because the 2nd generic type parameter U
could be entirely different from T
.
// The `b` function does not allow this, hence the incompatibility.
a<number, string>(42, 'foo');
Summary
Thank you for reading and I hope you enjoyed the article. Below you can find the key takeaways and a shareable sketch note. Make sure to catch the next episode TypeScript Thursday - S01E02, where we'll go over some more advanced usages of generics!
If you liked this article, maybe you'd also like some of my open-source projects at github.com/NiGhTTraX.
Takeaways:
generic types are like functions for types
they use the angled brackets
<>
syntaxthey have generic parameters as input, and produce another type as output
you can have generic types, generic functions, generic classes, and generic React components
functions and React components can infer their generic parameters from calls
classes can infer their generic parameters from constructors
generic parameters can be constrained with
extends
unconstrained generic type parameters are treated like
unknown
Bloopers
I'd like to finish the article with what I consider some funny things about the syntax for generic types.
You might have already heard or read that "TypeScript is a superset of JavaScript". This roughly translates to TypeScript recognizing all valid JavaScript code and not changing its behavior when compiling it. This is not always the case though.
console.log([1, 2, 3].map<Number>(x => x));
Does that look like valid JavaScript to you? Turns out, it is! It prints false
because the angled brackets <
and >
are treated as the comparison operators:
// A "less than" comparison between
// a function and the Number constructor
[1, 2, 3].map < Number // false
// and then a "greater than" comparison between
// a boolean and an arrow function
false > (x => x) // false
TypeScript of course interprets the same code as a simple map
iteration over an array, and it instantiates the generic type for the callback's return to be a number. The code prints [1, 2, 3]
when compiled with TypeScript.
I chose to capitalize the type Number
, because otherwise number
would have produced a reference error in JavaScript. The example is mostly academic, but it just goes to show some of the edge cases that can happen when parsing and compiling code.
A similar example happens when parsing C code as C++ (a superset of C):
int new = 2; // valid in C
// C++: error: expected unqualified-id before ‘new’
The above declares an integer variable in C, but compiles with an error in C++, because new
is a keyword.