0 day streak
javascript / expert
Snippet

Discriminated Union State Modeling for Async Resource Signals

Instead of tracking `loading`, `data`, and `error` as three separate optional fields (which allows impossible combinations like loading=true with data set), this models the resource as a discriminated union keyed by a `status` literal. TypeScript's control-flow narrowing means that inside each `@switch` branch, only the fields valid for that state are accessible — the compiler physically prevents accessing `.data` while `status` is `'loading'`. The helper `asLoaded`/`asError` functions perform a runtime assertion that mirrors the compile-time narrowing, which is necessary because template expressions don't retain TypeScript's control-flow narrowing across method calls the way inline code does.

snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type Loading = { status: 'loading' };
type Loaded<T> = { status: 'loaded'; data: T };
type Failed = { status: 'error'; error: string };
type ResourceState<T> = Loading | Loaded<T> | Failed;
@Component({
selector: 'app-user-panel',
standalone: true,
template: `
@switch (state().status) {
@case ('loading') { <p>Loading…</p> }
@case ('loaded') { <p>{{ asLoaded(state()).data.name }}</p> }
@case ('error') { <p class="err">{{ asError(state()).error }}</p> }
}
`,
})
export class UserPanelComponent {
private readonly userId = input.required<string>();
private readonly userService = inject(UserService);
readonly state = computed<ResourceState<User>>(() => {
const result = this.userService.fetchState(this.userId());
return result;
});
asLoaded(s: ResourceState<User>): Loaded<User> {
if (s.status !== 'loaded') throw new Error('not loaded');
return s;
}
asError(s: ResourceState<User>): Failed {
if (s.status !== 'error') throw new Error('not an error state');
return s;
}
}
angular
Breakdown
1
type ResourceState<T> = Loading | Loaded<T> | Failed;
A generic discriminated union — the single source of truth for every valid state combination, eliminating impossible states by construction.
2
@case ('loaded') { <p>{{ asLoaded(state()).data.name }}</p> }
Angular's new control-flow syntax narrows on the literal `status` value in the template itself, mirroring TypeScript's switch narrowing.
3
if (s.status !== 'loaded') throw new Error('not loaded');
Runtime guard that re-establishes type narrowing lost across the template-to-method boundary, converting a logic error into a loud failure instead of a silent `undefined`.