Mid-level 6 min · April 12, 2026

TypeScript Conditional Types — Why Unions Collapse to Never

Distributive conditionals silently widen unions when T extends never is bare.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Conditional types select between two types based on a condition: T extends U ? X : Y
  • Template literal types construct string types from unions and interpolations — they are the type-level equivalent of string templates
  • infer extracts types from within extends clauses — ReturnType is built on infer
  • Distributive conditional types iterate over union members automatically — one of TypeScript's least understood behaviors
  • Real-world use case: type-safe event emitters, API route handlers, and CSS-in-JS libraries rely on these features daily
  • Biggest mistake: confusing extends in conditional types (structural compatibility) with extends in classes (inheritance)
✦ Definition~90s read
What is TypeScript Conditional Types?

Conditional types are TypeScript's type-level equivalent of an if/else statement, letting you select one type over another based on a condition that evaluates against the input type. They exist because real-world TypeScript code often needs to model branching logic at the type level — for example, extracting the return type from a function signature, or narrowing a union based on a structural check.

Conditional types let TypeScript pick one type or another based on a condition — like an if/else for types.

Without them, you'd be stuck with overloads or type assertions that leak runtime assumptions into your types. The infer keyword extends this by letting you declare a type variable within a conditional branch, enabling pattern matching against complex structures like function parameters or promise internals.

Template literal types, introduced in TypeScript 4.1, complement conditionals by allowing string manipulation at the type level — think ${'get' | 'set'}${Capitalize<K>} to generate method names from a union of keys. Together, these features form the backbone of advanced type transformations used in libraries like Prisma (for deep path queries), tRPC (for type-safe API clients), and Zod (for schema inference).

The article's focus on why unions collapse to never is a critical gotcha: when a conditional type distributes over a union, each member is tested independently, and if none match the condition, the result is never — a behavior that often surprises developers who expect a union of the non-matching members instead.

Plain-English First

Conditional types let TypeScript pick one type or another based on a condition — like an if/else for types. Template literal types let you build string types by combining other types — like string interpolation, but at the type level. Together, they enable TypeScript to infer and enforce patterns that would otherwise require manual type annotations. These features power libraries like Zod, tRPC, and styled-components.

Conditional types and template literal types are TypeScript's most powerful type-level features. They enable patterns that eliminate boilerplate, enforce contracts at compile time, and make libraries like tRPC, Zod, and styled-components possible.

These features shipped in TypeScript 4.1 (template literals) and were strengthened in 4.5+ for recursive conditionals — you'll need TS 4.1 or later to use the patterns in this article.

Most engineers stop at generics and interfaces. The gap between 'knows TypeScript' and 'writes library-grade types' is conditional types, infer, and template literals. These features let you derive types from runtime structures, extract return types from functions, and construct string patterns that the compiler validates.

The misconception: these features are academic. They are not. Every major TypeScript codebase uses conditional types — through ReturnType, Exclude, Extract, Parameters, and Awaited. Understanding the primitives lets you build your own type utilities instead of relying on the standard library alone.

Why Conditional Types Collapse Unions to Never

Conditional types in TypeScript select one of two types based on a condition that checks assignability. The core mechanic is T extends U ? X : Y. When T is a union, TypeScript distributes the conditional over each union member — unless you wrap T in a tuple [T] to prevent distribution. This distributive behavior is why string | number extends string ? true : false evaluates to boolean (i.e., true | false), not false. The key property: distribution happens only with a bare type parameter. If you use T extends U where T is a concrete union, you get a single result. In practice, this means conditional types can map over unions, filter types, or unexpectedly produce never when all branches yield never. Use distribution to transform unions (e.g., extract keys of a certain type), but guard against it with tuple wrapping when you need a single, non-distributed check. This is essential for building precise utility types like Exclude, Extract, or custom type filters.

Distribution Trap
A bare T extends U distributes over unions; [T] extends [U] does not. Forgetting this causes never when you expect a concrete type.
Production Insight
A team wrote a conditional type to validate API response shapes, but the union of possible statuses caused the type to collapse to never — every branch returned never because distribution split the union into members that each failed the condition.
The symptom: all API responses typed as never, breaking every downstream consumer and forcing runtime type checks.
Rule of thumb: if you want a single conditional check on a union, wrap both sides in a tuple to suppress distribution.
Key Takeaway
Distribution over unions is automatic for bare type parameters — use [T] to opt out.
never from a conditional type often means all branches evaluated to never due to unintended distribution.
Master distribution to build type filters; suppress it to write type-level equality checks.

Conditional Types: The Type-Level If/Else

A conditional type selects one of two types based on a condition. The syntax mirrors a ternary expression: T extends U ? X : Y. If T is assignable to U, the type resolves to X. Otherwise, it resolves to Y.

