TypeScript can describe relationships that ordinary unions and interfaces cannot express. A function may return one shape for a string input and another for an array. An API client may associate each endpoint name with a distinct response and error. A library may need to extract the resolved value from any promise-like type without knowing that value in advance.

Conditional types make these relationships programmable. Their basic form resembles a ternary expression, but it runs in the type system:

ts
type MessageOf<Value> = Value extends { message: unknown }
  ? Value['message']
  : never;

type EmailMessage = MessageOf<{ message: string; sentAt: Date }>;
// string

type NoMessage = MessageOf<{ code: number }>;
// never

The power is real, but so is the cost. Conditional types interact with unions, any, never, inference, mapped types, and compiler recursion in ways that are easy to misread. The goal is not to perform the most elaborate type gymnastics possible. It is to encode useful guarantees while leaving code that the next engineer can still understand.

A mental model for type-level branching

Read Value extends Constraint ? Yes : No as a question: “Is every value represented by Value assignable to Constraint?” This is assignability, not class inheritance. A structural object type extends another when it provides the required properties, even if the two declarations have no explicit relationship.

ts
type JsonScalar<Value> = Value extends string | number | boolean | null
  ? Value
  : never;

type A = JsonScalar<'ready'>; // 'ready'
type B = JsonScalar<Date>; // never

When the checked type is a generic parameter, the compiler often postpones the decision until that parameter is known. This lets a reusable alias preserve a relationship instead of collapsing everything into a broad union.

ts
type LabelFor<Value> = Value extends number
  ? { kind: 'numeric'; value: Value }
  : { kind: 'text'; value: string };

declare function makeLabel<Value extends string | number>(
  value: Value,
): LabelFor<Value>;

const count = makeLabel(42);
// { kind: 'numeric'; value: 42 }

const caption = makeLabel('forty-two');
// { kind: 'text'; value: string }

This pattern can replace a long overload list when one generic rule explains the relationship. Overloads remain better when the public API has a few intentionally distinct call signatures or when the conditional return type would force an implementation to use assertions everywhere.

Tool Best fit Main tradeoff
Union A value may be one of several known shapes Does not preserve input-output correlation by itself
Function overloads A small set of distinct call signatures Repetition grows as combinations increase
Conditional type One type depends systematically on another Union distribution and edge cases require care
Mapped type Transform every property in an object type Complex key remapping can hide intent
Runtime schema Validate untrusted data that actually exists Adds runtime work and a schema dependency
flowchart TD A[Input type enters conditional] --> B{Naked generic parameter?} B -->|Union| C[Evaluate each union member] B -->|No| D[Evaluate the type once] C --> E{Assignable to constraint?} D --> E E -->|Yes| F[Choose true branch and bind infer variables] E -->|No| G[Choose false branch] F --> H[Combine resulting type] G --> H

Note

Conditional types are erased with every other TypeScript type. They influence checking and editor tooling; they do not emit a JavaScript branch or inspect a runtime value.

Extracting structure with infer

Inside the true branch of a conditional type, infer introduces a type variable for a part of the matched structure. It is pattern matching for types: describe the outer shape, give the unknown part a name, and use that name in the result.

ts
type ElementOf<Value> = Value extends readonly (infer Element)[]
  ? Element
  : never;

type Id = ElementOf<readonly string[]>; // string
type Mixed = ElementOf<[string, number, boolean]>;
// string | number | boolean

type ReturnOf<Value> = Value extends (...args: never[]) => infer Result
  ? Result
  : never;

type FactoryResult = ReturnOf<() => { id: string; active: boolean }>;
// { id: string; active: boolean }

The standard library already provides ReturnType, Parameters, Awaited, and several related utilities. Prefer those names when their semantics fit. Custom extraction types are useful when the domain structure is more specific or when the fallback must differ.

A recursive conditional can unwrap nested promises:

ts
type DeepAwaited<Value> = Value extends PromiseLike<infer Resolved>
  ? DeepAwaited<Resolved>
  : Value;

type Loaded = DeepAwaited<Promise<Promise<{ id: string }>>>;
// { id: string }

Multiple infer variables can capture correlated pieces at once. Template literal types make the same idea work on strings:

ts
type RouteParts<Path> = Path extends `${infer Resource}/${infer Id}`
  ? { resource: Resource; id: Id }
  : never;

type UserRoute = RouteParts<'users/42'>;
// { resource: 'users'; id: '42' }

