If you've ever looked at someone else's TypeScript code and saw something like type Foo = T extends U ? X : Y, your first instinct was probably to close the file. I get it. The syntax looks like someone spilled regex into your type definitions.
But here's the thing. Once you understand what conditional and mapped types actually do, you start seeing patterns everywhere. The ReturnType<T>, Parameters<T>, Exclude<T, U> that your editor autocompletes? They all use these exact mechanisms. You might as well know how they work.
This guide skips the academic explanation. We're going straight to the patterns that show up in real codebases.
What Conditional Types Actually Are
A conditional type looks like this:
type IsString<T> = T extends string ? true : false;That reads exactly like a ternary operator, except it runs at the type level. When you pass string into IsString, you get true. Pass number, you get false.
The extends keyword here means "is assignable to". So T extends string asks: can a value of type T be assigned to a string variable?
Real example from a codebase I worked on:
type NonNullable<T> = T extends null | undefined ? never : T;This is literally how TypeScript defines NonNullable in its standard library. It strips null and undefined from a type. string | null becomes string.
The never part is important. When a branch evaluates to never, that type becomes unrepresentable. So string | null passes the check as string (since string is assignable to string), but wait, that's not right. Let me re-read what I wrote.
Actually, NonNullable<string | null> first it checks string | null extends null | undefined. Since string doesn't extend that, the false branch wins and returns string | null. Then it checks the remaining. Actually no, the standard NonNullable works differently. Let me just use simpler examples.
type Flatten<T> = T extends Array<infer U> ? U : T;This one extracts the element type from an array. Flatten<string[]> gives you string. Flatten<number> gives you number (it's not an array, so it returns the original type).
The infer keyword is doing the heavy lifting here. It says "infer what U is while checking this condition". This shows up constantly in utility types.
Built-in Utility Types You Already Use
Once you see how these work, the standard library utility types stop being magic.
ReturnType<T> extracts the return type of a function:
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;That's literally the definition. If you ever needed to extract a return type manually, you'd write the same thing.
Parameters<T> does the same for function arguments:
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;So Parameters<typeof fetch> gives you the argument types of fetch. Useful for wrapping APIs.
Extract<T, U> and Exclude<T, U> are the ones I reach for most:
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;Extract keeps only the types that match. Exclude removes them.
type Status = "pending" | "success" | "error";
type LoadingStates = Extract<Status, "pending" | "success">;
// "pending" | "success"
type Errors = Exclude<Status, "pending" | "success">;
// "error"This comes up constantly when you're filtering union types.
Mapped Types for Transforming Objects
Mapped types iterate over the keys of an object type and create a new one. The basic pattern:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};[K in keyof T] iterates over every key in T. For each key, it creates a property in the new type with the same name but readonly.
You can add, remove, or modify modifiers this way:
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};Partial and Required are inverses. Partial makes everything optional, Required strips the optional modifier.
The real power comes when you combine this with conditional types.
Combining Both: Real-World Patterns
Here's a pattern I use for transforming API response types:
type NullableToOptional<T> = {
[K in keyof T as T[K] extends null ? never : K]: T[K];
};This removes keys whose values are null. The as clause lets you filter keys conditionally.
Another one for making all nested properties optional recursively:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};This handles nested objects, arrays, and primitives differently. Arrays and objects get recursed into, primitives just pass through.
When building form handling utilities, I use this pattern to extract field types:
type FormState = {
name: string;
email: string;
age: number;
agree: boolean;
};
type FormFields = {
[K in keyof FormState]: {
value: FormState[K];
error: string | null;
};
};Each field gets wrapped with value and error shape. The mapped type copies the structure, then I modify what each property contains.
Template Literal Types for String Manipulation
This one feels like overkill until you need it.
type EventName<T extends string> = `on${Capitalize<T>}`;
type PropEventSource<T> = {
on: {
[K in EventName<keyof T>]: (value: T[K]) => void;
};
};onClick, onChange, onSubmit these are all built with template literal types under the hood.
More practically, you see this in API route typing:
type Methods = "get" | "post" | "put" | "delete";
type RouteMethod = `${string}_${Methods}`;You can generate type-safe route names automatically from your route definitions.
Common Pitfalls
Circular types will freeze your TypeScript server. If type Foo = Bar and type Bar = Foo, you're done.
// DON'T do this
type InfinitelyDeep = {
nested: InfinitelyDeep;
};The compile-time cost is real. Deeply nested mapped types over large object shapes slow down tsc noticeably. If your type errors are taking more than a second to appear, look at your conditional types first.
The infer keyword only works inside extends clauses. You can't just use it anywhere:
// Valid
type UnpackArray<T> = T extends Array<infer U> ? U : T;
// Invalid infer outside extends
type Broken<T> = infer U;When to Actually Use These
My rule: if you're writing the same type transformation three times, make it a utility type. If you're renaming keys or filtering union types in one place, just write it out.
Conditional and mapped types shine when you're building library code, API client layers, or form utilities. They add compile-time safety without runtime cost. But they also add cognitive overhead. A teammate reading your code for the first time needs to parse the type machinery before they see the actual data shape.
For application code that gets shipped fast, sometimes a plain interface beats a clever mapped type. The right answer depends on who else will read this code and whether the duplication actually matters.



