TypeScript Thursday - S01E02 - Mapped types

TypeScript Thursday - S01E02 - Mapped types

Welcome to TypeScript Thursday, a series of articles covering TypeScript's type system. Today's episode is about mapped types.

Featured on Hashnode

Introduction

The last episode TypeScript Thursday - S01E01 introduced generic types, how they work, and how we can use type inference for function calls. All the examples shown there took one or several generic type parameters and produced types that contained them somewhere e.g. Box<T> = { content: T }.

In this episode, we're going to look over some techniques for transforming the generic type parameters and producing more complex types. A very common mapped type that you might have already used is the built-in Record type that lets us define objects with arbitrary keys and known value types:

const numberMap: Record<string, number> = {
  arbitrary: 1,
  keys: 2,
};

You may have also used Pick which lets us "pick" keys from an existing type:

type Person = {
  firstName: string;
  lastName: string;
  age: number;
}

type Name = Pick<Person, 'firstName' | 'lastName'>;
//    ^? { firstName: string; lastName: string }

The article will cover those examples and much more advanced ones. But first, we have to go over some basics that we'll use later to build our mapped types. Read on!

keyof

keyof will come in handy later when we'll build mapped types that copy the keys from a different type.

The keyof type operator takes a type and returns a union of its keys:

type Point = { x: number; y: number };

type Keys = keyof Point;
//    ^? 'x' | 'y'

It can also work in combination with generic types:

type Keys<T> = keyof T;

type Foo = Keys<{ foo: string; bar: number };
//    ^? 'foo' | 'bar'

Using the operator on a union of types will give us only the keys that are common between the members.

type Foo = { foo: string; bar: number }
type Bar = { bar: number; baz: () => void }

type Keys = keyof (Foo | Bar)
//    ^? 'bar'

This might seem counterintuitive, but consider the following:

const get = (foo: Foo, bar: Bar, key: Keys) => {
  return foo[key] * bar[key];
}

If keyof would return the union of keys of both types, we might try to access a key that exists on Bar, but not on Foo.

What happens if we use the operator on a type that isn't an object?

type K1 = keyof number;
//   ^? 'toString' | 'toFixed' | 'toExponential' ...

keyof on a number gives us all the properties available on numbers, such as toString, toFixed, toExponential etc.

type K2 = keyof number[];
//   ^? 0, 1, 2, 3, 4, ..., push, pop,...

type K3 = keyof [number, boolean];
//   ^? 0, 1, push, pop, ...

keyof on any kind of an array will give us the numeric keys and the array-specific properties like push and pop. Using it on tuples will work similarly to arrays, but the numeric keys will be limited to the ones defined in the tuple type. Above we only have 2 members in the tuple, so the returned keys are 0 and 1. For arrays we get an infinite number of positive numbers.

We can use keyof in combination with generic types to create a type-safe sort function that sorts an array of objects by a specific key:

const sort = <T, K extends keyof T>(
  objects: T[],
  key: K
): T[] => {
  return objects.slice().sort((a, b) => {
    if (a[key] < b[key]) return -1;
    if (a[key] > b[key]) return 1;
    return 0;
  })
}

sort([{ foo: 10 }, { foo: 2 }], 'foo');

//               Argument of type '"bar"' is not assignable to
//               parameter of type '"foo"'.(2345)
//                                V
sort([{ foo: 10 }, { foo: 2 }], 'bar');

Index signatures

The syntax of index signatures is similar to that of mapped types, and their functionalities overlap quite a bit.

Index signatures allow us to model types that have an undefined number of structured key-value pairs. They're useful for representing configuration data, map/cache structures, arrays etc.

type Visited = {
  [key: string]: boolean;
}

const visited: Visited = {
  node1: true,
  node2: false,
};

The square brackets [] syntax looks similar to computed properties on objects. The name of the key can be anything you'd like, and the type must be either string, number, or symbol. You can have at most one index signature of each type, and you can use unions to represent multiple signatures in one go.

type MultipleSignatures = {
  // This expands to 2 index signatures.
  [key: string | number]: number;
}

When you have both a string and number index signature, the number signature must be a subtype of the string signature. This is because foo[1] is converted at runtime to foo['1']. TypeScript lets us distinguish between the two so we can model more accurate types.

type MixedSignatures = {
  // 'number' index type 'string | number' is not assignable
  // to 'string' index type 'string'.(2413)
  [numberKey: number]: number | string;
  [stringKey: string]: string;
}

This restriction doesn't apply to symbol index signatures.

