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.
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.
BeforeMerge scans your pull requests against this rule and 3+ others. Get actionable feedback before code ships.
Consider a data fetching state with optional fields:
interface FetchState {
status: "idle" | "loading" | "success" | "error";
data?: User[];
error?: Error;
}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.
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: optional fields allow impossible states
interface 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: each state variant specifies exactly what fields exist
type 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} />;
}
}Look for interfaces with a status/type field alongside multiple optional fields that are only relevant in certain states:
grep -n "status.*|.*\?" --include="*.ts" --include="*.tsx" -r src/During code review, ask: "Can these optional fields be set in combinations that don't make sense?"
if/else chains with switch on the discriminantnever trick) to catch unhandled states:function assertNever(x: never): never {
throw new Error(`Unexpected state: ${JSON.stringify(x)}`);
}