TSer/07 · Type operators
CHAPTER 07 · keyof, conditional, mapped

Type operators

Every utility type from the last chapter is built from the same few parts: keyof, conditional types, infer, and mapped types. This chapter covers the parts one at a time. At the end you rebuild those five utility types yourself.

§01

Two worlds: types are a small language of their own

Before taking a utility type apart, set up one distinction. Every line of TypeScript you write lives in two places at once: the code that runs, and the types the compiler checks.

Opening one up

In the last chapter you used Partial and Pick without asking how they work. Open one up and there is nothing unusual inside: something that reads keys, something that reads a type, something that makes a decision, something that loops. Put those together and the type system becomes a language you can program in. It takes types as input, produces types as output, and runs only while the code is being compiled.

Most JavaScript operations you already know have a counterpart that works on types. This table is the map of the chapter: each row is one section.

Values (at run time, works on data)Types (at compile time, works on types)Section
Object.keys(order)keyof Order§02
order["size"]Order["size"]§03
cond ? a : bT extends U ? X : Y§04
Destructuring: const { x } = objinfer captures a part§05
arr.map(fn){ [K in keyof T]: … }§06
Building a string: `on${name}`Template literal type: `on${K}`§06

None of this exists at run time

Everything in this chapter happens while tsc checks your code. When it finishes, all of it is removed. This is type erasure, the same rule as in chapter 00. So a complicated type costs you compile time and readability, but not one byte of the JavaScript you ship.

§02

keyof and typeof: read the keys, read a value's type

The first two parts. One collects the keys of a type. The other takes the type of an existing value and brings it into the type world.

keyof T · collect the keys

keyof: the keys of a type, as a type
1type Size = "small" | "medium" | "large";
2type Sugar = 0 | 30 | 50 | 70 | 100;
3
4interface Order {
5 id: string;
6 drink: string;
7 size: Size;
8 sugar: Sugar;
9 toppings: string[];
10 internalNote: string;
11}
12
13type OrderKey = keyof Order;
14// "id" | "drink" | "size" | "sugar" | "toppings" | "internalNote"
15
16const k: OrderKey = "size";
17const bad: OrderKey = "cup";
18// Type '"cup"' is not assignable to type 'keyof Order'.
Compare the two: Object.keys(order) gives you an array of strings when the program runs. keyof Order gives you a union of string literal types while the program is compiled. Every member of that union is a key that really exists, which is why "cup" is rejected.

typeof x · read the type of a value

typeof: from a value back to its type
1const menu = {
2 jasmineMilkTea: 12,
3 grapeSago: 18,
4 berryCheese: 20,
5};
6
7type Menu = typeof menu;
8// { jasmineMilkTea: number; grapeSago: number; berryCheese: number }
9
10type DrinkName = keyof typeof menu;
11// "jasmineMilkTea" | "grapeSago" | "berryCheese"
12
13function priceOf(name: DrinkName) {
14 return menu[name]; // name is always a key of menu, so no check is needed
15}
Line 10 is the combination worth remembering: typeof first to get the type of the object, then keyof to get its keys. Add a drink to menu and DrinkName gains a member with no other edit. The typeof inside ReturnType<typeof makeOrder> in the last chapter is this same operator.

Two different operators share the name typeof

The JavaScript typeof runs at run time and returns one of eight strings, such as "string" or "object" (chapter 03 used it for narrowing). The TypeScript typeof appears only in a type position — after the = of a type declaration, or after the : of an annotation — and it is removed when the code is compiled.

Judge by position, not by the word: if (typeof x === "string") is the JavaScript one, and type M = typeof menu is the TypeScript one.

§03

Indexed access: T[K] reads a property type

The third part. You have the keys; now use one to read the type stored under it.

Indexed access types
1type OrderSize = Order["size"];
2// Size -- square brackets, not a dot: Order.size is not a type
3
4type IdOrDrink = Order["id" | "drink"];
5// string -- the key may be a union, reading several properties at once
6
7type OrderValue = Order[keyof Order];
8// string | 0 | 30 | 50 | 70 | 100 | string[]
9// Size is gone: its members are strings, and string is already in the union.
10
11const toppings = ["boba", "coconut jelly", "taro balls"] as const;
12type Topping = (typeof toppings)[number];
13// "boba" | "coconut jelly" | "taro balls"
Two things to take from this. First, Order[keyof Order] shows that a union of types collapses when one member already covers another: Size is a union of strings, and string is in the union too, so Size is absorbed. Second, T[number] is the standard way to read the element type of an array. Combined with as const, which turns the array into a readonly tuple of literal types, one list of data also becomes the list of allowed values: edit it in one place and both sides follow.