The extends keyword in conditional types means structural compatibility — not class inheritance. T extends U is true when T has all the properties and methods that U requires. This is the same check TypeScript uses for function argument assignment.

The critical behavior: when T is a union type, conditional types distribute over each member. A | B extends U ? X : Y becomes (A extends U ? X : Y) | (B extends U ? X : Y). This is powerful but dangerous — it is the source of most conditional type bugs in production codebases.

io.thecodeforge.types.conditional-basics.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ============================================
// Conditional Types — Core Patterns
// ============================================

// ---- Basic conditional type ----
// T extends U ? X : Y

type IsString<T> = T extends string ? 'yes' : 'no'

type A = IsString<string>    // 'yes'
type B = IsString<number>    // 'no'
type C = IsString<'hello'>   // 'yes' — string literal extends string

// ---- Distributive behavior: the source of most bugs ----
// When T is a bare type parameter, conditional types distribute over unions

type ToArray<T> = T extends unknown ? T[] : never

type D = ToArray<string | number>
// Resolves to: string[] | number[]
// NOT: (string | number)[]
// TypeScript distributes: (string extends unknown ? string[] : never) |
//                         (number extends unknown ? number[] : never)

// ---- Suppressing distribution with tuples ----
// Wrap T in a tuple to prevent distribution

type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never

type E = ToArrayNonDist<string | number>
// Resolves to: (string | number)[]
// Distribution suppressed — the entire union is treated as one type

// ---- Built on conditional types: Exclude, Extract, NonNullable ----
// These are in TypeScript's standard library — understanding them requires
// understanding distributive conditional types

type Exclude<T, U> = T extends U ? never : T

type F = Exclude<'a' | 'b' | 'c', 'a'>
// Distributes: ('a' extends 'a' ? never : 'a') |
//              ('b' extends 'a' ? never : 'b') |
//              ('c' extends 'a' ? never : 'c')
// Resolves to: 'b' | 'c'

// ---- Practical: Filter union members by shape ----

type FilterByShape<T, U> = T extends U ? T : never

interface HasId {
  id: string
}

type User = { id: string; name: string }
type Product = { id: string; price: number }
type Error = { message: string }

type Entities = FilterByShape<User | Product | Error, HasId>
// Resolves to: User | Product
// Error is excluded because it lacks 'id'

// ---- Practical: Conditional return type based on input ----
// The conditional type describes the relationship; overloads implement it

type ProcessReturn<T> = T extends string ? string[] : number

function process(value: string): string[]
function process(value: number): number
function process(value: string | number): string[] | number {
  return typeof value === 'string' ? value.split('') : value * 2
}

const resultA = process('hello')  // string[]
const resultB = process(42)       // number
// TypeScript infers ProcessReturn<T> via overloads
Distributive vs Non-Distributive Conditional Types
  • T extends U ? X : Y distributes — each union member is checked independently
  • [T] extends [U] ? X : Y does not distribute — the entire union is checked as one type
  • Distribution is automatic when T is a bare type parameter — wrapping in a tuple suppresses it
  • Exclude, Extract, and NonNullable all rely on distributive behavior
  • Most production bugs come from unexpected distribution — always test with union inputs
Production Insight
Distributive conditional types silently iterate over union members — this changes behavior.
Wrapping in a tuple [T] extends [U] suppresses distribution — use it when you need union-as-one.
Rule: test every conditional type with a union input — single-type tests miss distribution bugs.
Key Takeaway
T extends U distributes over unions; [T] extends [U] does not — this is the #1 source of conditional type bugs.
Exclude and Extract are distributive conditional types — understanding distribution explains their behavior.
Rule: always test conditional types with union inputs — single-type tests are insufficient.

The infer Keyword: Extracting Types from Structures

infer lets you extract a type from within an extends clause. It introduces a type variable that TypeScript binds to the matched portion of the type. The syntax: T extends SomePattern<infer X> ? X : fallback.

infer only works inside the extends clause of a conditional type. You cannot use it in interfaces, type aliases, or function signatures directly. It is a pattern-matching tool — the pattern must match the input type's structure for infer to bind.

The most common use: extracting return types, parameter types, and resolved promise types. ReturnType<T>, Parameters<T>, and Awaited<T> are all built on infer. Understanding infer lets you build custom extractors for any structure.

io.thecodeforge.types.infer-patterns.tsTYPESCRIPT
1
2
3
4
// ============================================
// infer Keyword — Type Extraction Patterns
...all the code...
// Resolves to: [code: number, message: string]
infer Is Pattern Matching for Types
  • infer only works inside extends clauses — you cannot use it in interfaces or standalone type aliases
  • The pattern must structurally match the input type — if it does not match, infer produces never
  • Multiple infer positions extract multiple parts: (args: infer P) => infer R binds both P and R
  • infer works inside template literals too: ${infer Head}.${infer Tail} — that's how you split strings at the type level
  • ReturnType, Parameters, Awaited are all built on infer — you can build custom extractors the same way
Production Insight
infer only binds when the pattern matches — mismatched structures produce never silently.
Multiple infer positions extract several parts of a type in one conditional — use for complex structures.
Rule: test infer-based types with edge cases — empty objects, optional properties, and union inputs.
Key Takeaway
infer is pattern matching for types — it binds a type variable to the matched portion of a structure.
Multiple infer positions in one conditional extract several parts: (args: infer P) => infer R binds both.
Rule: if infer produces never, the pattern does not match — check structural compatibility first.

Template Literal Types: String Construction at the Type Level

Template literal types build string types by interpolating other types into a string template. The syntax mirrors template literals in JavaScript: prefix-${Type}-suffix. TypeScript computes the resulting string type at compile time.

When the interpolated type is a union, the template literal produces a union of all combinations. get-${'user' | 'post'} resolves to 'get-user' | 'get-post'. This combinatorial explosion is powerful but can produce very large union types — TypeScript has a limit of 100,000 members before it collapses to string.

Template literal types pair with mapped types and conditional types to create type-safe APIs. The pattern: define a template, constrain inputs to match, and let TypeScript enforce the contract. This is how tRPC infers procedure names and how styled-components type CSS properties.

io.thecodeforge.types.template-literals.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// ============================================
// Template Literal Types — String Construction Patterns
// ============================================

// ---- Basic template literal type ----

type EventName = `on${Capitalize<'click' | 'focus' | 'blur'>}`
// Resolves to: 'onClick' | 'onFocus' | 'onBlur'

// ---- Combinatorial expansion ----
// Union × Union = all combinations

type HTTPMethod = 'get' | 'post' | 'put' | 'delete'
type Resource = 'user' | 'post' | 'comment'

type APIRoute = `/${Resource}` | `/${Resource}/${string}`
// Resolves to: '/user' | '/user/${string}' | '/post' | ...

// Combinatorial growth example
type A = 'a' | 'b' | 'c'           // 3 members
type AB = `${A}-${A}`              // 9 members (3×3)
type ABC = `${AB}-${AB}`           // 81 members (9×9) — grows exponentially

// ---- Practical: type-safe CSS property access ----

type CSSProperty = `--${string}`

type CSSValue = {
  [K in CSSProperty]?: string
}

const theme: CSSValue = {
  '--color-primary': '#3b82f6',
  '--color-background': '#ffffff',
  '--spacing-unit': '8px',
}

// TypeScript validates that all keys start with '--'
// const bad: CSSValue = { 'color': 'red' }  // Error

// ---- Practical: type-safe API endpoint builder ----

type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never

type Params = ExtractParams<'/users/:userId/posts/:postId'>
// Resolves to: 'userId' | 'postId'

// Build a type-safe route function
function createRoute<T extends string>(
  template: T,
  params: Record<ExtractParams<T>, string>
): string {
  let result = template as string
  for (const [key, value] of Object.entries(params)) {
    result = result.replace(`:${key}`, value)
  }
  return result
}

const url = createRoute('/users/:userId/posts/:postId', {
  userId: 'u-123',
  postId: 'p-456',
})
// url: string — '/users/u-123/posts/p-456'

// TypeScript catches missing or extra params:
// createRoute('/users/:userId', { wrong: 'key' })  // Error
// createRoute('/users/:userId', {})                 // Error: missing userId

// ---- Mapped types with template literal keys ----
// Remap object keys using template literals

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type User = {
  id: string
  name: string
  email: string
}

type UserGetters = Getters<User>
// Resolves to: {
//   getId: () => string
//   getName: () => string
//   getEmail: () => string
// }

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void
}

type UserSetters = Setters<User>
// Resolves to: {
//   setId: (value: string) => void
//   setName: (value: string) => void
//   setEmail: (value: string) => void
// }

// ---- Combining template literals with conditional types ----

type ExtractPrefix<T extends string> =
  T extends `${infer Prefix}:${string}` ? Prefix : T

type Prefix = ExtractPrefix<'content-type:application/json'>
// Resolves to: 'content-type'

type ExtractSuffix<T extends string> =
  T extends `${string}:${infer Suffix}` ? Suffix : T

type Suffix = ExtractSuffix<'content-type:application/json'>
// Resolves to: 'application/json'
Template Literal Types Are Combinatorial
  • prefix-${Union} produces a union of all combinations — 10 × 10 = 100 members
  • TypeScript collapses to string above 100,000 members — monitor union sizes in mapped types
  • Capitalize, Uppercase, Lowercase, Uncapitalize are built-in utility types for template literals
  • Mapped type key remapping (as prefix-${K}) lets you transform object keys at the type level
  • Template literals + infer enable recursive string parsing — extract segments from dot-separated or slash-separated paths
