Conditional Types in TypeScript
Conditional types are one of TypeScript’s most powerful type-system features. They allow you to create types that adapt based on other types, enabling truly generic and reusable type transformations. In this tutorial, you’ll learn how to harness conditional types to write more expressive and flexible TypeScript code.
Basic conditional type syntax
A conditional type selects one of two types based on a type relationship:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
The syntax reads like: “If T extends string, then the type is true, otherwise it’s false.” The extends keyword checks whether the left type is assignable to the right at compile time. When T is string, the condition holds and IsString<string> resolves to true. When T is number, the check fails, giving false.
This simple true/false branching becomes much more useful when you use it to transform types rather than just test them. By returning never in the false branch, you can filter unwanted types out of a union:
type Nullable<T> = T extends null | undefined ? never : T;
type A = Nullable<string>; // string
type B = Nullable<null>; // never
type C = Nullable<string | null>; // string
The never type in the false branch effectively removes null and undefined from the union. When T is string | null, the conditional distributes over the union and filters out the nullish members, leaving only string. This filtering pattern is the basis for TypeScript’s built-in NonNullable utility type, which you will see again later.
The infer Keyword
The infer keyword lets you extract types from within other types—it’s like pattern matching for TypeScript:
// Extract the return type from a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Alice" };
}
type User = ReturnType<typeof getUser>;
// { id: number; name: string }
The ReturnType helper works because infer R captures whatever shape the function signature produces. TypeScript deduces that getUser returns { id: number; name: string }, and R binds to that shape. The same infer mechanism can extract other structural pieces, not just return types. For example, you can capture the first element of a tuple:
// Extract the first element from an array
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type A = First<[string, number, boolean]>; // string
type B = First<[]>; // never
The First helper uses a tuple pattern to match the first element. The rest pattern ...any[] acts as a catch-all for remaining elements. When the array is empty, the pattern fails to match, so never is returned. This pattern-matching approach with infer scales to extracting other positions like the last element or a specific prefix.
Practical conditional types
Let’s build some useful conditional types from scratch. Filtering nullish types is one of the most common use cases:
Filter out nullish types
type NonNullable<T> = T extends null | undefined ? never : T;
type Clean = NonNullable<string | null | undefined | number>;
// string | number
Filtering nullish types is common enough that TypeScript ships NonNullable<T> in the standard library. It is a one-liner powered by the same conditional pattern. But conditional types can do more than filter; they can also reshape types by extracting specific pieces like function parameters:
Extract function parameters
type Parameters<T extends (...args: any[]) => any> =
T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number, email: string) {
return { name, age, email };
}
type Params = Parameters<typeof createUser>;
// [string, number, string]
Parameters<T> gives you a tuple of the function’s parameter types, which is useful for higher-order functions that wrap or transform other functions. The same infer technique works for constructor parameters via ConstructorParameters and for instance types via InstanceType. For a more unusual challenge, consider detecting the any type itself:
Check if a type is any
type IsAny<T> = 0 extends (1 & T) ? true : false;
type A = IsAny<any>; // true
type B = IsAny<string>; // false
type C = IsAny<never>; // false
This works because any is the only type that can intersect with anything and produce 0. The expression 1 & any evaluates to any, and 0 extends any is true, making the check pass only for the any type. While this pattern is clever, you rarely need to detect any in practice. More commonly, you will combine conditional types with mapped types to transform object shapes.
Mapped Conditionals
Combine conditional types with mapped types for powerful transformations:
type MakeOptional<T> = {
[K in keyof T]?: T[K];
};
type MakeRequired<T> = {
[K in keyof T]-?: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
type OptionalUser = MakeOptional<User>;
type RequiredUser = MakeRequired<OptionalUser>; // Back to original
The basic mapped types above apply the same modifier to every property. By embedding a conditional type inside a mapped type, you can apply different rules to different kinds of properties. This is how you build recursive transformations that reach into nested objects:
Conditional property types
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
api: {
url: string;
headers: Record<string, string>;
};
maxRetries: number;
}
type FrozenConfig = DeepReadonly<Config>;
// {
// readonly api: {
// readonly url: string;
// readonly headers: Readonly<Record<string, string>>;
// };
// readonly maxRetries: number;
// }
DeepReadonly checks each property: if it is an object, it recurses; otherwise it just marks it readonly. This conditional-inside-mapped pattern is how utility types like DeepPartial and DeepRequired work. The key insight is that the conditional type decides the transformation for each property independently. A related but distinct behavior happens when the checked type is a union:
Distributive conditional types
When the checked type is a union, conditional types become distributive:
type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArray = ToArray<string | number>;
// string[] | number[]
The distributive behavior means ToArray<string | number> becomes string[] | number[], not (string | number)[]. Each union member gets wrapped in its own array type. This is also why conditional types are the mechanism behind Exclude and Extract, two of the most-used built-in utility types:
This is why Exclude and Extract work:
// Exclude: remove T from U
type Exclude<T, U> = T extends U ? never : T;
// Extract: keep only T from U
type Extract<T, U> = T extends U ? T : never;
type A = Exclude<string | number, string>; // number
type B = Extract<string | number, string>; // string
Exclude removes types from a union by returning never for matching members, while Extract keeps only the matching members. Both rely on distributive conditional typing, which is why they process each union member individually rather than the union as a whole. These patterns come together in real codebases:
Real-world example: API response types
Here’s a practical example combining everything:
// Define API response shapes
interface ApiSuccess<T> {
ok: true;
data: T;
}
interface ApiError {
ok: false;
error: {
code: number;
message: string;
};
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
// Type guard to narrow the response
function isSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
return response.ok === true;
}
// Usage
async function fetchUser(id: number): Promise<ApiResponse<{ id: number; name: string }>> {
try {
const response = await fetch(`/api/users/${id}`);
return { ok: true, data: await response.json() };
} catch {
return { ok: false, error: { code: 500, message: "Network error" } };
}
}
// Narrowing the type
const result = await fetchUser(1);
if (isSuccess(result)) {
console.log(result.data.name); // TypeScript knows data exists
} else {
console.error(result.error.message); // TypeScript knows error exists
}
How conditional types are evaluated
Conditional types are checked at compile time by comparing the left side of extends against the right side. If the relationship holds, TypeScript chooses the true branch; otherwise it chooses the false branch. That means the compiler is not inspecting runtime values. It is reasoning about assignability between types. Once you see that distinction, many conditional patterns start to make sense: they are type-level decisions, not JavaScript branching.
This also explains why the same helper can produce different results for different inputs. The compiler substitutes the concrete type argument, evaluates the relationship, and then keeps going with the selected branch. That is what makes conditional types flexible enough to power reusable utilities.
Distribution over unions
When the checked type is a naked type parameter, unions are processed one member at a time. This is why Exclude<string | number, string> becomes number rather than a single combined type. The compiler applies the conditional to each union member, then joins the surviving pieces back together. That behavior is called distributive conditional typing, and it is one of the main reasons these helpers are so expressive.
Distribution is useful, but it can also be surprising. If you expected the entire union to be treated as one unit, you need to wrap the type parameter in another container type first. A tuple is the usual trick. That small change stops distribution and gives you a different kind of comparison.
Preventing unwanted distribution
Sometimes you want to compare a union as a whole instead of member by member. Wrapping the type parameter in a tuple can do that:
type IsNever<T> = [T] extends [never] ? true : false;
Without the tuple, never can disappear in ways that are hard to reason about. With the tuple, the compiler checks the entire type together. This pattern shows up in more advanced utility types, especially when a helper needs to distinguish between an empty union, a broad type, and a specific literal union.
Why infer reduces repetition
infer lets you capture part of a type without restating the whole shape. That keeps helpers small and avoids duplicate structure in the true branch. It is especially handy for extracting function parameters, array elements, promise values, and tuple members. Instead of spelling out the same nested type in several places, you describe the pattern once and let the compiler fill in the part you want.
In practice, infer makes utility types easier to read because the intent is obvious: “pull this piece out of the larger type.” That is a better long-term maintenance story than writing a long conditional with repeated type syntax in every branch.
Recursive helpers need a base case
Recursive conditional types are powerful, but they need a stopping point. A helper like DeepReadonly works because it eventually reaches a non-object value and returns it unchanged. Without a clear base case, the compiler can keep expanding the type until it becomes unreadable or exceeds practical limits. When you design a recursive helper, always ask what the simplest leaf value should look like.
This is also a good place to split logic into smaller pieces. One helper can handle the recursion, while another can decide how a single node should be transformed. That separation keeps the type easier to debug and makes the recursion easier to trust.
Built-in conditional types
TypeScript provides several built-in conditional types in the standard library:
| Type | Description |
|---|---|
ReturnType<T> | Extracts the return type of a function |
Parameters<T> | Extracts the parameter types as a tuple |
InstanceType<T> | Extracts the instance type of a class |
Exclude<T, U> | Excludes types from T that are assignable to U |
Extract<T, U> | Extracts types from T that are assignable to U |
NonNullable<T> | Removes null and undefined from T |
Choosing built-ins first
Before writing a custom conditional type, check the standard library. Exclude, Extract, ReturnType, Parameters, InstanceType, and NonNullable already cover many common cases. Using the built-in version keeps your code shorter and makes it easier for other TypeScript developers to recognize the behavior immediately. Custom helpers still matter, but they should usually build on the standard types rather than replace them.
The same rule applies when you are naming your own helpers. Prefer names that describe the transformation clearly, such as UnwrapPromise, First, or DeepReadonly. Good names make the type-level logic easier to scan, which matters a lot once a project starts using several utilities together.
Debugging conditional types
When a type does not behave the way you expect, break it into smaller aliases and inspect each one separately. That is often the fastest way to see which branch is being chosen. You can also assign the intermediate type to a named alias so the editor shows it in hover text. This is especially helpful with unions, where one branch may be distributing in a way you did not intend.
Another practical trick is to test helpers with a few concrete examples instead of relying on a single input. If the type works for string, number, and string | number, you have a much better sense of how it behaves in a real codebase.
Summary
Conditional types are essential for advanced TypeScript:
- Basic syntax:
T extends U ? X : Ychooses types based on relationships infer: Extracts types from within other types- Distribution: Union types distribute across conditional types
- Practical applications: Build utilities like
Nullable,Optional, andReturnType
Master conditional types, and you can use TypeScript’s type system more confidently. In the next tutorial, you’ll explore more advanced type patterns that build on these concepts.