type MixedSignatures = {
  [symbolKey: symbol]: boolean;
  [stringKey: string]: string;
}

Literal types and generic types are not allowed, even if they're of type string | number | symbol:

type Incorrect = {
  // An index signature parameter type cannot be a literal type or
  // generic type. Consider using a mapped object type instead.(1337)
  [literal: 'a' | 'b']: string;
}

Since index signatures allow any number of keys of the given type, TypeScript will let us access arbitrary keys even if they don't exist at runtime:

type Visited = { [city: string]: boolean; }
const visited: Visited = { boston: true };

console.log(visited.boston); // true
//                    ^? boolean

console.log(visited.london); // undefined
//                    ^? boolean

To guard against potential mistakes, you can turn on the compiler flag noUncheckedIndexedAccess which will add undefined to the value type for index signatures, so both properties above would return boolean | undefined. This can prove especially useful for arrays, to prevent out-of-bounds access:

type NumberArray = {
  [K: number]: number;
}

const array: NumberArray = [1, 2, 3];

console.log(array[6]);
//                ^? `number | undefined` with the flag on

The flag makes index signatures behave similarly to Map:

const map = new Map<string, number>();
map.set('foo', 42);

console.log(map.get('foo')); // 42
//                ^? `number | undefined`

console.log(map.get('bar')); // undefined
//                ^? `number | undefined`

We can combine index signatures with known properties as long as the types match:

type KnownKeysAndSignature = {
  [index: string]: number;

  // OK, matches the string index signature.
  length: number;

  // Property 'name' of type 'string' is not assignable
  // to 'string' index type 'number'.(2411)
  name: string;
}

Using the keyof operator on an index signature gives us the key type:

type StringMap = {
  [key: string]: string
}

type MixedMap = {
  [key: number | symbol]: string;
  known: string;
}

type K1 = keyof StringMap;
//   ^? string | number

type K2 = keyof MixedMap;
//   ^? number | symbol | 'known'

You'll notice that keyof on the string signature gave string | number, because of the aforementioned conversion of numeric keys to string keys. keyof will also return any known keys that are mixed with the index signatures.

Indexed access types

We'll use indexed access types later to build mapped types that copy the values from a different type.

Just like we can access a property of an object using the square bracket notation foo['bar'], we can also look up a specific property on a type:

type Person = {
  name: string;
  age: number;
  employed: boolean;
};

type Age = Person["age"];
//    ^? number

We can use unions to get the type of more than one key:

type Person = {
  name: string;
  age: number;
  employed: boolean;
};

type Keys = 'name' | 'age';

type Values = Person[Keys];
//     ^? string | number

We can also use the keyof operator to get the types of all the keys:

type Person = {
  name: string;
  age: number;
  employed: boolean;
};

type Bar = Person[keyof Person];
//    ^? string | number | boolean

If we have a type with an index signature we can use the key type to get the value type:

type Visited = {
  [key: string]: boolean;
};

type Value = Visited[string];
//     ^? boolean

Using an indexed access type on a union will give us a union, as long as all the union members have the type we're accessing:

type Foo = { foo: string }
type Bar = { foo: number }
type Baz = { bar: boolean }

type K1 = (Foo | Bar)['foo']
//   ^? string | number

// Property 'foo' does not exist on type 'Foo | Baz'.(2339)
//                      V
type K2 = (Foo | Baz)['foo']

We can use indexed access types to create a type-safe dispatch method that changes its payload type according to the action:

enum Action { A, B, C }

type Payloads = {
  [Action.A]: number;
  [Action.B]: { foo: string };
  [Action.C]: string;
}

const dispatch = <T extends Action>(
  action: T,
  payload: Payloads[T]
) => {}

dispatch(Action.A, 42);
dispatch(Action.B, { foo: 'bar' });
// Argument of type 'boolean' is not assignable
// to parameter of type 'string'.(2345)
//                   V
dispatch(Action.C, false);

We can also use them in combination with generic types to create a function that "plucks" a key from an array of objects and returns the proper value type:

const pluck = <T, K extends keyof T>(
  objects: T[],
  key: K
): T[K][] => {
  return objects.map(o => o[key]);
}

const foos = pluck([{ foo: 1 }, { foo: 2 }], 'foo');
//     ^? number[]

Intermission