Production Insight
Template literal unions expand combinatorially — 10 × 10 unions produce 100 members.
TypeScript collapses to string above 100,000 members — monitor union sizes in mapped types.
Rule: if autocomplete stops working on a template literal type, check if the union collapsed to string.
Key Takeaway
Template literal types construct string types from unions — prefix-${Union} produces all combinations.
Mapped type key remapping with as transforms object keys: get${Capitalize<K>} generates getter signatures.
Rule: monitor combinatorial expansion — TypeScript collapses to string above 100,000 members.

Pattern: Type-Safe Event Emitter

Event emitters are a natural fit for conditional types and template literals. The goal: enforce that event handlers match their event's payload type, and that event names are constrained to a known set. Without these type features, event emitters accept any string and any handler — runtime errors are the only safety net.

The pattern combines mapped types (for the handler registry), template literals (for event name construction), and conditional types (for extracting handler signatures). Libraries like Node.js EventEmitter, Socket.io, and browser EventTargets all benefit from this pattern.

io.thecodeforge.types.event-emitter.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// ============================================
// Type-Safe Event Emitter — Conditional + Template Literal Pattern
// ============================================

// ---- Event map: defines events and their payload types ----

type EventMap = {
  'user:login': { userId: string; timestamp: number }
  'user:logout': { userId: string }
  'order:created': { orderId: string; total: number }
  'order:shipped': { orderId: string; trackingNumber: string }
  'error': { code: number; message: string }
}

// ---- Type-safe event emitter ----

class TypedEmitter<TEvents extends Record<string, any>> {
  private listeners = new Map<string, Set<Function>>()

  on<K extends keyof TEvents>(
    event: K,
    handler: (payload: TEvents[K]) => void
  ): () => void {
    if (!this.listeners.has(event as string)) {
      this.listeners.set(event as string, new Set())
    }
    this.listeners.get(event as string)!.add(handler)

    // Return unsubscribe function
    return () => {
      this.listeners.get(event as string)?.delete(handler)
    }
  }

  emit<K extends keyof TEvents>(
    event: K,
    payload: TEvents[K]
  ): void {
    this.listeners.get(event as string)?.forEach((handler) => {
      handler(payload)
    })
  }

  off<K extends keyof TEvents>(
    event: K,
    handler: (payload: TEvents[K]) => void
  ): void {
    this.listeners.get(event as string)?.delete(handler)
  }
}

// ---- Usage: full type safety ----

const emitter = new TypedEmitter<EventMap>()

// Correct: handler payload type matches event
emitter.on('user:login', (data) => {
  console.log(data.userId)    // string — typed
  console.log(data.timestamp) // number — typed
})

// Error: handler receives wrong payload type
// emitter.on('user:login', (data: { wrong: boolean }) => {})

// Error: unknown event name
// emitter.on('unknown:event', () => {})

// Emit with correct payload
emitter.emit('order:created', {
  orderId: 'ord-123',
  total: 99.99,
})

// Error: missing payload property
// emitter.emit('order:created', { orderId: 'ord-123' })

// ---- Wildcard event handler with conditional types ----

type AnyEvent<T> = keyof T

type AnyPayload<T> = T[keyof T]

// Handler that receives all events with discriminated payload

type WildcardHandler<TEvents extends Record<string, any>> = <
  K extends keyof TEvents
>(
  event: K,
  payload: TEvents[K]
) => void

// Register wildcard handler
// Note: simplified for type demonstration — a real implementation iterates
// over known event keys from the EventMap and subscribes individually
function onAny<TEvents extends Record<string, any>>(
  emitter: TypedEmitter<TEvents>,
  handler: WildcardHandler<TEvents>
): () => void {
  // production code would register handler for each key in TEvents
  return () => {
    // unsubscribe logic
  }
}

// ---- Event name builder using template literals ----

type Domain = 'user' | 'order' | 'payment'
type Action = 'created' | 'updated' | 'deleted'

type BuildEventName<D extends string, A extends string> = `${D}:${A}`

type AllEvents = BuildEventName<Domain, Action>
// Resolves to: 'user:created' | 'user:updated' | 'user:deleted' |
//              'order:created' | 'order:updated' | 'order:deleted' |
//              'payment:created' | 'payment:updated' | 'payment:deleted'
Event Emitters Are the Best Test Case for Advanced Types
  • Event maps define the contract — one type maps event names to payload types
  • Mapped types enforce that handlers receive the correct payload for each event
  • Template literals build event name unions from domain + action combinations
  • Unsubscribe functions are returned from on() — prevents memory leaks from orphaned listeners
  • Wildcard handlers use conditional types to accept any event with a discriminated payload
