You are viewing a preview of this lesson. Sign in to start learning
Back to TypeScript

Conditional Types

Create types that depend on conditions, allowing for dynamic type selection based on type relationships.

Introduction to Conditional Types

Have you ever written a function that returns different types based on its input? Perhaps a function that returns a string when given a number, but returns a number when given a string? If you've tried to type this accurately in TypeScript, you've likely hit a wall where your types feel... inadequate. You end up using any or overly broad union types, losing the precision that makes TypeScript valuable in the first place. This is where conditional types transform from an advanced feature into an essential tool. In this lesson, we'll explore how conditional types let you write types that think—making decisions based on the shape of other types. And before we dive deep, remember that you can reinforce these concepts with free flashcards embedded throughout this lesson.

Conditional types are TypeScript's way of expressing "if-then" logic at the type level. They allow you to create types that change their structure based on conditions—much like a ternary operator does for values in regular JavaScript. The syntax follows a pattern that looks remarkably familiar:

T extends U ? X : Y

This reads as: "If type T is assignable to (extends) type U, then the result is type X, otherwise it's type Y."

🎯 Key Principle: Conditional types enable type-level programming, where your types can compute and transform based on the characteristics of other types, not just their literal values.

Why Static Types Need Dynamic Behavior

At first glance, the phrase "dynamic types" in a statically-typed system sounds contradictory. But consider this real-world scenario: you're building a data fetching library. When users call fetch() with different options, they should get back different response types:

// We want this level of precision:
const jsonData = fetch({ format: 'json' }); // Should be typed as object
const textData = fetch({ format: 'text' }); // Should be typed as string
const blobData = fetch({ format: 'blob' }); // Should be typed as Blob

Without conditional types, you'd need to write three separate functions or accept a loss of type safety. With conditional types, you can write one generic function where the return type intelligently adapts:

type FetchOptions = 
  | { format: 'json' }
  | { format: 'text' }
  | { format: 'blob' };

type FetchResponse<T extends FetchOptions> = 
  T extends { format: 'json' } ? object :
  T extends { format: 'text' } ? string :
  T extends { format: 'blob' } ? Blob :
  never;

function fetch<T extends FetchOptions>(options: T): FetchResponse<T> {
  // Implementation details...
  return null as any; // Placeholder
}

// TypeScript now knows the exact return type!
const result = fetch({ format: 'json' }); // Type: object

💡 Real-World Example: Think of conditional types like a smart assistant that reads context. When you ask for "directions," it checks whether you're in a car, on foot, or using transit, then provides route types accordingly—all from a single request interface.

The Foundation: Type Constraints and Inference

Conditional types don't just branch—they enable type inference, letting TypeScript figure out types from context. This becomes incredibly powerful when combined with generics. The extends keyword here isn't about class inheritance; it's asking "is this type assignable to that type?"

Consider how TypeScript handles arrays:

type Flatten<T> = T extends Array<infer U> ? U : T;

type NumArray = Flatten<number[]>;     // number
type StrArray = Flatten<string[]>;     // string
type NotArray = Flatten<boolean>;      // boolean (no change)

Here, infer U tells TypeScript: "If T is an array, figure out what type the array contains and call it U, then return that." This is type-level pattern matching.

🧠 Mental Model: Think of extends as asking "does this puzzle piece fit?" and infer as saying "if it fits, tell me what shape the inner piece is."

Built-In Utility Types: Standing on Giants' Shoulders

TypeScript's standard library includes several utility types built entirely with conditional types. Understanding these gives you both practical tools and learning examples:

📋 Quick Reference Card: Core Conditional Utility Types

🔧 Type 📝 Purpose 💻 Example
Exclude<T, U> Remove types from T that are assignable to U Exclude<'a'\|'b'\|'c', 'a'>'b'\|'c'
Extract<T, U> Keep only types from T that are assignable to U Extract<'a'\|'b'\|'c', 'a'\|'f'>'a'
NonNullable<T> Remove null and undefined from T NonNullable<string\|null>string
ReturnType<T> Extract the return type of a function ReturnType<() => number>number

🤔 Did you know? Exclude is implemented in just one line: type Exclude<T, U> = T extends U ? never : T. That's the entire source code!

Why This Matters for Your Code

Conditional types solve three fundamental challenges:

🎯 Type Safety Without Duplication — Write one implementation with precise types for all use cases

🎯 API Ergonomics — Users of your library get autocomplete and errors that match their specific usage

🎯 Type-Level Computation — Transform types programmatically, enabling meta-programming patterns

⚠️ Common Mistake: Thinking conditional types execute at runtime. They're purely compile-time constructs—they disappear completely in JavaScript. Mistake 1: Writing if (T extends U) in regular code instead of using them in type positions. ⚠️

❌ Wrong thinking: "Conditional types make my code run differently" ✅ Correct thinking: "Conditional types make my types change shape based on other types"

As we move forward, you'll discover that conditional types are the foundation for TypeScript's most sophisticated type manipulations. They're the difference between types that merely describe your code and types that actively work with your code to prevent bugs before they happen. In the next section, we'll dive into the mechanics of how conditional types distribute over unions, how the infer keyword unlocks pattern matching, and how to nest conditions for complex type logic.

💡 Pro Tip: Start by reading TypeScript's built-in utility type definitions (node_modules/typescript/lib/lib.es5.d.ts). They're masterclasses in conditional type usage, written by the language designers themselves.

Core Mechanics and Type Inference

Conditional types in TypeScript follow the form T extends U ? X : Y, but beneath this simple syntax lies sophisticated behavior that makes them extraordinarily powerful. Understanding these core mechanics—particularly how types distribute over unions and how we can infer types from patterns—unlocks advanced type-level programming capabilities.

Distributive Conditional Types

When you apply a conditional type to a union type, TypeScript automatically distributes the conditional check across each member of the union. This distributive behavior is one of the most powerful features of conditional types, though it can be surprising at first.

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

// When T is a union, it distributes:
type Result = ToArray<string | number>;
// TypeScript evaluates this as:
// ToArray<string> | ToArray<number>
// Which becomes: string[] | number[]

// Practical example: filtering null/undefined from unions
type NonNullable<T> = T extends null | undefined ? never : T;

type Clean = NonNullable<string | null | number | undefined>;
// Result: string | number

The distribution happens because TypeScript treats each union member independently, applies the conditional type to it, and then unions the results back together. You can visualize this process:

Input: string | number | null
         |
         v
    [Distribution]
         |
    +----+----+
    |    |    |
  string number null
    |    |    |
    v    v    v
 [Conditional Check: T extends null | undefined ? never : T]
    |    |    |
  string number never
    |    |    |
    +----+----+
         |
         v
   [Union Result]
         |
    string | number

🎯 Key Principle: Distribution only occurs when the type parameter appears "naked" on the left side of extends. If it's wrapped in another type (like an array or tuple), distribution doesn't happen.

The Power of infer

The infer keyword allows you to extract and capture types from within conditional type expressions. Think of infer as declaring a type variable that TypeScript will "fill in" based on pattern matching.

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

type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>;          // number

// Extracting function return types
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type Func = (x: number) => string;
type FuncReturn = ReturnType<Func>; // string

// Extracting array element types
type ArrayElement<T> = T extends (infer E)[] ? E : never;

type Numbers = ArrayElement<number[]>;     // number
type Mixed = ArrayElement<(string | boolean)[]>; // string | boolean

💡 Mental Model: The infer keyword says "I don't know what this type is yet, but when you match this pattern, capture whatever type fits here and call it U (or R, or E)."

You can use multiple infer declarations in a single conditional type to extract multiple type components:

// Extract first parameter type from a function
type FirstParameter<T> = T extends (first: infer F, ...rest: any[]) => any 
  ? F 
  : never;

type Func = (name: string, age: number) => void;
type First = FirstParameter<Func>; // string

// Extract both parameters and return type
type FunctionParts<T> = T extends (...args: infer P) => infer R
  ? { params: P; returnType: R }
  : never;

type Parts = FunctionParts<(x: number, y: string) => boolean>;
// Result: { params: [number, string]; returnType: boolean }

⚠️ Common Mistake: Trying to use infer outside of a conditional type's extends clause. The infer keyword only works within the pattern matching context of conditional types. ⚠️

Nested Conditional Types

Nested conditional types enable complex type transformations by chaining multiple conditional checks together. This pattern is essential for sophisticated type-level logic:

// Deep unwrapping of nested Promises
type DeepUnwrapPromise<T> = 
  T extends Promise<infer U>
    ? DeepUnwrapPromise<U>  // Recursively unwrap
    : T;                     // Base case: not a Promise

type Nested = DeepUnwrapPromise<Promise<Promise<Promise<number>>>>;
// Result: number

// Complex type filtering with nested conditions
type Flatten<T> = 
  T extends any[]
    ? T[number] extends infer U
      ? U extends any[]
        ? Flatten<U>  // Nested array, recurse
        : U           // Base element
      : never
    : T;              // Not an array

