TypeScript in practice

Generics and inference

Generic functions and constraints, inference sites and NoInfer, conditional and distributive types, infer, mapped types with key remapping, template literal types, the utility types worth knowing, and when a generic is over-engineering.

Generics questions come in two flavours: "type this function so callers get the right type back" (practical, common) and "what does this conditional type evaluate to" (puzzle, less common). The senior skill is knowing when a generic preserves information and when it is showing off.

Know

  • A generic preserves information through a function. function first<T>(xs: T[]): T | undefined returns the element type the caller passed in; without the parameter you would return unknown. If a type parameter appears only once in a signature, it is usually unnecessary.
  • Constraints (T extends { id: string }) let you use properties inside the function while staying generic; defaults (T = string) apply when inference has nothing to go on. keyof T and T[K] type property access (get<T, K extends keyof T>(obj: T, key: K): T[K]).
  • Inference sites: TypeScript infers T from arguments, from the return position when contextual typing applies, and from as const/const T parameters for literals. NoInfer<T> (5.4) blocks a position from contributing candidates so a default value cannot widen the inferred type.
  • Conditional types (T extends U ? X : Y) branch on types; over a naked type parameter they distribute across unions (ToArray<string | number> → string[] | number[]); wrap in a tuple ([T] extends [U]) to prevent distribution. infer extracts a type from a position (ReturnType, Awaited, element types).
  • Mapped types iterate keys: { [K in keyof T]: ... } with modifiers (readonly, ?, -?) and key remapping via as (`on${Capitalize<K & string>}`), filtering keys with never. Template literal types build string unions and pair with Uppercase/Lowercase/Capitalize/Uncapitalize.
  • Utility types worth knowing cold: Partial, Required, Readonly, Pick, Omit, Record, Exclude, Extract, NonNullable, ReturnType, Parameters, ConstructorParameters, InstanceType, Awaited, NoInfer. Vue adds PropType, MaybeRef, MaybeRefOrGetter, ExtractPropTypes, ComponentExposed (from vue-component-type-helpers).
  • Function types and variance: parameters are checked contravariantly under strictFunctionTypes (for function-typed properties, not methods); this is why a callback accepting Animal can be passed where (dog: Dog) => void is expected but not vice versa. Overloads describe several call shapes; a union parameter is often simpler.
  • Recursive types model JSON and trees (type Json = string | number | boolean | null | Json[] | { [k: string]: Json }); deep utilities (DeepPartial) work but stress the checker.
  • Type-level performance: deeply nested conditional and recursive types, huge unions (thousands of literals) and complex template literal permutations slow the checker and the editor; keep such types in libraries, not application code, and measure with --extendedDiagnostics or --generateTrace. TypeScript 7's speed does not make quadratic types cheap.
  • When not to: a generic that callers must always specify explicitly, a helper that is generic over one caller, or a type puzzle that a plain union plus a small function would express. The Vue guide's advice about unnecessary abstractions applies to types as well.

How it works

Practical generics an interviewer might ask you to type:

shared/utils/typed.ts
// 1. Preserve the element and key types: callers get back exactly what they passed in.
export function groupBy<T, K extends PropertyKey>(items: readonly T[], key: (item: T) => K): Record<K, T[]> {
  const out = {} as Record<K, T[]>
  for (const item of items) (out[key(item)] ??= []).push(item)
  return out
}

// 2. Pick with autocomplete on keys and a precise result type.
export function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
  return Object.fromEntries(keys.filter(k => k in obj).map(k => [k, obj[k]])) as Pick<T, K>
}

// 3. NoInfer: the default may not widen the inferred literal union.
export function createSelect<T extends string>(options: readonly T[], initial: NoInfer<T>) {
  return { options, value: initial }
}
createSelect(['sm', 'md'], 'lg') // error: 'lg' is not 'sm' | 'md'; without NoInfer, T would widen to include 'lg'

// 4. Event map -> handler map with key remapping and template literals.
type Events = { select: { id: string }, close: void }
type Handlers = { [K in keyof Events as `on${Capitalize<K>}`]?: Events[K] extends void ? () => void : (payload: Events[K]) => void }
// { onSelect?: (payload: { id: string }) => void; onClose?: () => void }

// 5. Conditional + infer: unwrap the resolved data type of an async function.
type Data<F> = F extends (...args: never[]) => Promise<infer R> ? R : never
type Product = Data<typeof getProduct>

// 6. Distribution, and how to stop it.
type ToArray<T> = T extends unknown ? T[] : never          // string | number -> string[] | number[]
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never    // string | number -> (string | number)[]

// 7. A typed path getter with template literal parsing (library-grade; keep it out of app code).
type PathValue<T, P extends string> = P extends `${infer Head}.${infer Rest}`
  ? Head extends keyof T ? PathValue<T[Head], Rest> : never
  : P extends keyof T ? T[P] : never
export function get<T, P extends string>(obj: T, path: P): PathValue<T, P> {
  return path.split('.').reduce<unknown>((acc, k) => (acc as Record<string, unknown>)?.[k], obj) as PathValue<T, P>
}

Variance in one example, because it explains a whole class of "why does this not type-check":

variance.ts
type Handler<E> = (event: E) => void
declare let onAnimal: Handler<Animal>
declare let onDog: Handler<Dog>            // Dog extends Animal

onDog = onAnimal    // ok: a handler that copes with any Animal certainly copes with a Dog
onAnimal = onDog    // error under strictFunctionTypes: it might receive a Cat and call bark()
Gotcha· A type parameter used once

function log<T>(value: T): void gains nothing from T; unknown says the same and reads more honestly. Generics earn their place when the parameter appears at least twice, typically input and output.

Gotcha· Distributive surprises

type IsString<T> = T extends string ? true : false gives boolean for string | number, because it distributes and unions the results. When you mean "is the whole union a string", wrap both sides in tuples.

Exercise

Exercise
  • Type groupBy and pick from memory, then check them against the versions above; add a test file that must fail to compile for a wrong key.
  • Write Handlers for a real component's emits and use it to type an options object.
  • Take a DeepPartial you find online, apply it to a 200-line interface, and measure the check time with --extendedDiagnostics before and after.

Be able to say

Be able to say· When do you write a generic, and when is it over-engineering?

"I write a generic when a function must preserve information through it: first<T>(xs: T[]) gives back the element type, pick<T, K extends keyof T> gives autocomplete on keys and a precise result. The test is whether the type parameter appears at least twice, usually as input and output; if it appears once, unknown says the same thing honestly. It becomes over-engineering when callers always have to spell the parameter out, when one helper is generic for one call site, or when a conditional-type puzzle replaces a small union and a plain function. Deep recursive or template-literal types belong in libraries; in application code they cost editor speed and reviewer patience."

Be able to say· Explain conditional types, distribution and infer in two minutes.

"A conditional type branches on a type relation: T extends U ? X : Y. When T is a naked type parameter and you pass a union, the conditional distributes, evaluating per member and unioning the results, which is why ToArray<string | number> is string[] | number[]; wrapping both sides in a tuple turns distribution off. infer declares a type variable inside the extends clause and captures whatever sits in that position, which is how ReturnType, Awaited and element-type extraction are written. Together with mapped types and key remapping they let me derive one type from another, for instance an onSelect handler map from an events map, so the two cannot drift apart."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.