<< Home

Chapter 5: Generics and Advanced Types

Generics let you write code that carries meaning from one type to another; advanced types reshape those meanings into new forms. This chapter explores constraints for safer generic functions, the expressive power of mapped and conditional types, the precision of keyof and infer, and the design of reusable APIs built from TypeScript’s utility types.

Generic functions

Generics describe relationships between input and output types. Without them, functions lose context and collapse into any. Constraints protect your logic by limiting what a generic type parameter must support.

Basic generic functions

A simple generic function preserves type information across parameters and return values. The caller’s argument drives the type parameter, which gives predictable results.

function identity<T>(value: T): T { return value; }

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

Constraints with extends

Use a constraint when your logic depends on certain members. A constraint does not change the type itself; it simply restricts what callers can provide.

function lengthOf<T extends { length: number }>(value: T): number {
  return value.length;
}

lengthOf("abc");
lengthOf([1, 2, 3]);
💡 Constraints catch intent leaks; they prevent calls that would break your logic by passing objects without the required structure.

Generic types

Generic interfaces and classes capture the shape of reusable containers such as stores, caches, or collections. They preserve type relationships between operations.

interface Box<T> { value: T; }

class Store<T> {
  constructor(private item: T) {}
  get(): T { return this.item; }
  set(value: T): void { this.item = value; }
}

const s = new Store<number>(10);

Default type parameters

Default type parameters smooth over common use cases. They let callers omit a parameter when the default communicates a reasonable choice.

interface Response<T = unknown> {
  status: number;
  data: T;
}

const r: Response = { status: 200, data: { … } };
⚠️ Avoid stacking many defaults in a single API; they can make inference harder and reduce clarity at call sites.

Mapped, conditional, and template literal types

Advanced types reshape old structures into new ones. Mapped types transform properties, conditional types branch on type relationships, and template literal types blend string patterns with type safety.

Mapped types

Mapped types iterate over property keys and produce new objects that reuse those keys with transformed rules. This is the mechanism behind built in utility types.

type Readonlyify<T> = { readonly [K in keyof T]: T[K] };

type User = { id: string; name: string };
type LockedUser = Readonlyify<User>;

Conditional types

Conditional types follow the form A extends B ? X : Y. They describe logic that produces different shapes depending on relationships between types.

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

type A = IsString<string>;  // true
type B = IsString<number>;  // false

Distributive conditional types

When a conditional type receives a union, it distributes across each member. This builds powerful transformations that feel like type level iteration.

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

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

Template literal types

Template literal types combine unions and string interpolation to describe allowable string patterns. They are useful for event names, route keys, or command identifiers.

type EventName = `user:${"create" | "delete" | "update"}`;

const ok: EventName = "user:create";
// const bad: EventName = "user:login"; // Error

keyof, indexed access, and infer

The keyof operator extracts property names. Indexed access retrieves the type of a specific property. The infer keyword captures types inside conditional branches, allowing you to pull out pieces such as return types or parameter types.

keyof for property keys

keyof produces a union of the keys of an object type. This feeds into generic utilities that operate on specific members.

type Config = { host: string; port: number };
type Keys = keyof Config; // "host" | "port"

Indexed access for property types

Use indexed access to reach into object types the same way you reach into values.

type HostType = Config["host"]; // string

Capturing types with infer

infer introduces a placeholder inside a conditional type. This extracts a hidden type from another type.

type ReturnTypeOf<F> = F extends (...args: any[]) => infer R ? R : never;

function f() { return { ok: true }; }
type R = ReturnTypeOf<typeof f>; // { ok: true }
💡 infer is a quiet sidekick; it gives you the missing pieces when building expressive type transformations.

Extracting tuple members

Conditional types with infer can deconstruct tuples or argument lists. This proves useful when modelling decorators or function wrappers.

type First<T> = T extends [infer A, ...any[]] ? A : never;

type X = First<[string, number, boolean]>; // string

Utility types

Utility types are packaged patterns built on advanced types. Studying them helps you design your own reusable APIs. Combine them with constraints and generics to produce consistent and predictable interfaces.

Core utility types

Most projects rely on Partial, Required, Readonly, Pick, and Record. They cover common transformations such as optionality, immutability, and selection.

type PartialUser = Partial<User>;
type RequiredUser = Required<User>;
type UserRecord = Record<string, User>;

Composing utility types

Combine utilities to create specialised versions of data models. This lets you carve precise shapes from broad interfaces.

type UpdateUser = Partial<Pick<User, "name" | "email">>;

Designing fluent and predictable APIs

When building libraries, rely on patterns that preserve relationships across calls. Use generics to link inputs and outputs; use constraints to enforce shape expectations. This creates APIs that feel consistent and help readers follow your intent.

interface Query<T> {
  where<K extends keyof T>(key: K, value: T[K]): Query<T>;
  select<K extends keyof T>(...keys: K[]): Pick<T, K>[];
}
⚠️ Avoid overdesigning types; complicated generic signatures often signal that a simpler runtime model would be clearer.

Balancing precision with usability

Precise types feel impressive, but the goal is trust and clarity. A tiny reduction in precision can make an API easier for real callers. Aim for types that explain themselves and match the runtime behavior without overwhelming readers.