TypeScript NotesRohit’s interview study guide
Chapter 11

Mapped & Conditional Types

The machinery behind every utility type — mapping over keys, branching on types, infer and template literals.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Mapped types

A mapped type is a for…in loop that runs inside the type checker. { [P in K]: X } reads as "for every member P of the union K, create a property named P whose type is X". Most of the time K is keyof T, so you are looping over another type's keys, and X mentions T[P] (an indexed access type) to look up what that property's type was. The result is a brand-new object type; the original is never changed.

The runtime analogy is Object.fromEntries(Object.keys(obj).map((k) => [k, f(obj[k])])) — take every key, compute a new value, build a new object. A mapped type does the same thing to types, at compile time, and leaves nothing behind in the emitted JavaScript. Every utility type in How utility types work — Partial, Readonly, Pick, Record — is a one-line mapped type.

The mental model that makes them readable: picture the loop running once per key and write down the object it produces. That's what every walkthrough in this chapter does.

From my notes — "Mapped Types allow you to create new types by transforming existing types":

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Person {
  name: string;
  age: number;
  location: string;
}

type ReadOnlyPerson = { [P in keyof Person]: Readonly<Person[P]> };

const person: ReadOnlyPerson = { name: "Rohit", age: 30, location: "Toronto" };
person.age = 31; // compiles — nothing in ReadOnlyPerson is readonly

type T1 = Expect<Equal<ReadOnlyPerson, Person>>; // it's just Person again
What’s happening
  1. keyof Person is the union "name" | "age" | "location", so the loop variable P takes those three values one after another.
  2. P = "name": the property type is Readonly<Person["name"]> = Readonly<string>. Readonly is itself a mapped type, and a mapped type applied to a primitive gives back the primitive unchanged — there are no keys to loop over. So the property is name: string.
  3. P = "age" gives Readonly<number> = number; P = "location" gives string. The intermediate object is { name: string; age: number; location: string } — identical to Person, which is exactly what the T1 assertion proves.
  4. The key point: readonly is a property modifier, and in a mapped type it goes in front of the key: readonly [P in keyof Person]. Wrapping the value in Readonly<…> makes the value's own contents readonly (if it has any), but never the property that holds it.
  5. So person.age = 31 compiles and would run fine. The type's name says "read-only"; the type itself says nothing of the sort.
AdvancedCorrecting my notes: mutable mapped copy
TypeScript
interface Person {
  name: string;
  age: number;
  location: string;
}

type ReadOnlyPerson = { readonly [P in keyof Person]: Person[P] };
// type ReadOnlyPerson = { readonly name: string; readonly age: number; readonly location: string }

const person: ReadOnlyPerson = { name: "Rohit", age: 30, location: "Toronto" };
person.age = 31; // ❌ TS2540: Cannot assign to 'age' because it is a read-only property.
console.log(person.age); // reading is fine
What’s happening
  1. Same loop over "name" | "age" | "location", but now the template is readonly [P]: Person[P] — the modifier is part of the property being created.
  2. P = "name" produces readonly name: string (Person["name"] is string), P = "age" produces readonly age: number, P = "location" produces readonly location: string.
  3. The assignment now fails with "TS2540: Cannot assign to 'age' because it is a read-only property." Reading is unaffected.
  4. This is exactly how the built-in Readonly<T> is written, just with Person replaced by a type parameter. Like every type, it's erased at compile time: nothing stops plain JavaScript from changing the object (that's what Object.freeze is for).
AdvancedThe generic identity mapped type

Replace Person with a type parameter and you get the generic version — the "identity" mapped type from my notes, which copies a type key by key:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type NewType<T> = { [P in keyof T]: T[P] };

interface Todo {
  readonly id: number;
  title: string;
  done?: boolean;
}

type T1 = Expect<Equal<NewType<Todo>, { readonly id: number; title: string; done?: boolean }>>;
type T2 = Expect<Equal<NewType<string[]>, string[]>>; // arrays stay arrays
type T3 = Expect<Equal<NewType<[1, 2]>, [1, 2]>>;     // tuples stay tuples
type T4 = Expect<Equal<NewType<string>, string>>;     // primitives pass through
What’s happening
  1. NewType<Todo> loops over keyof Todo = "id" | "title" | "done" and copies each T[P]: id: number, title: string, done: boolean.
  2. But the result also keeps readonly on id and ? on done. A mapped type of the form { [P in keyof T]: … } is called homomorphic ("same shape"): because the keys come straight from keyof T, the checker knows each P is a real property of T and copies that property's modifiers onto the new one.
  3. Homomorphic mapped types have two more special rules. Given an array or tuple type, they map the elements and produce an array or tuple again (T2, T3) — not an object with length, push and friends. Given a primitive, they return it untouched (T4) — the same rule that made Readonly<string> equal string above.
  4. So "identity" is accurate: for objects, arrays, tuples and primitives, NewType<X> is X. It's not useless, though: applied to an intersection like A & B it produces one flat object type, which is the Prettify helper you'll use in the challenges.
AdvancedWhere mapped types show up

Where mapped types show up: every built-in object utility (Partial, Required, Readonly, Pick, Record, Omit), form-state types ({ [K in keyof Form]: string } for error messages per field), and API layers that derive "create" and "update" shapes from one model. They're also the typed alternative to index signatures when you know the keys up front — see Index signatures vs mapped types.

#Rebuilding Pick, Required and Omit

Rebuilding the built-ins is the fastest way to stop being scared of them — each is a few tokens long once you see the loop. My notes called building my own Required<T> "super crazy"; it's actually one character different from Partial<T>.

The concept that ties this topic together is the one from the end of the last topic: homomorphic mapped types preserve modifiers. If the checker can see that the keys you loop over came from T, it copies each property's readonly and ? across. That happens in two situations: looping over keyof T directly, or looping over a type parameter K that is constrained with K extends keyof T.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Todo {
  readonly id: number;
  title: string;
  done?: boolean;
}

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyPartial<T> = { [P in keyof T]?: T[P] };
type MyRequired<T> = { [P in keyof T]-?: T[P] };

type T1 = Expect<Equal<MyPick<Todo, "id" | "done">, { readonly id: number; done?: boolean }>>;
type T2 = Expect<Equal<MyPartial<Todo>, { readonly id?: number; title?: string; done?: boolean }>>;
type T3 = Expect<Equal<MyRequired<Todo>, { readonly id: number; title: string; done: boolean }>>;

