MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

TypeScript Conditional Types: Advanced Patterns

Master conditional types: infer keyword, distributive types, and template literal types.

TypeScriptConditional TypesAdvancedTypes

By MinhVo

Introduction

Conditional types are one of TypeScript's most powerful type-level features, enabling type transformations that adapt based on input types. Introduced in TypeScript 2.8, conditional types bring the expressiveness of ternary operators to the type system, allowing developers to create sophisticated type-level programs that can extract, transform, and constrain types with remarkable precision.

TypeScript conditional types concept

From utility types like Exclude, Extract, and ReturnType to complex framework patterns in libraries like tRPC and Zod, conditional types form the backbone of TypeScript's most advanced type manipulations. Mastering them is essential for any developer who wants to build truly type-safe APIs, create reusable type utilities, or contribute to the TypeScript ecosystem. This guide covers conditional types from foundational concepts to advanced patterns involving the infer keyword, distributive conditional types, and template literal types.

Understanding Conditional Types: The Foundation

A conditional type follows the pattern T extends U ? X : Y—if T is assignable to U, the type resolves to X; otherwise, it resolves to Y. This mirrors JavaScript's ternary operator but operates entirely at the type level.

// Basic conditional type
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">;  // true
type B = IsString<42>;       // false
type C = IsString<string>;   // true

How Conditional Types Are Evaluated

The TypeScript compiler evaluates conditional types through a process called deferred conditional types. When the type parameter T is not yet known (it's a generic parameter), the conditional type is deferred—it becomes a type-level function that will be evaluated when T is concrete:

type Unwrap<T> = T extends Promise<infer U> ? U : T;
 
// When T is concrete, evaluation is immediate:
type A = Unwrap<Promise<string>>;  // string
type B = Unwrap<number>;           // number
 
// When T is generic, evaluation is deferred:
function unwrap<T>(value: Unwrap<T>): Unwrap<T> {
  // The compiler defers evaluation until T is known
  return value as Unwrap<T>;
}

Type-level programming concepts

The infer Keyword: Extracting Types

The infer keyword is the most powerful feature of conditional types. It allows you to extract a type from within another type, essentially pattern-matching at the type level.

Basic infer Usage

// Extract the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
 
type A = ReturnType<() => string>;        // string
type B = ReturnType<(x: number) => void>; // void

Extracting Function Parameters

// Extract the first parameter type
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
 
type A = FirstParam<(name: string, age: number) => void>;  // string
type B = FirstParam<(x: number) => void>;                   // number

Extracting Promise Types

// Deep unwrap nested promises
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
 
type A = DeepAwaited<Promise<string>>;                    // string
type B = DeepAwaited<Promise<Promise<number>>>;           // number
type C = DeepAwaited<Promise<Promise<Promise<boolean>>>>; // boolean

Multiple infer Positions

// Extract both input and output types of a function
type FunctionSignature<T> = T extends (arg: infer A) => infer R
  ? { input: A; output: R }
  : never;
 
type Sig = FunctionSignature<(x: number) => string>;
// { input: number; output: string }

Distributive Conditional Types

When a conditional type operates on a union type, it distributes over each member of the union. This is one of the most important behaviors to understand:

type ToArray<T> = T extends any ? T[] : never;
 
// Distribution over union:
type A = ToArray<string | number>;
// Equivalent to:
// ToArray<string> | ToArray<number>
// = string[] | number[]

Controlling Distribution

To prevent distribution, wrap the type in a tuple:

type NoDistribute<T> = [T] extends [string] ? "string" : "other";
 
type A = NoDistribute<string | number>;  // "other" (not distributed)
type B = NoDistribute<string>;           // "string"

Practical Distributive Patterns

Exclude and Extract

// Exclude types from a union
type Exclude<T, U> = T extends U ? never : T;
 
type A = Exclude<"a" | "b" | "c", "a">;  // "b" | "c"
 
// Extract types from a union
type Extract<T, U> = T extends U ? T : never;
 
type B = Extract<"a" | "b" | "c", "a" | "b">;  // "a" | "b"

NonNullable

type NonNullable<T> = T extends null | undefined ? never : T;
 
type A = NonNullable<string | null | undefined>;  // string

Distributive types visualization

Advanced Pattern: Recursive Conditional Types

TypeScript supports recursive conditional types, enabling complex type-level computations:

Deep Readonly

type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
}
 
