2 / 3
Make combinations that must never exist impossible to write
A rule that depends on people paying attention falls apart the moment something that can't pay attention writes the code.
In the beginning
The profile screen's state had three fields: whether it was loading, the data, and the error.
The person who built it was careful. On success, they turned loading off. On failure, they turned loading off and set error. They never missed it once.
type State = {
loading: boolean;
data?: User;
error?: Error;
};Meanwhile
More and more places started changing the state. The first load, pull to refresh, polling every 30 seconds, refetching after an edit was saved, and resetting when the user signed out in another tab.
Five places, five people. One of them added a separate isRefreshing to show a refresh indicator. Now two fields meant "loading." Only the original author knew that all five places had to follow the same rule.
type State = {
loading: boolean;
isRefreshing: boolean;
data?: User;
error?: Error;
lastFetchedAt?: number;
};setState({ ...state, loading: true });
const data = await fetchProfile();
setState({ ...state, loading: false, data, lastFetchedAt: Date.now() });setState({ ...state, isRefreshing: true });
await refetch();
setState({ ...state, isRefreshing: false });useEffect(() => {
const id = setInterval(refetch, 30_000);
return () => clearInterval(id);
}, []);await saveProfile(form);
setState({ ...state, loading: true });
refetch();onSignOut(() => {
setState({ loading: false, isRefreshing: false });
});Three months later, the sixth place
"Show a retry button when there's an error."
The new developer asked an AI. The AI used one of the five places as a reference and rewrote the catch block cleanly. It looked right, and it passed the type check.
catch (e) {
setState({ ...state, error: e });
// loading stays true
}Friday evening
The API server wobbled for a moment. On users' screens, the spinner kept spinning, with an error message and a retry button right below it.
Pressing retry added another spinner under the spinner. A screenshot showed up in Slack.
Why it happened
The type allowed a state that was loading and failed at the same time. With five fields and six places changing them, remembering every time that those two must never coexist is hard even for people.
The AI didn't even have that memory. It just picked the most plausible code among the options the type allowed.
Before it breaks
List only the states that can actually happen, as a union. A state that is both loading and failed can no longer even be written.
If the AI makes the same mistake, this time the build stops it first.
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };switch (state.status) {
case "loading": return <Spinner />;
case "success": return <Profile user={state.data} />;
case "error": return <ErrorView error={state.error} />;
}