typescript
41 lines · 8 steps
Modeling request state with discriminated unions
A discriminated union makes each request phase carry exactly the data it needs, and the compiler enforces every case.
Explained by
highlit
1type RequestState<T, E = string> =
2 | { status: "idle" }
3 | { status: "loading" }
4 | { status: "success"; data: T; fetchedAt: number }
5 | { status: "error"; error: E; retryable: boolean };
6
7function reducer<T>(
8 state: RequestState<T>,
9 action:
10 | { type: "fetch" }
11 | { type: "resolve"; data: T }
12 | { type: "reject"; error: string; retryable: boolean }
13): RequestState<T> {
14 switch (action.type) {
15 case "fetch":
16 return { status: "loading" };
17 case "resolve":
18 return { status: "success", data: action.data, fetchedAt: Date.now() };
19 case "reject":
20 return { status: "error", error: action.error, retryable: action.retryable };
21 }
22}
23
24function render<T>(state: RequestState<T>, view: (data: T) => string): string {
25 switch (state.status) {
26 case "idle":
27 return "Nothing loaded yet";
28 case "loading":
29 return "Loading\u2026";
30 case "success":
31 return view(state.data);
32 case "error":
33 return state.retryable
34 ? `${state.error} \u2014 tap to retry`
35 : `Failed: ${state.error}`;
36 default: {
37 const _exhaustive: never = state;
38 return _exhaustive;
39 }
40 }
41}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Discriminated unions let each state own only the fields that make sense for it, so impossible combinations become unrepresentable.
- 2Switching on the shared discriminant field narrows the type, giving you safe access to phase-specific data without casts.
- 3Assigning the remaining case to `never` turns a forgotten branch into a compile error, keeping switches in sync with the union.
Related explainers
typescript
import { Directive, Input, TemplateRef,
Building a structural *appUnless directive in Angular
structural-directive
template-rendering
dependency-injection
Intermediate
8 steps
typescript
type EventMap = Record<string, unknown[]>; type Listener<Args extends unknown[]> = (...args: Args) => void;
A type-safe event emitter in TypeScript
generics
mapped-types
event-emitter
Advanced
8 steps
java
public final class Debouncer<T> { private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
How a debouncer collapses bursts in Java
debounce
concurrency
scheduling
Intermediate
9 steps
typescript
import { inject } from '@angular/core'; import { ResolveFn, Router, ActivatedRouteSnapshot } from '@angular/router'; import { catchError, of, EMPTY } from 'rxjs'; import { Article } from './models/article';
Prefetching route data with an Angular resolver
route-resolver
dependency-injection
rxjs
Intermediate
6 steps
typescript
interface Order { id: number; customerId: string; total: number;
A type-safe groupBy in TypeScript
generics
reduce
type-inference
Intermediate
6 steps
typescript
import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm';
Scheduled session cleanup in NestJS
cron-scheduling
background-jobs
repository-pattern
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/modeling-request-state-with-discriminated-unions-explained-typescript-7d81/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.