type ReadonlyConfig = DeepReadonly<Config>;
// {
//   readonly database: {
//     readonly host: string;
//     readonly port: number;
//     readonly credentials: {
//       readonly username: string;
//       readonly password: string;
//     };
//   };
// }

Deep Partial

type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;
 
// Partial update for nested objects
function updateConfig(
  current: Config,
  updates: DeepPartial<Config>
): Config {
  return { ...current, ...updates };
}

Tuple Manipulation

// Get the last element of a tuple
type Last<T extends any[]> = T extends [...infer _, infer L] ? L : never;
 
type A = Last<[1, 2, 3]>;  // 3
type B = Last<[string]>;    // string
 
// Reverse a tuple
type Reverse<T extends any[]> = T extends [infer H, ...infer Rest]
  ? [...Reverse<Rest>, H]
  : T;
 
type C = Reverse<[1, 2, 3]>;  // [3, 2, 1]
 
// Get the length of a tuple
type Length<T extends any[]> = T["length"];
 
type D = Length<[1, 2, 3]>;  // 3

Template Literal Types with Conditionals

Template literal types combined with conditional types enable powerful string manipulation at the type level:

String Parsing

type ParseRoute<T extends string> =
  T extends `/${infer Segment}/${infer Rest}`
    ? { segment: Segment; rest: ParseRoute<`/${Rest}`> }
    : T extends `/${infer Segment}`
      ? { segment: Segment; rest: null }
      : never;
 
type Route = ParseRoute<"/users/123/posts">;
// { segment: "users"; rest: { segment: "123"; rest: { segment: "posts"; rest: null } } }

Type-Safe Event Names

type EventName<T extends string> = T extends `on${infer Event}`
  ? Uncapitalize<Event>
  : never;
 
type A = EventName<"onClick">;  // "click"
type B = EventName<"onSubmit">; // "submit"
 
// Extract event handler type
type EventHandler<T extends string> =
  T extends `on${infer Event}`
    ? (event: Lowercase<Event>) => void
    : never;

Path Extraction

type ExtractPathParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractPathParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;
 
type Params = ExtractPathParams<"/users/:id/posts/:postId">;
// "id" | "postId"

Step-by-Step Implementation

Building a Type-Safe API Client

// Extract response type from API definition
type ApiResponse<T> = T extends {
  response: infer R;
}
  ? R
  : never;
 
// Extract request body type
type ApiBody<T> = T extends {
  body: infer B;
}
  ? B
  : never;
 
// Define API endpoints
interface ApiEndpoints {
  "GET /users": {
    response: User[];
    params: { page: number; limit: number };
  };
  "POST /users": {
    response: User;
    body: CreateUserRequest;
  };
  "GET /users/:id": {
    response: User;
    params: { id: string };
  };
}
 
// Type-safe fetch function
async function api<
  TEndpoint extends keyof ApiEndpoints,
  TMethod extends Extract<TEndpoint, `${string} ${string}`> extends `${infer M} ${string}` ? M : never
>(
  endpoint: TEndpoint,
  ...args: ApiEndpoints[TEndpoint] extends { body: infer B }
    ? [options: { body: B; params?: ApiEndpoints[TEndpoint]["params"] }]
    : [options?: { params?: ApiEndpoints[TEndpoint]["params"] }]
): Promise<ApiResponse<ApiEndpoints[TEndpoint]>> {
  // Implementation
  return {} as any;
}
 
// Usage - fully type-safe
const users = await api("GET /users", { params: { page: 1, limit: 10 } });
const newUser = await api("POST /users", { body: { name: "John", email: "john@example.com" } });

Type-safe API architecture

Building Type-Safe Builders

type BuilderState<T extends Record<string, any>> = T;
 