Three parts are now in place: keyof reads keys, typeof reads the type of a value, and T[K] reads the type of a property. Everything later in the chapter is built on these.

§04

Conditional types and distribution

The fourth part, and the most important one in this chapter. First the conditional type itself. Then what happens when the type being checked is a union.

Conditional types: T extends U ? X : Y
1type IsSize<T> = T extends Size ? "a cup size" : "not a cup size";
2
3type A = IsSize<"large">; // "a cup size" -- "large" is a member of Size
4type B = IsSize<number>; // "not a cup size"
extends here means "is assignable to" — the same compatibility check as in chapter 04, now used as the condition of a ternary. If the check holds, the type is X; otherwise it is Y.

On its own that is not much. The important behavior appears when the checked type is a union. The last chapter said that Exclude is defined in a single line, and this is that line. But how can a ternary remove members from a union?

Exclude in one line, worked through by hand
1type OrderStatus = "queued" | "making" | "ready" | "cancelled";
2
3type MyExclude<T, U> = T extends U ? never : T;
4
5type Active = MyExclude<OrderStatus, "cancelled">;
6// T is a naked type parameter, so the union is not checked as a whole.
7// Each member is checked on its own:
8// "queued" extends "cancelled" ? -> false -> keep "queued"
9// "making" extends "cancelled" ? -> false -> keep "making"
10// "ready" extends "cancelled" ? -> false -> keep "ready"
11// "cancelled" extends "cancelled" ? -> true -> never
12// Joined: "queued" | "making" | "ready" | never
13// = "queued" | "making" | "ready"
14// never is the empty union, so it leaves no trace in a union.
Distribution, step by step: how MyExclude<OrderStatus, "cancelled"> is computed
type MyExclude<T, U> = T extends U ? never : T
Union members
"queued"
"making"
"ready"
"cancelled"
extends "cancelled" ?
Waiting for the first member…
Dropped · became never
(empty)
Kept · to be joined
No member kept yet…
= ? (known once every member is checked)
The rule: a conditional type is checked one union member at a time, but only when the type on the left of extends is a naked type parameter — a bare T, as it is here. So the four members line up on the left and go through the check separately.
1 / 6

This behavior is called a distributive conditional type, and it has one condition that you have to know: it happens only when the type on the left of extends is a naked type parameter — a bare T, with nothing wrapped around it. When it happens and T is A | B, the compiler rewrites the whole conditional type as (A extends U ? X : Y) | (B extends U ? X : Y). That is the entire rule. Without it, no one can predict what a conditional type will return.

Distribution needs a naked T. Wrap it and it stops

Naked and wrapped give different answers
1type NakedCheck<T> = T extends string ? "all strings" : "something else";
2type WrappedCheck<T> = [T] extends [string] ? "all strings" : "something else";
3
4type C = NakedCheck<"a" | 1>;
5// "all strings" | "something else"
6// Checked member by member, so the result is a union too.
7
8type D = WrappedCheck<"a" | 1>;
9// "something else"
10// [T] is a tuple, so T is not naked. The union is checked as one type.

This is the design, not a defect. When you want each member handled separately, as Exclude does, use a naked T. When you want the union judged as a single type, wrap both sides in a one-element tuple: [T] extends [U]. Wrapping only one side changes what you are comparing, so wrap both.

Two results that surprise people, and the same rule explains both

never and boolean under distribution
1type E1 = MyExclude<never, string>;
2// never -- never is the empty union. There is nothing to check,
3// so the conditional type produces nothing.
4
5type E2 = MyExclude<boolean, true>;
6// false -- boolean is exactly true | false, so the check runs twice:
7// true becomes never, false is kept.
8
9type E3 = NakedCheck<never>;
10// never -- same reason as E1: no members, so no results.
11
12type E4 = WrappedCheck<never>;
13// "all strings" -- [never] is checked as one type, and never is
14// assignable to string, so the check is true.

never is the union with no members. Distributing over it runs the check zero times, so the result is never. It is not a special case in the compiler; there is simply nothing to distribute over. And boolean is not a single type: it is true | false, so a distributive conditional type runs twice over it. Remember these two and most confusing conditional-type results stop being confusing.

§05

infer: capture a type while matching

The fifth part. A conditional type answers whether a type has a certain shape. infer also hands you the piece inside that shape.

