TypeScript Conditional Types: Dynamic Type Logic
July 25, 2025
The Type System That Makes Decisions
When you're building TypeScript applications, you've probably noticed how the type system usually works with fixed, predetermined types. But what happens when you need types that can adapt and change based on the input they receive? What if your types could make decisions, just like your runtime code does with if statements?
This is exactly what TypeScript conditional types solve. They bring programming logic directly into the type system, allowing you to create types that behave differently depending on the types they work with.
Consider this common scenario: you're building a function that fetches data from an API. Sometimes it returns the full data object, and sometimes it only returns the ID for performance reasons. With regular types, you'd need separate types for each case. With conditional types, you can create a single type that adapts automatically.
// Without conditional types - multiple separate types needed
type UserFull = { id: string; name: string; email: string; };
type UserBasic = { id: string; };
// With conditional types - one adaptive type
type ApiResponse<T extends boolean> = T extends true
? { id: string; name: string; email: string; }
: { id: string; };
// TypeScript automatically knows which shape you get
const fullUser: ApiResponse<true> = fetchUser(true); // Full user object
const basicUser: ApiResponse<false> = fetchUser(false); // Just ID
This article will walk you through how conditional types work, why they're powerful, and how to use them effectively in your TypeScript projects.
How Conditional Types Work
At their core, conditional types are like if statements for TypeScript's type system. They use the extends
keyword to check if one type is compatible with another, then choose between two possible types based on that check.
The basic syntax looks like this:
T extends U ? X : Y
This reads as: "If type T extends (is compatible with) type U, then use type X, otherwise use type Y."
Let's start with a simple example that demonstrates this concept:
type IsString<T> = T extends string ? true : false;
// TypeScript evaluates these at compile time
type Test1 = IsString<string>; // true
type Test2 = IsString<number>; // false
type Test3 = IsString<"hello">; // true (string literal extends string)
Here, IsString<T>
checks if the type T
is compatible with string
. If it is, the conditional type resolves to true
; otherwise, it resolves to false
.
The extends
keyword in conditional types is broader than inheritance. It asks: "Can type T be assigned to type U?" This includes exact matches, subtypes, and compatible shapes:
type CanAssign<T, U> = T extends U ? "yes" : "no";
type Test1 = CanAssign<string, string>; // "yes" - exact match
type Test2 = CanAssign<"hello", string>; // "yes" - literal extends base type
type Test3 = CanAssign<string, number>; // "no" - incompatible
type Test4 = CanAssign<{ a: string }, { a: string; b: number }>; // "no" - missing property
type Test5 = CanAssign<{ a: string; b: number }, { a: string }>; // "yes" - extra properties OK
This flexibility makes conditional types incredibly useful for creating types that respond to the structure and characteristics of other types.
Let's look at a more practical example. Suppose you're building a logging function that should return different information based on the log level:
type LogLevel = "info" | "warn" | "error";
type LogOutput<T extends LogLevel> = T extends "error"
? { message: string; stack: string; timestamp: Date }
: T extends "warn"
? { message: string; timestamp: Date }
: { message: string };
function createLog<T extends LogLevel>(level: T, message: string): LogOutput<T> {
const base = { message };
if (level === "error") {
return { ...base, stack: new Error().stack!, timestamp: new Date() } as LogOutput<T>;
} else if (level === "warn") {
return { ...base, timestamp: new Date() } as LogOutput<T>;
} else {
return base as LogOutput<T>;
}
}
// TypeScript knows the exact return type for each case
const errorLog = createLog("error", "Something broke"); // Has message, stack, and timestamp
const warnLog = createLog("warn", "Be careful"); // Has message and timestamp
const infoLog = createLog("info", "All good"); // Has only message
Notice how TypeScript automatically provides the correct type for each log level. The conditional type ensures that error logs include stack traces, warning logs include timestamps, and info logs are kept minimal.
Understanding Distributive Conditional Types
One of the most powerful features of conditional types is how they handle union types. When you pass a union type to a conditional type, TypeScript automatically distributes the conditional logic across each member of the union. This creates some remarkably useful type transformations.
Here's how distribution works:
type ToArray<T> = T extends any ? T[] : never;
// When T is a union, the conditional distributes over each member
type Test = ToArray<string | number>;
// Becomes: (string extends any ? string[] : never) | (number extends any ? number[] : never)
// Results in: string[] | number[]
This distribution behavior is automatic and happens whenever the type being checked (the T
in T extends U
) is a union type. Let's see a practical example:
// Extract only the string types from a union
type ExtractStrings<T> = T extends string ? T : never;
type MixedUnion = string | number | boolean | "hello" | 42;
type OnlyStrings = ExtractStrings<MixedUnion>; // string | "hello"
// Create a type that excludes null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string
The never
type plays a crucial role in these patterns. When a branch of a conditional type evaluates to never
, TypeScript removes it from the resulting union. This makes never
perfect for filtering operations.
Let's look at a more complex example that demonstrates the power of distributive conditional types:
// Transform different types in different ways
type Normalize<T> = T extends string
? `str_${T}`
: T extends number
? `num_${T}`
: T extends boolean
? T extends true ? "yes" : "no"
: "unknown";
type Input = "hello" | 42 | true | false | object;
type Output = Normalize<Input>;
// Results in: "str_hello" | "num_42" | "yes" | "no" | "unknown"
Distribution is particularly useful when building utility types that work with APIs or data transformations. For example, you might want to create a type that extracts all the property names that have specific types:
type User = {
id: string;
name: string;
age: number;
isActive: boolean;
lastLogin: Date | null;
};
// Extract property names that have string values
type StringPropertyNames<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type UserStringProps = StringPropertyNames<User>; // "id" | "name"
// Extract property names that might be null
type NullablePropertyNames<T> = {
[K in keyof T]: T[K] extends null | undefined ? K : never;
}[keyof T];
type UserNullableProps = NullablePropertyNames<User>; // "lastLogin"
Sometimes you don't want distributive behavior. You can prevent distribution by wrapping the type being checked in a tuple:
// Distributive version
type DistributiveCheck<T> = T extends string ? true : false;
type Test1 = DistributiveCheck<string | number>; // boolean (true | false)
// Non-distributive version
type NonDistributiveCheck<T> = [T] extends [string] ? true : false;
type Test2 = NonDistributiveCheck<string | number>; // false
The non-distributive version treats string | number
as a single type rather than distributing over each member, which can be useful in certain scenarios.
Type Extraction with the Infer Keyword
The infer
keyword is one of TypeScript's most powerful features for working with conditional types. It allows you to extract and capture type information from within type patterns, essentially letting you "reach inside" complex types and pull out the pieces you need.
Think of infer
as creating a type variable that TypeScript will figure out for you based on the pattern you provide. Here's the basic syntax:
type ExtractReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// TypeScript infers R from the function's return type
type Test1 = ExtractReturnType<() => string>; // string
type Test2 = ExtractReturnType<(x: number) => boolean>; // boolean
type Test3 = ExtractReturnType<string>; // never (not a function)
In this example, infer R
tells TypeScript: "Figure out what the return type should be and call it R." If the type matches the function pattern, TypeScript extracts the return type; otherwise, it returns never
.
Let's explore more practical applications of infer
. One common use case is extracting elements from array types:
// Extract the element type from an array
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type StringArray = string[];
type NumberArray = number[];
type ElementType1 = ArrayElement<StringArray>; // string
type ElementType2 = ArrayElement<NumberArray>; // number
type ElementType3 = ArrayElement<string>; // never (not an array)
// Extract from nested arrays
type NestedArrayElement<T> = T extends (infer U)[][] ? U : never;
type NestedNumbers = number[][];
type InnerType = NestedArrayElement<NestedNumbers>; // number
You can use multiple infer
keywords in a single conditional type to extract different parts of a pattern:
// Extract both parameter and return types from a function
type FunctionInfo<T> = T extends (arg: infer P) => infer R
? { parameter: P; returnType: R }
: never;
function exampleFunction(name: string): boolean {
return name.length > 0;
}
type Info = FunctionInfo<typeof exampleFunction>;
// { parameter: string; returnType: boolean }
// Extract multiple function parameters
type FirstTwoParams<T> = T extends (first: infer A, second: infer B, ...rest: any[]) => any
? [A, B]
: never;
function multiParam(a: string, b: number, c: boolean): void {}
type Params = FirstTwoParams<typeof multiParam>; // [string, number]
The infer
keyword becomes particularly powerful when working with complex nested types. Here's an example that extracts the resolved type from a Promise:
// Extract the resolved type from a Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Test1 = UnwrapPromise<Promise<string>>; // string
type Test2 = UnwrapPromise<Promise<number[]>>; // number[]
type Test3 = UnwrapPromise<string>; // string (not a Promise)
// Handle nested Promises
type DeepUnwrapPromise<T> = T extends Promise<infer U>
? DeepUnwrapPromise<U>
: T;
type Nested = Promise<Promise<Promise<string>>>;
type Unwrapped = DeepUnwrapPromise<Nested>; // string
You can also use infer
to work with more complex patterns like extracting types from object structures:
// Extract the value type from an object property
type PropertyType<T, K extends keyof T> = T extends { [P in K]: infer V } ? V : never;
type User = { id: string; name: string; age: number };
type NameType = PropertyType<User, "name">; // string
// Extract all property types as a union
type AllPropertyTypes<T> = T extends { [K in keyof T]: infer V } ? V : never;
type UserPropertyTypes = AllPropertyTypes<User>; // string | number
// Extract the key type from a Record-like structure
type ExtractKeys<T> = T extends Record<infer K, any> ? K : never;
type MyRecord = Record<"a" | "b" | "c", string>;
type Keys = ExtractKeys<MyRecord>; // "a" | "b" | "c"
One of the most advanced uses of infer
is in building recursive type patterns. Here's an example that flattens nested array types:
type Flatten<T> = T extends (infer U)[]
? U extends any[]
? Flatten<U>
: U
: T;
type Nested = string[][][];
type Flattened = Flatten<Nested>; // string
type Mixed = (string | number[])[];
type MixedFlattened = Flatten<Mixed>; // string | number
The infer
keyword opens up possibilities for creating sophisticated type transformations that can adapt to complex patterns in your codebase, making your types more flexible and expressive.
Real-World Applications
Now that you understand the fundamentals of conditional types, let's explore how they solve real problems in TypeScript applications. These patterns appear frequently in well-designed APIs and libraries, and understanding them will help you build more robust and flexible type systems.
API Response Handling
One of the most common uses of conditional types is handling different API response formats. Consider an API that returns different data structures based on request parameters:
interface BaseApiOptions {
includeMetadata?: boolean;
format?: "summary" | "full";
}
// The response type changes based on the options
type ApiResponse<T extends BaseApiOptions> = {
data: T["format"] extends "full"
? { id: string; name: string; description: string; tags: string[] }
: { id: string; name: string };
} & (T["includeMetadata"] extends true
? { metadata: { lastModified: Date; version: number } }
: {});
// TypeScript knows exactly what you'll get back
async function fetchArticle<T extends BaseApiOptions>(options: T): Promise<ApiResponse<T>> {
// Implementation here
return {} as ApiResponse<T>;
}
// Each call gets the precise return type
const summaryData = await fetchArticle({ format: "summary" });
// { data: { id: string; name: string } }
const fullWithMeta = await fetchArticle({ format: "full", includeMetadata: true });
// { data: { id: string; name: string; description: string; tags: string[] }, metadata: { ... } }
This pattern ensures that your code gets exactly the right types based on the options you pass, preventing runtime errors and improving developer experience.
Form Validation Types
Conditional types excel at modeling form validation where different fields have different validation requirements:
interface FieldConfig {
type: "text" | "email" | "number" | "select";
required?: boolean;
options?: string[]; // Only for select fields
}
type FieldValue<T extends FieldConfig> = T["type"] extends "number"
? number
: T["type"] extends "select"
? T["options"] extends string[]
? T["options"][number] // One of the option values
: string
: string;
type ValidationResult<T extends FieldConfig> = T["required"] extends true
? { isValid: boolean; value: FieldValue<T>; error?: string }
: { isValid: boolean; value?: FieldValue<T>; error?: string };
// Define your form fields
type EmailField = { type: "email"; required: true };
type AgeField = { type: "number"; required: false };
type CountryField = { type: "select"; required: true; options: ["US", "CA", "UK"] };
// TypeScript knows the exact validation result type for each field
const emailResult: ValidationResult<EmailField> = validateField({ /* ... */ });
// { isValid: boolean; value: string; error?: string }
const ageResult: ValidationResult<AgeField> = validateField({ /* ... */ });
// { isValid: boolean; value?: number; error?: string }
const countryResult: ValidationResult<CountryField> = validateField({ /* ... */ });
// { isValid: boolean; value: "US" | "CA" | "UK"; error?: string }
Event System Types
Conditional types are perfect for creating type-safe event systems where different events carry different payload types:
interface EventMap {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"page:view": { path: string; referrer?: string };
"error:occurred": { message: string; stack: string };
}
type EventHandler<T extends keyof EventMap> = (payload: EventMap[T]) => void;
type EventListener = {
[K in keyof EventMap]: {
event: K;
handler: EventHandler<K>;
}
}[keyof EventMap];
class EventBus {
private listeners: Map<string, EventHandler<any>[]> = new Map();
on<T extends keyof EventMap>(event: T, handler: EventHandler<T>) {
const handlers = this.listeners.get(event) || [];
handlers.push(handler);
this.listeners.set(event, handlers);
}
emit<T extends keyof EventMap>(event: T, payload: EventMap[T]) {
const handlers = this.listeners.get(event) || [];
handlers.forEach(handler => handler(payload));
}
}
const bus = new EventBus();
// TypeScript enforces the correct payload type for each event
bus.on("user:login", (payload) => {
console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);
});
bus.on("page:view", (payload) => {
console.log(`Page viewed: ${payload.path}, referrer: ${payload.referrer || "direct"}`);
});
// TypeScript catches type mismatches
bus.emit("user:login", { userId: "123", timestamp: new Date() }); // ✓ Correct
bus.emit("user:login", { userId: "123" }); // ✗ Error: missing timestamp
Database Query Builder Types
Conditional types can create sophisticated query builder APIs that maintain type safety across complex operations:
interface QueryOptions {
select?: string[];
where?: Record<string, any>;
orderBy?: string;
limit?: number;
}
type QueryResult<T, Options extends QueryOptions> =
Options["select"] extends string[]
? Pick<T, Options["select"][number] & keyof T>
: T;
interface User {
id: string;
name: string;
email: string;
age: number;
lastLogin: Date;
}
class QueryBuilder<T> {
constructor(private table: string) {}
find<Options extends QueryOptions>(options: Options): Promise<QueryResult<T, Options>[]> {
// Implementation would build and execute the actual query
return Promise.resolve([]);
}
}
const userQuery = new QueryBuilder<User>("users");
// TypeScript knows you only get the selected fields
const users = await userQuery.find({
select: ["name", "email"],
where: { age: { gt: 18 } }
});
// Type: { name: string; email: string }[]
const fullUsers = await userQuery.find({
where: { lastLogin: { gt: new Date("2024-01-01") } }
});
// Type: User[] (all fields included when no select clause)
These real-world examples demonstrate how conditional types create APIs that are both flexible and type-safe, preventing common programming errors while maintaining excellent developer experience.
Advanced Patterns and Recursive Types
As you become more comfortable with conditional types, you can combine them with other TypeScript features to create sophisticated type transformations. These advanced patterns often involve recursion, where a conditional type references itself to handle nested structures.
Recursive Type Transformations
One powerful pattern is creating types that can transform nested object structures. Here's an example that converts all properties in an object to optional, even in nested objects:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends any[]
? T[P] // Keep arrays as-is
: DeepPartial<T[P]> // Recursively make nested objects partial
: T[P];
};
interface User {
id: string;
profile: {
name: string;
settings: {
theme: "light" | "dark";
notifications: boolean;
};
};
permissions: string[];
}
type PartialUser = DeepPartial<User>;
// Result: {
// id?: string;
// profile?: {
// name?: string;
// settings?: {
// theme?: "light" | "dark";
// notifications?: boolean;
// };
// };
// permissions?: string[];
// }
Path-Based Type Access
You can create types that allow safe access to nested properties using string paths:
type PathsToStringProps<T> = {
[K in keyof T]: T[K] extends string
? K
: T[K] extends object
? `${K & string}.${PathsToStringProps<T[K]> & string}`
: never;
}[keyof T];
type GetValueAtPath<T, P extends string> = P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueAtPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
interface ApiData {
user: {
profile: {
name: string;
bio: string;
};
preferences: {
language: string;
};
};
metadata: {
version: string;
};
}
// TypeScript generates all valid paths to string properties
type ValidPaths = PathsToStringProps<ApiData>;
// "user.profile.name" | "user.profile.bio" | "user.preferences.language" | "metadata.version"
// Type-safe property access
function getNestedValue<T, P extends PathsToStringProps<T>>(
obj: T,
path: P
): GetValueAtPath<T, P> {
return path.split('.').reduce((current: any, key) => current[key], obj);
}
const data: ApiData = { /* ... */ };
const userName = getNestedValue(data, "user.profile.name"); // Type: string
const version = getNestedValue(data, "metadata.version"); // Type: string
// const invalid = getNestedValue(data, "invalid.path"); // TypeScript error
Template Literal Type Manipulation
Conditional types work beautifully with template literal types to create sophisticated string manipulation at the type level:
// Convert camelCase to snake_case
type CamelToSnake<S extends string> = S extends `${infer T}${infer U}`
? `${T extends Capitalize<T> ? "_" : ""}${Lowercase<T>}${CamelToSnake<U>}`
: S;
// Convert object keys from camelCase to snake_case
type SnakeCaseKeys<T> = {
[K in keyof T as CamelToSnake<K & string>]: T[K];
};
interface CamelCaseAPI {
userId: string;
firstName: string;
lastName: string;
isActive: boolean;
}
type SnakeCaseAPI = SnakeCaseKeys<CamelCaseAPI>;
// Result: {
// user_id: string;
// first_name: string;
// last_name: string;
// is_active: boolean;
// }
// Parse URL parameters from a route string
type ParseRouteParams<T extends string> = T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ParseRouteParams<Rest>
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {};
type UserRoute = "/users/:userId/posts/:postId";
type RouteParams = ParseRouteParams<UserRoute>;
// Result: { userId: string; postId: string }
Advanced Utility Type Patterns
Here are some sophisticated utility types that demonstrate the full power of conditional types:
// Create a type that requires at least one property from a set
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
interface SearchOptions {
query?: string;
category?: string;
tag?: string;
author?: string;
}
// Must specify at least one of query, category, or tag
type SearchRequirement = RequireAtLeastOne<SearchOptions, "query" | "category" | "tag">;
// Valid uses:
const search1: SearchRequirement = { query: "typescript" };
const search2: SearchRequirement = { category: "tutorial", author: "john" };
// const invalid: SearchRequirement = { author: "john" }; // Error: missing required property
// Create function overloads based on conditional types
type FetchFunction<T extends boolean> = T extends true
? <Data>(url: string, options: { parse: true }) => Promise<Data>
: (url: string, options?: { parse?: false }) => Promise<string>;
declare const fetch: FetchFunction<boolean>;
// TypeScript infers the return type based on the parse option
const jsonData = await fetch("/api/data", { parse: true }); // Promise<unknown>
const textData = await fetch("/api/data"); // Promise<string>
These advanced patterns show how conditional types can create incredibly expressive and type-safe APIs. While they require careful thought to implement correctly, they can dramatically improve the developer experience by catching errors early and providing precise type information.
Best Practices and Common Pitfalls
Working with conditional types effectively requires understanding both their capabilities and their limitations. Here are key practices that will help you write maintainable and reliable conditional types.
Keep Conditional Types Simple and Focused
Complex nested conditional types can become difficult to understand and debug. When possible, break them into smaller, composable pieces:
// Hard to understand and maintain
type ComplexType<T> = T extends string
? T extends `${infer Start}:${infer End}`
? End extends number
? { parsed: Start; value: End; type: "number" }
: End extends boolean
? { parsed: Start; value: End; type: "boolean" }
: { parsed: Start; value: End; type: "string" }
: { raw: T; type: "string" }
: T extends number
? { value: T; type: "number" }
: never;
// Better: break into smaller, focused types
type ParsePrefix<T extends string> = T extends `${infer Start}:${infer End}`
? { prefix: Start; suffix: End }
: { raw: T };
type ClassifyValue<T> = T extends number
? "number"
: T extends boolean
? "boolean"
: "string";
type ProcessString<T extends string> = ParsePrefix<T> extends { prefix: infer P; suffix: infer S }
? { parsed: P; value: S; type: ClassifyValue<S> }
: { raw: T; type: "string" };
// Now the main type is much clearer
type SimpleType<T> = T extends string
? ProcessString<T>
: T extends number
? { value: T; type: "number" }
: never;
Provide Meaningful Names and Documentation
Conditional types can be abstract, so clear naming and documentation are crucial:
/**
* Extracts the element type from an array type.
* For nested arrays, extracts the innermost element type.
*
* @example
* ArrayElementType<string[]> // string
* ArrayElementType<number[][]> // number
* ArrayElementType<string> // never (not an array)
*/
type ArrayElementType<T> = T extends (infer U)[]
? U extends any[]
? ArrayElementType<U> // Recursively handle nested arrays
: U
: never;
/**
* Creates a type where all properties are optional, including nested objects.
* Arrays are preserved as-is to maintain their structure.
*/
type DeepOptional<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends any[]
? T[P] // Preserve arrays
: DeepOptional<T[P]> // Make nested objects optional
: T[P];
};
Handle Edge Cases Explicitly
Always consider how your conditional types behave with edge cases like any
, never
, unknown
, and union types:
// Consider what happens with edge cases
type SafeExtractArray<T> = T extends readonly (infer U)[]
? U
: T extends any[] // Handle generic arrays
? T[number]
: never;
// Test with edge cases
type Test1 = SafeExtractArray<string[]>; // string
type Test2 = SafeExtractArray<readonly number[]>; // number
type Test3 = SafeExtractArray<any[]>; // any
type Test4 = SafeExtractArray<never>; // never
type Test5 = SafeExtractArray<unknown>; // never
// Handle union types appropriately
type ExtractStringLiterals<T> = T extends string
? T extends `${string}`
? T // Keep string literals
: never
: never;
type Mixed = "hello" | "world" | string | number;
type Literals = ExtractStringLiterals<Mixed>; // "hello" | "world"
Avoid Deep Recursion
TypeScript has limits on recursion depth. Design your types to avoid hitting these limits:
// This can hit recursion limits with deeply nested objects
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]> // Unlimited recursion
: T[P];
};
// Better: limit recursion depth or use a different approach
type DeepReadonlyLimited<T, Depth extends number = 10> = {
readonly [P in keyof T]: Depth extends 0
? T[P]
: T[P] extends object
? DeepReadonlyLimited<T[P], Prev<Depth>>
: T[P];
};
// Helper type to decrement numbers (simplified version)
type Prev<T extends number> = T extends 1 ? 0 : T extends 2 ? 1 : T extends 3 ? 2 : number;
Understand Performance Implications
Complex conditional types can slow down TypeScript compilation. Profile your build times and simplify when necessary:
// This type might be slow for large unions
type ExpensiveTransform<T> = T extends infer U
? U extends string
? ComplexStringTransform<U>
: U extends number
? ComplexNumberTransform<U>
: U extends object
? ComplexObjectTransform<U>
: U
: never;
// Consider caching intermediate results or using simpler approaches
type CachedTransform<T> = T extends string
? StringTransforms[T & keyof StringTransforms]
: T extends number
? NumberTransforms[T & keyof NumberTransforms]
: DefaultTransform<T>;
Common Pitfalls to Avoid
Pitfall 1: Forgetting about distribution
// This distributes over union members
type WrapInArray<T> = T extends any ? T[] : never;
type Test = WrapInArray<string | number>; // string[] | number[]
// To prevent distribution, wrap in a tuple
type NoDistribution<T> = [T] extends [any] ? T[] : never;
type Test2 = NoDistribution<string | number>; // (string | number)[]
Pitfall 2: Incorrect extends
usage
// Wrong: This doesn't check if T is exactly string
type IsExactlyString<T> = T extends string ? true : false;
type Test = IsExactlyString<"hello">; // true (but "hello" is a string literal, not string)
// Better: Use more specific checks when needed
type IsStringLiteral<T> = T extends string
? string extends T
? false // T is the general string type
: true // T is a specific string literal
: false;
Pitfall 3: Not considering never
behavior
// Never propagates through conditional types
type Transform<T> = T extends string ? T : number;
type Test = Transform<never>; // never (not number as you might expect)
// Handle never explicitly if needed
type SafeTransform<T> = [T] extends [never]
? never
: T extends string
? T
: number;
By following these best practices and avoiding common pitfalls, you'll create conditional types that are not only powerful but also maintainable and reliable for your team.