gap·map

Generics & utility types

[ middle depth ]

How TypeScript infers generic type arguments

You almost never pass a type argument to a generic function yourself. Type arguments are inferred at the call site from the value arguments, and the generic's job is to preserve a relationship between input and output that a concrete signature would destroy:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
pluck(users, "age");  // number[]  — T from users, K from the literal "age"
pluck(users, "name"); // string[]  — same function, different T[K]

Two things to internalize from those five lines:

  • Where inference comes from. Each type parameter is inferred from the argument positions where it appears: T from items, K from key. ❌ "You must call it as pluck<User, "age">(...)": explicit type arguments are the exception, needed only when a parameter appears nowhere inferable (a return-type-only T, for example).
  • Constraints relate parameters; indexed access keeps precision. K extends keyof T validates the key, and it also lets T[K] resolve per call. The lazy alternative, key: keyof T with return T[keyof T][], compiles but returns (string | number)[] for every key. If your generic returns a union of everything, you've written the lazy version.

Also: the constraint is a bound, not the answer. With <K extends string>, passing "age" infers K = "age", the literal. It does not widen to string. That literal-preserving behavior is what everything in this topic builds on.

And the erasure reminder, because it decides what generics can't do: T produces zero JavaScript. There is no runtime check, no typeof T, and no validation. A generic on an API-response parser is a promise you make to the compiler, and nothing enforces it at runtime.

What extends and default type parameters mean

<T extends { length: number }> means "any type assignable to this shape": a string, an array, a tuple, your own object. This is the structural assignability rule from ts-type-system applied as an admission filter, and it has nothing to do with class inheritance.

Default type parameters (<T = string>) fill in when the parameter is neither passed nor inferable. They belong to generic types (type ApiResponse<T = unknown>) far more than to functions, where inference usually has something to grab. Defaults must satisfy the constraint, and a defaulted parameter can't precede a required one.

TypeScript utility types and what each one is for

Each of these is a shipped one-liner over the machinery in the next section. Learn them by job:

UtilityJob
Partial<T> / Required<T>patch/update payloads; builder outputs. Shallow (see below)
Readonly<T>freeze a shape at the type level (also shallow)
Pick<T, K> / Omit<T, K>derive DTOs and view models from one source-of-truth type instead of re-declaring
Record<K, V>dictionaries with a closed key set: Record<Locale, Messages> won't compile until every locale is present
Exclude<T, U> / Extract<T, U>union surgery, subtracting or selecting members
NonNullable<T>strip null and undefined after a filter/guard boundary
ReturnType<F> / Parameters<F>pull types out of functions you don't own (or don't want to export types from)
Awaited<T>what await gives you; recurses through nested promises

The one that bites people: Partial is shallow. Partial<Settings> makes theme optional, but a theme you do pass must be complete. ❌ "Partial makes the whole tree optional": for that you write a recursive DeepPartial yourself, and accept that it gets weird around arrays and unions.

How mapped types work: rebuilding Partial, Pick, and Omit

