Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 046 / 100

Conditional types and infer: what developers should know

Types that make decisions.

Expona Admin9 min read

Hook

TypeScript's conditional types and infer keyword unlock powerful type transformations that go far beyond simple type aliases. They let your types make decisions, extract information from other types, and build sophisticated abstractions that would otherwise require runtime logic. Understanding conditional types and infer is what developers should know to write libraries that feel magical—and to avoid the pitfalls that make maintainers regret ever reading your type definitions.

Conditional types — T extends U ? X : Y

At their core, conditional types bring ternary logic into the type system. The syntax is straightforward: T extends U ? X : Y means "if type T is assignable to type U, resolve to type X; otherwise resolve to type Y."

Consider a real-world scenario: your API client needs to return different response shapes depending on the HTTP status code. With conditional types, you can encode that logic in the type system itself.

typescript
type ApiResponse<T, Status> = Status extends 200
  ? { success: true; data: T }
  : Status extends 201
  ? { success: true; data: T; created: true }
  : Status extends 400
  ? { success: false; error: string }
  : never;

// Usage
type SuccessResponse = ApiResponse<{ id: number }, 200>;
// { success: true; data: { id: number } }

type CreatedResponse = ApiResponse<{ id: number }, 201>;
// { success: true; data: { id: number }; created: true }

type ErrorResponse = ApiResponse<{ id: number }, 400>;
// { success: false; error: string }

Without conditional types, you'd either need function overloads or a massive union type that accepts all status codes at once, losing type safety along the way. Conditional types let the type system mirror your actual logic.

Another practical example from event handling: extracting the event type from a handler function and ensuring the listener matches.

typescript
type EventHandler<T> = T extends (event: infer E) => void
  ? E
  : never;

function on<H extends (event: any) => void>(
  handler: H,
  listener: EventHandler<H>
) {
  // listener is now typed as the event parameter of handler
}

on((event: MouseEvent) => {}, new MouseEvent('click'));
// listener is narrowed to MouseEvent type

This pattern ensures that if someone passes a handler expecting a MouseEvent, the listener argument is validated to be a MouseEvent at compile time.

infer — Extract types from other types

The infer keyword lets you capture and extract types from complex type structures. It acts as a placeholder that TypeScript fills in based on what it sees in the actual type being checked.

The most common use case is extracting the element type from an array:

typescript
type ElementOf<T> =
  T extends (infer E)[] ? E : never;

type X = ElementOf<string[]>; // string
type Y = ElementOf<number[]>; // number
type Z = ElementOf<string>; // never

When you pass string[] to ElementOf, TypeScript unifies the pattern (infer E)[] with string[], and infers that E must be string.

In a real production system handling Promise-based API calls, you often need to extract the resolved type from a Promise:

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

async function fetchUser(id: number) {
  return { id, name: 'Alice' };
}

type UserData = Awaited<ReturnType<typeof fetchUser>>;
// { id: number; name: string }

This is so useful that TypeScript includes a built-in Awaited utility type. But the principle extends to any nested structure.

Here's a practical example from form validation libraries: extracting the validated value type from a validator function:

typescript
type Validator<T> = (value: unknown) => T | null;

type ValidatedType<V> =
  V extends Validator<infer T> ? T : never;

const emailValidator: Validator<string> = (value) =>
  typeof value === 'string' && value.includes('@')
    ? value
    : null;

type EmailType = ValidatedType<typeof emailValidator>;
// string

Now your form library can automatically infer what type of data a validator produces, and compose multiple validators while maintaining perfect type safety.

Distributive behavior — Conditional types distribute over unions

Conditional types have a special behavior when applied to union types: they distribute across each member of the union. This is powerful but often surprising.

Here's what happens:

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

type A = Flatten<string[]>; // string
type B = Flatten<string[] | number[]>;
// Distributes as: Flatten<string[]> | Flatten<number[]>
// Result: string | number

Without distributive behavior, B would try to match the entire union against a single array pattern and return string[] | number[] (wrong). Instead, TypeScript applies the conditional type to each union member separately.

