Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Conditional Types: Type-Level If Statements

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Type Aliases vs Interfaces
TypeScript
7m
Jan 17, 2026

Type Aliases vs Interfaces

TypeScript offers both type aliases and interfaces to define types. This guide explains their differences, when to use each, and how to leverage composition patterns for flexible and maintainable code.

#TypeScript#Type Aliases+3
Type Assertions and Type Casting
TypeScript
6m
Dec 13, 2025

Type Assertions and Type Casting

Type assertions tell TypeScript to trust your judgment about a value's type. While they're powerful tools for working with DOM elements, API responses, and type narrowing, they can also bypass type safety if misused. This guide covers the as syntax, angle-bracket syntax, when to use assertions safely, and the crucial difference between TypeScript assertions and runtime type casting. Learn to wield this tool responsibly while maintaining type safety in your applications.

#TypeScript#Type Assertions+4
The any, unknown, and never Types
TypeScript
8m
Dec 8, 2025

The any, unknown, and never Types

TypeScript has three special types that handle edge cases: any (the escape hatch), unknown (the type-safe alternative), and never (for impossible values). Understanding when and how to use these types is crucial for writing safe yet flexible TypeScript code. This guide covers the differences between these types, shows you how to work with unknown safely, and demonstrates how never enables exhaustive type checking and prevents runtime errors. Learn to use these types effectively while maintaining type safety.

#TypeScript#Type System+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Conditional Types
  • Basic Conditional Type Syntax
    • Distributive Conditional Types
  • Filtering Union Members
  • Nested Conditional Types
  • Real-World Patterns
  • Best Practices
  • What Is Next
  • Key Takeaways
TypeScript

Conditional Types: Type-Level If Statements

June 28, 2026•7 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript conditional types for type-level if statements and type branching
Conditional Types: Type-Level If Statements

Conditional types are TypeScript's way of writing if-else logic at the type level. Just like a ternary expression picks between two values based on a condition, a conditional type picks between two types based on whether a type relationship holds. They're what makes utility types like NonNullable, ReturnType, and Extract possible. In this post, you'll learn how conditional types work, how distribution over union types makes them powerful, and how to use them to build your own type-level utilities.

What You'll Learn

Conditional types use the syntax T extends U ? X : Y — if T is assignable to U, the type resolves to X, otherwise Y. They're evaluated lazily when TypeScript has enough information to make the decision.

What Are Conditional Types

Conditional types let you express a choice between two types based on a type-level condition. The condition is an extends check: does the left-hand type satisfy the right-hand type's constraint?

type IsString<T> = T extends string ? true : false;
 
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true — string literal extends string

The extends here doesn't mean inheritance. It means "is assignable to." If T can be used wherever U is expected, the condition is true.

Basic Conditional Type Syntax

The pattern is always T extends U ? TrueType : FalseType. You can use any types on either side:

type Flatten<T> = T extends Array<infer Item> ? Item : T;
 
type Str = Flatten<string[]>; // string
type Num = Flatten<number[]>; // number
type Raw = Flatten<boolean>; // boolean — not an array, returns T itself

This Flatten type unwraps an array type to its element type. If T is not an array, it returns T unchanged. The infer keyword is covered in the next post — for now notice how conditional types can extract type information.

Distributive Conditional Types

When you apply a conditional type to a union type, TypeScript distributes the condition over each member of the union:

type IsString<T> = T extends string ? "yes" : "no";
 
type Result = IsString<string | number | boolean>;
// Distributes to:
// IsString<string> | IsString<number> | IsString<boolean>
// = "yes" | "no" | "no"
// = "yes" | "no"

This distribution only happens when T is a naked type parameter — a bare type variable, not wrapped in anything. If you wrap T, distribution is suppressed:

type IsStringNonDistributive<T> = [T] extends [string] ? "yes" : "no";
 
type Result1 = IsStringNonDistributive<string | number>; // "no"
// [string | number] extends [string] — the whole union is checked at once

Distribution Gotcha

Distributive behavior is the default for naked type parameters. Wrapping in a tuple [T] suppresses it. This matters when you want to check whether an entire union satisfies a condition, rather than filtering it member-by-member.

Filtering Union Members

Distribution makes conditional types a powerful filter for union types. You can remove types from a union based on any condition:

// Keep only types that extend a given constraint
type Filter<T, U> = T extends U ? T : never;
 