Production Insight
Untyped event emitters accept any string and any handler — runtime errors are the only safety net.
TypedEmitter<TEvents> enforces event names and payload types at compile time.
Rule: if your event system uses plain strings, add a type map before the next production incident.
Key Takeaway
Event emitters are the ideal pattern for conditional types + template literals — enforce event names and payloads at compile time.
Mapped types map event names to handler signatures; template literals build name unions from domain + action.
Rule: every event system needs a type map — plain string event names are runtime error factories.

Pattern: Recursive Type for Deep Path Access

Deep path access — reading nested object properties via dot-separated strings — is common in form libraries, ORM query builders, and configuration systems. Without advanced types, these systems accept any string and return unknown. With recursive conditional types and template literals, TypeScript validates paths at compile time and infers the value type.

The pattern: split the path string on '.' using template literal + infer, recurse into the object type using the first segment as a key, and continue until the path is exhausted. The base case: when the path has no dots, look up the key directly.

This pattern powers libraries like lodash's get/set functions with type safety, react-hook-form's register function, and Prisma's select/include query builders.

io.thecodeforge.types.deep-path-access.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
// ============================================
// Recursive Deep Path Access — Template Literal + Conditional Pattern
// ============================================

// ---- Deep value extractor ----
// Splits dot-separated path and recurses into object type

type DeepValue<
  T,
  Path extends string
> = Path extends `${infer Key}.${infer Rest}`
  ? Key extends keyof T
    ? DeepValue<T[Key], Rest>
    : never
  : Path extends keyof T
    ? T[Path]
    : never

// ---- Deep path generator ----
// Produces all valid dot-separated paths for an object type

type DeepPaths<T, Prefix extends string = ''> = T extends object
  ? T extends any[] | Date | Function
    ? never
    : {
        [K in keyof T & string]:
          T[K] extends object
            ? T[K] extends any[] | Date | Function
              ? `${Prefix}${K}`
              : `${Prefix}${K}` | DeepPaths<T[K], `${Prefix}${K}.`>
            : `${Prefix}${K}`
      }[keyof T & string]
  : never

// ---- Usage ----

interface AppConfig {
  server: {
    host: string
    port: number
    ssl: {
      enabled: boolean
      cert: string
      key: string
    }
  }
  database: {
    host: string
    port: number
    name: string
    pool: {
      min: number
      max: number
    }
  }
  cache: {
    ttl: number
    driver: 'memory' | 'redis'
  }
}

// All valid paths

type ConfigPaths = DeepPaths<AppConfig>
// Resolves to:
// 'server' | 'server.host' | 'server.port' | 'server.ssl' |
// 'server.ssl.enabled' | 'server.ssl.cert' | 'server.ssl.key' |
// 'database' | 'database.host' | 'database.port' | 'database.name' |
// 'database.pool' | 'database.pool.min' | 'database.pool.max' |
// 'cache' | 'cache.ttl' | 'cache.driver'

// Value type from path

type SslEnabled = DeepValue<AppConfig, 'server.ssl.enabled'>
// Resolves to: boolean

type PoolMax = DeepValue<AppConfig, 'database.pool.max'>
// Resolves to: number

type CacheDriver = DeepValue<AppConfig, 'cache.driver'>
// Resolves to: 'memory' | 'redis'

// ---- Type-safe get function ----

function deepGet<T extends object, P extends DeepPaths<T>>(
  obj: T,
  path: P
): DeepValue<T, P> {
  const keys = (path as string).split('.')
  let result: any = obj
  for (const key of keys) {
    result = result[key]
  }
  return result
}

const config: AppConfig = {
  server: { host: 'localhost', port: 3000, ssl: { enabled: true, cert: '/cert.pem', key: '/key.pem' } },
  database: { host: 'db.local', port: 5432, name: 'app', pool: { min: 2, max: 10 } },
  cache: { ttl: 3600, driver: 'redis' },
}

const sslEnabled = deepGet(config, 'server.ssl.enabled')  // boolean
const poolMax = deepGet(config, 'database.pool.max')      // number
const driver = deepGet(config, 'cache.driver')            // 'memory' | 'redis'

// Error: invalid path
// deepGet(config, 'server.ssl.nonexistent')  // Type error
// deepGet(config, 'invalid.path')            // Type error

// ---- Type-safe set function ----

function deepSet<T extends object, P extends DeepPaths<T>>(
  obj: T,
  path: P,
  value: DeepValue<T, P>
): void {
  const keys = (path as string).split('.')
  let target: any = obj
  for (let i = 0; i < keys.length - 1; i++) {
    target = target[keys[i]]
  }
  target[keys[keys.length - 1]] = value
}