type BuilderSet<T extends Record<string, any>, K extends string, V> =
  BuilderState<T & Record<K, V>>;
 
interface Builder<T extends Record<string, any>> {
  set<K extends string, V>(key: K, value: V): Builder<BuilderSet<T, K, V>>;
  build(): T;
}
 
function createBuilder<T extends Record<string, any>>(state: T): Builder<T> {
  return {
    set(key, value) {
      return createBuilder({ ...state, [key]: value }) as any;
    },
    build() {
      return state;
    }
  };
}
 
// Usage - type-safe builder
const config = createBuilder({})
  .set("host", "localhost")
  .set("port", 3000)
  .set("debug", true)
  .build();
 
// typeof config is { host: string; port: number; debug: boolean }

Real-World Use Cases

Use Case 1: ORM Query Types

type QueryResult<T> = T extends {
  select: infer S extends Record<string, any>;
}
  ? { [K in keyof S]: S[K] }
  : T extends { table: infer Tbl extends string }
    ? Tbl extends keyof Schema
      ? Schema[Tbl]
      : unknown
    : never;
 
function query<T extends QueryConfig>(config: T): QueryResult<T> {
  // Implementation
  return {} as any;
}

Use Case 2: Form Validation Types

type ValidationResult<T> = T extends { required: true }
  ? { valid: boolean; value: NonNullable<T["value"]> }
  : { valid: boolean; value: T["value"] | undefined };
 
type FormField<T> = {
  value: T;
  validate: (value: T) => ValidationResult<{ value: T; required: true }>;
};

Use Case 3: Configuration Type Inference

type InferConfig<T> = T extends { type: "string" }
  ? string
  : T extends { type: "number" }
    ? number
    : T extends { type: "boolean" }
      ? boolean
      : T extends { type: "array"; items: infer I }
        ? InferConfig<I>[]
        : T extends { type: "object"; properties: infer P }
          ? { [K in keyof P]: InferConfig<P[K]> }
          : never;

Best Practices for Production

  1. Keep conditional types readable: Complex conditional types should be broken into smaller, named types with clear documentation.

  2. Use infer sparingly: While powerful, multiple infer positions can make types difficult to understand. Prefer simpler patterns when possible.

  3. Prevent unintentional distribution: Use the [T] extends [U] pattern when you don't want distribution over unions.

  4. Test type-level code: Use expect-type or tsd to write tests for your conditional types. Type-level bugs are subtle and hard to debug.

  5. Document complex types: Add JSDoc comments explaining what a conditional type does and provide usage examples.

  6. Avoid deep recursion: TypeScript has a recursion depth limit for conditional types. Keep recursive types shallow (typically under 10 levels).

  7. Use declare for type-only constructs: When defining conditional types that don't have runtime representation, use declare to signal intent.

  8. Prefer built-in utility types: Before creating custom conditional types, check if TypeScript's built-in Partial, Required, Pick, Omit, Record, etc. already solve your problem.

Common Pitfalls and Solutions

PitfallImpactSolution
Unintended distribution over unionsWrong type resultsWrap in tuple: [T] extends [U]
Excessive recursion depthType instantiation errorsLimit recursion depth or use iterative approaches
Missing infer keywordCannot extract typesUse infer TypeVar in extends clause
Confusing distributive and non-distributiveUnexpected behaviorTest with union inputs to verify distribution
Using any in conditional typesLoss of type safetyUse unknown and narrow with constraints

Performance Optimization

TypeScript's type checker has limits on type instantiation depth. Optimize conditional types by:

// Instead of deeply recursive types:
type BadDeepReadonly<T> = T extends object
  ? T extends Array<infer U>
    ? ReadonlyArray<BadDeepReadonly<U>>
    : { readonly [K in keyof T]: BadDeepReadonly<T[K]> }
  : T;
 
// Use bounded recursion:
type GoodDeepReadonly<T, Depth extends number = 0> = Depth extends 5
  ? T  // Stop recursion at depth 5
  : T extends object
    ? T extends Array<infer U>
      ? ReadonlyArray<GoodDeepReadonly<U, Increment<Depth>>>
      : { readonly [K in keyof T]: GoodDeepReadonly<T[K], Increment<Depth>> }
    : T;