This is extremely useful for filtering union types. Imagine a type that removes null from a union:

typescript
type NonNull<T> =
  T extends null ? never : T;

type Result = NonNull<string | null | number | undefined>;
// Distributes across each member
// NonNull<string> | NonNull<null> | NonNull<number> | NonNull<undefined>
// string | never | number | undefined
// Final result: string | number | undefined

But distributive behavior can bite you if you're not careful. Sometimes you want to match the entire structure, not distribute:

typescript
// Problem: This distributes when we don't want it to
type IsArray<T> =
  T extends Array<any> ? true : false;

type Check1 = IsArray<string[]>; // true (correct)
type Check2 = IsArray<string[] | number>; 
// Distributes to: IsArray<string[]> | IsArray<number>
// Result: true | false (not what we want!)

// Solution: Wrap in a tuple to prevent distribution
type IsArrayFixed<T> =
  [T] extends [Array<any>] ? true : false;

type Check3 = IsArrayFixed<string[] | number>; // false (correct)

When you wrap a type in brackets like [T], TypeScript treats it as a concrete structure and doesn't distribute. This is a critical technique for controlling when distributive behavior applies.

Use sparingly — Great in libraries, confusing in app code

Conditional types are powerful tools, but with great power comes great responsibility. We've seen production teams where conditional types in application code became a maintenance nightmare.

Libraries benefit tremendously from conditional types because they formalize invariants that users rely on:

typescript
// In a database ORM library: excellent use of conditionals
type QueryResult<Model, Options> =
  Options extends { raw: true }
    ? Model[]
    : Options extends { single: true }
    ? Model | null
    : Model[];

// Users get exactly what they ask for
const users = await db.query(User, { raw: true });
// users is User[]

const first = await db.query(User, { single: true });
// first is User | null

This is library code because it's exposed to external developers and they benefit from the type safety without seeing the implementation.

Application code, however, should use them sparingly. Here's why:

  • Debugging is harder. When a conditional type resolves unexpectedly, the error messages are cryptic and the type chain is hard to follow.
  • Team velocity suffers. Junior developers slow down trying to understand nested conditionals. You're building complexity that makes refactoring painful.
  • The simpler approach usually works. Method overloads, function overloads, or explicit type branches are often clearer for application-specific logic.

A real example from a trading platform we worked on: the team initially used complex conditionals to infer trade direction and validation rules. After six months, they found that explicit union types and discriminated unions were far easier to reason about, test, and maintain. Conditional types were overkill for domain logic that didn't need to be parametrized in the same way a library does.

The rule we follow: Use conditional types to solve problems for users of your code, not for the code itself.

Common mistakes

  • Forgetting about distributivity. A conditional type applied to a union will almost certainly distribute when you don't expect it. Always ask: "Is this a union?" If yes, do I need to wrap it to prevent distribution?
  • Over-constraining with infer. Using infer in places where a simpler type extraction would work. If you can use a lookup type like T['property'], do that instead. It's clearer.
  • Making conditionals too nested. If you have three or more levels of nesting, you're probably solving a library problem in application code. Step back and reconsider the design.
  • Ignoring error messages. Conditional type errors are hard to parse. Use tools like TypeScript Playground to hover and inspect intermediate types instead of guessing.
  • Not documenting the intent. A conditional type without a comment is a code smell. Future maintainers (including yourself) won't know why you chose this approach over simpler alternatives.

Key takeaways

  • Conditional types bring ternary logic to the type system, letting types make decisions based on what they're applied to.
  • infer extracts and captures types from other types, unlocking the ability to decompose complex structures like arrays, promises, and functions.
  • Conditional types distribute over unions by default, which is powerful for filtering and transforming union types—but requires the tuple wrapping technique to disable when needed.
  • Use conditional types in libraries to formalize invariants and provide better developer experience, but keep them out of application code unless the complexity is truly justified.
  • When debugging conditional types, use the TypeScript Playground to inspect intermediate types. Error messages alone are rarely enough.
  • Always document why you chose a conditional type over simpler alternatives. Future maintainers will thank you.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles