Generic Constraints and Default Type Parameters

Intermediate
12 min

Generic Constraints and Default Type Parameters

The Generics lesson showed how a type parameter such as T lets one function work for many types. On its own, though, T could be anything, so the body cannot call .length or read .id on it. Constraints narrow what T may be, defaults let callers omit type arguments, and a few newer modifiers fine-tune inference. After this lesson you will be able to write generic code that is both flexible and fully checked.

Why Constraints Are Needed

typescript
function longest<T>(a: T, b: T): T { return a.length >= b.length ? a : b; // Error: Property 'length' does not exist on type 'T' }

Inside the function T is opaque. Adding extends tells the compiler the minimum shape every argument must have:

typescript
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; } longest("apple", "fig"); // "apple" (T = string) longest([1, 2, 3], [4]); // number[] longest(10, 20); // Error: number has no 'length'

The return type is still T, not { length: number }, so longest("a", "b").toUpperCase() works. A constraint limits what may come in without discarding the precise type.

Constraints Between Type Parameters

A constraint can reference another type parameter. The classic case is a key that must belong to an object:

typescript
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const cfg = { host: "localhost", port: 8080 }; getProp(cfg, "port"); // number getProp(cfg, "user"); // Error: '"user"' is not assignable to '"host" | "port"'

Other useful shapes:

  • T extends string | number restricts to primitives usable as keys.
  • T extends (...args: any[]) => any accepts any function, after which ReturnType<T> and Parameters<T> become available.
  • T extends new (...args: any[]) => any accepts any class constructor.
  • T extends readonly unknown[] accepts arrays and tuples without allowing mutation.

Default Type Parameters

Just like default function parameters, a type parameter can have a default that applies when the caller neither passes a type argument nor gives the compiler enough to infer one:

typescript
class EventEmitter<TEvents extends Record<string, unknown[]> = Record<string, unknown[]>> { private listeners: { [K in keyof TEvents]?: ((...args: TEvents[K]) => void)[] } = {}; on<K extends keyof TEvents>(event: K, fn: (...args: TEvents[K]) => void) { (this.listeners[event] ??= []).push(fn); } } const generic = new EventEmitter(); // uses the default const typed = new EventEmitter<{ login: [userId: string] }>(); typed.on("login", (id) => id.toUpperCase()); // id: string

Rules: defaults must satisfy their own constraint, required type parameters cannot follow defaulted ones, and a default applies only when inference has no other source.

Fine-Tuning Inference

Explicit type arguments. When inference picks something too wide, pass the argument yourself: useState<string | null>(null).

const type parameters (TypeScript 5.0). Normally T inferred from an array literal is widened to string[]. Adding const before the parameter infers the readonly literal tuple instead, as if the caller had written as const:

typescript
function routes<const T extends readonly string[]>(paths: T): T { return paths; } const r = routes(["/home", "/about"]); // readonly ["/home", "/about"]

NoInfer<T> (TypeScript 5.4). Marks a position that should not contribute to inference, so one argument decides T and the others are checked against it:

typescript
function createRange<T extends string>(values: T[], initial: NoInfer<T>) { /* ... */ } createRange(["draft", "live"], "live"); // OK createRange(["draft", "live"], "archived"); // Error: not assignable to "draft" | "live"

Without NoInfer, T would silently widen to include "archived".

Reading Constraint Errors

| Message | Meaning | |---|---| | Type 'X' does not satisfy the constraint 'Y' | The type argument (explicit or inferred) breaks extends Y | | Property 'p' does not exist on type 'T' | Add a constraint that includes p | | 'T' could be instantiated with an arbitrary type | You returned a specific type where the caller's T was expected |

Common mistakes

  • Constraining to any or object when a precise shape ({ id: string }) would document the requirement better.
  • Writing <T extends string> when the function never uses string behaviour; drop the unnecessary constraint.
  • In .tsx files, <T>(x: T) => x is parsed as JSX; write <T,>(x: T) => x or use a function declaration.
Quick Quiz
Question 1 of 3

Why does `function f<T>(x: T) { return x.length; }` fail to compile?

Key Takeaways

  • T extends Shape tells the compiler the minimum structure of every argument while keeping the precise return type.
  • Constraints can reference other parameters; K extends keyof T is the standard way to type property access.
  • Default type parameters (T = string) apply only when nothing is passed or inferred and must satisfy their own constraint.
  • const type parameters infer readonly literal types; NoInfer<T> stops a position from widening inference.
  • Read constraint errors carefully; they usually name the exact property or type that is missing.

Next lesson: keyof, typeof and Indexed Access Types — derive types from existing types and values instead of repeating them.

Generic Constraints and Default Type Parameters - TypeScript | CodeYourCraft | CodeYourCraft