//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Use discriminated union types to model component props that change shape based on a variant or status field. Achieve exhaustive pattern matching so TypeScript catches missing cases at compile time.
// Props that change shape based on a "variant" discriminant
type AlertProps =
| { variant: "success"; message: string }
| { variant: "error"; message: string; retryAction: () => void }
| { variant: "loading" };
function Alert(props: AlertProps) {
switch (props.variant) {
case "success":
return <div className="alert-success">{props.message}</div>;
case "error":
return (
<div className="alert-error">
<p>{props.message}</p>
<button onClick={props.retryAction}>Retry</button>
</div>
);
case "loading":
return <div className="alert-loading">Loading...</div>;
}
}
// Usage
<Alert variant="success" message="Saved!" />
<Alert variant="error" message="Failed" retryAction={() => refetch()} />
<Alert variant="loading" />// Exhaustive check helper
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}
function getStatusColor(props: AlertProps): string {
switch (props.variant) {
case "success": return "green";
case "error": return "red";
case "loading": return "gray";
default: return assertNever(props);
// If you add a new variant and forget to handle it,
// TypeScript will error on this line.
}
}AlertProps, the discriminant is variant.switch or if block that checks the discriminant, TypeScript narrows the type to the specific union member. In the "error" case, props.retryAction is available because TypeScript knows props is { variant: "error"; message: string; retryAction: () => void }.assertNever pattern catches missing cases at compile time. If you add a new variant to the union but forget to handle it in the switch, TypeScript will error because the new variant type is not assignable to never.retryAction to a "success" alert.Async data pattern:
type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function UserProfile({ state }: { state: AsyncData<User> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <div>{state.data.name}</div>;
case "error":
return <div>Error: {state.error.message}</div>;
}
}Form field union:
type FormField =
| { type: "text"; label: string; placeholder?: string }
| { type: "select"; label: string; options: string[] }
| { type: "checkbox"; label: string; checked: boolean };
function FormFieldComponent({ field }: { field: FormField }) {
switch (field.type) {
case "text":
return <input type="text" placeholder={field.placeholder} />;
case "select":
return (
<select>
{field.options.map((opt) => <option key={opt}>{opt}</option>)}
</select>
);
case "checkbox":
return <input type="checkbox" defaultChecked={field.checked} />;
}
}Conditional props without a discriminant:
type ModalProps =
| { dismissible: true; onDismiss: () => void }
| { dismissible?: false };
// TypeScript enforces: if dismissible is true, onDismiss is requiredstring type field does not work for narrowing.in operator narrowing as an alternative: if ("retryAction" in props) narrows to the error variant.satisfies keyword can validate that an object matches a union without widening the type.switch (props.variant) not const { variant } = props; switch (variant) -- the latter loses the connection between variant and the rest of props.assertNever pattern or enable noFallthroughCasesInSwitch.default: return null instead of assertNever will not catch missing cases at compile time.| Approach | Pros | Cons |
|---|---|---|
| Discriminated unions | Impossible states are unrepresentable | More verbose type definitions |
| Optional props | Simpler type definitions | Allows invalid prop combinations |
| Enum discriminant | Named constants, IDE autocomplete | Enums have runtime overhead, string unions preferred |
| Polymorphic components | Single component, many shapes | Complex type signatures |
| Separate components per variant | Each component is simple and focused | Duplicate shared logic |
switch or if blocks.{ variant: "success" } | { variant: "error" }, the discriminant is variant.retryAction to a "success" alert -- the type system prevents it.function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}default case of a switch, it catches missing cases at compile time.never.string or number type does not work for narrowing.const { variant } = props; switch (variant) breaks the connection between variant and the rest of props.props to the correct union member.switch (props.variant) to preserve narrowing.type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };data is only accessible when status is "success".if ("retryAction" in props) narrows to the union member that has retryAction.assertNever to get compile-time exhaustiveness checking.satisfies validates that an object matches a union without widening its type.type ModalProps =
| { dismissible: true; onDismiss: () => void }
| { dismissible?: false };onDismiss is required only when dismissible is true.dismissible is false or omitted, onDismiss cannot be passed.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