Make illegal states unrepresentable
GoByte Skills #15: Three booleans are 8 states and your screen means three. Model React state as a tagged union and the compiler rules out the other 5. Validate the data at the edge, and the inside stays honest.
Arthur is one of GoByte's characters. This post was drafted by AI agents in Arthur's voice, then fact checked, run and edited by the GoByte team.
If the type cannot express an invalid state, no test is needed to rule it out. So model props and state as a tagged union, not a bag of booleans.
Transcript
Three boolean switches flicker through invalid combinations, then collapse into one tagged union with three cases while the TypeScript types in, and a switch narrows each case to one shape, because a discriminant leaves no room for the states in between.
The arithmetic#
isLoading, isError and isSuccess are three flags, so 2^3 = 8 combinations. Your screen means three of them. The other 5 (loading and failed at once, failed and succeeded at once, none of the above) are still legal values, so every render must defend against them and every test must prove it did.
import type { ReactElement } from "react";
type Load =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "ok"; users: string[] };
const li = (u: string) => <li key={u}>{u}</li>;
function Users({ s }: { s: Load }): ReactElement {
switch (s.status) {
case "loading":
return <p>Loading</p>;
case "error":
return <p role="alert">{s.error}</p>;
case "ok":
return <ul>{s.users.map(li)}</ul>;
}
}
const bad: Load = { status: "ok", error: "?" };
tsc --strict refuses the last line: error TS2353: ... 'error' does not exist in type '{ status: "ok"; users: string[]; }'. Delete it and the file compiles.
Why it works#
status is a discriminant: a property whose type is a different string literal in each case. Comparing it narrows s to exactly one case, so s.error exists only in the "error" branch and s.users only in "ok". Three cases, three shapes, nothing in between. Delete a case and the build fails again: TS2366: Function lacks ending return statement, because the declared return type does not include undefined. The compiler now does the exhaustive testing for free. The same goes for state: useState<Load>({ status: "loading" }) forces every setS to build a whole case. You cannot set the data and forget to clear the error, because a case with both does not exist.
The boundary#
Types are erased at build time. await res.json() returns any, and the compiler will believe whatever you assert about it. Validate at the edge (a parser such as zod, or a handwritten check) and construct a Load there. Inside, the union holds.
The cost#
You cannot destructure error from s before narrowing (TS2339: Property 'error' does not exist on type 'Load'), and adding a case means touching every switch. That second one is the feature: the compiler hands you the list.
Rule of thumb#
Count your booleans. n flags are 2^n states, and if you cannot name every one of them, neither can your tests. Booleans are how a component says "it depends". A union says on what.
Your product here? Partner with us
Back to topDiscussion
No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines
Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.