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]);
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: { … } };
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>[];
}
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.