type Deep = Flatten<(string | number[][])[]>;
// Result: string | number

Non-Distributive Conditionals

Sometimes you want to check a union type as a whole rather than distributing over its members. The tuple wrapping technique [T] extends [U] prevents distribution:

// Distributive (default)
type IsString<T> = T extends string ? true : false;
type Test1 = IsString<string | number>; // boolean (true | false)

// Non-distributive using tuple wrapper
type IsExactlyString<T> = [T] extends [string] ? true : false;
type Test2 = IsExactlyString<string | number>; // false

💡 Pro Tip: Use non-distributive conditionals when you need to check if a type is exactly something, or when you want to treat a union as a single entity rather than processing its members individually.

📋 Quick Reference Card:

Pattern Behavior Example
🔄 T extends U Distributes over unions T extends string ? X : Y with T = 'a' \| 'b'
🔒 [T] extends [U] Non-distributive check [T] extends [string] treats union as whole
🎯 infer R Captures matched type Promise<infer R> extracts wrapped type
🔁 Recursive Self-referencing conditional T extends X ? Conditional<T> : Y

🤔 Did you know? TypeScript's built-in utility types like ReturnType, Parameters, and NonNullable are all implemented using these exact conditional type patterns!

Mastering these core mechanics—distribution, inference, and nesting—provides the foundation for building sophisticated type transformations that make your TypeScript code safer and more expressive.

Practical Patterns and Best Practices

Now that you understand the mechanics of conditional types, let's explore how to apply them effectively in real-world TypeScript projects. This section covers practical patterns, common pitfalls, and best practices that will help you write maintainable, performant type-level code.

Building Custom Utility Types

One of the most powerful applications of conditional types is creating reusable type helpers tailored to your codebase. These custom utilities can encode business logic at the type level, making your code more self-documenting and type-safe.

// Extract all properties of a specific type
type PropertiesOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never
}[keyof T];

// Make properties required based on a condition
type RequireIfPresent<T, K extends keyof T> = 
  T & { [P in K]-?: NonNullable<T[P]> };

// Deep partial that works recursively
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface User {
  id: number;
  name: string;
  email: string;
  age?: number;
}

// Extract only string properties
type UserStringKeys = PropertiesOfType<User, string>; // "name" | "email"

// Make email required even if it wasn't before
type UserWithEmail = RequireIfPresent<User, "email">;

💡 Pro Tip: When building utility types, start simple and add complexity only when needed. A clear, single-purpose utility type is more maintainable than one that tries to handle every edge case.

Type Narrowing and Refinement Patterns

Conditional types excel at type refinement—taking a broad type and narrowing it to more specific variants. This pattern is especially useful when working with discriminated unions or API responses.

// Extract types based on discriminated unions
type ActionType = 
  | { type: 'LOGIN'; payload: { username: string } }
  | { type: 'LOGOUT' }
  | { type: 'UPDATE_PROFILE'; payload: { name: string; avatar: string } };

// Extract actions that have a payload
type ActionsWithPayload<A> = A extends { payload: any } ? A : never;

// Extract specific action by type
type ExtractAction<A, T extends string> = 
  A extends { type: T } ? A : never;

type PayloadActions = ActionsWithPayload<ActionType>;
// { type: 'LOGIN'; payload: { username: string } } | 
// { type: 'UPDATE_PROFILE'; payload: { name: string; avatar: string } }

type LoginAction = ExtractAction<ActionType, 'LOGIN'>;
// { type: 'LOGIN'; payload: { username: string } }

// Practical usage in handlers
function handleAction<T extends ActionType['type']>(
  action: ExtractAction<ActionType, T>
) {
  // action is now narrowed to the specific type!
}

🎯 Key Principle: Use conditional types to encode invariants at the type level. If two properties must always appear together, make the types express that relationship.

Common Pitfalls and Debugging Strategies

⚠️ Common Mistake 1: Infinite Type Recursion ⚠️

Conditional types can create infinite recursion when they reference themselves without a proper base case:

// ❌ Wrong: No base case for primitives
type DeepReadonly<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};

