Model State with Discriminated Unions in TypeScript
Flags TypeScript state built from booleans and optional fields, like isLoading plus data?, that allows impossible states a discriminated union rules out.
What it flags
import type { Report } from './report';
type ReportState = {
isLoading: boolean;
isError: boolean;
report?: Report;
error?: string;
};
export function describeReport(state: ReportState): string {
if (state.isLoading) {
return 'Generating report...';
}
if (state.isError) {
return `Failed: ${state.error ?? 'unknown error'}`;
}
// the type allows report to be missing here
return `Ready: ${state.report!.rowCount} rows`;
}import type { Report } from './report';
type ReportState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'ready'; report: Report };
export function describeReport(state: ReportState): string {
switch (state.status) {
case 'loading':
return 'Generating report...';
case 'error':
return `Failed: ${state.error}`;
case 'ready':
return `Ready: ${state.report.rowCount} rows`;
}
}Why it matters
A type with isLoading, isError, data?, and error? has sixteen combinations, and most of them are meaningless. Code that sets these fields in several places eventually produces one, such as a spinner over stale data or an error state with no message, and every reader has to guess which combinations can occur. A discriminated union makes each state explicit and lets the compiler narrow fields without non-null assertions or fallback values.
Agent instructions
cubic checks every pull request against these instructions. Paste them into AGENTS.md or Cursor rules and your AI coding tool follows them while it writes code.
AGENTS.md
Add this section to AGENTS.md at the repository root. Codex, Cursor, and other AI coding tools read it.
## Model State with Discriminated Unions in TypeScript
Applies to `**/*.ts`, `**/*.tsx`, `**/*.mts`, `**/*.cts`.
Review TypeScript types for state, results, and domain records that allow combinations of fields that should never exist together.
Flag changes that:
- Model async or request state with separate booleans and optional fields, such as `isLoading`, `isError`, `data?`, and `error?`, where a union keyed on `status` would make invalid combinations unrepresentable.
- Add a `status` or `kind` field but keep variant-specific fields optional at the top level instead of attaching each field to the variant where it exists.
- Return result objects such as `{ ok: boolean; value?: T; error?: string }` instead of `{ ok: true; value: T } | { ok: false; error: string }`.
- Represent mutually exclusive modes with several booleans, such as `isEditing`, `isSaving`, and `isDeleting`, that the code must keep in sync by hand.
- Add optional fields that are only valid together, such as `shippedAt?` and `trackingNumber?`, without grouping them into one variant or one optional object.
- Read fields with `!`, `?.`, or fallbacks such as `?? ''` because the type cannot show which fields exist in the current state.
Do not flag:
- Fields that are independently optional, such as a `nickname?` on a profile, or independent UI toggles such as `isMenuOpen`.
- Types that mirror an external API response or database row at the boundary, as long as the code converts them before other logic uses them.
- State objects returned by third-party libraries; flag only types the diff defines.
Checks files matching **/*.ts, **/*.tsx, **/*.mts, **/*.cts.
More TypeScript agents
See all TypeScript code review standards
Run this agent on every pull request
cubic reviews every pull request against the agents you turn on and comments on the lines that break them.