02 · Advanced TypeScript¶
TypeScript's type system is Turing-complete in practice — you can express remarkably precise constraints about your data and catch entire classes of bugs before running a single test. This module covers the features that show up constantly in production TypeScript codebases.
Generics¶
Generics let a function, type, or class work with any type while preserving the relationship between its inputs and outputs.
// Without generics — loses type information
function firstUnsafe(arr: any[]): any {
return arr[0];
}
// With generics — the return type is tied to the input type
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // n: number | undefined
const s = first(["a", "b"]); // s: string | undefined
// Generic constraints — restrict T to types with a known shape
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
interface User extends HasId {
name: string;
}
const users: User[] = [{ id: "1", name: "Ada" }];
const found = findById(users, "1"); // found: User | undefined
// Generic classes — a type-safe cache keyed by string
class Cache<T> {
private store = new Map<string, T>();
set(key: string, value: T): void {
this.store.set(key, value);
}
get(key: string): T | undefined {
return this.store.get(key);
}
}
const userCache = new Cache<User>();
userCache.set("1", { id: "1", name: "Ada" });
Utility types¶
TypeScript ships built-in utility types that transform existing types instead of redeclaring them.
interface Product {
id: string;
name: string;
price: number;
description?: string;
}
type ProductPreview = Pick<Product, "id" | "name">;
// { id: string; name: string }
type ProductWithoutPrice = Omit<Product, "price">;
// { id: string; name: string; description?: string }
type PartialProduct = Partial<Product>;
// every field optional — useful for PATCH-style update payloads
type RequiredProduct = Required<Product>;
// every field mandatory — description is no longer optional
type ReadonlyProduct = Readonly<Product>;
// all fields cannot be reassigned after creation
type ProductPriceMap = Record<string, number>;
// { [key: string]: number } — e.g. { "sku-1": 19.99, "sku-2": 24.99 }
function updateProduct(id: string, changes: Partial<Product>): Product {
const existing = getProductById(id); // pretend this exists
return { ...existing, ...changes };
}
updateProduct("p1", { price: 29.99 }); // only price needs to be supplied
Conditional types¶
Conditional types choose between two types based on a check, similar to a ternary operator but evaluated at the type level.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
// Extracting the return type of a function, without calling it
type ReturnOf<Fn> = Fn extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "1", name: "Ada" };
}
type User = ReturnOf<typeof getUser>; // { id: string; name: string }
// A conditional type that unwraps a Promise
type Awaited2<T> = T extends Promise<infer V> ? V : T;
type A1 = Awaited2<Promise<number>>; // number
type A2 = Awaited2<string>; // string (unchanged, not a Promise)
infer introduces a new type variable inside a conditional type — it lets you
pull a piece out of a more complex type (a return type, a Promise's resolved
value, an array's element type) instead of restating it manually.
Mapped types¶
Mapped types build a new object type by iterating over the keys of an
existing one — this is how Partial, Required, and Readonly are actually
implemented under the hood.
// A simplified version of what lib.es5.d.ts defines for Partial<T>
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Mapped type with a transformation: wrap every field in a validator function
type Validators<T> = {
[K in keyof T]: (value: T[K]) => boolean;
};
interface Form {
email: string;
age: number;
}
const formValidators: Validators<Form> = {
email: (value) => value.includes("@"),
age: (value) => value >= 0,
};
// Key remapping with `as` — rename keys while mapping (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ProductGetters = Getters<Product>;
// { getId: () => string; getName: () => string; getPrice: () => number; ... }
Discriminated unions and exhaustiveness checks¶
A discriminated union uses a shared literal field (often called type or
kind) so TypeScript can narrow which shape you're working with — and the
compiler can force you to handle every case.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// if a new Shape variant is added and not handled above,
// this line fails to compile — `shape` can't be `never`
const exhaustive: never = shape;
throw new Error(`unhandled shape: ${exhaustive}`);
}
}
Template literal types¶
Template literal types build string types out of other types, useful for typed route paths, CSS-in-JS keys, or event names.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "orders";
type Route = `${HttpMethod} /${Resource}`;
// "GET /users" | "GET /orders" | "POST /users" | "POST /orders" | ...
function request(route: Route) {
/* ... */
}
request("GET /users"); // OK
// request("PATCH /users"); // Error: "PATCH /users" is not assignable to Route
Utility types cheat sheet¶
| Utility | Purpose |
|---|---|
Partial<T> |
Every field becomes optional |
Required<T> |
Every field becomes mandatory |
Pick<T, K> |
Keep only keys K |
Omit<T, K> |
Remove keys K |
Record<K, V> |
Object type with keys K mapped to values V |
ReturnType<Fn> |
The return type of a function type |
Awaited<T> |
The resolved value type of a Promise |
NonNullable<T> |
Removes null/undefined from a union |
How It Actually Works¶
Generics in TypeScript are resolved entirely at compile time through a process called
type inference and substitution — when you call identity<number>(5) or just
identity(5) and let TypeScript infer T, the compiler walks the call site, unifies
the argument's type against the generic parameter, and substitutes that concrete type
everywhere T appears in the function's declared type, purely to type-check the call;
none of this exists once compiled to JS, where identity is just a function with no
knowledge it was ever generic. This is fundamentally different from generics in a
language like Java or C#, which (for reference types) retain some runtime information
via type erasure with bridge methods, or from C++ templates, which generate a distinct
compiled function per instantiation — TypeScript's generics leave zero runtime
footprint of any kind.
Conditional types (T extends U ? X : Y) and mapped types ({[K in keyof T]: ...}) are
evaluated by the compiler's type-checker as a kind of functional program that operates
on types instead of values, resolved through repeated substitution until it reaches a
fixed type or the compiler's recursion depth limit. This is why deeply recursive
conditional types can hit "type instantiation is excessively deep" errors — you've
written something equivalent to an infinite (or very long) recursive function, except
the "function" runs during compilation over the type-space rather than during execution
over the value-space.
Exercise¶
Given this base type:
interface Task {
id: string;
title: string;
status: "todo" | "in-progress" | "done";
assigneeId?: string;
}
Write: (1) a CreateTaskInput type derived from Task via utility types that
omits id and makes status optional (defaulting elsewhere to "todo");
(2) a generic Result<T> discriminated union type with two variants,
{ ok: true; value: T } and { ok: false; error: string }; and (3) a function
updateTaskStatus(task: Task, status: Task["status"]): Result<Task> that
returns an error result if attempting to move a "done" task back to
"todo".