Conditional types, mapped types, template literal types, and infer — the deepest corners of TypeScript's type system.
These are the tools that TypeScript power users use to build utility libraries and expressive type systems.
A conditional type selects one of two possible types based on whether a condition is true.
// Basic conditional type: T extends U ? X : Y type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no" // Practical: unwrap a Promise type type Awaited<T> = T extends Promise<infer R> ? R : T; type C = Awaited<Promise<string>>; // string type D = Awaited<number>; // number (not a Promise, passes through) // Distributive conditional types over unions type ToArray<T> = T extends any ? T[] : never; type StrOrNumArr = ToArray<string | number>; // string[] | number[] (distributes over the union)
Mapped types iterate over all keys in a type and transform each property — this is exactly how Partial, Required, and Readonly work under the hood.
interface User { name: string; age: number; email: string; } // Make every property optional (how Partial<T> works) type MyPartial<T> = { [K in keyof T]?: T[K]; }; // Make every property readonly (how Readonly<T> works) type MyReadonly<T> = { readonly [K in keyof T]: T[K]; }; // Transform every value type to a getter function type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]; }; type UserGetters = Getters<User>; // { // getName: () => string; // getAge: () => number; // getEmail: () => string; // } // Filter keys by value type type StringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T]; type UserStringKeys = StringKeys<User>; // "name" | "email"
Build new string types by combining other types — like template literals at the type level.
// Combine string unions type Color = "red" | "green" | "blue"; type Shade = "light" | "dark"; type Palette = `${Shade}-${Color}`; // "light-red" | "light-green" | "light-blue" // | "dark-red" | "dark-green" | "dark-blue" // Typed event names type EventName<T extends string> = `on${Capitalize<T>}`; type ClickEvent = EventName<"click">; // "onClick" type ChangeEvent = EventName<"change">; // "onChange" // CSS property builder type Side = "top" | "right" | "bottom" | "left"; type MarginProp = `margin-${Side}`; // "margin-top" | "margin-right" | "margin-bottom" | "margin-left" // Extract route params from a URL string type ExtractRouteParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractRouteParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never; type Params = ExtractRouteParams<"/users/:id/posts/:postId">; // "id" | "postId" 🤯
infer lets you capture a type from within a conditional type — pulling types out of other types.
// Get the return type of a function type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; function getUser() { return { name: "Alice", age: 25 }; } type UserData = MyReturnType<typeof getUser>; // { name: string; age: number } // Get first argument type type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never; type Arg = FirstArg<(name: string, age: number) => void>; // string // Unwrap a Promise (this is what Awaited<T> does) type UnwrapPromise<T> = T extends Promise<infer V> ? V : T; type E = UnwrapPromise<Promise<User>>; // User // Extract array element type type ElementType<T> = T extends (infer E)[] ? E : never; type F = ElementType<string[]>; // string type G = ElementType<number[]>; // number
🧠 Mental model: Think of infer R as a "capture variable". When TypeScript pattern-matches the type, it assigns the matched portion to R, which you can then use on the right side.
interface Serializable { serialize(): string; } interface Loggable { log(): void; } interface User { name: string; age: number; } // Must implement ALL interfaces type LoggableUser = User & Serializable & Loggable; const user: LoggableUser = { name: "Alice", age: 25, serialize() { return JSON.stringify(this); }, log() { console.log(this.name); } }; // Intersection for mixins function merge<T, U>(a: T, b: U): T & U { return { ...a, ...b } as T & U; } const combined = merge({ name: "Alice" }, { age: 25 }); // combined: { name: string } & { age: number } // combined.name ✅ combined.age ✅
A powerful pattern for modeling state machines and API responses safely.
// Each variant has a "discriminant" field (kind/type/status) type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number } | { kind: "triangle"; base: number; height: number }; function getArea(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; // TS knows: radius exists case "rectangle": return shape.width * shape.height; // TS knows: width, height exist case "triangle": return 0.5 * shape.base * shape.height; // TS knows: base, height exist } } // Exhaustiveness check — TypeScript errors if a case is missing function assertNever(x: never): never { throw new Error("Unexpected value: " + x); }
⚡ Pro tip: Discriminated unions + exhaustiveness checking = compile-time guarantee that you've handled every case. Add a default: assertNever(shape) to catch missing branches.