type Bad = MyPick<Todo, "nope">; // ❌ TS2344: Type '"nope"' does not satisfy the constraint 'keyof Todo'.
What’s happening
  1. MyPick<Todo, "id" | "done">: K extends keyof T does two jobs. At the use site it rejects keys that don't exist (the Bad line fails with "TS2344: Type '"nope"' does not satisfy the constraint 'keyof Todo'."). Inside the mapped type it tells the checker that every P is a key of T, which makes the mapping homomorphic.
  2. The loop runs for P = "id": type Todo["id"] = number, and the modifier is copied from Todo.id, giving readonly id: number. Then P = "done": done?: boolean. "title" isn't in K, so it never appears. Result: { readonly id: number; done?: boolean }.
  3. MyPartial<Todo> loops over every key and adds ? with the bare ? after ] — that's shorthand for +?. id keeps its readonly (copied) and gains ?; done was already optional and stays so.
  4. MyRequired<Todo> uses -?, which removes optionality: done?: boolean becomes done: boolean. readonly on id is untouched, because -? only talks about ?. That's the whole difference between Partial and Required: ? versus -? (more in the next topic).
  5. These are character-for-character how Pick, Partial and Required are declared in lib.es5.d.ts.
AdvancedThree ways to write Omit

Omit is the interesting one, because there are three natural ways to write it and one of them silently drops the modifiers. My notes had "another way of rebuilding OMIT" — here are all three side by side:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Todo {
  readonly id: number;
  title: string;
  done?: boolean;
}

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };

type OmitViaPick<T, K extends PropertyKey> = MyPick<T, Exclude<keyof T, K>>;
type OmitViaAs<T, K extends PropertyKey> = { [P in keyof T as P extends K ? never : P]: T[P] };
type OmitNaive<T, K extends PropertyKey> = { [P in Exclude<keyof T, K>]: T[P] };

type T1 = Expect<Equal<OmitViaPick<Todo, "title">, { readonly id: number; done?: boolean }>>;
type T2 = Expect<Equal<OmitViaAs<Todo, "title">, { readonly id: number; done?: boolean }>>;
type T3 = Expect<Equal<OmitNaive<Todo, "title">, { id: number; done: boolean | undefined }>>; // modifiers lost!
What’s happening
  1. All three start the same way: Exclude<keyof Todo, "title"> = Exclude<"id" | "title" | "done", "title">, which distributes over the union and keeps "id" | "done".
  2. OmitViaPick hands "id" | "done" to MyPick. Inside MyPick the keys arrive through the parameter K extends keyof T, so the mapping is homomorphic and the modifiers come along: { readonly id: number; done?: boolean }. This is how the built-in Omit is defined (Pick<T, Exclude<keyof T, K>>).
  3. OmitViaAs loops over keyof T itself — homomorphic — and uses an as clause to filter: for P = "title", "title" extends "title" is true, the new key is never, and a key mapped to never is dropped. id and done map to themselves with modifiers intact.
  4. OmitNaive loops over the computed union Exclude<keyof T, K> directly. That's just a union of string literals now — the checker no longer sees "keys of T", so it doesn't copy modifiers. id loses readonly, and done becomes a required property of type boolean | undefined (reading an optional property gives boolean | undefined, and that read type is what T["done"] returns).
  5. The rule to remember: if you want modifiers preserved, loop over keyof T (filtering with as), or route the keys through a parameter constrained by keyof T. Computing the key union inline breaks the link.
AdvancedPitfall: typos in Omit keys

#Modifier operators: -readonly and -?

Inside a mapped type, readonly and ? can be prefixed with + or -. + adds the modifier, - removes it, and a bare readonly or ? means +. Without a prefix, a homomorphic mapped type just copies whatever the source property had.

