Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~38 min· topic 5 of 5

Topic 17.5

Capstone: A Fully Typed App

In one line

Build one typed task tracker step by step: branded IDs, a discriminated-union task state, a typed event bus, validated storage, a typed fetch client and exhaustive rendering, then run the whole app in one file.

Think of it like this

A LEGO model with the instruction booklet. You do not pour out the bricks and hope. Step 1 builds the base, step 2 the walls, and each step clicks onto the one before. This capstone is the same. Five small pieces, each type-checked and run on its own, then clicked together into one app. Every piece uses a lesson from this course, and the compiler checks every join.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Branded type
A string or number with an invisible tag, so two kinds of ID cannot be mixed up.
Event map
One type that lists every event name and its payload, used to type on and emit.
Event bus
A small object where parts of an app publish events and others subscribe, without knowing each other.
Boundary validation
Checking outside data (storage, network) once, where it enters, so the rest of the code can trust its types.
Result type
A union of success and failure, { ok: true; value } | { ok: false; status }, that forces callers to handle both.
Route map
An interface that ties each URL path to the type of its response.
Exhaustive rendering
A switch on the state that ends in assertNever, so every state has a screen.
Capstone
A final project that uses everything you learned together.

Step by step

01The plan

Data flows in one direction: the fake server (typed client) gives tasks, events change the state, storage saves it, and render shows it. Every arrow has a type, and the compiler checks every arrow.

The plandiagram
Rendering diagram…

02Step 1: domain types

Brand<T, B> is T & { readonly __brand: B }. TaskId and UserId are both strings at runtime, but find(tasks, userId('t-1')) is TS2345 because UserId is not a TaskId. By convention only the constructor function taskId(raw) creates one, and it validates the format (t- plus digits). The compiler cannot enforce that: an as TaskId written anywhere else would also compile (Topic 10.4), so treat every other as TaskId as a review finding.

TaskState has three members, each with a literal status. describe switches on s.status, so inside case 'doing' TypeScript knows s.assignee exists. The default assigns s to a never variable: the compiler's way of saying 'no case is left'.

domain.tswhole filets
type Brand<T, B extends string> = T & { readonly __brand: B };
type TaskId = Brand<string, "TaskId">;

type TaskState =
  | { status: "todo" }
  | { status: "doing"; assignee: UserId }
  | { status: "done"; finishedAt: string };

03Step 2: the typed event bus

Bus<Events extends Record<string, unknown>> stores listeners per event name. on<K extends keyof Events>(event: K, listener: Listener<Events[K]>) links the name to the payload type through K. Hover e in the first on: { title: string }.

on returns an unsubscribe function, so the example calls off() and the last emit reaches nobody. The mapped type { [K in keyof Events]?: Listener<Events[K]>[] } makes the listener store itself typed by the map.

04Step 3: validated storage

load returns Saved[] only after isSavedList proves the shape. The example stores a valid list, then a list where done is the text 'yes', then broken JSON. The outputs are saved: [ { title: 'Write docs', done: false } ], tampered: [] and corrupt: [].

try/catch handles JSON.parse throwing; the type predicate handles valid JSON of the wrong shape. Both are needed.

05Step 4: the typed fetch client

Routes maps paths to responses. get('/tasks/1') returns Result<ApiTask> (see the hover on one), and the check if (one.ok) narrows to the value or the failure. get('/nope') does not compile: Argument of type '"/nope"' is not assignable to parameter of type 'keyof Routes' (add that line to the example to see it).

The fake fakeFetch resolves after 50 ms with a setTimeout, so the example waits for it. The one cast, as Routes[P], is the documented boundary; Topic 13.4's validators replace it in a real client.

06Step 5: the whole app

The last example is the complete app in one file: the domain, the bus, storage, the client and render, wired by three listeners. The pieces are the ones from Steps 1 to 4 (the same taskId that validates the format, the same Bus, the same Result and route map). Only two things change on purpose: storage keeps { id, title, status } instead of Step 3's { title, done }, because the app needs to find a task again, and the fake server is shortened to one route. Run it. The console shows each state change as one line, then the saved list: saved: [ 't-1=done', 't-2=todo' ].

Now do the final check: add | { status: "archived" } to TaskState and read the error. The compiler tells you render has not handled the new state. In a real app that single error stands for every screen, report and API that would have silently shown the wrong thing.