type FunctionParts<Value> = Value extends (
  ...args: infer Args
) => infer Result
  ? { args: Args; result: Result }
  : never;

type SaveParts = FunctionParts<(id: string, force?: boolean) => Promise<void>>;
// { args: [id: string, force?: boolean]; result: Promise<void> }

Inference is constrained by the pattern. If Value does not match, no variable is bound and the false branch wins. An infer variable can also have a constraint, which keeps the result precise without a second conditional:

ts
type FirstString<Value> = Value extends readonly [
  infer First extends string,
  ...unknown[],
]
  ? First
  : never;

type Command = FirstString<['build', '--watch']>; // 'build'
type NotACommand = FirstString<[404, 'missing']>; // never

Distributivity, tuple wrappers, and sharp edges

A conditional type distributes over a union when the checked side is a naked type parameter. “Naked” means the parameter appears directly to the left of extends, rather than inside an array, tuple, promise, or other wrapper.

ts
type ToArray<Value> = Value extends unknown ? Value[] : never;

type Distributed = ToArray<string | number>;
// string[] | number[]

The compiler evaluates ToArray<string> and ToArray<number> separately, then unions the results. This behavior powers filters such as Exclude:

ts
type OnlyStrings<Value> = Value extends string ? Value : never;

type Names = OnlyStrings<'Ada' | 'Linus' | 404>;
// 'Ada' | 'Linus'

Sometimes the question concerns the union as a whole. Wrap both sides in one-element tuples to prevent distribution:

ts
type ToArrayTogether<Value> = [Value] extends [unknown] ? Value[] : never;

type NotDistributed = ToArrayTogether<string | number>;
// (string | number)[]

type AllStrings<Value> = [Value] extends [string] ? true : false;

type Yes = AllStrings<'Ada' | 'Linus'>; // true
type No = AllStrings<'Ada' | 404>; // false

The wrapper does not merely change syntax. It changes the question from “Does each member satisfy this constraint?” to “Is the complete union assignable to this constraint?”

Three special types deserve explicit treatment:

  • never is the empty union. A distributive conditional applied to never has no member to evaluate, so the result is never, not the true or false branch.
  • any opts out of normal safety. In many conditional types it causes both branches to contribute, and it can silently contaminate later inference.
  • unknown is a safe top type. Everything is assignable to it, but it cannot be used as a more specific value until narrowed.
ts
type IsNever<Value> = [Value] extends [never] ? true : false;
type IsAny<Value> = 0 extends 1 & Value ? true : false;

type N = IsNever<never>; // true
type A = IsAny<any>; // true
type U = IsAny<unknown>; // false

Warning

Avoid building a large utility library around clever IsAny-style probes. They are occasionally necessary at library boundaries, but application models are usually clearer when any is prohibited and external values enter as unknown.

Combining conditional and mapped types

Mapped types iterate over keys; conditional types decide what to do with each key or value. Together they can select properties, change modifiers, and rename keys while preserving the source model.

ts
type NullableKeys<Model> = {
  [Key in keyof Model]-?: null extends Model[Key] ? Key : never;
}[keyof Model];

type User = {
  id: string;
  displayName: string;
  avatarUrl: string | null;
  deletedAt?: Date | null;
};

type NullableUserKey = NullableKeys<User>;
// 'avatarUrl' | 'deletedAt'

The inner mapped type creates an object whose values are either a key or never; indexing it with keyof Model collects those values into a union. Because never disappears from unions, only matching keys remain.

Key remapping can then build a new interface:

ts
type EventHandlers<Events> = {
  [Key in keyof Events as Key extends string
    ? `on${Capitalize<Key>}`
    : never]: (payload: Events[Key]) => void;
};

type DomainEvents = {
  saved: { id: string };
  failed: { reason: string; retryable: boolean };
};

type Handlers = EventHandlers<DomainEvents>;
// {
//   onSaved: (payload: { id: string }) => void;
//   onFailed: (payload: { reason: string; retryable: boolean }) => void;
// }

Recursive transforms need a stopping rule. Without one, primitives and built-in objects may be expanded into meaningless property maps, or the compiler may report that type instantiation is excessively deep.

ts
type Atomic = string | number | boolean | bigint | symbol | null | undefined;