The previous sections cover important building blocks for mapped types, so let's recap what we've learned so far before continuing:

  • an index signature { [key: number]: number } can have any number of keys, including 0

  • you can have at most 1 index signature per key type (number, string, symbol)

  • the number index signature must be a subtype of the string index signature if both are present

  • you can combine an index signature with known keys, as long as they're compatible

  • the keyof operator returns the type of keys for the given type, which will be a subtype of number | string | symbol

  • we can pick a subset of keys from a type using an indexed access type Person['age']

type Foo = {
  [key: number]: number;
  known: string;
}

type Keys = keyof Foo;
//    ^? number | 'known'

type Bar = Foo['known'];
//    ^? string

Mapped types

Mapped types are types created by iterating (or mapping) over a list of keys. The list of keys can come from a union (like in the example below), a keyof type, or a generic type parameter.

type Keys = 'a' | 'b';

// { a: number; b: number }
type NumberMap = {
  [K in Keys]: number
}

The syntax looks very similar to an index signature, but we have the in keyword between the name of the key and the type of the key. The type of the key must be string | number | symbol, just like index signatures, but we can also use literal types. We can still create an index signature with a mapped type, but it doesn't provide any extra value:

type NumberMap = {
  // A regular index signature.
  [K in number]: number
}

We can also use generic types for the keys, given they're constrained to string | number | symbol:

type NumberMap<Keys extends string> = {
  [K in Keys]: number;
}

const map: NumberMap<'a' | 'b'> = {
  a: 1,
  b: 2
};

Notice how our constraint for the keys is string, not string[]. You can think of in as iterating over a list, but the type has to be a union, not an array. If we use the keyof operator over the generic type parameter then that constraint is automatically taken care of, as the operator will always return string | number | symbol:

type Person = {
  name: string;
  age: number;
}

type Numberify<T> = {
  [K in keyof T]: number
}

type Baz = Numberify<Person>;
//    ^? { name: number; age: number }

The example above iterates over the keys of one type and changes the value type to number. We can use an indexed access type to preserve the original value type:

type FormValues<T> = {
  [K in keyof T]: {
    // Get the type in `T` for the value at key `K`.
    //      VV
    value: T[K];
    label: string;
  }
}

type Person = { name: string; age: number }
//                      ^             ^
//                      |             |
//                      |             +-------+
//                      |                     |
//                      +-----------------+   |
//                                        |   |
const options: FormValues<Person> = { //  |   |
//  +----------`string`-------------------+   |
//  |              |                          |
//  V              V                          |
  name: { value: 'Bob', label: 'Name' }, //   |
//  +--------`number`-------------------------+
//  |           |
//  V           V
  age: { value: 42, label: 'Age' },
}

Using all of the above we can re-create TypeScript's built-in Record and Pick types:

type Record<K extends string | number | symbol, V> = {
  [Key in K]: V
}

const map: Record<'age' | 'height', number> = {
  age: 42,
  height: 180
};
type Pick<T, K extends keyof T> = {
  [Key in K]: T[Key];
}

type Bar = Pick<Person, 'age'>;
//    ^? { age: number }

The K extends keyof T constraint means that the list of keys we pass in must be a subset of all the keys of T. This prevents us from trying to pick an incorrect key.

// Type '"oops"' does not satisfy the constraint 'keyof Foo'.(2344)
//                        VV
type Bar = Pick<Person, 'oops'>;

Mapping modifiers

When mapping keys we can change their mutability and/or their optionality with the readonly and ? modifiers. We can remove the modifiers with -, or add them with +. + is assumed by default so we can leave it out.

type Person = {
  name: string;
  readonly born: Date;
  died?: Date;
}

The above type contains a mix of immutable and optional properties, so let's see how we can change them using a mapped type.

type Partial<T> = {
  [K in keyof T]?: T[K];
}

type MaybePerson = Partial<Person>;
//     ^? { name?: string; readonly born?: Date; died?: Date }

The first example showcases the built-in Partial type which makes all mapped properties optional.

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
}

type ReadonlyPerson = Readonly<Person>;
//      ^?  { readonly name: string; readonly born: Date; readonly died?: Date }

Here we make all mapped properties immutable using the built-in Readonly type.

type Required<T> = {
  [K in keyof T]-?: T[K];
};

type RequiredPerson = Required<Person>;
//      ^? { name: string; readonly born: Date; died: Date }

This example removes the optionality with the built-in Required type.

type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
}

type MutablePerson = Mutable<Person>;
//     ^? { name: string; born: Date; died?: Date }

The above example removes the immutability of all mapped properties and is the only type that's not built-in.

type MutableAndRequired<T> = {
  -readonly [K in keyof T]-?: T[K];
}