// never disappears from unions — it's the empty type
type OnlyStrings = Filter<string | number | boolean | null, string>;
// = string
 
type OnlyObjects = Filter<string | object | number | null, object>;
// = object

never is the identity element of union types — adding never to a union removes it, just like adding 0 to a sum. This is how Extract and Exclude work in TypeScript's standard library:

// From TypeScript's lib.es5.d.ts
type Extract<T, U> = T extends U ? T : never;
type Exclude<T, U> = T extends U ? never : T;
 
type StringOrNumber = Extract<string | number | boolean, string | number>;
// = string | number
 
type NoNull = Exclude<string | number | null | undefined, null | undefined>;
// = string | number

Nested Conditional Types

You can chain conditional types just like ternary expressions for multi-branch logic:

type TypeName<T> = T extends string
  ? "string"
  : T extends number
    ? "number"
    : T extends boolean
      ? "boolean"
      : T extends null
        ? "null"
        : T extends undefined
          ? "undefined"
          : T extends Function
            ? "function"
            : "object";
 
type T1 = TypeName<string>; // "string"
type T2 = TypeName<42>; // "number"
type T3 = TypeName<() => void>; // "function"
type T4 = TypeName<{ a: 1 }>; // "object"

Deep nesting can get hard to read. When you have more than three branches, consider whether a mapped type approach might be clearer.

Formatting Tip

Format nested conditional types with aligned indentation (like the example above). TypeScript will still parse it correctly, and your teammates will thank you.

Real-World Patterns

Here's a practical conditional type that flattens nested Promise types — similar to how Awaited works in TypeScript's standard library:

// Recursively unwrap Promise types
type Awaited<T> = T extends null | undefined
  ? T
  : T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
    ? F extends (value: infer V, ...args: infer _) => any
      ? Awaited<V>
      : never
    : T;
 
// Simpler version for most use cases
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<Promise<Promise<number>>>; // Promise<number> — one level
type C = UnwrapPromise<boolean>; // boolean

Another common pattern is building an API that returns different types based on input:

type ApiResponse<T> = T extends "json"
  ? { data: unknown; headers: Headers }
  : T extends "text"
    ? string
    : T extends "blob"
      ? Blob
      : never;
 
function fetchData<T extends "json" | "text" | "blob">(
  url: string,
  format: T
): Promise<ApiResponse<T>> {
  // implementation
  return fetch(url).then((r) => r[format]() as any);
}
 
// TypeScript knows exactly what type you get back
const result = await fetchData("/api/data", "json");
//    ^? { data: unknown; headers: Headers }

Type-Safe APIs

Conditional types let you encode the relationship between input and output at the type level. The caller gets precise return types without overloads or casting.

Best Practices

Use conditional types when overloads become unwieldy. If a function has 3+ overloads that follow a pattern, a conditional type may express that pattern more cleanly.

Prefer simple over clever. A conditional type that requires a comment to explain is a sign to reconsider. If you can express the same constraint with generics + constraints, do that instead.

Avoid overly deep nesting. More than three levels of nested conditional types is a maintenance burden. Break them into named intermediate types.

Test your conditional types. Write a few type assertions to verify your conditional type works as expected across edge cases:

type Assert<T extends true> = T;
type Equals<A, B> = A extends B ? (B extends A ? true : false) : false;
 
type Test1 = Assert<Equals<Filter<string | number, string>, string>>;
type Test2 = Assert<Equals<Filter<boolean | null, never>, never>>;

Anti-Pattern

Don't use conditional types as a workaround for missing type information. If you find yourself writing T extends any ? ... : never just to "activate" distribution, step back and reconsider the overall design.

What Is Next

Now that you understand how conditional types choose between two types, the next post dives into the infer keyword — the tool that lets you extract types from within a conditional check. infer is what makes patterns like ReturnType<F> and Parameters<F> possible.

Key Takeaways

  • Conditional types use T extends U ? X : Y to choose between two types based on assignability
  • Applied to union types with a naked type parameter, conditional types distribute over each member
  • Wrapping in [T] suppresses distribution when you want to check the whole union
  • never acts as the empty type in unions, making it the natural result for "filter out" branches
  • Standard utility types like Extract, Exclude, and NonNullable are all conditional types

Phase 5 Begins

Welcome to the type system mastery phase. The tools you build from here — conditional types, mapped types, template literal types — are what separate TypeScript power users from everyone else.