type Partial<T> = { [K in keyof T]?: T[K] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Omit<T, K extends PropertyKey> = Pick<T, Exclude<keyof T, K>>;

The entire implementation fits in those three lines. A mapped type { [K in Keys]: Value } is a for...in loop over a union of keys; it produces one property per member. keyof T supplies the keys, T[K] reads the original value type, and whatever you do around T[K] transforms it.

Modifiers are the other half of the syntax. ? and readonly can be added or removed with +/- (bare means +):

type Mutable<T>  = { -readonly [K in keyof T]: T[K] };  // strip readonly
type Concrete<T> = { [K in keyof T]-?: T[K] };          // strip optional

One subtlety has its own name: mapping over keyof T directly (a homomorphic map) preserves each property's existing ? and readonly, which is why Pick doesn't accidentally make things required. Mapping over an arbitrary union of keys doesn't.

A good self-test for this depth: rebuild Partial, Pick, and Omit from memory, and say where every type parameter in pluck gets inferred from. Conditional types, infer, key remapping, and the design-judgment questions build on the same machinery; the senior notes cover them.

[ senior depth ]

How conditional types distribute over unions

T extends U ? X : Y picks a branch by assignability. The rule that makes conditionals powerful and surprising: when T is a naked type parameter and you pass a union, the conditional runs once per member:

type IsString<T> = T extends string ? true : false;
type A = IsString<string | number>; // boolean — true | false, not false!

Exclude and Extract are this rule packaged as utilities: Exclude<T, U> = T extends U ? never : T tests each member, maps the losers to never, and never vanishes from unions. Every union filter you've ever used works because of distribution.

The off-switch is wrapping both sides in one-element tuples, [T] extends [U], which makes the check see the union as a whole. You need it whenever "the union as one thing" is the question, most famously:

type IsNever<T> = [T] extends [never] ? true : false;
// naked version returns never for never — distribution over zero members runs zero times

❌ "Distribution is an edge case": it is the default, and never-in, never-out is its most common bite.

How infer works: pattern matching on types

infer declares a type variable inside the extends pattern and captures whatever sits at that position:

type MyReturnType<F> = F extends (...args: never[]) => infer R ? R : never;
type Unwrap<T> = T extends Promise<infer U> ? U : T;      // one level
type Tail<T extends unknown[]> = T extends [unknown, ...infer Rest] ? Rest : [];

Three details come up in practice: the built-in Awaited recurses (a naive Unwrap leaves Promise<Promise<string>> half-peeled); ReturnType and Parameters on an overloaded function see only the last overload; and infer U extends string lets you constrain the captured type in place instead of re-checking it in another conditional.

Key remapping and template literal types

Mapped types (middle depth) plus an as clause let you compute new keys, and template literal types make those keys meaningful:

type Handlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}`]: (value: T[K]) => void;
};
// Handlers<{ name: string }> → { onName: (value: string) => void }

type DropMeta<T> = { [K in keyof T as Exclude<K, `_${string}`>]: T[K] };
// remap to never = delete the key

This mechanism sits behind typed event emitters (on("nameChanged", cb) where the payload follows the name), typed route params, and ORM query builders. Template literals distribute in interpolation positions: `${"a" | "b"}:${"x" | "y"}` is a 4-member union. The same property is the failure mode. Cross products grow multiplicatively, and "parse the whole route DSL at the type level" is where compile times and error messages go to die. Use them for narrow string contracts at boundaries.

How to control what gets inferred

Senior generic design is deciding which argument is the source of truth:

function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {}
createStreetLight(["red", "green"], "blue"); // error — exactly what you want

Without NoInfer, "blue" joins the inference candidates and C quietly widens to include it. The pre-5.4 idiom, a second D extends C parameter used once, solved the same problem and is now a smell.

const type parameters (<const T extends readonly string[]>) make a call site infer literals and readonly tuples without the caller writing as const. Caveats: it only affects literal expressions written in the call (a pre-assigned variable stays widened), and pair it with readonly constraints or inference falls back to the mutable constraint. At the value level, satisfies (see ts-type-system) covers the sibling problem of checking against a type without widening to it.

Variance: functions are covariant in returns, contravariant in parameters. This is why a (payload: A) => void is not assignable where (payload: A | B) => void is expected, and why "union of handlers" ≠ "handler of the union". TypeScript infers variance automatically; the explicit in/out annotations exist for spelling out intent and for breaking circularities in deeply recursive types, and the handbook itself says don't reach for them without a measured reason.

When generics earn their keep and when they don't

The typical senior failure mode in this topic is résumé-driven type gymnastics. The heuristics:

  • Boundaries. Generics belong at API boundaries that preserve caller types: collection helpers, fetch wrappers, emitters, stores. Inside a module, if T is only ever instantiated with one type, it's indirection, not a generic.
  • Single-occurrence parameters. A type parameter that appears exactly once in a signature does nothing: <T>(x: T): void is unknown with extra steps. Every parameter should tie at least two positions together (or feed a computed return).
  • Error messages. Error-message ergonomics is a design input. A conditional-type return produces errors like Type 'X' is not assignable to type 'T extends A ? B : C'. Two or three overloads produce errors a caller can read. Overloads compose worse and look less clever; choose them anyway when the consumer is a human at a call site.
  • Validation. The type level is not a validation layer. Erasure means the fanciest generic accepts whatever arrives at runtime. Parse and validate at the boundary (zod et al., which is itself inference-driven generics done right: types derived from the runtime schema, one source of truth).

Interviewers grade this topic on both halves: whether you can build the 40-line conditional type, and whether you can explain why you'd ship the boring version instead.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more TypeScript