07What you practised

Branded types and discriminated unions (Phases 4 and 10), generics with constraints (Phase 7), mapped and indexed types (Phase 8), type guards and unknown (Phases 1 and 4), async and storage typing (Phase 13), and exhaustiveness. This is what 'fully typed' means: the compiler checks every join between the pieces.

Try it yourself

  1. 1

    Add a state

    In the last example add | { status: "archived" } to TaskState. Read the error, then add a case "archived" to render and watch the error go away.

  2. 2

    Add an event

    Add renamed: { id: TaskId; title: string } to AppEvents, a listener that changes the title, and an emit that uses it. Try emitting with a plain string id and read the error.

  3. 3

    Add a route

    In the fetch client example, add "/users": { id: string; name: string }[] to Routes and the fake server. Call get("/users") and hover the result. What changed in the types without you touching get?

Code & diagrams

Step 1: branded IDs and a discriminated-union state

TaskId and UserId are different types. The task state is one of three shapes, and describe is exhaustive.

Console

(nothing was printed)

Expected console output

Write docs: in progress (asha)
Write docs
Step 2: a typed event bus

The event map types on and emit. The returned function unsubscribes, so the last emit reaches nobody.

Console

(nothing was printed)

Expected console output

added Write docs
finished Write docs 2026-10-12
Step 3: storage that validates what it reads

Valid data loads, tampered data and corrupt JSON both become an empty list.

Console

(nothing was printed)

Expected console output

empty: []
saved: [ { title: 'Write docs', done: false } ]
tampered: []
corrupt: []
Step 4: a typed client for a fake API

The route map ties each path to its response type, and the Result union forces a check before use.

Console

(nothing was printed)

Expected console output

[ 'Write docs', 'Ship it' ]
Write docs
false
Step 5: the whole app in one file

Domain, bus, storage, client and exhaustive rendering together. Add a fourth state to TaskState to watch the compiler find render.

Console

(nothing was printed)

Expected console output

[ ] Write docs
[ ] Write docs | [ ] Ship it
[~] Write docs (asha) | [ ] Ship it
[x] Write docs at 2026-10-12 | [ ] Ship it
saved: [ 't-1=done', 't-2=todo' ]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Add a state without a screen

In the last example, add | { status: "archived" } to the end of TaskState.

terminal
$ Types panel
── what you'll see ──
index.ts(108,13): error TS2322: Type '{ status: "archived"; }' is not assignable to type 'never'.

Break #2

Emit the wrong payload

At the end of the event bus example, add bus.emit("added", { name: "wrong shape" });.

terminal
$ Types panel
── what you'll see ──
index.ts(34,21): error TS2353: Object literal may only specify known properties, and 'name' does not exist in type '{ title: string; }'.

Break #3

Mix up the IDs

At the end of the domain example, add the call find([task], "t-1");.

terminal
$ Types panel
── what you'll see ──
index.ts(49,14): error TS2345: Argument of type 'string' is not assignable to parameter of type 'TaskId'.
Type 'string' is not assignable to type '{ readonly __brand: "TaskId"; }'.

Myth vs fact

Myth

A fully typed app cannot have runtime errors.

Fact

Types cover what the compiler can see. Storage, network and users need runtime validation at the boundary, which is why steps 3 and 4 have it.

Myth

This much typing is only for big teams.

Fact

The same pieces (branded IDs, a union state, one validated boundary) prevent the bugs that small apps hit first: wrong IDs, missing states and bad stored data.

Myth

Branded types and event maps are advanced tricks you can skip.

Fact

They are small amounts of code (a few lines each) with large payoffs, and they are how the compiler is taught what your app means.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The brand constructor is where validation belongs: taskId throws on a bad format, so a TaskId anywhere in the program has been checked. Compare it to Zod's .brand<'TaskId'>(), which does the same with a schema. A brand is a compile-time promise, not a lock: 'oops' as TaskId forges one, and so would a unique symbol brand, so keep the as in one function and flag every other one in review (Topic 17.4).

  • ▸

    The event map pattern is the same idea as the DOM's HTMLElementEventMap and React's useReducer actions: one type drives names, payloads and handlers. Keep payloads plain data (unknown-safe) so events can be logged or sent over a wire.

  • ▸

    In a real app the storage and network boundaries should share one validation layer (a schema library), and the Result type should carry an error union (network | status | parse) instead of a bare status.

  • ▸

    The whole app type-checks identically in TypeScript 6.0 and 7.0, and the live editor has been cross-checked against the native compiler. In your own project, tsc --noEmit in CI gives the same guarantee.