deepSet(config, 'server.port', 8080)         // OK — number
deepSet(config, 'cache.driver', 'memory')    // OK — 'memory' | 'redis'

// Error: wrong value type
// deepSet(config, 'server.port', 'eighty')   // Type error — string not assignable to number
Recursive Types Need a Base Case
  • Base case: when the path has no dots, look up the key directly — this stops recursion
  • TypeScript has a default depth limit of 50 instantiations — deeply nested paths may hit this
  • DeepPaths<T> generates all valid paths — use it to constrain the path parameter
  • DeepValue<T, P> extracts the value type — use it as the return type or value constraint
  • This pattern powers react-hook-form's register, Prisma's select, and lodash's typed get/set
Production Insight
Recursive types without a base case hit TypeScript's depth limit (50 instantiations).
Deep path access requires two types: DeepPaths for valid paths, DeepValue for return types.
Rule: if you see 'excessively deep' errors, add a terminal condition to stop recursion.
Key Takeaway
Deep path access combines template literals (split on dots) with recursive conditional types (recurse into object).
Two types needed: DeepPaths<T> generates valid paths; DeepValue<T, P> extracts the value type.
Rule: every recursive conditional type needs a base case — without it, TypeScript hits its depth limit.

Generic Conditional Types: Why Your Abstract “Utilities” Keep Breaking in Prod

Generic conditional types are where conditional types earn their keep. Without generics, you’re just writing a fancy switch statement. With them, you build type-level functions that adapt to whatever concrete type gets thrown at them.

The syntax looks like a type-level function signature: type ExtractId<T> = T extends { id: infer I } ? I : never. The generic parameter T is the input, the conditional is the logic, and the result is the output. No runtime overhead. Zero cost abstraction.

Here’s what your juniors get wrong: they put the conditional inside the generic definition but forget to distribute over unions. TypeScript distributes conditional types over union members automatically when the checked type is a bare generic parameter. If you wrap it in an array or a tuple, distribution breaks and you get never for mixed unions.

Real production case: you’ve got type ApiResponse<T> = T extends 'user' ? User : T extends 'order' ? Order : never. If someone passes 'user' | 'order', TypeScript evaluates each member separately: User | Order. Perfect. If you accidentally nest it inside a wrapper type, you’ll see never and spend an hour debugging.

GenericConditionalDistributive.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — javascript tutorial

type ApiResponse<T extends string> = T extends 'user'
  ? { id: number; name: string }
  : T extends 'order'
  ? { orderId: number; total: number }
  : never;

// Distribution works on bare T
type ResponseUnion = ApiResponse<'user' | 'order'>;
//    ^? { id: number; name: string } | { orderId: number; total: number }

// Wrapping breaks distribution
type Wrapped<T extends string> = [T] extends ['user']
  ? { id: number }
  : never;

type BrokenUnion = Wrapped<'user' | 'order'>;
//    ^? never

// Production bug: forgot to unwrap before conditional
// Fix: keep T bare in the condition
Output
// ResponseUnion = { id: number; name: string } | { orderId: number; total: number }
// BrokenUnion = never
Production Trap:
If your generic conditional type returns never for a union input, check that you didn’t accidentally wrap the generic parameter in [T] or T[]. The square bracket syntax is for disabling distribution, not a default pattern.
Key Takeaway
Bare generic parameters distribute over unions; wrapped parameters disable distribution. Know the difference before you ship.

Conditional Type Constraints: Stop Passing Garbage to Your Type Functions

Conditional constraints act as the bouncer at the type-level club. They ensure only valid inputs get processed. Syntax: type SafeString<T extends string> = T. That’s a simple constraint. But you can get conditional with it: type Constrained<T> = T extends string ? T : never.

The difference is subtle but critical. A constraint on the generic parameter itself (T extends string) means TypeScript will error at the call site if you pass a number. A conditional inside the type body (T extends string ? T : never) silently produces never and lets the error bubble up ten layers deep.

I’ve seen a codebase where a utility type silently swallowed an incorrect type argument, and the error surfaced as a cryptic “Type ‘never’ is not assignable” in a completely unrelated file. Three developers spent two days chasing it. The fix: move the constraint to the generic parameter declaration.

When should you use conditional constraints? When the valid types depend on other type parameters. Example: type GetKey<T, K extends keyof T> = T[K] is clean for known keys. But if you want “accept any object, but only extract properties that are strings,” you need a conditional: type StringKeys<T> = { [K in keyof T]: T[K] extends string ? K : never }[keyof T]. Now the constraint is conditional on the property type.

ConditionalConstraintVsGenericConstraint.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — javascript tutorial

