Skip to content
teach

Type-level Tools

Lookup sheet for stage 6. The question it exists to answer: which construct computes which type, and the test a clever one has to pass?

Which construct for which job

Construct What it computes The one-line case for it
Mapped type A new object type, one property per key of another type A "partial update" or "getters" shape that stays in step with its source. See lesson 36
Conditional type A type chosen by an assignability test, decided fresh on each use A return type that depends on the caller's argument, with nothing left to narrow or assert. See lesson 37
infer A blank inside a conditional type's extends pattern, bound to whatever matched there A wrapper's return type that tracks the wrapped function's own, instead of a second type argument. See lesson 38
Template literal type A backtick pattern in type position, each ${...} holding a type instead of a value An event name where a typo is rejected at the call, not a handler that silently never fires. See lesson 39

The four compose: a template literal type can sit inside a key remapping, and infer inside any conditional type's pattern.

Mapped type syntax

Form Written Effect
Iteration { [K in keyof T]: T[K] } one property per key of T, value unchanged
Add a modifier { [K in keyof T]?: T[K] } every property becomes optional
Remove a modifier { -readonly [K in keyof T]-?: T[K] } strips readonly and ?; Required strips only -?, the same trick
Key remapping { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] } the key changes; string & K guards against a key that is a number or symbol
Filtering { [K in keyof T as Test extends true ? K : never]: T[K] } remapping a key to never removes it, which is how a mapped type selects rather than only transforms

See lesson 36.

Conditional types

Question Answer
Form T extends U ? X : Y, a question about assignability, re-asked at every use rather than declared once
never as the empty branch one arm produces nothing; a union that absorbs a never member is the same union with that member gone
Distribution over a union a naked type parameter on the left of extends, substituted with a union, runs once per member and unions the results back together
Switching distribution off wrap both sides in a one-element tuple, [T] extends [U] ? X : Y; the union is then tested once, as a whole
Built from exactly this Exclude, Extract and NonNullable: ordinary conditional types, no separate mechanism

The same condition asked two ways. With a naked type parameter it runs once per member, giving never and "b", whose union is "b". With both sides wrapped in a tuple it is asked once about the whole union, which does not match, so the result is the original union.

Three rows of the table above, run as one worked example: distribution, the never that gets absorbed, and the tuple that switches distribution off. Nothing in the condition changed between the two sides, and neither did T. The brackets alone decide whether the question is asked about the members or about the union, and that is the difference between "b" and "a" | "b".

See lesson 37.

infer positions

Position Captures Standard-library type built on it
Return position, (...a: never[]) => infer R the function's return type ReturnType
Parameter list, (...a: infer P) => any the whole parameter list, as a tuple Parameters
A promise's inner type, matched recursively the eventually resolved type Awaited, using infer more than once
An array pattern, (infer E)[] the element type not named, same shape
A tuple slot, [infer H, ...unknown[]], optionally infer H extends string one element, matched only if it satisfies the constraint not named; the constraint decides whether the pattern matches
A slot inside a template literal, `on${infer E}` the captured slice of the string literal not named, same binding behaviour

See lesson 38.

Variance

Position Variance Reader-facing consequence
Return type covariant a function returning a narrower type may stand in for one declared to return something wider
Function parameter, written as a property contravariant a handler narrower than the promised parameter is rejected, TS2322
Function parameter, written as a method bivariant neither narrowing nor widening is caught; a deliberate exemption, not an oversight
A type parameter used in both a getter and a setter invariant must match exactly, since no direction of substitution is safe for both positions at once
Array element type covariant, and unsound; this is deliberate a narrower array assigned to a wider name can be mutated through that name, and the narrower name then reads a value its own type never promised
Variance annotation, in, out, or both documents variance the checker already computes a hint to the checker and to a reader, not a new capability; a wrong claim is caught, at the declaration, as TS2636

See lesson 40.

Signature design

  • A type parameter is inferred when it appears in a parameter position the call actually fills, fixing it before the return type is considered.
  • A type parameter is not inferred when it appears only in the return position, or the function is called with no arguments; every call then needs it written by hand.
  • First signal a signature is wrong: the caller must write the type argument on every call, costing exactly what an explicit cast would have cost.
  • Second signal a signature is wrong: a wrong call's error names the type's own machinery, such as an intermediate alias, rather than the caller's mistake.
  • const on a type parameter moves the cost of keeping a literal type, otherwise as const at every call site, into the signature, paid once by the author.

See lesson 41.

Cost and limits

Question Answer
How to measure tsc --diagnostics or --extendedDiagnostics for a summary; --generateTrace to find the responsible type once the summary says something is worth chasing
What to read the instantiation count, not check time; instantiations grow without bound as recursion or a union grows, while check time can stay small for a while
The exact ceiling a tail-recursive tuple-building type compiles at a target length of 999, fails at 1000 with TS2589, and the ceiling held again at 1500
Why check time is the weaker argument now close to half a million instantiations checked in under half a second; "will slow the build down" now needs a --diagnostics run behind it
The stronger argument maintenance: a long, truncated error against a clever type costs every reader, whether or not the build itself is fast

See lesson 42.

The stopping test

  • Cover the type's definition, look only at where it is used, and say what it evaluates to for a new input, in under a minute.
  • Name the caller plainly: a colleague reviewing the change, or the same author in six months, having forgotten today's trick.
  • Ask what that caller writes, and what they see when they get it wrong; a signature can satisfy the first and still fail the second.
  • If a technique cannot be tied to a caller who writes less, gets a better error, or cannot make a mistake they could otherwise make, it does not earn its place.

See lesson 41 and lesson 42.

Diagnostics seen in this stage

TS number Meaning Usual cause
TS2322 not assignable the survivor a conditional type computed, a rejected template literal pattern, or a mismatched infer capture
TS2339 property does not exist a key remapped to never, or a property a filtering mapped type dropped
TS2345 argument not assignable a callback narrower than a promised parameter, or a key a conditional type rejected
TS2589 type instantiation excessively deep recursion past the compiler's depth limit, exact at 1000 for a tail-recursive tuple build
TS2636 contradicts a stated variance annotation in or out claimed on a generic not actually used that way
TS2741 required property missing a mapped type computed from a type that has since grown a field
TS18046 value is of type unknown a type parameter with nowhere in the call to infer it from

Sources

Table of contents