Conditional Types for Type-Safe Props Guide
Conditional types in TypeScript enable you to pick one type or another based on whether another type satisfies a condition, using extends and a ternary operator structure. In React, conditional types solve the problem of prop relationships: you can enforce that if a user passes a variant prop with value "primary", then certain props become required or forbidden, creating exhaustive, type-safe component APIs where invalid prop combinations are impossible.
Understanding Conditional Type Syntax
A conditional type follows the pattern: T extends U ? X : Y. If type T is assignable to type U, the result is type X; otherwise, it is type Y. This syntax mirrors JavaScript ternary logic but operates on types rather than values.
Conditional types shine when combined with component props. For example, a Button component might require different props depending on its variant:
type ButtonVariant = "primary" | "secondary" | "danger";
interface BasButtonProps {
children: React.ReactNode;
variant: ButtonVariant;
onClick: () => void;
}
type ButtonProps = BasButtonProps &
(
| {
variant: "primary" | "secondary";
// These variants don't require special props
}
| {
variant: "danger";
// Danger variant requires confirmation
confirmMessage: string;
onConfirm: () => void;
}
);
const Button = ({ children, variant, onClick, ...rest }: ButtonProps) => {
const className = `btn btn-${variant}`;
// rest is narrowed: if variant is 'danger', rest includes confirmMessage
return (
<button className={className} onClick={onClick}>
{children}
</button>
);
};
// ✓ Valid: primary without extra props
<Button variant="primary" onClick={() => {}}>
Click me
</Button>;
// ✓ Valid: danger with confirmMessage
<Button
variant="danger"
onClick={() => {}}
confirmMessage="Are you sure?"
onConfirm={() => {}}
>
Delete
</Button>;
// ✗ Error: danger without confirmMessage
<Button variant="danger" onClick={() => {}}>
Delete
</Button>;
Conditional Types with Generic Constraints
Conditional types become powerful when combined with generic constraints. A reusable form component can adjust its validation behavior based on the form data type:
// A helper to extract error type from a validator
type ExtractError<T> = T extends (value: any) => { error: infer E }
? E
: never;
interface FormInputProps<T, K extends keyof T> {
name: K;
value: T[K];
onChange: (newValue: T[K]) => void;
// Conditionally require error display if validator returns errors
validator?: (val: T[K]) => { error: string } | { success: true };
showError?: ExtractError<
FormInputProps<T, K>["validator"]
> extends never
? false
: boolean;
}
const FormInput = <T, K extends keyof T>({
name,
value,
onChange,
validator,
showError,
}: FormInputProps<T, K>) => {
const [error, setError] = React.useState<string | null>(null);
const handleChange = (newValue: T[K]) => {
if (validator) {
const result = validator(newValue);
if ("error" in result) {
setError(result.error);
} else {
setError(null);
}
}
onChange(newValue);
};
return (
<div>
<input
value={String(value)}
onChange={(e) => handleChange(e.target.value as T[K])}
/>
{showError && error && <span className="error">{error}</span>}
</div>
);
};
Conditional Type Helpers for Component APIs
Building reusable conditional type helpers makes component APIs clearer. For example, a component that renders differently based on whether it receives children:
// Conditional type helper: if T is never, return a string; else return T
type Fallback<T, F> = T extends never ? F : T;
interface CardProps {
title: string;
children?: React.ReactNode;
emptyMessage?: string; // Only useful if no children
}
// Refined props that enforce the relationship
type RefinedCardProps = CardProps &
(
| { children: React.ReactNode; emptyMessage?: undefined }
| { children?: undefined; emptyMessage: string }
);
export const Card = ({ title, children, emptyMessage }: RefinedCardProps) => (
<div className="card">
<h2>{title}</h2>
{children ? <div>{children}</div> : <p>{emptyMessage}</p>}
</div>
);
// ✓ Valid: children provided
<Card title="Users">User list</Card>;
// ✓ Valid: emptyMessage provided
<Card title="Users" emptyMessage="No users found" />;
// ✗ Error: neither children nor emptyMessage
<Card title="Users" />;
Conditional Type Patterns Table
| Pattern | Description | Use Case |
|---|---|---|
T extends U ? X : Y | Basic ternary | Type narrowing on simple conditions |
T extends A | B ? X : Y | Union distribution | Handle multiple types in unions |
T extends any[] ? T[0] : never | Array element extraction | Get first element type |
keyof T extends never ? true : false | Empty type detection | Distinguish empty objects |
T extends readonly [...any[]] ? true : false | Tuple detection | Identify tuple vs array |
Key Takeaways
- Conditional types use
extends ? :syntax to select one type based on another type's assignability. - Combine conditional types with union types to enforce prop relationships in components (e.g., variant-specific required props).
- Conditional types enable "impossible states": the TypeScript compiler prevents invalid prop combinations before runtime.
- Use conditional type helpers to reduce repetition and clarify intent in complex component APIs.
- Conditional types distribute over unions automatically, simplifying code for multi-variant components.
Frequently Asked Questions
How do conditional types interact with type inference?
When you write a function with a conditional type parameter, TypeScript infers the type parameter from your argument and evaluates the conditional. For example, if a function is <T>() => T extends string ? "text" : "other", and you pass a number, the return type is "other". TypeScript evaluates conditionals during inference automatically.
Can I use && or || operators in a conditional type?
No. Conditional types only support the ternary ? : operator. If you need complex logic, compose multiple conditional types or use union types to express alternatives explicitly.
What's the difference between conditional types and function overloads?
Function overloads declare multiple signatures before a single implementation. Conditional types embed type logic directly in generic types. Overloads are better for API clarity when you have a small number of cases; conditional types scale better for generated or highly generic scenarios.
Do conditional types hurt performance or build time?
Very deeply nested conditional types can slow TypeScript compilation. For production code, keep conditional types reasonably shallow (2–3 levels deep). If your types grow too complex, refactor into smaller helper types or consider simplifying your API.