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 { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 steps
typescript
import { Injectable, Scope, Inject, NotFoundException } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; import { DataSource } from 'typeorm';
Per-tenant database connections in NestJS
multi-tenancy
connection-pooling
dependency-injection
Advanced
8 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.