This is the only way to remove a modifier. There is no built-in Mutable<T> (my notes' "Remove readonly"), but it's one line, and Required<T> is the -? version of the same idea.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

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

interface Account {
  readonly id: string;
  readonly balance: number;
  nickname?: string;
}

type T1 = Expect<Equal<Mutable<Account>, { id: string; balance: number; nickname?: string }>>;
type T2 = Expect<Equal<Concrete<Account>, { id: string; balance: number; nickname: string }>>;

declare const saved: Account;
declare const draft: Mutable<Account>;
saved.balance = 0; // ❌ TS2540: Cannot assign to 'balance' because it is a read-only property.
draft.balance = 0; // fine

type T3 = Expect<Equal<Mutable<readonly string[]>, string[]>>;
type T4 = Expect<Equal<Mutable<{ readonly a: { readonly b: 1 } }>, { a: { readonly b: 1 } }>>; // shallow
What’s happening
  1. Mutable<Account> loops over "id" | "balance" | "nickname". Normally a homomorphic mapping copies readonly; -readonly overrides that and strips it. nickname? has no readonly to strip and keeps its ? (copied, since we said nothing about ?). Result: { id: string; balance: number; nickname?: string }.
  2. Concrete<Account> applies both operators: -readonly strips readonly, -? strips ?. Every property becomes writable and required.
  3. That's why saved.balance = 0 fails with TS2540 while draft.balance = 0 is allowed — same runtime object shape, different compile-time contract. A typical use is a builder or a test fixture that needs to fill in fields before handing out the readonly version.
  4. T3: because homomorphic mapped types map arrays to arrays, -readonly on readonly string[] gives string[] — the modifier on an array type is the array's own readonly-ness.
  5. T4: it's shallow. Only the top-level properties lose readonly; the nested object still has readonly b. For the deep version you need recursion — see DeepReadonly, which is the same pattern in the other direction.
AdvancedEdge case: Required strips explicit undefined

#Mapping over a literal union

The K in [P in K] doesn't have to come from keyof. Any union of string, number or symbol literal types works, so you can create an object type from a plain list of names. That's exactly what Record<K, V> is: type Record<K extends keyof any, T> = { [P in K]: T }.

The difference from the earlier examples is that there's no source object type, so the mapping isn't homomorphic and there are no modifiers to copy — every property comes out plain and required unless you add ? or readonly yourself.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Feature = "darkMode" | "betaSearch";
type Flags = { [F in Feature]: boolean };
// type Flags = { darkMode: boolean; betaSearch: boolean }

const flags: Flags = { darkMode: true }; // ❌ TS2741: Property 'betaSearch' is missing in type '{ darkMode: true; }' but required in type 'Flags'.

type EventName = "click" | "focus";
type Handlers = { [E in EventName]: (event: { type: E }) => void };

type T1 = Expect<Equal<Handlers["focus"], (event: { type: "focus" }) => void>>;
type T2 = Expect<Equal<Flags, Record<Feature, boolean>>>;
type T3 = Expect<Equal<{ [K in string]: number }, { [x: string]: number }>>;
What’s happening
  1. Flags: the loop runs for F = "darkMode" and F = "betaSearch", each producing a required boolean property. The result is { darkMode: boolean; betaSearch: boolean }.
  2. The object literal is missing betaSearch, so you get "TS2741: Property 'betaSearch' is missing…". This is the real value: add "newCheckout" to Feature and every Flags object in the codebase becomes an error until it handles the new flag. An index signature ({ [key: string]: boolean }) would let it slide.
  3. Handlers: the value type can use the loop variable. For E = "click" the property is click: (event: { type: "click" }) => void; for E = "focus" the event's type is "focus". Each handler gets a precisely typed event — that's how DOM-style event maps are typed.
  4. T2 confirms Flags is literally Record<Feature, boolean>. Use Record when every value has the same type, and write the mapped type when the value depends on the key, like Handlers.
  5. T3: if K is the wide type string, there are no individual keys to stamp out, so you get an index signature { [x: string]: number }. That's why Record<string, number> behaves like a dictionary. More on the trade-off in Index signatures vs mapped types.

#Key remapping with as

Since TypeScript 4.1 a mapped type can have an as clause that computes a new key for each property: { [K in keyof T as NewKey]: … }. The loop still runs over the original keys — K and T[K] mean the same as before — but the property is created under whatever NewKey evaluates to. Two things fall out of that:

  • Renaming: compute a different key, often with a template literal type (get${Capitalize<K>}).
  • Filtering: if NewKey is never, the property is dropped. Combined with a conditional type, that lets you keep only the keys you want.

My notes said this was a "head twister", and wrote down the two things that unlocked it: "1) as to convert [the key]. 2) TypeScript doesn't have ==, so you use extends." There's no equality operator at the type level; "is K the key "name"?" is spelled K extends "name" ? … : ….

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Person {
  name: string;
  age: number;
  email?: string;
}

type Renamed = { [K in keyof Person as K extends "name" ? "fullName" : K]: Person[K] };

type T1 = Expect<Equal<Renamed, { fullName: string; age: number; email?: string }>>;
What’s happening
  1. The loop runs over keyof Person = "name" | "age" | "email". For each K, the as clause decides the property's name, and Person[K] (using the original key) decides its type.
  2. K = "name": "name" extends "name" is true, so the new key is "fullName" and the type is Person["name"] = string. Property: fullName: string.
  3. K = "age": "age" extends "name" is false, so the key stays "age". Property: age: number. Same for email.
  4. Because the loop is still over keyof Person, it's homomorphic and email keeps its ?. Result: { fullName: string; age: number; email?: string }.
  5. This is the shape of "rename one field in an API response" — and the extends in the as clause is the type-level === my notes were talking about.
AdvancedGenerating getters with template literals

Generating getters — renaming every key with a template literal:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Person {
  name: string;
  age: number;
  email?: string;
}

type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };

type T1 = Expect<Equal<Getters<Person>, {
  getName: () => string;
  getAge: () => number;
  getEmail?: () => string | undefined;
}>>;

type Broken<T> = { [K in keyof T as `get${Capitalize<K>}`]: () => T[K] }; // ❌ TS2344: Type 'K' does not satisfy the constraint 'string'.
What’s happening
  1. For K = "name", the template literal `get${Capitalize<"name">}` becomes "getName", and the value is () => Person["name"] = () => string. Same for "age" → getAge: () => number.
  2. For K = "email", the new key is getEmail, the ? is copied (still homomorphic), and T["email"] is string | undefined — the read type of an optional property — so the getter returns string | undefined.
  3. Why string & K? keyof T can include number and symbol keys, and Capitalize only accepts strings. Without it, Broken fails with "TS2344: Type 'K' does not satisfy the constraint 'string'." Intersecting with string keeps string keys as they are and turns number and symbol keys into never — which, as a key, drops them.
  4. So the whole as clause is both a rename (name → getName) and a filter (non-string keys disappear) in one expression.
AdvancedFiltering properties by value type

Filtering by the value type — keep only the properties whose type matches:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

interface Person {
  name: string;
  age: number;
  email?: string;
}

type PickByType<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };

type T1 = Expect<Equal<PickByType<Person, string>, { name: string }>>;
type T2 = Expect<Equal<PickByType<Person, string | undefined>, { name: string; email?: string }>>;

type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
type ByKind = { [S in Shape as S["kind"]]: S };

type T3 = Expect<Equal<ByKind, {
  circle: { kind: "circle"; radius: number };
  square: { kind: "square"; side: number };
}>>;
What’s happening
  1. PickByType<Person, string>: the as clause asks, per key, "is this property's type assignable to V?" name: string extends string → keep "name". age: number extends string → never, dropped.
  2. email is the subtle one: its type T["email"] is string | undefined, and string | undefined extends string is false (this extends isn't distributive — T[K] isn't a naked type parameter — so the whole union is tested at once). Dropped. Result: { name: string }.
  3. With V = string | undefined, email passes and keeps its ?: { name: string; email?: string }.
  4. ByKind loops over a union of object types instead of keys — [S in Shape …] is allowed as long as the as clause turns each member into a valid key. S = { kind: "circle"; … } gives key "circle" with the whole member as the value; same for "square". That's a lookup table from discriminant to variant of a discriminated union, for free.
AdvancedEdge case: key collisions when remapping

#Conditional types

A conditional type is a ternary operator for types: T extends U ? X : Y. Read it as "if T is assignable to U, the result is X, otherwise Y". extends here doesn't mean inheritance; it means assignable to — the same check the compiler does when you assign a value of type T to a variable of type U. Since there's no == at the type level, extends is how you ask every question.

Conditional types are what make types compute. A mapped type changes every property the same way; a conditional type lets the output depend on what the input is. Combined with infer and recursion, they make the type system a small functional language — every "advanced" built-in like Exclude, NonNullable, ReturnType and Awaited is a conditional type.

One more rule matters for generic code: if T is still an unresolved type parameter (inside a generic function body, say), the checker can't pick a branch yet, so the conditional type stays deferred until someone supplies a real type. That's the root of Conditional types vs overloads.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type IsString<T> = T extends string ? true : false;
type Unboxed<T> = T extends readonly unknown[] ? T[number] : T;

