
TypeScript Conditional Types: If-Else at the Type Level
When your function's return type depends on input, conditional types replace 'any' with precise type inference. Type-level if-else explained.

When your function's return type depends on input, conditional types replace 'any' with precise type inference. Type-level if-else explained.
Stop using 'any'. How to build reusable, type-safe components using Generics. Advanced patterns with extends, keyof, and infer.

Fixing the crash caused by props coming through as undefined from the parent component.

Troubleshooting absolute path import configuration issues in TypeScript/JavaScript projects. Exploring the 'Map vs Taxi Driver' analogy, CommonJS vs ESM history, and Monorepo setup.

Managing API state with `isLoading`, `isError`, `data`? Learn how Discriminated Unions prevent 'impossible states' and simplify your logic.

When I first started building APIs in TypeScript, I kept running into the same problem: functions whose return types depended on input parameters. Before I learned about conditional types, I had two bad options: use any and lose type safety, or write repetitive function overloads that were a maintenance nightmare.
// I was doing this - just giving up with 'any'
function fetchData(includeDetails: boolean): any {
if (includeDetails) {
return { id: 1, name: "User", details: { age: 30 } };
}
return { id: 1, name: "User" };
}
const basic = fetchData(false); // any type... no type checking
const detailed = fetchData(true); // also any...
Function overloads helped a bit, but as cases multiplied, the code became messy. Then I discovered conditional types and had this realization: "You can use if-else logic at the type level." It felt like taking runtime conditionals and moving them to compile time.
The syntax looks exactly like a ternary operator:
T extends U ? X : Y
It means "if T is assignable to U, then X, otherwise Y." At first, I didn't see the point. But combined with generics, it unlocks incredible flexibility.
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true (literal types are assignable to string)
Applied to my API function problem:
type User = { id: number; name: string };
type DetailedUser = User & { details: { age: number; email: string } };
function fetchData<T extends boolean>(
includeDetails: T
): T extends true ? DetailedUser : User {
if (includeDetails) {
return {
id: 1,
name: "User",
details: { age: 30, email: "user@example.com" }
} as any; // Implementation needs type assertion
}
return { id: 1, name: "User" } as any;
}
const basic = fetchData(false); // Inferred as User
const detailed = fetchData(true); // Inferred as DetailedUser
console.log(basic.name); // OK
console.log(detailed.details.age); // OK - type-safe access
When I first saw this work, I thought "finally, I can ditch any."
As you explore conditional types, you'll encounter the infer keyword. Initially, it was confusing. But thinking of it as "pattern matching for types" made it click - like regex capture groups, but for types.
// Extract a function's return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "John" };
}
type UserType = ReturnType<typeof getUser>; // { id: number; name: string }
infer R means "capture the type at this position into a variable called R." It captures the function's return type into R and returns it.
Extracting array element types was another useful pattern:
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type Numbers = ArrayElement<number[]>; // number
type Strings = ArrayElement<string[]>; // string
type Complex = ArrayElement<Array<{ id: number }>>; // { id: number }
Unwrapping Promise types was even more practical:
type Awaited<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: 1, name: "Alice" };
}
type User = Awaited<ReturnType<typeof fetchUser>>; // { id: number; name: string }
Before this, I manually defined types for async function returns. With Awaited and ReturnType, it's automatically inferred.
Something interesting happens when you apply conditional types to union types - they automatically distribute across the union members.
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (distributed application)
// NOT (string | number)[]
I expected (string | number)[], but got string[] | number[] instead. Turns out, conditional types applied to unions are applied to each member individually.
This pattern powers TypeScript's built-in utility types:
// NonNullable: remove null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;
type A = NonNullable<string | null | undefined>; // string
// Extract: extract specific types
type Extract<T, U> = T extends U ? T : never;
type B = Extract<"a" | "b" | "c", "a" | "b">; // "a" | "b"
// Exclude: exclude specific types
type Exclude<T, U> = T extends U ? never : T;
type C = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
Understanding this pattern made the TypeScript docs' utility types finally make sense.
Conditional types really shined when building event handlers where payload types depended on event names.
type EventMap = {
click: { x: number; y: number };
keypress: { key: string; shift: boolean };
scroll: { deltaY: number };
};
// Handler parameter type determined by event name
type EventHandler<E extends keyof EventMap> = (
payload: EventMap[E]
) => void;
class EventEmitter {
on<E extends keyof EventMap>(
event: E,
handler: EventHandler<E>
) {
// implementation...
}
}
const emitter = new EventEmitter();
emitter.on("click", (payload) => {
console.log(payload.x, payload.y); // payload is { x: number; y: number }
});
emitter.on("keypress", (payload) => {
console.log(payload.key); // payload is { key: string; shift: boolean }
});
// Type error: wrong payload type
emitter.on("scroll", (payload) => {
// console.log(payload.key); // Error: 'key' does not exist
});
Previously, I either accepted any handlers or created separate methods for each event. Conditional types let me unify them into one method while keeping type safety.
The most useful pattern in real projects was changing API response types based on options.
type Post = {
id: number;
title: string;
authorId: number;
};
type Author = {
id: number;
name: string;
email: string;
};
type PostWithAuthor = Post & { author: Author };
interface FetchOptions {
includeAuthor?: boolean;
includeDraft?: boolean;
}
type FetchResult<T extends FetchOptions> =
T["includeAuthor"] extends true
? PostWithAuthor
: Post;
async function fetchPost<T extends FetchOptions>(
id: number,
options: T
): Promise<FetchResult<T>> {
const post = await fetch(`/api/posts/${id}`).then(r => r.json());
if (options.includeAuthor) {
const author = await fetch(`/api/authors/${post.authorId}`).then(r => r.json());
return { ...post, author } as any;
}
return post as any;
}
// Usage examples
const postOnly = await fetchPost(1, { includeAuthor: false });
console.log(postOnly.title); // OK
const postWithAuthor = await fetchPost(1, { includeAuthor: true });
console.log(postWithAuthor.author.name); // OK - knows author exists
The key insight: derive return types from the options object's type. Runtime values (includeAuthor: true) become compile-time type information.
TypeScript 4.1 introduced template literal types. Combined with conditional types, you can manipulate string types.
// Check if string matches a pattern
type IsEvent<T> = T extends `on${string}` ? true : false;
type A = IsEvent<"onClick">; // true
type B = IsEvent<"onHover">; // true
type C = IsEvent<"handleClick">; // false
// Extract event name from handler name
type ExtractEventName<T> = T extends `on${infer E}` ? Lowercase<E> : never;
type Event1 = ExtractEventName<"onClick">; // "click"
type Event2 = ExtractEventName<"onMouseMove">; // "mousemove"
// Practical example: auto-generate React component props
type EventProps<T extends string> = {
[K in T as `on${Capitalize<K>}`]: (event: { type: K }) => void;
};
type ButtonProps = EventProps<"click" | "hover">;
// {
// onClick: (event: { type: "click" }) => void;
// onHover: (event: { type: "hover" }) => void;
// }
When I first saw this, I was amazed: "The type system can do string parsing?" It's like having a parser at the type level.
Conditional types are powerful, but not always necessary. Overusing them hurts readability.
Too complex:// Overly nested conditional type
type SuperComplex<T> = T extends string
? T extends `${infer A}_${infer B}`
? A extends "user"
? B extends "admin"
? "UserAdmin"
: "UserNormal"
: "Other"
: "SimpleString"
: never;
This is hard to read and debug. Better approach:
// Simple type mapping is clearer
type RoleMap = {
"user_admin": "UserAdmin";
"user_normal": "UserNormal";
};
type GetRole<T extends keyof RoleMap> = RoleMap[T];
When unions suffice:
// Conditional types unnecessary
type Status = "loading" | "success" | "error";
function handleStatus(status: Status) {
// Union types are type-safe enough
}
The lesson: use conditional types when output types depend on input types.
As conditional types get complex, you'll wonder "why is this type inferred this way?" Here's my debugging pattern:
// 1. Break types into steps
type Step1<T> = T extends string ? true : false;
type Step2<T> = Step1<T> extends true ? "is string" : "not string";
// 2. Extract types to variables for inspection
type Test = Step2<"hello">; // inferred as "is string"
// 3. Directly test extends conditions
type Check = "hello" extends string ? "yes" : "no"; // "yes"
VSCode shows final types on hover, but to see intermediate steps, you need to break them down like this.
Debug helper type:// Force type evaluation for display
type Evaluate<T> = T extends infer U ? U : never;
type Complex = { a: string } & { b: number };
type Simple = Evaluate<Complex>; // VSCode shows { a: string; b: number }
Conditional types are if-else logic for types. They let you create functions with input-dependent return types without using any.
Key patterns:
T extends U ? X : YAt first, the concept of "conditionals in types" felt alien. But once it clicked, I could eliminate any from API functions, event handlers, and utility types. Ultimately, conditional types let you bring runtime flexibility to the type level without sacrificing type safety.