type CleanedUpPerson = MutableAndRequired<Person>;
//     ^? { name: string; born: Date; died: Date }

Lastly, the above example shows how we can mix both modifiers at the same time.

Homomorphic mapped types

Whenever a mapped type uses keyof to get the list of keys, or a generic type parameter with an extends keyof constraint, it becomes a homomorphic mapped type. This gives it a few interesting properties, the first being that TypeScript will preserve the property modifiers from the input type:

type Person = {
  name: string;
  readonly born: Date;
  died?: Date;
}

//                   modifiers are copied over
//                    |                     |
//                    V                     V
// { name: string; readonly born: Date; died?: Date }
type HomomorphicMapping = {
  [K in keyof Person]: Person[K];
}

Compare the above with the non-homomorphic mapped type below which hardcodes the list of keys:

//            modifiers are not copied over
//                   |           |
//                   V           V
// { name: string; born: Date; died: Date }
type NonHomomorphicMapping = {
  [K in 'name' | 'born' | 'died']: Person[K];
}

Even though we're still iterating over the keys from the Person type, and we're using the same indexed access type for the values, TypeScript no longer sees the relationship between the input type Person and our mapping. Non-homomorphic types are essentially creating new properties, so they can’t copy property modifiers from anywhere.

The other property homomorphic mapped types have is they preserve tuples and arrays instead of converting them to object types:

type Coordinate = [number, number];

type HomomorphicPromisify<T> = {
  [K in keyof T]: Promise<T[K]>;
};

type PromiseCoordinate = HomomorphicPromisify<Coordinate>;
//     ^? [Promise<number>, Promise<number>]

type Length = PromiseCoordinate['length'];
//     ^? 2

Notice how the length property was kept as number, and not mapped to Promise<number> by our mapped type. Even though keyof on arrays and tuples return the numeric keys and methods and properties such as length, homomorphic types will not apply any transformations to them.

TypeScript has a bug (microsoft/TypeScript#27995) that treats mapping over non-generic tuples as non-homomorphic.

Moreover, another bug microsoft/TypeScript#/27351 may prevent using an indexed access type over constrained arrays.

Lastly, homomorphic mapped types can be "unwrapped" to get the original type, even if the new type only copied a subset of keys, such as in the case of Pick:

const unwrap = <T, K extends keyof T>(partial: Pick<T, K>): T => {}

const partial: Pick<Person, 'name'> = {
  name: 'Bob'
}

const original = unwrap(partial);
//       ^? Person

In general, most mapped types you would create, or come across, would be homomorphic. The built-in Partial, Pick, Readonly and Required types are homomorphic, while Record is not, because it receives the set of keys directly, rather than copying them from another type.

Key remapping

All of the previous examples keep the original keys from the type we're iterating on, but we can also change them using the as operator. Together with indexed access types, we can "re-index" a nested object by a different key:

type Analytics = Record<string, { title: string; views: number }>;

type ViewsByTitle<T extends Analytics> = {
  [K in keyof T as T[K]['title']]: T[K]['views']
}

// { 'Home page': 42 }
type Bar = ViewsByTitle<{
  index: {
    title: 'Home page',
    views: 42
  }
}>

We can also use template literal types to rename the keys (stay tuned for a future episode that will cover them in more detail):

type Prefix<T> = {
  [K in keyof T as `base_${K & string}`]: T[K];
}

type Bar = Prefix<{ foo: string }>
//    ^? { base_foo: string }

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, which will explore ways of filtering out certain keys in mapped types!

If you liked this article, maybe you'd also like some of my open-source projects at github.com/NiGhTTraX.

Takeaways:

  • index signatures create types with any number of keys

  • they look like objects and use the square bracket [] syntax to denote the key type

  • the keyof operator returns the union of keys for the given type

  • an index signature { [key: number]: number } can have any number of keys, including 0

  • we can pick a subset of keys from a type using an indexed access type

  • a mapped type constructs a type from a union of keys all having the same value type

  • the syntax is similar to that of index signatures, but it adds the in keyword

  • index signatures always have "infinite" keys, mapped types can have "finite" keys

  • you can use generic types and/or keyof to get the keys from another type

  • a mapped type is homomorphic if it copies all or a subset of keys from another type with keyof

  • homomorphic mapped types preserve property modifiers, preserve tuples and arrays, and can be unwrapped to the original type

  • you can add or remove readonly and ? property modifiers with + and -

  • TypeScript ships with Record, Pick, Partial, Readonly and Required types