type DeepReadonly<Value> = Value extends Atomic | Date | RegExp | Function
  ? Value
  : Value extends readonly (infer Element)[]
    ? readonly DeepReadonly<Element>[]
    : Value extends object
      ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
      : Value;

This version stops at common atomic and built-in values, handles arrays before general objects, and only then maps object properties. It is still a policy, not a universal truth: Map, Set, branded values, tuples, and domain classes may require different preservation rules.

Compiler recursion is finite. Deeply nested JSON, recursive template parsing, or unions that multiply at every step can hit the instantiation limit and slow editor feedback long before an error appears. Add a depth budget when input depth is unbounded:

ts
type Previous = [never, 0, 1, 2, 3, 4, 5];

type JsonLike<Value, Depth extends number = 5> = Depth extends 0
  ? unknown
  : Value extends Atomic
    ? Value
    : Value extends readonly (infer Element)[]
      ? JsonLike<Element, Previous[Depth]>[]
      : Value extends object
        ? { [Key in keyof Value]: JsonLike<Value[Key], Previous[Depth]> }
        : never;

Tip

Name intermediate transformations and cap recursion deliberately. A slightly longer chain of KeysMatching, PayloadOf, and ResultFor aliases is easier to debug than one compressed type that does everything.

Practical API modeling without imaginary safety

Conditional types are most valuable when they preserve relationships at an API boundary. Consider a client with endpoint-specific success and failure payloads:

ts
type Endpoints = {
  getUser: {
    response: { id: string; name: string };
    error: { code: 'NOT_FOUND' };
  };
  listProjects: {
    response: { items: { id: string; title: string }[]; next: string | null };
    error: { code: 'UNAUTHORIZED' | 'RATE_LIMITED'; retryAfter?: number };
  };
};

type EndpointName = keyof Endpoints;

type ApiResult<Name extends EndpointName> =
  | { ok: true; data: Endpoints[Name]['response'] }
  | { ok: false; error: Endpoints[Name]['error'] };

type ErrorCode<Name extends EndpointName> = Endpoints[Name]['error'] extends {
  code: infer Code extends string;
}
  ? Code
  : never;

type ProjectError = ErrorCode<'listProjects'>;
// 'UNAUTHORIZED' | 'RATE_LIMITED'

The endpoint name now selects the exact result. A mapped type can derive a complete client without repeating method signatures:

ts
type ApiClient = {
  [Name in EndpointName]: () => Promise<ApiResult<Name>>;
};

declare const client: ApiClient;

const result = await client.getUser();
if (result.ok) {
  result.data.name; // string
} else {
  result.error.code; // 'NOT_FOUND'
}

That correlation is a compile-time guarantee only. fetch().json() is not validated merely because a return annotation claims it is. Network data, local storage, environment variables, and user input all cross runtime trust boundaries. Parse them as unknown with a schema or explicit validator before returning the typed result.

ts
import { parseApiResult } from './schema.js';

const routes: Record<EndpointName, string> = {
  getUser: '/api/users/current',
  listProjects: '/api/projects',
};

async function request<Name extends EndpointName>(
  name: Name,
): Promise<ApiResult<Name>> {
  const response = await fetch(routes[name]);
  const input: unknown = await response.json();

  return parseApiResult(name, response.ok, input);
}

The validator behind parseApiResult must check discriminants, required fields, arrays, and endpoint-specific payloads at runtime. Its generic signature can preserve the same relationship, but the implementation earns that type through validation rather than an as ApiResult<Name> assertion.

Conditional types become less helpful when a reader must simulate several distributed transformations to understand one field. Export domain names such as ApiResult<'getUser'>, keep low-level machinery private, and test public types with positive and negative compile-time cases. If an overload or an explicit interface communicates the contract faster, use it. Type-level abstraction should remove repetition without removing meaning.

Takeaways

  • A conditional type encodes an assignability question and preserves systematic relationships between types.
  • infer extracts a type from a matched structure; built-in utilities should be preferred when their behavior already fits.
  • Naked generic parameters distribute over unions. Tuple wrappers disable distribution when the whole union must be tested.
  • never, any, and unknown have intentionally different semantics. Treat unknown as the default for untrusted values and keep any exceptional.
  • Conditional and mapped types work well together, but recursive transforms need explicit base cases and sometimes a depth budget.
  • API types improve callers only after runtime validation establishes that external data matches the declared model.
  • Readability is part of correctness. Name intermediate types, expose domain concepts, and stop when an explicit type is easier to understand.