Taking the value type out of a Promise
1type Unbox<T> = T extends Promise<infer U> ? U : T;
2
3type A = Unbox<Promise<Order>>; // Order -- it is a Promise, take the inside
4type B = Unbox<string>; // string -- not a Promise, returned unchanged
Read it as: if T has the shape Promise<something>, call that something U and return U. infer declares a type variable inside the pattern after extends, and the compiler fills it in while matching. It is destructuring, done on types. The variable is available in the true branch only.
The pattern can have the hole anywhere
1// Capture the element type of an array
2type ElementOf<T> = T extends (infer E)[] ? E : never;
3type T1 = ElementOf<string[]>; // string
4type T2 = ElementOf<Order[]>; // Order
5
6// Capture the return type of a function -- does this look familiar?
7type MyReturnType<T> = T extends (...args: any) => infer R ? R : never;
8type T3 = MyReturnType<() => Size>; // Size
9type T4 = MyReturnType<typeof Math.random>; // number
Line 7 is the core of ReturnType from the last chapter: if T is a function, capture its return type. TypeScript also allows a constraint on the variable (infer U extends …), which this chapter does not use.
§06

Mapped types: a loop over the keys

The last group of parts: the loop that rebuilds a type key by key, the modifiers you can add or remove, and the as clause that renames keys.

Mapped types: Partial in one line
1type MyPartial<T> = { [K in keyof T]?: T[K] };
Three parts, all visible: [K in keyof T] loops over the keys from §02; ? is a modifier that makes each property optional; T[K] is the indexed access from §03, which copies the original property type. That is the whole of Partial.
The mapped type factory: the same keys, four rules — pick one
{ [K in keyof T]?: T[K] }
In · each key of T
size: Size
sugar: Sugar
toppings: string[]
Out · after the rule
size?: Size
sugar?: Sugar
toppings?: string[]
Every key is copied, with a ? added before the colon. That is the entire definition of Partial.
Modifiers: + adds one, - removes one
1type Mutable<T> = { -readonly [K in keyof T]: T[K] };
2type Concrete<T> = { [K in keyof T]-?: T[K] };
3
4// -readonly removes readonly. -? removes the optional marker, which is
5// how Required is defined. A plus sign adds a modifier, but +? means the
6// same as ?, so the plus is normally left out. The minus is the new part.

A mapped type over keyof T copies the modifiers you did not change

Written in the form { [K in keyof T]: … }, a mapped type does more than build a new object type. It keeps the ? and readonly of the source wherever it does not change them, and it passes an array through as an array and a tuple through as a tuple. Such a mapped type is called homomorphic. This is the reason -? and -readonly have to exist: the modifiers would otherwise be copied and there would be no way to drop them.

What is copied, and what passes through
1interface Draft {
2 readonly id: string;
3 note?: string;
4}
5
6type P = MyPartial<Draft>;
7// { readonly id?: string | undefined; note?: string | undefined }
8// readonly came from Draft. The mapped type only changed ?, and copied
9// every modifier it did not change -- which is why -? and -readonly exist.
10
11type M = Mutable<Draft>;
12// { id: string; note?: string | undefined }
13
14type A = MyPartial<string[]>;
15// (string | undefined)[] -- an array is still an array
16
17type B = MyPartial<[string, number]>;
18// [(string | undefined)?, (number | undefined)?] -- a tuple is still a tuple

as + template literal · rename the keys as well (TS 4.1)

Key remapping: an event handler type from an object type
1type Watchers<T> = {
2 [K in keyof T as `on${Capitalize<string & K>}Change`]: (next: T[K]) => void;
3};
4
5type OrderWatchers = Watchers<Pick<Order, "size" | "sugar">>;
6// {
7// onSizeChange: (next: Size) => void;
8// onSugarChange: (next: Sugar) => void;
9// }
The as clause gives the new key name. A template literal type builds that name from pieces, and Capitalize raises the first letter. The string & K is required, not decoration: keyof T can also contain number and symbol, while Capitalize<S> only accepts S extends string. Leave it out and the compiler reports Type 'K' does not satisfy the constraint 'string'. Mapping a key to never instead removes it, which is how a mapped type can filter keys as well as rename them. Both as and template literal types arrived in TypeScript 4.1.
§07

Rebuild the five utility types yourself

Chapter goal

All the parts are covered. On the left is the definition from lib.es5.d.ts; on the right is the version you would write. Each one uses only the parts from this chapter.

MyPartial · make every property optionalParts: mapped type + ? + T[K]

lib.es5.d.ts · the library definition
1type Partial<T> = {
2 [P in keyof T]?: T[P];
3};
Your version
1type MyPartial<T> = {
2 [K in keyof T]?: T[K];
3};

MyReadonly · make every property readonlyParts: mapped type + readonly + T[K]

lib.es5.d.ts · the library definition
1type Readonly<T> = {
2 readonly [P in keyof T]: T[P];
3};
Your version
1type MyReadonly<T> = {
2 readonly [K in keyof T]: T[K];
3};

