TypeScript

Master Conditional Types in Generics: 7 Pro Tips (2025)

Unlock the full potential of TypeScript generics! Our 2025 guide offers 7 pro tips to master conditional types, from `infer` to recursive type-level logic.

E

Elena Petrova

Principal TypeScript Engineer passionate about type systems and building robust, scalable applications.

7 min read11 views

Ever stumbled upon a piece of TypeScript code that looked something like T extends U ? X : Y and felt your brain start to short-circuit? You're not alone. At first glance, this syntax looks like a cryptic message from the type system's deepest catacombs. But what if I told you it's one of the most powerful features in your TypeScript arsenal?

This is a conditional type. It’s a simple concept with profound implications: it’s an if/else statement for your types. It lets you create dynamic, intelligent, and incredibly flexible generic components that adapt based on the types you pass them. In 2025, mastering them isn't just for library authors—it's a key skill for any developer serious about type safety and clean API design.

Forget the dry documentation. We're going to dive into seven practical, pro-level tips that will transform conditional types from a source of confusion into your new favorite superpower.

Tip 1: Think "If/Else" for Your Types

Let's start at the beginning. The core syntax T extends U ? X : Y is a direct parallel to a JavaScript ternary operator:

// JavaScript
const result = someValue > 10 ? 'Greater' : 'Lesser';

Now, for types:

// TypeScript
type Result<T> = T extends 'someValue' ? 'Greater' : 'Lesser';

The logic is identical: "If type T is assignable to type U, then the resulting type is X. Otherwise, it's Y."

A simple, real-world example is checking if a type is a string:

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>; // Type A is 'true'
type B = IsString<123>;     // Type B is 'false'

This is the fundamental building block. Once you internalize this "if/else" mental model, the rest of the tips will click into place.

Tip 2: Unleash Power with the `infer` Keyword

This is where things get truly exciting. The infer keyword allows you to declare a new type variable within the extends clause. Think of it as capturing a piece of a type so you can use it later.

The classic example is unwrapping a Promise. How can you get the type of the value a promise resolves to? With infer, it's surprisingly elegant:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

// Let's test it
type P1 = Promise<string>;
type T1 = UnwrapPromise<P1>; // T1 is 'string'

type P2 = number;
type T2 = UnwrapPromise<P2>; // T2 is 'number' (falls back to T)

What's happening here? TypeScript checks if T matches the shape of Promise<...>. If it does, it takes whatever type is inside the promise (the resolved value's type) and puts it into a new, temporary type variable we named U. We then simply return U. If T isn't a promise, the condition is false, and we just return the original type T.

You can use infer to pull types out of arrays, function parameters, return types—you name it. It's the key to deconstructing and manipulating types.

Tip 3: Master Distributive Conditional Types

Here’s a behavior that often trips people up. When the type you're checking in a conditional type (the T in T extends U) is a "naked" generic type parameter that is a union, the conditional type gets distributed over that union.

Advertisement

That sounds complicated, but it's simple in practice. It means the condition is applied to each member of the union individually.

type ToArray<T> = T extends any ? T[] : never;

// T is a naked type parameter, so this will be distributive
type MyUnion = string | number;
type Result = ToArray<MyUnion>; // Result is string[] | number[]

See? It didn't become (string | number)[]. Instead, TypeScript ran the logic for string (getting string[]) and for number (getting number[]) and then combined the results back into a union: string[] | number[].

What if you don't want this behavior? You can prevent distribution by wrapping each side of the extends clause in square brackets:

type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never;

type MyUnion = string | number;
type Result = ToArrayNonDistributive<MyUnion>; // Result is (string | number)[]

By wrapping T in [T], it's no longer a "naked" type parameter, and the distributive magic is turned off.

Distributive vs. Non-Distributive at a Glance

Type Definition Input Type Output Type
T extends any ? T[] : never string | boolean string[] | boolean[]
[T] extends [any] ? T[] : never string | boolean (string | boolean)[]

Tip 4: Create Exclusion and Extraction Utilities

Ever used TypeScript's built-in Exclude<T, U> or Extract<T, U>? Guess what—they're just simple conditional types! Understanding how they work is a huge step toward mastery.

Exclude<T, U> removes types from T that are assignable to U.

// The actual implementation in lib.d.ts
type Exclude<T, U> = T extends U ? never : T;

