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.
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;}}