Model mutually exclusive states with discriminated unions, not optional fields. Optional fields allow impossible states (e.g., `status: 'success'` with `error: 'failed'`) that compile but crash at runtime.
Why This Matters
When you model state with optional fields, TypeScript allows combinations that should be impossible. A response object with both `data` and `error` set, or a form with `status: "idle"` and `submittingAt: Date` — these compile without errors but create logic bugs that are invisible until they hit production. Discriminated unions make impossible states unrepresentable, catching these bugs at compile time.
This type allows { status: "success", error: new Error("failed") } — a state that should be impossible. Your code must handle this "impossible" combination defensively, or it will silently produce wrong results.
Discriminated unions make impossible states unrepresentable. When each variant of the union specifies exactly which fields exist, TypeScript ensures you can never create or access an invalid combination. Bugs become compile errors instead of production incidents.
The rule
When a type has mutually exclusive states (loading vs. error vs. success, authenticated vs. anonymous, etc.), model it as a discriminated union with a literal type or status field as the discriminant. Each variant should include only the fields relevant to that state.
Bad example
// BAD: optional fields allow impossible statesinterface RequestState { status: "idle" | "loading" | "success" | "error"; data?: User[]; error?: Error; retryCount?: number;}function handleState(state: RequestState) { if (state.status === "success") { // TypeScript allows state.error to exist here — is that a bug? console.log(state.data); // data might be undefined even on success }}
Good example
// GOOD: each state variant specifies exactly what fields existtype RequestState = | { status: "idle" } | { status: "loading" } | { status: "success"; data: User[] } | { status: "error"; error: Error; retryCount: number };function handleState(state: RequestState) { switch (state.status) { case "idle": return <EmptyState />; case "loading": return <Spinner />; case "success": // TypeScript knows data exists and is User[] return <UserList users={state.data} />; case "error": // TypeScript knows error and retryCount exist return <ErrorBanner error={state.error} retries={state.retryCount} />; }}
How to detect
Look for interfaces with a status/type field alongside multiple optional fields that are only relevant in certain states: