Types
Making impossible states unrepresentable in TypeScript
Here is a form status type you have almost certainly written, or inherited:
type FormStatus = {
isValid: boolean
isSubmitting: boolean
isSuccess: boolean
isFailure: boolean
} Looks harmless. Now count the states. Each boolean has 2 values, and there are four of them: 2 × 2 × 2 × 2 = 16 possible states. How many does your form actually have? Maybe five: editing, invalid, submitting, succeeded, failed.
So this type can represent eleven states that should never exist. What does it mean when isSuccess and isFailure are both true? When the form is isSubmitting while isSuccess? Nobody knows — but the type system happily allows all of them, so eventually some code path produces one, and some other code path renders a success message and an error banner at the same time. You've seen this bug. Everyone has.
Count your states: cardinality
The cardinality of a type is the number of legal values it can hold. boolean has cardinality 2. "North" | "South" | "East" | "West" has cardinality 4. string has cardinality ∞ — which is why type Email = string is such a weak claim.
Type design is a counting exercise: make the cardinality of your type equal to the number of states your domain actually has. Every extra representable state is a bug waiting for a code path.
The fix: sum types
Object types multiply cardinalities (they're called product types for a reason). Union types add them — which is what we want here:
type FormStatus =
| { _t: "Editing" }
| { _t: "Invalid"; error: string }
| { _t: "Submitting" }
| { _t: "Success" }
| { _t: "Failure"; error: string } Five states. Not sixteen, not eleven illegal ones — five. "Success and failure at the same time" is no longer a bug you can write; it's a type error. And notice the bonus: the error string only exists in the states that have an error. You can't forget to clear it, because transitioning to Success structurally discards it.
The compiler also pays you back when you consume the type. switch on _t and TypeScript narrows each branch — and if you add a sixth state next sprint, every non-exhaustive switch in your codebase fails to compile until you handle it:
function statusMessage(status: FormStatus): string {
switch (status._t) {
case "Editing": return ""
case "Invalid": return status.error
case "Submitting": return "Saving…"
case "Success": return "Saved!"
case "Failure": return status.error
}
} A real example: authentication state
This is not just a forms trick. Here is the actual top-level state of the ts-bedrock web app:
// Web/src/State.ts
export type State = PublicState | AuthState
export type PublicState = {
_t: "Public" | "LoadingAuth"
route: Route
login: LoginState
}
export type AuthState = Omit<PublicState, "_t"> & {
_t: "Auth"
profile: User
updateProfile: UpdateProfileState
} Read what this guarantees. The profile: User field — not User | null, not User | undefined — only exists on AuthState. There is no way to be logged in without a user, and no way to have a user while logged out. The classic crash — user.name where user is null because someone forgot a loading check — cannot be written, because the state where it would happen cannot be constructed.
Code that needs authentication doesn't check a flag; it pattern-matches. In ts-bedrock this is wrapped in a tiny lens so auth-only actions are a no-op in public state:
export function _AuthState(
fn: (authState: AuthState) => [State, Cmd],
): Action {
return (state: State) =>
state._t === "Auth" ? fn(state) : [state, []]
} Where to apply this
- Async data:
RemoteData<E, T>=NotAsked | Loading | Failure<E> | Success<T>— instead of{ data?, error?, isLoading }, which has the same 16-states disease. - Multi-step flows: wizards, checkout, onboarding — one variant per step, each carrying only the data that exists at that step.
- Selections that can't be empty or can't repeat: don't reach for
Arrayby default; an array of platforms can hold duplicates and be empty even when your domain forbids both.
The pattern is always the same: enumerate the real states, give each one a variant, attach data only to the variants that have it, and let exhaustive switch statements do the rest. You stop writing defensive checks for states that "shouldn't happen" — because they can't.