Remember this

  1. 1

    The problem this solves: knowing each feature on its own is not the same as using them together. A real app has data coming in, state, events, storage and output, and the places where these layers join are where types quietly go wrong. Building one small app step by step shows how the compiler guards every join. What we build. A task tracker. A task has an ID, a title and a state (waiting, in progress, done). The app loads tasks from a fake server, lets users start and finish them, saves the result to storage, and prints a screen of tasks. Small, but it has every layer a real app has: data in, state, events, persistence, and output.

  2. 2

    Piece 1, the domain (Topic 10.4, 4.5, 4.6). A branded ID (TaskId, UserId) is a string with an invisible tag (a compile-time label only, nothing is added at runtime), so passing a user ID where a task ID is expected is a compile error. The task state is a discriminated union: todo, doing (with an assignee) or done (with a finish date). Fields exist only in the state where they make sense, so a todo task cannot have a finish date. A render function with an assertNever default means a new state is a compile error everywhere it matters.

  3. 3

    Piece 2, the event bus (Topic 7.5, 8.1, 8.3). A generic class Bus<Events> whose methods are typed by an event map: on('added', (e) => ...) knows e is { title: string }, and emit('added', { name: ... }) or emit('removed', ...) are compile errors. The event map is the single source of truth, and the compiler derives everything else from it.

  4. 4

    Piece 3, validated storage (Topic 13.4, 13.6). localStorage only holds text, and anything could be in it: an old format, a hand-edited value, nothing. So load reads unknown, parses it inside try/catch, and checks the shape with a type predicate before returning typed data. Tampered or corrupt data becomes an empty list, never a crash.

  5. 5

    Piece 4, the typed fetch client (Topic 13.3, 13.4, 7.5). A Routes interface maps each path to the type of its response, and get<P extends keyof Routes>(path) returns Promise<Result<Routes[P]>>. Calling get('/tasks') gives a list, get('/tasks/1') gives one task, and get('/nope') is a compile error. The result is a Result union, so callers must handle failure. (In a real client, the body is validated as in piece 3. Here the fake server is ours, so the cast is documented.)

  6. 6

    Piece 5, put it together and render (Topic 4.6). The final file wires the pieces: the bus updates a Map of tasks and calls render, which prints one line per task using an exhaustive switch. When you finish, try adding a fourth state (archived) and watch the compiler list the spot to update. That moment is the point of the whole course.

  7. 7

    How to use this topic. Read each step, predict the Types panel, run it, then do one 'Break it'. Finish by writing your own extension (the last exercise is a capstone-sized build). If you can explain each piece in an interview, you are ready.

Explain it without notes

01

Describe the architecture of a fully typed front-end app and where you validate data at runtime.

02

Why use branded IDs and a discriminated union for state?

03

How would you type an event bus so a wrong event name or payload fails to compile?

04

How do you keep a typed API client honest when the server can change?

Practice

01

Add a priority to Task that is "low" | "high", and make render show a ! for high priority. What do you need to change so nothing else compiles with a missing priority?

02

Add a "removed" event to the bus with payload { id: TaskId } and a listener that deletes the task. Show the call that must fail.

03

Write loadSaved so that it returns Result<Saved[]> with an error message instead of silently returning [].

Trade-offs

  • ↔

    Every layer of types costs a few lines and a little thinking. For a quick script it is too much. For anything that lives longer than a week, the boundary validation and the exhaustive state pay for themselves the first time data or requirements change.

  • ↔

    A hand-written guard is explicit and has no dependency; a schema library removes duplication between the type and the check. Pick one per project and use it at every boundary.

Done when you can

  • I can build a branded ID with a validating constructor.

  • I can model a task state as a discriminated union and render it exhaustively.

  • I can type an event bus from an event map.

  • I can validate storage and network data with unknown and a type guard.

  • I can type a client with a route map and a Result union.

  • I can add a new state or event and follow the compiler's errors to every place to update.