// Bad: constraint is conditional, errors are silent
// User passes number, gets never, chaos follows
type BadExtract<T> = T extends string ? T : never;
type ResultBad = BadExtract<42>;
//    ^? never

// Better: constraint on generic parameter
// TypeScript yells immediately at the call site
type GoodExtract<T extends string> = T;
type ResultGood = GoodExtract<'hello'>;
//    ^? 'hello'
// type ResultError = GoodExtract<42>;
//    ^? Type '42' does not satisfy the constraint 'string'

// Advanced: conditional constraint based on shape
type ExtractStringValues<T> = {
  [K in keyof T]: T[K] extends string ? T[K] : never;
}[keyof T];

type User = { id: number; name: string; role: 'admin' | 'user' };
type UserStringValues = ExtractStringValues<User>;
//    ^? string
Output
// ResultBad = never
// ResultGood = 'hello'
// UserStringValues = string
Senior Shortcut:
Use the generic parameter constraint (T extends AllowedType) for simple validation. Use conditional constraints inside the type body only when the constraint depends on the structure of the input, not just its base type.
Key Takeaway
Put constraints on the generic parameter when possible. Silent never from a conditional is a debugging time bomb.
● Production incidentPOST-MORTEMseverity: high

Distributive conditional type silently widened union to never

Symptom
API response types resolved to never after a refactor. Autocomplete stopped working. TypeScript did not report an error — the types were technically valid, just empty.
Assumption
The team assumed the conditional type utility worked identically for wrapped and unwrapped union members.
Root cause
The utility used a bare conditional type to detect empty results: type IsNever<T> = T extends never ? true : false. It was called with a union produced by Filter<Keys, ValidKeys>. Because T was a bare type parameter, TypeScript distributed the check over each union member. A union never distributes to zero constituents, so T extends never never matched and always resolved to false. Downstream code treated 'not never' as 'has values' and propagated a union that should have been discarded, eventually collapsing to never.
Fix
Wrapped the type parameter in a tuple to suppress distribution: type IsNever<T> = [T] extends [never] ? true : false. This forced TypeScript to evaluate the union as a whole. With the fix, empty extractions correctly resolved to true, allowing the upstream utility to return never for invalid keys, and API response types inferred properly again.
Key lesson
  • Distributive conditional types iterate over union members automatically — wrap in a tuple to suppress when you need a whole-type check
  • T extends U distributes; [T] extends [U] does not — this is the most common conditional type bug
  • Test conditional types with union inputs, not just single types — distribution changes behavior silently
Production debug guideDiagnose type inference failures, distribution issues, and template literal constraints6 entries
Symptom · 01
Conditional type resolves to never for all inputs
Fix
Check if distribution is the cause — wrap the type parameter in a tuple: [T] extends [U] instead of T extends U
Symptom · 02
Template literal type produces overly wide string type
Fix
Constrain the input type to a union of specific strings before interpolating — wide string types collapse template literals to string
Symptom · 03
infer keyword extracts never instead of the expected type
Fix
Verify the extends clause matches the structure exactly — infer only works when the pattern matches at the type level
Symptom · 04
TypeScript reports 'Type instantiation is excessively deep and possibly infinite'
Fix
A recursive conditional type has no base case — add a terminal condition that stops recursion before hitting the depth limit (default 50)
Symptom · 05
Mapped type with template literal keys produces unexpected key names
Fix
Check the as clause in the mapped type — the remapped key type must resolve to string | number | symbol
Symptom · 06
Conditional type works in isolation but fails when composed with other types
Fix
Use the TypeScript Playground's 'Evaluate' panel to step through type resolution — composition changes distribution behavior
★ TypeScript Conditional Types Quick Debug ReferenceFast checks for conditional type and template literal issues
Type resolves to never unexpectedly
Immediate action
Check distribution — is T a bare type parameter in a conditional?
Commands
npx tsc --noEmit --explainFiles 2>&1 | grep -i 'never'
grep -rn 'extends.*?' src/ --include='*.ts' | grep -v '\[' | head -10
Fix now
Wrap bare type parameter in tuple: [T] extends [U] ? T : never
Template literal type collapses to string+
Immediate action
Check if the interpolated type is string instead of a string literal union
Commands
npx tsc --noEmit 2>&1 | grep 'not assignable to type' | head -5
cat src/types.ts | grep -A 2 '\${' | head -20
Fix now
Constrain the interpolated type parameter: <T extends string> instead of <T>
Recursive type hits depth limit+
Immediate action
Add a base case that returns a concrete type before recursion
Commands
npx tsc --noEmit 2>&1 | grep 'excessively deep'
grep -rn 'T extends' src/types.ts | grep -v 'infer' | head -10
Fix now
Add terminal condition: T extends string ? T : RecursiveCall<Remaining>
Mapped type key remapping fails+
Immediate action
Verify the as clause resolves to a valid key type
Commands
npx tsc --noEmit 2>&1 | grep 'Type .* is not assignable' | head -5
cat src/types.ts | grep -B 2 -A 2 '\[K in' | head -20
Fix now
Ensure the as clause produces string | number | symbol — use Extract<K, string> if needed
Conditional Types vs Template Literal Types
FeatureConditional TypesTemplate Literal Types
Primary useSelect between types based on a conditionConstruct string types from unions and interpolations
SyntaxT extends U ? X : Yprefix-${Type}-suffix
DistributionDistributes over unions by defaultExpands unions combinatorially
Key keywordextends (structural compatibility)infer (pattern extraction within extends)
Built-in examplesExclude, Extract, NonNullable, ReturnTypeCapitalize, Uppercase, Lowercase, Uncapitalize
ComposabilityUsed inside template literals via inferUsed as the extends clause target in conditionals
Performance riskRecursive types can hit depth limitLarge unions collapse to string above 100K members
Common bugUnexpected distribution over unionsCollapsed to string when interpolated type is too wide