type T0 = Exclude<'a' | 'b' | 'c', 'a'>; // T0 is 'b' | 'c'

Thanks to distributive conditional types (Tip #3!), this works beautifully with unions. TypeScript checks 'a' extends 'a' (true, returns never), then 'b' extends 'a' (false, returns 'b'), and so on. The never type is a special type that gets filtered out of unions, effectively deleting the member.

Extract<T, U> does the opposite—it keeps only the types from T that are assignable to U.

// The actual implementation
type Extract<T, U> = T extends U ? T : never;

type T1 = Extract<'a' | 'b' | 'c', 'a' | 'f'>; // T1 is 'a'

Building your own utility types like this is the best way to practice.

Tip 5: Map Object Properties with Precision

Here's where we combine forces. Conditional types become a true powerhouse when used inside Mapped Types ({ [K in keyof T]: ... }). You can create new object types by filtering the keys of an existing type.

Let's say you want a type that contains only the properties of an object that are functions. How would you do that?

interface UserProfile {
  id: number;
  name: string;
  getName(): string;
  isActive(): boolean;
}

// Step 1: Get a union of keys that point to functions
type FunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;
}[keyof T];

// Step 2: Use Pick to create a new type with just those keys
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;

// Let's try it!
type UserFunctions = FunctionProperties<UserProfile>;
// UserFunctions is: { getName: () => string; isActive: () => boolean; }

The magic is in FunctionPropertyNames. It iterates over each key K. If the property type T[K] is a function, it keeps the key K; otherwise, it uses never. The [keyof T] at the end creates a union of all the property types, and since never is filtered from unions, we're left with just the keys we want: 'getName' | 'isActive'. The final Pick utility does the rest.

Tip 6: Use `never` for Powerful Type-Level Assertions

You can use conditional types to enforce constraints on your generic functions, making your APIs safer and more expressive.

Instead of just returning a type, you can use never in the "false" branch to cause a compile-time error if the type doesn't meet your criteria. This acts as a type-level assertion.

Imagine a function that should only work with objects that have a string id property.

// This type resolves to T if it has an id, otherwise it's 'never'
type WithId<T> = T extends { id: string } ? T : never;

function processEntity<T extends object>(entity: WithId<T>) {
  // We know for sure 'entity.id' is a string here!
  console.log('Processing ID:', entity.id.toUpperCase());
}

processEntity({ id: 'user-123', name: 'Alice' }); // OK!

// This will cause a compile-time error:
// Argument of type '{ name: string; }' is not assignable to parameter of type 'never'.
processEntity({ name: 'Bob' });

When you call processEntity with an object missing an id, the type WithId<{ name: 'Bob' }> resolves to never. Since you can't pass a value of type { name: 'Bob' } where never is expected, TypeScript throws an error. It's a clean, declarative way to enforce complex rules.

Tip 7: Simplify Complex Chains with Recursive Conditional Types

Since TypeScript 4.1, conditional types can reference themselves, allowing for recursion. This opens the door to type-level programming that was previously impossible, letting you model complex, recursive data structures.

A great example is a type that deeply flattens a nested array.

type DeepFlatten<T> = T extends (infer U)[]
  ? DeepFlatten<U> // If it's an array, recurse on the inner type
  : T;               // Otherwise, we've hit the bottom

type A = DeepFlatten<string[][][]>; // A is 'string'
type B = DeepFlatten<[1, [2, [3, 4]], 5]>; // B is '1 | 2 | 3 | 4 | 5'
// Note: This simplified version doesn't fully handle mixed-type tuples, 
// but it demonstrates the recursive principle perfectly.

The type DeepFlatten checks if T is an array. If it is, it infers the inner type U and calls itself with U. This process repeats until it finds a type that is no longer an array, at which point it returns that base type. It's like a loop, but for your types!


Your New Superpower

We've journeyed from a simple type-level "if/else" to the mind-bending power of recursive type manipulation. The key takeaway is that conditional types aren't just an esoteric feature; they are a practical tool for solving everyday problems in a type-safe way.

By using them to extract types with infer, filter unions, map object shapes, and enforce API contracts, you're not just writing code—you're describing its behavior to the compiler. Start small, experiment with these tips in your next project, and watch as you begin to build smarter, more resilient, and self-documenting components.

Tags

You May Also Like