TypeScript Thursday - S01E01 - Generics

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 include U (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 with T 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 <> syntax

  • they 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.