type T1 = Expect<Equal<IsString<"hello">, true>>;
type T2 = Expect<Equal<IsString<42>, false>>;
type T3 = Expect<Equal<Unboxed<string[]>, string>>;
type T4 = Expect<Equal<Unboxed<readonly [1, "a"]>, 1 | "a">>;
type T5 = Expect<Equal<Unboxed<Date>, Date>>;
What’s happening
  1. IsString<"hello">: the checker substitutes T = "hello" and asks "is "hello" assignable to string?" Yes — every string literal type is a subtype of string — so the result is the true branch, true.
  2. IsString<42>: 42 isn't assignable to string, so the false branch, false. These are types true and false, not values.
  3. Unboxed<string[]>: string[] is assignable to readonly unknown[] (a mutable array can be used where a readonly one is expected), so the result is string[][number] — the element type, string (see [Element types with T[number]](#element-types-with-t-number)).
  4. Unboxed<readonly [1, "a"]>: a readonly tuple is also a readonly unknown[], and indexing a tuple with number gives the union of its elements, 1 | "a". Writing readonly unknown[] rather than unknown[] is deliberate — unknown[] would reject readonly arrays and tuples.
  5. Unboxed<Date>: Date isn't an array, so we fall through to T and get Date back. That's the typical shape: test for a pattern, transform if it matches, pass through if not.
Advancedany, never and unknown in conditionals

any, never and unknown don't behave like normal types in the checked position, and interviewers love them:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type IsString<T> = T extends string ? true : false;

type A = IsString<any>;     // boolean — both branches!
type N = IsString<never>;   // never — no branch at all
type U = IsString<unknown>; // false

type T1 = Expect<Equal<A, boolean>>;
type T2 = Expect<Equal<N, never>>;
type T3 = Expect<Equal<U, false>>;
What’s happening
  1. IsString<any>: any is special-cased. It's assignable to string, but it also might not be a string, so the checker returns the union of both branches: true | false, which is boolean.
  2. IsString<never>: T is a naked type parameter, so the conditional distributes over the union passed in. never is the empty union — zero members — so the conditional is evaluated zero times and the result is never. Not true, even though never is assignable to everything. (Stopping distribution shows how to get true.)
  3. IsString<unknown>: unknown is the top type — not assignable to string — so the false branch. Nothing special here; it's included because people expect it to behave like any.
  4. Practical consequence: a helper that "obviously" returns true or false can return boolean or never when any or never sneak in. If your output must be one branch, handle those two first.
AdvancedHow the Equal test helper works

#Distributive conditional types

My notes: "Conditional types (unions values are distributed)." When the type being checked is a naked type parameter — just T, not T[] or [T] or Promise<T> — and you instantiate it with a union, the conditional type runs once per union member and the results are joined into a new union. In symbols: F<A | B> becomes F<A> | F<B>.

Think of it as .map over the union followed by "union the results". It's what makes Exclude and NonNullable-style filtering possible: a member whose branch returns never simply disappears, because X | never is X.

The "important example" from my notes — convert string | number to string[] | number[]:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;
// type R = string[] | number[]

type T1 = Expect<Equal<R, string[] | number[]>>;

const ok: R = ["a", "b"];
const mixed: R = ["a", 1]; // ❌ TS2322: Type '(string | number)[]' is not assignable to type 'R'.
What’s happening
  1. ToArray<string | number>: T appears naked before extends, so the checker splits the union: ToArray<string> | ToArray<number>.
  2. ToArray<string>: string extends any is true, result string[]. ToArray<number>: result number[]. The T extends any test is always true — it's there purely to switch distribution on.
  3. Join the results: string[] | number[]. That's "an array of all strings or an array of all numbers".
  4. Which is why ["a", 1] fails: its type is (string | number)[], an array whose elements can be mixed, and that's neither string[] nor number[]. The compiler reports "TS2322: Type '(string | number)[]' is not assignable to type 'R'."
  5. Without distribution you'd get (string | number)[] instead — a different, looser type. The next topic shows how to choose that one.
AdvancedExclude rebuilt with distribution

Distribution is how filtering works. Here's Exclude, rebuilt:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;

type Status = "idle" | "loading" | "success" | "error";

type T1 = Expect<Equal<MyExclude<Status, "idle">, "loading" | "success" | "error">>;
type T2 = Expect<Equal<MyExtract<Status, "success" | "error">, "success" | "error">>;
type T3 = Expect<Equal<MyExclude<string | null | undefined, null | undefined>, string>>;
What’s happening
  1. MyExclude<Status, "idle"> distributes over the four members of Status: MyExclude<"idle", "idle"> | MyExclude<"loading", "idle"> | ….
  2. "idle" extends "idle" → true → never. "loading" extends "idle" → false → "loading". Likewise "success" and "error".
  3. The pieces are never | "loading" | "success" | "error". never is the identity element of unions — it adds nothing — so the result is "loading" | "success" | "error".
  4. MyExtract is the mirror image: keep members that are assignable to U. With U = "success" | "error", only those two survive.
  5. T3 is NonNullable the old way: drop null and undefined from a union. (The modern built-in is T & {} — see NonNullable<T>.) Note U is a union too, but only the checked type T is distributed; U is used as a whole in each extends test.
AdvancedWhen distribution does and doesn't happen

Distribution only happens in one specific situation, and that catches people out:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Naked<T> = T extends string ? "yes" : "no";
type Wrapped<T> = Array<T> extends Array<string> ? "yes" : "no";

type T1 = Expect<Equal<Naked<string | number>, "yes" | "no">>;
type T2 = Expect<Equal<Wrapped<string | number>, "no">>;
type T3 = Expect<Equal<(string | number) extends string ? "yes" : "no", "no">>;
What’s happening
  1. Naked<string | number>: T is naked, so Naked<string> | Naked<number> = "yes" | "no".
  2. Wrapped<string | number>: the checked type is Array<T>, not T. No distribution; the checker builds Array<string | number> and asks if it's assignable to Array<string>. It isn't, so the single answer is "no".
  3. T3: no type parameter at all — a union written inline is checked as a whole. Distribution is a property of instantiating a generic type with a union, not of unions in general.
  4. Rule of thumb: if you see T extends … where T is a bare type parameter of the alias, assume it distributes.
Predict ToArray<boolean>
TypeScript
type ToArray<T> = T extends any ? T[] : never;
type B = ToArray<boolean>;
Show answer

false[] | true[] — not boolean[].

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type ToArray<T> = T extends any ? T[] : never;

type T1 = Expect<Equal<ToArray<boolean>, false[] | true[]>>;
What’s happening
  1. boolean isn't a primitive the checker treats as atomic — it's literally defined as the union true | false.
  2. T is naked, so ToArray<boolean> distributes: ToArray<true> | ToArray<false>.
  3. That's true[] | false[]: an array of only trues or an array of only falses. [true, false] would not be assignable to it.
  4. The same surprise hits any union-shaped type you didn't think of as a union: boolean, an enum type (a union of its members), or keyof T.

#Stopping distribution with [T]

Sometimes distribution is exactly wrong: you want to ask a question about the union as a whole. My notes flag this twice as "very very important". The fix is a trick: wrap both sides of extends in one-element tuples, [T] extends [U]. Now the checked type is [T], not a naked T, so no distribution happens — and because tuples compare element by element, [T] extends [U] asks the same assignability question as T extends U, just once, for the whole union.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type ToArray<T> = T extends any ? T[] : never;
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type T1 = Expect<Equal<ToArray<string | number>, string[] | number[]>>;
type T2 = Expect<Equal<ToArrayNonDist<string | number>, (string | number)[]>>;

type AllStrings<T> = [T] extends [string] ? true : false;
type T3 = Expect<Equal<AllStrings<"a" | "b">, true>>;
type T4 = Expect<Equal<AllStrings<"a" | 1>, false>>;
What’s happening
  1. ToArrayNonDist<string | number>: the checked type is [T], so the union isn't split. The checker asks once: is [string | number] assignable to [any]? Yes, so the result is T[] with the whole union: (string | number)[].
  2. Compare T1/T2: same input, different answers — string[] | number[] (each element type separately) versus (string | number)[] (mixed). Neither is "right"; it depends on what you mean.
  3. AllStrings<"a" | "b">: one question, "is ["a" | "b"] assignable to [string]?" — yes, true.
  4. AllStrings<"a" | 1>: 1 isn't a string, so the union as a whole isn't assignable — false. A distributive version would have answered true | false = boolean, which is useless as a yes/no.
AdvancedDetecting never with [T]

The second "really important" place for the tuple trick: detecting never.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type IsNeverBroken<T> = T extends never ? true : false;
type IsNever<T> = [T] extends [never] ? true : false;

type T1 = Expect<Equal<IsNeverBroken<never>, never>>; // not true!
type T2 = Expect<Equal<IsNever<never>, true>>;
type T3 = Expect<Equal<IsNever<string>, false>>;
type T4 = Expect<Equal<IsNever<undefined>, false>>;
What’s happening
  1. IsNeverBroken<never>: T is naked, so the checker distributes over the members of never. never is the empty union, so there are no members, the conditional runs zero times, and the union of zero results is never. You never even reach the extends never test.
  2. IsNever<never>: the checked type is [never], a real tuple type, so no distribution. Is [never] assignable to [never]? Yes — true.
  3. IsNever<string>: [string] isn't assignable to [never] (nothing but never is assignable to never), so false. Same for undefined.
  4. You'll need IsNever whenever recursion might produce never and you want to stop or report it — for example, checking that an exhaustive switch left nothing behind, or a "no keys matched" check in a mapped type.
AdvancedPuzzle: is this type a union

A classic interview puzzle combines both behaviours — "is this type a union?":

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type IsUnion<T, Whole = T> =
  [T] extends [never] ? false
  : T extends any
    ? ([Whole] extends [T] ? false : true)
    : never;

type T1 = Expect<Equal<IsUnion<"a" | "b">, true>>;
type T2 = Expect<Equal<IsUnion<"a">, false>>;
type T3 = Expect<Equal<IsUnion<never>, false>>;
What’s happening
  1. The default Whole = T takes a copy of the undistributed input before anything splits it. For IsUnion<"a" | "b">, Whole is "a" | "b" for the whole evaluation.
  2. [T] extends [never] handles the empty case first (non-distributive, as above) — never isn't a union, so false.
  3. T extends any is naked, so it distributes: inside the true branch, T is first "a", then "b", while Whole stays "a" | "b".
  4. For T = "a": is ["a" | "b"] assignable to ["a"]? No ("b" isn't "a"), so true. For T = "b": same, true. Union of results: true.
  5. For IsUnion<"a">: the only iteration has T = "a" and Whole = "a"; ["a"] extends ["a"] is true, so false. The trick is comparing one member against the whole — they only match if there was only one member.
AdvancedEdge case: [any] and [never] checks

#Conditional types vs overloads

Both let a function's return type depend on its arguments. Overloads list the cases: "called with a string, returns a number; called with a number, returns a string". A conditional return type computes it: T extends string ? number : string. They behave differently in two places — unions at the call site and checking inside the implementation — and that's what this question is about.

With overloads, each call is matched against the signatures one at a time; the implementation signature is hidden, so a caller with a string | number matches neither:

TypeScript
function convert(input: string): number;
function convert(input: number): string;
function convert(input: string | number): number | string {
  return typeof input === "string" ? Number(input) : String(input);
}

const a = convert("42"); // number
const b = convert(42);   // string

declare const input: string | number;
const c = convert(input); // ❌ TS2769: No overload matches this call.
What’s happening
  1. convert("42") is checked against the first overload, (input: string): number. It matches, so a: number.
  2. convert(42) fails the first overload and matches the second, so b: string.
  3. convert(input) with input: string | number: it's not assignable to string (overload 1) or to number (overload 2). The third signature is the implementation, which callers can't see. Result: "TS2769: No overload matches this call."
  4. The implementation body is only checked against its own loose signature (string | number in, number | string out). If you swapped the branches and returned String(input) for strings, every overload would still claim number and nothing would complain.
  5. Fix for the union case: add a third public overload, function convert(input: string | number): number | string;, before the implementation.
AdvancedUnchecked conditional return implementations

The conditional version handles unions for free — but the implementation can't be checked:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Converted<T extends string | number> = T extends string ? number : string;

function convertStrict<T extends string | number>(input: T): Converted<T> {
  if (typeof input === "string") {
    return Number(input); // ❌ TS2322: Type 'number' is not assignable to type 'Converted<T>'.
  }
  return String(input); // ❌ TS2322: Type 'string' is not assignable to type 'Converted<T>'.
}

function convert<T extends string | number>(input: T): Converted<T> {
  if (typeof input === "string") {
    return Number(input) as Converted<T>;
  }
  return String(input) as Converted<T>;
}

declare const input: string | number;
const a = convert("42");
const c = convert(input);

type T1 = Expect<Equal<typeof a, number>>;
type T2 = Expect<Equal<typeof c, string | number>>;
What’s happening
  1. Inside convertStrict, T is still an unresolved type parameter, so Converted<T> is a deferred conditional type — the checker can't pick a branch. typeof input === "string" narrows the value input, but it doesn't narrow the type parameter T (T could even be "a" | 1), so it can't conclude Converted<T> is number.
  2. Hence both returns fail: "TS2322: Type 'number' is not assignable to type 'Converted<T>'." and the same for string. The usual workaround is an as Converted<T> on each return, as in convert — which means you've told the compiler to trust you, the same level of safety as an overload implementation.
  3. At the call site everything is resolved: convert("42") infers T = "42", and Converted<"42"> = number.
  4. convert(input) infers T = string | number. Converted is distributive, so the result is Converted<string> | Converted<number> = number | string. No extra signature needed.
  5. Rule of thumb: use overloads for a small fixed set of cases (they read well in tooltips and give nicer errors), and a conditional type when the output is a real function of the input type, especially if unions must flow through. Often a plain generic is better than either — see Generics are often better than overloads.

#Constraints vs conditional types

These two use the same keyword and do opposite jobs. A constraint — <T extends X> on a type parameter — is a gate: it restricts what callers may pass, and anything else is an error at the call site. A conditional type — T extends X ? A : B — is a branch: it accepts anything and computes a different result depending on what it got. (See Generic constraints for the basics.)

The useful link between them: inside the true branch of a conditional type, the checker knows T extends X holds, so T can be used where an X is required — the conditional acts as a local constraint.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

// Constraint: reject anything without a length
function len<T extends { length: number }>(value: T): number {
  return value.length;
}
len("hello");
len([1, 2, 3]);
len(42); // ❌ TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

// Conditional: accept anything, compute an answer
type LengthOf<T> = T extends { length: infer L } ? L : never;
type T1 = Expect<Equal<LengthOf<[1, 2, 3]>, 3>>;
type T2 = Expect<Equal<LengthOf<string>, number>>;
type T3 = Expect<Equal<LengthOf<42>, never>>;

// The true branch narrows T
type Shout<T> = T extends string ? Uppercase<T> : T;
type T4 = Expect<Equal<Shout<"hi" | 1>, "HI" | 1>>;

type ShoutBroken<T> = Uppercase<T>; // ❌ TS2344: Type 'T' does not satisfy the constraint 'string'.
What’s happening
  1. len(42) fails at the call site with "TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'." The constraint also lets the body use value.length — the checker knows every T has it.
  2. LengthOf takes any type. A tuple's length is a literal ([1, 2, 3] has length: 3), so T1 is 3; a string's is number; 42 has no length, so it falls to never — no error, just a different answer.
  3. Shout<"hi" | 1> distributes. For T = "hi": in the true branch the checker knows T is a string, so Uppercase<T> is allowed and gives "HI". For T = 1: the false branch returns 1 unchanged. Result "HI" | 1.
  4. ShoutBroken has no such guarantee: Uppercase requires a string, T could be anything, so "TS2344: Type 'T' does not satisfy the constraint 'string'." Adding <T extends string> would also fix it — but then Shout<1> would be rejected instead of passed through.
  5. Choosing: use a constraint when other types are a mistake (callers get an early, clear error). Use a conditional when every type is legitimate and the result just differs. When both make sense, the constraint usually gives better error messages.

#infer: pattern matching on types

infer lets a conditional type capture part of a type while it matches. You write the shape you expect in the extends clause and put infer X in the spot you want to extract; if the type matches, X is bound to whatever was in that spot and you can use it in the true branch. It's destructuring for types — const [first, ...rest] = arr, but T extends [infer First, ...infer Rest] — or a regex capture group.

My notes repeat "infer only works in conditional types!!!" for good reason: there's nowhere else it makes sense. It only binds during a match, and only the true branch can use the binding.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never;

function createUser(id: number, name: string) {
  return { id, name, createdAt: new Date() };
}

type T1 = Expect<Equal<MyReturnType<typeof createUser>, { id: number; name: string; createdAt: Date }>>;
type T2 = Expect<Equal<MyParameters<typeof createUser>, [id: number, name: string]>>;
type T3 = Expect<Equal<FirstArg<typeof createUser>, number>>;
type T4 = Expect<Equal<MyReturnType<string>, never>>;
What’s happening
  1. typeof createUser is the function type (id: number, name: string) => { id: number; name: string; createdAt: Date } (see typeof in type positions).
  2. MyReturnType: the checker lines that type up against the pattern (...args: any[]) => infer R. The parameters fit any[]; the return-type slot holds { id: number; name: string; createdAt: Date }, so R is bound to that and the true branch returns it.
  3. MyParameters: same pattern, but the capture is on the parameter list, so P is the tuple [id: number, name: string] — parameter names are kept as tuple labels (see Named, optional and variadic tuples).
  4. FirstArg: the pattern pins the first parameter as infer A and soaks up the rest, so A = number. These three are exactly the built-in ReturnType and Parameters.
  5. MyReturnType<string>: string doesn't match a function pattern at all, so nothing is bound and we take the false branch, never.
AdvancedCapturing element and promise types

Capturing from containers — "what kind of elements are in this array?" and "what does this promise resolve to?":

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type ElementType<T> = T extends readonly (infer E)[] ? E : T;
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
type UnwrapAll<T> = T extends Promise<infer V> ? UnwrapAll<V> : T;

type T1 = Expect<Equal<ElementType<string[]>, string>>;
type T2 = Expect<Equal<ElementType<(string | number)[]>, string | number>>;
type T3 = Expect<Equal<ElementType<number>, number>>;

type T4 = Expect<Equal<UnwrapPromise<Promise<number>>, number>>;
type T5 = Expect<Equal<UnwrapPromise<Promise<Promise<number>>>, Promise<number>>>; // one layer only
type T6 = Expect<Equal<UnwrapAll<Promise<Promise<number>>>, number>>;
What’s happening
  1. ElementType<string[]>: string[] matches readonly (infer E)[] with E = string. For (string | number)[] the element slot holds the whole union, so E = string | number — infer captures what's there, it doesn't split it.
  2. ElementType<number>: no match, so the false branch returns T itself. Choosing T rather than never is a design decision: "if it's not an array, it's already an element".
  3. UnwrapPromise<Promise<number>> binds V = number. With a nested promise, V is bound to the inner Promise<number> — one match peels one layer.
  4. UnwrapAll calls itself on V in the true branch: Promise<Promise<number>> → UnwrapAll<Promise<number>> → UnwrapAll<number> → number doesn't match → number. That recursion is the core idea of the built-in Awaited<T>, which also handles any "thenable" object, not just Promise.
AdvancedCompiler error: infer outside a conditional
TypeScript
type Bad<T> = infer U; // ❌ TS1338: 'infer' declarations are only permitted in the 'extends' clause of a conditional type.
AdvancedConstrained infer and multiple candidates

That's the error behind "infer only works in conditional types": "TS1338: 'infer' declarations are only permitted in the 'extends' clause of a conditional type." There's no match happening on that line, so there's nothing to bind U to.

Two refinements worth knowing. Since TypeScript 4.7 you can constrain an infer with extends, and the match only succeeds if the captured type fits. And if the same name is inferred in more than one place, the checker has to combine the candidates:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
type ToNumber<T> = T extends `${infer N extends number}` ? N : never;

type T1 = Expect<Equal<FirstString<["a", 1]>, "a">>;
type T2 = Expect<Equal<FirstString<[1, "a"]>, never>>;
type T3 = Expect<Equal<ToNumber<"42">, 42>>;

type Both<T> = T extends { a: infer U; b: infer U } ? U : never;
type T4 = Expect<Equal<Both<{ a: string; b: number }>, string | number>>;

type BothArgs<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type T5 = Expect<Equal<BothArgs<{ a: (x: { id: 1 }) => void; b: (x: { name: "n" }) => void }>, { id: 1 } & { name: "n" }>>;
What’s happening
  1. FirstString<["a", 1]>: the first element "a" is captured and checked against string; it passes, so S = "a". For [1, "a"], the first element 1 fails the extends string check, the whole match fails, and the result is never.
  2. ToNumber<"42">: the template literal pattern captures the text "42", and extends number tells the checker to try reading it as a number literal — giving the number type 42, not the string. Without the constraint you'd get the string "42".
  3. Both: U appears in two property positions. Properties are covariant (output positions), so the checker takes the union of the candidates: string | number.
  4. BothArgs: U appears in two parameter positions, which are contravariant (input positions). A value that can be passed to both functions must satisfy both, so the checker takes the intersection: { id: 1 } & { name: "n" }. That's the basis of the famous "union to intersection" type.
AdvancedPitfalls: readonly arrays, overloads, generics
Predict the result
TypeScript
type ElementType<T> = T extends (infer E)[] ? E : T;
const roles = ["admin", "user"] as const;
type R = ElementType<typeof roles>;
Show answer

readonly ["admin", "user"] — the tuple itself, not "admin" | "user".

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type ElementType<T> = T extends (infer E)[] ? E : T;
const roles = ["admin", "user"] as const;

type T1 = Expect<Equal<ElementType<typeof roles>, readonly ["admin", "user"]>>;
What’s happening
  1. as const makes typeof roles the readonly tuple readonly ["admin", "user"].
  2. The pattern (infer E)[] describes a mutable array. A readonly array is missing mutating methods like push, so it isn't assignable to a mutable array type — the match fails.
  3. With no match, the false branch returns T unchanged: readonly ["admin", "user"].
  4. Change the pattern to readonly (infer E)[] and it matches (mutable arrays are assignable to readonly ones, so both kinds fit), giving E = "admin" | "user". Or skip infer and use (typeof roles)[number] — see [Element types with T[number]](#element-types-with-t-number).

#Template literal types

Template literal types are JavaScript's template strings, moved into the type system: `on${Capitalize<K>}` is a type that's built from other string literal types. When a placeholder holds a union, the result is every combination — the "cartesian product" from my notes. And in the extends clause of a conditional type, a template literal becomes a pattern, and infer can capture the pieces — so the checker can parse strings.

They exist because so many JavaScript APIs are string-keyed by convention: event names ("onClick"), CSS classes ("btn-primary"), route parameters ("/users/:id"), object paths ("user.address.city"). Template literal types let you type those conventions instead of falling back to string.

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Size = "sm" | "lg";
type Variant = "primary" | "ghost";
type ButtonClass = `btn-${Size}-${Variant}`;

type T1 = Expect<Equal<ButtonClass, "btn-sm-primary" | "btn-sm-ghost" | "btn-lg-primary" | "btn-lg-ghost">>;

type OrderId = `order-${number}`;
const good: OrderId = "order-42";
const bad: OrderId = "order-abc"; // ❌ TS2322: Type '"order-abc"' is not assignable to type '`order-${number}`'.
What’s happening
  1. ButtonClass has two placeholders, each holding a two-member union. The checker produces every combination: "sm" with each variant, then "lg" with each — 2 × 2 = 4 string literal types.
  2. T1 lists them all. Add a third size and you get 6 automatically — the type stays in sync with the unions it's built from.
  3. A placeholder can also hold a wide type like number or string. Then the result isn't a finite union but a pattern: `order-${number}` accepts any string that is "order-" followed by something that parses as a number.
  4. "order-42" fits. "order-abc" doesn't, and the error quotes the pattern: "TS2322: Type '"order-abc"' is not assignable to type 'order-${number}'."
AdvancedTemplate literals with key remapping

Combined with key remapping and the string manipulation types, template literals generate whole APIs from a model — the "really nice example" pattern from my notes:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type ChangeHandlers<T> = {
  [K in keyof T & string as `on${Capitalize<K>}Change`]: (value: T[K]) => void;
};

interface FormState {
  name: string;
  age: number;
}

type T1 = Expect<Equal<ChangeHandlers<FormState>, {
  onNameChange: (value: string) => void;
  onAgeChange: (value: number) => void;
}>>;
What’s happening
  1. keyof T & string keeps only string keys ("name" | "age"), so K is always a string and Capitalize<K> is allowed — the same job string & K did in the getters example.
  2. K = "name": Capitalize<"name"> is "Name", the template gives "onNameChange", and the value is (value: FormState["name"]) => void = (value: string) => void.
  3. K = "age": "onAgeChange" with (value: number) => void.
  4. The handler names and their parameter types both come from FormState. Rename name to fullName and the type demands onFullNameChange everywhere — this is how libraries type on* props and generated setters.
AdvancedParsing strings with infer

Parsing with infer — "extracting a prefix" from my notes, plus a recursive version that pulls every :param out of a route:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type Prefix<S> = S extends `${infer P}_${string}` ? P : never;

type T1 = Expect<Equal<Prefix<"user_id">, "user">>;
type T2 = Expect<Equal<Prefix<"user_account_id">, "user">>;
type T3 = Expect<Equal<Prefix<"id">, never>>;

type RouteParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<Rest>
    : S extends `${string}:${infer Param}`
      ? Param
      : never;

type T4 = Expect<Equal<RouteParams<"/users/:userId/posts/:postId">, "userId" | "postId">>;
type T5 = Expect<Equal<RouteParams<"/health">, never>>;
What’s happening
  1. Prefix<"user_id">: the pattern is "some text P, then _, then anything". The checker scans left to right and binds P to the shortest prefix that lets the rest match: "user".
  2. Prefix<"user_account_id"> is still "user", not "user_account". An infer placeholder followed by literal text matches as little as possible — it stops at the first _. Prefix<"id"> has no _, so no match and never.
  3. RouteParams<"/users/:userId/posts/:postId">, first pass: ${string} eats "/users/", then :, then Param takes the shortest text up to the next / — "userId" — and Rest gets "posts/:postId". Result so far: "userId" | RouteParams<"posts/:postId">.
  4. Second pass on "posts/:postId": the first pattern needs a / after the parameter, and there isn't one, so it fails. The second pattern matches with Param = "postId". Final result: "userId" | "postId".
  5. RouteParams<"/health"> has no : at all; both patterns fail and the result is never. This is essentially how typed routers (Express types, React Router, Hono) give you params.userId with autocomplete.
AdvancedPitfall: combinatorial template unions

#Recursive types

A type alias can refer to itself, which is how you describe data that nests to any depth — JSON, trees, nested arrays, menus. And because conditional types can call themselves too, recursion is also how you loop at the type level: there are no for loops in the type system, so "do this for every character / element / level" means "handle one, then recurse on the rest". UnwrapAll, RouteParams and every challenge in Type Challenges use this.

The mental model is ordinary recursion: a base case that stops, and a recursive case that makes the input smaller. The difference is that the "call stack" is the type checker's, and it's much shallower than a JavaScript one.

TypeScript
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

const ok: Json = { name: "Ada", tags: ["x", 1, { deep: [true, null] }] };
const badUndefined: Json = { a: undefined }; // ❌ TS2322: Type '{ a: undefined; }' is not assignable to type 'Json'.
const badDate: Json = { at: new Date() }; // ❌ TS2322: Type '{ at: Date; }' is not assignable to type 'Json'.

type NestedArray<T> = T | NestedArray<T>[];
const nested: NestedArray<number> = [1, [2, [3, [4]]]];
What’s happening
  1. Json is a union whose last two members mention Json again: an array of JSON values, or an object whose values are JSON values. The checker resolves it lazily, one level at a time, as it compares.
  2. Checking ok: "Ada" is a string; the tags array is a Json[] whose elements are a string, a number, and an object whose deep property is again a Json[]. Each nesting level is just another trip through the same union.
  3. { a: undefined } fails because undefined isn't in the union — which matches reality: JSON.stringify drops undefined properties, so they can't round-trip. The compiler reports "TS2322: Type '{ a: undefined; }' is not assignable to type 'Json'."
  4. { at: new Date() } fails because a Date isn't a plain { [key: string]: Json } object — another real JSON gotcha (JSON.stringify turns it into a string, and parsing gives you a string back, not a Date).
  5. NestedArray<number> is "a number, or an array of nested arrays of numbers", so [1, [2, [3, [4]]]] fits at any depth.
AdvancedLooping with recursive conditional types

Recursion in a conditional type is how you loop. Each step handles one piece and recurses on the rest:

TypeScript
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

type TrimLeft<S extends string> = S extends ` ${infer Rest}` ? TrimLeft<Rest> : S;

type T1 = Expect<Equal<TrimLeft<"   hi">, "hi">>;
type T2 = Expect<Equal<TrimLeft<"hi">, "hi">>;
What’s happening
  1. TrimLeft<" hi"> (three spaces): does it match "a space followed by Rest"? Yes, Rest = " hi" (two spaces). Recurse.
  2. TrimLeft<" hi"> → Rest = " hi" → TrimLeft<" hi"> → Rest = "hi" → TrimLeft<"hi">.
  3. "hi" doesn't start with a space: base case, return "hi". Four evaluations in total, each one character shorter — the input shrinks every time, which is what guarantees termination.
  4. Notice the recursive call is the entire true branch — nothing is done with its result. That's called tail position, and it matters for the limits below.
AdvancedRecursion depth limits

Recursion has limits, and they're lower than you'd expect. This is where "type instantiation is excessively deep" comes from:

TypeScript
type BuildTuple<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>;

type Reverse<T> = T extends [infer First, ...infer Rest] ? [...Reverse<Rest>, First] : T;
type ReverseAcc<T, Acc extends unknown[] = []> =
  T extends [infer First, ...infer Rest] ? ReverseAcc<Rest, [First, ...Acc]> : Acc;

type Small = Reverse<[1, 2, 3]>; // [3, 2, 1] — fine
type Big = Reverse<BuildTuple<100>>["length"]; // ❌ TS2589: Type instantiation is excessively deep and possibly infinite.
type BigAcc = ReverseAcc<BuildTuple<999>>["length"]; // 999 — fine

type Infinite<T> = Infinite<T[]>; // ❌ TS2456: Type alias 'Infinite' circularly references itself.
What’s happening
  1. BuildTuple<N> is a counting loop: start with [], add one unknown per step, stop when Acc["length"] equals N. The recursive call is in tail position, so it can run for about 1,000 steps.
  2. Reverse (the version from Reverse a tuple) is not tail-recursive: after Reverse<Rest> returns, the result still gets spread into [..., First]. The checker must keep every pending level open, like a call stack, and it gives up after a few dozen levels. In my tests with TypeScript 7.0 it failed from 49 elements up: "TS2589: Type instantiation is excessively deep and possibly infinite." (Results are cached, so if Reverse of a 40-element tuple of unknowns had already been computed elsewhere, a 60-element call would only need 20 new levels and pass — one reason these errors can seem to come and go.)
  3. ReverseAcc does the same job with an accumulator: each step moves First to the front of Acc and recurses with nothing left to do afterwards. Since TypeScript 4.5, conditional types in tail position are evaluated in a loop instead of nested, so 999 elements works.
  4. Infinite<T> never reaches a base case — it isn't even a conditional, so there's no branch that stops — and the checker rejects the declaration itself with "TS2456: Type alias 'Infinite' circularly references itself."
  5. The exact limits are implementation details and can change between versions, but the lesson doesn't: if a recursive type has to handle long inputs, make it tail-recursive with an accumulator.
AdvancedPerformance cost of deep recursion

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.