MyPick · keep only the listed keysParts: mapped type + constraint + T[K]

lib.es5.d.ts · the library definition
1type Pick<T, K extends keyof T> = {
2 [P in K]: T[P];
3};
Your version
1type MyPick<T, K extends keyof T> = {
2 [P in K]: T[P];
3};

Notice what changed: the loop runs over K, the keys the caller asked for, not over keyof T. And K extends keyof T — a generic constraint from chapter 05 — makes sure every requested key really exists. That is why a misspelled key is reported by Pick and not by Omit: Omit constrains its keys to keyof any instead. The open question from the last chapter is answered.

MyExclude · filter a unionParts: conditional type + distribution + never

lib.es5.d.ts · the library definition
1type Exclude<T, U> =
2 T extends U ? never : T;
Your version
1type MyExclude<T, U> =
2 T extends U ? never : T;

MyReturnType · capture a function's return typeParts: conditional type + infer

lib.es5.d.ts · the library definition
1type ReturnType<
2 T extends (...args: any) => any
3> = T extends (...args: any) => infer R
4 ? R
5 : any;
Your version
1type MyReturnType<
2 T extends (...args: any) => any
3> = T extends (...args: any) => infer R
4 ? R
5 : never;

The only difference is the fallback branch: the library uses any, and this version uses never. The constraint already guarantees that T is a function, so under normal use that branch is never taken. Both are correct; never is the stricter choice.

You can now read the library

Five utility types, rewritten by hand, and your versions are nearly word for word the same as the library's. There are only a few parts; the rest is combination. In VS Code, hold Cmd or Ctrl and click Partial to jump into lib.es5.d.ts. The file that looked unreadable one chapter ago now reads as plain code. And when the built-in types are not enough — a deep Readonly, a strict Omit — you can write your own. One of the labs below is exactly that.

Being able to write it is not a reason to

Type-level code follows the same rule as ordinary code: if a reader can understand it at a glance, do not write it as three nested conditionals. If an interface says it clearly, do not reach for a conditional type. A type that takes a colleague ten seconds of hovering to understand is a cost, not an achievement. The final chapter, on how to think about types, returns to where that line sits.

§08

Hands-on tasks

Four tasks, getting harder: turn distribution off, build getters with a template literal type, unwrap nested Promises, and build a utility type the library does not ship.

§09

Chapter quiz

Eight questions. Distribution and infer are where most mistakes happen; if you are unsure, replay the visualization in §04 one step at a time.

QUESTION 01 / 8

Order has six fields: id, drink, size, sugar, toppings, internalNote. What is keyof Order?

QUESTION 02 / 8

Which of these four uses of typeof belongs to the type world?

QUESTION 03 / 8

After const t = ["boba", "coconut jelly"] as const;, what is (typeof t)[number]?

QUESTION 04 / 8

How does the compiler arrive at Exclude<"a" | "b" | "c", "b">?

QUESTION 05 / 8

Given type W<T> = [T] extends [string] ? "pure" : "mixed";, what is W<"a" | 1>?

QUESTION 06 / 8

What does the infer keyword do?

QUESTION 07 / 8

In a mapped type, which symbol goes before ? to remove the optional modifier, so that every property becomes required? Type the symbol: ____

QUESTION 08 / 8

Which pieces does the hand-written MyPick<T, K extends keyof T> = { [P in K]: T[P] } use? (choose all that apply)

What to take away from this chapter
  • Types are a small language of their own: types in, types out, run only while the code is compiled, and removed afterwards. Most value-level operations have a type-level counterpart.
  • Three ways to read: keyof T gives the union of key names, typeof x gives the type of a value, and T[K] gives the type of a property (T[number] for the element type of an array).
  • T extends U ? X : Y is the ternary of the type world. It distributes over a union only when the checked type is a naked type parameter: A | B becomes (A extends U ? X : Y) | (B extends U ? X : Y). A member that becomes never disappears, because never is the empty union. That is all of Exclude.
  • Wrapping both sides — [T] extends [U] — turns distribution off. Two follow-on facts: never distributes over nothing and yields never, and boolean is true | false, so it distributes twice.
  • infer captures a type out of a matched shape. A mapped type { [K in keyof T]: … } rebuilds a type key by key, copies the modifiers it does not change, and accepts ?, -?, readonly and -readonly. An as clause with a template literal type renames keys, and mapping a key to never removes it (TS 4.1).
  • You have rewritten all five utility types from the last chapter by hand. Keep the restraint that goes with it: readability comes before cleverness, and a recursive type still has a depth limit (ts(2589)).