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

Utility Types Part 2: Readonly and Record
TypeScript
10m
Aug 23, 2026

Utility Types Part 2: Readonly and Record

Two utility types that occupy opposite ends of the structural transformation space: Readonly locks an object's properties against mutation at the type level, while Record builds typed dictionary structures from a set of keys and a value type. Neither modifies optionality — they operate on shape and mutability instead.

#Utility Types#Readonly+5
Utility Types Part 1: Partial and Required
TypeScript
9m
Aug 16, 2026

Utility Types Part 1: Partial and Required

TypeScript ships with a set of built-in generic types — called utility types — that perform common type transformations on existing types. Rather than manually rewriting every property of an object type, you pass your type to a utility type and receive a transformed version. Partial and Required are the two complementary utility types that control whether every property in an object is optional or required.

#Utility Types#Partial+5
Recursive Types in TypeScript
TypeScript
10m
Aug 9, 2026

Recursive Types in TypeScript

Some data structures are inherently self-referential — a tree node that contains other tree nodes, a JSON value that can itself be a JSON object containing more JSON values, a linked list whose nodes point to other nodes of the same type. TypeScript's recursive types handle all of these by allowing a type alias to reference itself in its own definition.

#Recursive Types#TypeScript Advanced Types+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.