// ✅ Correct: Check if T is an object first
type DeepReadonly<T> = T extends object
  ? T extends Function  // Don't recurse into functions
    ? T
    : { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

💡 Remember: Always provide a base case in recursive conditional types. Check for primitives, functions, or specific types before recursing.

⚠️ Common Mistake 2: Overly Complex Conditions ⚠️

Nesting too many conditions creates unreadable and unmaintainable types:

// ❌ Wrong: Too complex to understand
type ComplexType<T> = T extends Array<infer U>
  ? U extends object
    ? U extends { id: any }
      ? U extends { name: string }
        ? "NamedEntity"
        : "EntityWithId"
      : "ObjectArray"
    : "PrimitiveArray"
  : "NotArray";

// ✅ Correct: Break into smaller, named types
type IsArray<T> = T extends Array<infer U> ? U : never;
type HasId<T> = T extends { id: any } ? true : false;
type HasName<T> = T extends { name: string } ? true : false;

type EntityType<T> = 
  HasId<T> extends true
    ? HasName<T> extends true
      ? "NamedEntity"
      : "EntityWithId"
    : "BasicObject";

🧠 Mnemonic: "KISS your types" - Keep It Simple, Stupid. If a conditional type is hard to read, break it down.

Debugging Strategy:

🔧 Use type assertions to inspect intermediate results:

// Add a debug helper
type Debug<T> = { debug: T };

type MyComplexType<T> = T extends Array<infer U>
  ? Debug<U> // Hover over this to see what U is!
  : never;

Performance Considerations

Conditional types can impact compilation time, especially in large codebases. Here's what to watch for:

🤔 Did you know? The TypeScript compiler has a recursion depth limit (around 50 levels) to prevent infinite loops. Hitting this limit results in a "Type instantiation is excessively deep" error.

Performance Guidelines:

🎯 Avoid unnecessary distributivity - If you don't need conditional types to distribute over unions, wrap the checked type in a tuple:

// Distributes over unions (slower for large unions)
type IsString<T> = T extends string ? true : false;

// Doesn't distribute (faster)
type IsString<T> = [T] extends [string] ? true : false;

🎯 Cache complex type computations - Use type aliases to avoid recalculating:

// Recalculates every time
function process<T>(value: DeepReadonly<ComplexTransform<T>>) {}

// Calculated once
type ProcessedType<T> = DeepReadonly<ComplexTransform<T>>;
function process<T>(value: ProcessedType<T>) {}

Best Practices for Maintainable Types

Naming Conventions:

📚 Descriptive names that explain what the type does:

  • ExtractFunctions<T> (clear intent)
  • EF<T> (requires explanation)

📚 Consistent prefixes for type operations:

  • Extract... for filtering types
  • Require... for making properties required
  • Deep... for recursive operations
  • If... for conditional selections

Documentation:

Always document complex conditional types with examples:

/**
 * Extracts all properties from T that are assignable to type U.
 * 
 * @example
 * interface User { name: string; age: number; active: boolean; }
 * type Strings = PropertiesOfType<User, string>; // "name"
 * type Numbers = PropertiesOfType<User, number>; // "age"
 */
type PropertiesOfType<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never
}[keyof T];

📋 Quick Reference Card:

Pattern When to Use Example
🔧 Extract Properties Filter object properties by type type Strings<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T]
🎯 Type Narrowing Refine union types type Extract<T, U> = T extends U ? T : never
🔄 Deep Transformation Recursive type changes type DeepReadonly<T> = T extends object ? {...} : T
⚡ Non-Distributive Check whole type at once [T] extends [U] ? X : Y

Summary

You've now mastered practical conditional type patterns that go far beyond basic type checking. You understand:

✅ How to build reusable utility types that encode your domain logic at the type level

Type narrowing patterns for working with complex unions and discriminated types

✅ Common pitfalls like infinite recursion and overly complex conditions, plus strategies to avoid them

Performance considerations and when conditional types might impact compilation time

Best practices for naming, documenting, and maintaining type-level code

⚠️ Critical Points to Remember:

  • Always provide a base case in recursive conditional types
  • Break complex conditions into smaller, named types
  • Use non-distributive checks [T] extends [U] when you don't need distribution
  • Document your utility types with examples

Practical Applications and Next Steps

🔧 Build a type-safe API client - Use conditional types to infer response types from endpoint definitions

🔧 Create form validation types - Design types that ensure validation rules match your data structures

🔧 Implement advanced state management - Use discriminated unions with conditional type helpers to build type-safe Redux or Zustand stores

Next Steps:

  1. Explore template literal types - Combine with conditional types for string manipulation at the type level
  2. Study TypeScript's built-in utilities - Read the source code of Pick, Omit, and Extract to see patterns in action
  3. Practice with real projects - Apply these patterns to refactor existing code, starting with simple utility types and building up complexity

Conditional types are one of TypeScript's most powerful features. With these patterns and best practices, you're equipped to write sophisticated, maintainable type-level code that makes your applications more robust and self-documenting.