Key takeaways

1
Conditional types are the type-level if/else
T extends U ? X : Y selects between types based on structural compatibility
2
Distribution is automatic when T is a bare type parameter
wrap in a tuple [T] to suppress it
3
infer is pattern matching for types
it binds a type variable to the matched portion of a structure
4
Template literal types construct string types from unions
combinatorial expansion can exceed 100K members
5
Recursive conditional types need a base case
without one, TypeScript hits its instantiation depth limit
6
These features power tRPC, Zod, Prisma, react-hook-form, and styled-components
they are not academic

Common mistakes to avoid

5 patterns
×

Assuming conditional types do not distribute over unions

Symptom
A conditional type utility produces unexpected results when given a union — some members resolve to never, others to the wrong type.
Fix
Test every conditional type with a union input. If distribution is unwanted, wrap the type parameter in a tuple: [T] extends [U] ? X : Y.
×

Using infer outside of an extends clause

Symptom
TypeScript reports 'infer declarations are only permitted in the extends clause of a conditional type'.
Fix
infer only works inside conditional type extends clauses. Extract the conditional type into a separate type alias if needed.
×

Interpolating a wide string type into a template literal

Symptom
The template literal type collapses to string — autocomplete stops working and type narrowing is lost.
Fix
Constrain the interpolated type parameter: <T extends string> or use a specific union type instead of the generic string type.
×

Writing recursive conditional types without a base case

Symptom
TypeScript reports 'Type instantiation is excessively deep and possibly infinite'.
Fix
Add a terminal condition that returns a concrete type before recursion continues. For path parsing: when there are no dots left, return the direct key lookup.
×

Using extends in conditional types expecting class inheritance behavior

Symptom
A conditional type returns the wrong branch because extends means structural compatibility, not class hierarchy.
Fix
Remember: T extends U in conditional types means T is structurally assignable to U. It checks property shapes, not class ancestry. Use satisfies or explicit type guards for runtime checks.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between distributive and non-distributive conditi...
Q02SENIOR
How does the infer keyword work in conditional types, and what are its l...
Q03SENIOR
What happens when you interpolate a union type into a template literal t...
Q04SENIOR
How would you implement a type-safe deep path accessor like lodash's get...
Q05SENIOR
What is the relationship between conditional types and TypeScript's buil...
Q01 of 05SENIOR

What is the difference between distributive and non-distributive conditional types in TypeScript?

ANSWER
A distributive conditional type automatically iterates over each member of a union type. When T is a bare type parameter in T extends U ? X : Y, and T is A | B, TypeScript evaluates (A extends U ? X : Y) | (B extends U ? X : Y). A non-distributive conditional type treats the union as a single unit by wrapping the type parameter in a tuple: [T] extends [U] ? X : Y. This suppresses distribution — the entire union A | B is checked against U as one type. Built-in types like Exclude and Extract use distributive behavior; that is why Exclude<'a' | 'b', 'a'> produces 'b' — it distributes and filters each member independently.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
When should I use conditional types versus simple type narrowing?
02
Can template literal types be used for runtime validation?
03
How do I debug a conditional type that produces unexpected results?
04
What is the performance impact of complex conditional types on TypeScript compilation?
05
How do conditional types relate to covariance and contravariance in TypeScript?
🔥

That's TypeScript. Mark it forged?

6 min read · try the examples if you haven't

Previous
Mapped Types in TypeScript
14 / 15 · TypeScript
Next
Zod Advanced Patterns 2026: Discriminated Unions, Recursion, and Production Validation