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 { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger';
Wiring validation and Swagger docs in NestJS
validation
openapi
decorators
Intermediate
8 steps
typescript
import { Component, Input } from '@angular/core'; interface Order { id: string;
How Angular ICU plurals localize an order summary
i18n
pluralization
standalone-component
Intermediate
8 steps
typescript
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable, catchError, concatMap, finalize } from 'rxjs'; import { DataSource, QueryRunner } from 'typeorm';
Wrapping requests in a transaction with NestJS
interceptors
transactions
rxjs
Advanced
7 steps
javascript
import { useReducer, useEffect } from "react"; const initialState = { status: "idle", data: null, error: null };
Building a data-fetching hook in React
custom-hooks
usereducer
data-fetching
Intermediate
9 steps
typescript
type CsvColumn<T> = { header: string; value: (row: T) => string | number | boolean | null | undefined; };
Building a type-safe CSV writer in TypeScript
generics
serialization
escaping
Intermediate
7 steps
typescript
import { parsePhoneNumberFromString, CountryCode } from 'libphonenumber-js'; export interface NormalizedPhone { e164: string;
Normalizing phone numbers to E.164 in TypeScript
validation
normalization
error-handling
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.