Kill the boolean soup
Almost every Angular service I've inherited has a corner that looks like this:
interface SoupState<T> {
isLoading: boolean;
error?: string;
data?: T;
}
It reads harmless. It isn't, and the reason is arithmetic: a boolean and two optionals give you more representable combinations than real situations. isLoading: true with error already set. An error and data both present at once. Data present while loading is true. None of these should happen in the simple case — one request, one result, no stale-while-revalidate caching layered on top — and all of them compile:
const lie: SoupState<string[]> = { isLoading: true, error: 'timeout', data: [] };
So every component that consumes this state carries little defensive rituals — check one flag before the other, hope the order is right, add a ?? [] just in case. The bugs that slip through are the "spinner and error showing at the same time" class. Most Angular developers have seen that screenshot.
Model the states, not the flags
The fix is to stop describing state with independent flags and describe it as what it actually is — one of a small set of shapes:
export type ApiState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'error'; error: string }
| { kind: 'loaded'; data: T };
Four states, four shapes. error exists only in the error state. data exists only when loaded. The lie from earlier is now a type error — it cannot be constructed.
Consumption gets simpler too, because narrowing does the guard work:
switch (state.kind) {
case 'idle': return 'nothing requested yet';
case 'loading': return 'spinner';
case 'error': return `error: ${state.error}`; // error is available here
case 'loaded': return `rows: ${JSON.stringify(state.data)}`; // data is available here
}
In an Angular component this pairs naturally with a signal — signal<ApiState<User[]>>({ kind: 'idle' }) — and the template switches on kind. No flag can contradict another, because there are no flags left.
The part people skip: exhaustiveness
The quiet superpower is the default arm:
default: {
const unreachable: never = state;
return unreachable;
}
If someone adds a fifth state next quarter — say 'stale' — every switch written with this never-typed default stops compiling until it handles the new case, and the error points at that exact line. That's the difference between a refactor you find at build time and one you find in production, one forgotten component at a time. A switch without the pattern isn't automatically silent, though: under strictNullChecks, TypeScript's own TS2366 check already catches a missing case when the switch is the function's last statement and the declared return type excludes undefined. It stays quiet without strictNullChecks, when the return type is inferred, when it includes undefined, or when a fallback return follows the switch. Angular 21.2+ templates have their own version now too — @switch supports @default never;, or @default never(state); when the discriminant is nested like our state.kind — but only if the switched value is a plain variable; switching on a signal call such as state().kind directly defeats the narrowing, so assign it to an @let first if you want the template checked the same way. Both checks only cover the switches someone remembered to write them into — the union type itself is what protects every consumer, checked or not.
The whole pattern is maybe twenty lines and it removes this class of bug from code that stays typed — an any cast or a raw JSON.parse result can still manufacture the impossible shape at runtime. The demo repo compiles under --strict and shows both versions side by side — including the lie the soup happily accepts.