Comparison with Alternatives

FeatureConditional TypesMapped TypesGeneric Constraints
Type extractionYes (with infer)NoNo
Union distributionAutomaticPer-keyN/A
Recursive patternsSupportedLimitedN/A
ReadabilityModerateHighHigh
PerformanceVariableFastFast

Testing Strategies

import { expectTypeOf } from "vitest";
 
describe("Conditional types", () => {
  it("should unwrap promises", () => {
    type Unwrap<T> = T extends Promise<infer U> ? U : T;
    expectTypeOf<Unwrap<Promise<string>>>().toEqualTypeOf<string>();
    expectTypeOf<Unwrap<number>>().toEqualTypeOf<number>();
  });
 
  it("should distribute over unions", () => {
    type ToArray<T> = T extends any ? T[] : never;
    expectTypeOf<ToArray<string | number>>().toEqualTypeOf<string[] | number[]>();
  });
 
  it("should not distribute when wrapped", () => {
    type NoDistribute<T> = [T] extends [string] ? "string" : "other";
    expectTypeOf<NoDistribute<string | number>>().toEqualTypeOf<"other">();
  });
});

Future Outlook

TypeScript continues to improve conditional type capabilities. Recent versions have improved inference in conditional types, and future versions may introduce pattern matching syntax that could simplify some conditional type patterns. The satisfies operator (TypeScript 4.9) complements conditional types by enabling better type inference at the value level.

Architecture Decision Records

When evaluating architectural choices for your project, documenting your decision-making process through Architecture Decision Records (ADRs) provides invaluable context for future team members and stakeholders. Each ADR captures the context, decision, and consequences of a specific architectural choice.

Creating Effective ADRs

An ADR should include the date of the decision, the status (proposed, accepted, deprecated, or superseded), the context that motivated the decision, the decision itself, and the expected consequences both positive and negative. This structured approach ensures that decisions are traceable and reversible when circumstances change.

# ADR-001: Choose React for Frontend Framework
 
## Status: Accepted
 
## Context
We need a frontend framework that supports component-based architecture,
has a large ecosystem, and provides good TypeScript support.
 
## Decision
We will use React 18+ with TypeScript for all new frontend projects.
 
## Consequences
- Large talent pool available for hiring
- Mature ecosystem with extensive third-party libraries
- Strong TypeScript integration
- Requires additional libraries for routing and state management

Decision Matrix for Technology Selection

Create a weighted decision matrix when comparing multiple options. List your evaluation criteria (performance, learning curve, ecosystem maturity, community support, long-term viability) and assign weights based on your project priorities. Score each option on a scale of 1-5 for each criterion, then calculate weighted totals.

This systematic approach removes emotion from technology decisions and provides a defensible rationale when stakeholders question your choices. Document the matrix alongside your ADR so future teams understand not just what was chosen, but why alternatives were rejected.

Reversibility and Migration Paths

Every architectural decision should include a migration path in case the decision needs to be reversed. Consider the cost of changing course at six months, twelve months, and two years. Decisions with low reversal costs can be made more aggressively, while irreversible decisions warrant extended evaluation periods and proof-of-concept implementations.

For example, choosing a CSS-in-JS library has a relatively low reversal cost since styles can be migrated incrementally component by component. However, choosing a database technology has a high reversal cost due to data migration complexity and potential schema changes throughout the codebase.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Conditional types are the backbone of TypeScript's advanced type system. Key takeaways:

  1. Conditional types use T extends U ? X : Y syntax for type-level branching
  2. infer extracts types from complex type structures
  3. Distribution over unions happens automatically unless wrapped in tuples
  4. Recursive conditional types enable deep type transformations
  5. Template literal types combined with conditionals enable string-level type programming

Mastering conditional types unlocks the full power of TypeScript's type system, enabling you to build APIs that are both flexible and completely type-safe.