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
onandemit. - 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
switchon the state that ends inassertNever, 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.
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'.
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
Add a state
In the last example add
| { status: "archived" }toTaskState. Read the error, then add acase "archived"torenderand watch the error go away. - 2
Add an event
Add
renamed: { id: TaskId; title: string }toAppEvents, a listener that changes the title, and anemitthat uses it. Try emitting with a plain string id and read the error. - 3
Add a route
In the fetch client example, add
"/users": { id: string; name: string }[]toRoutesand the fake server. Callget("/users")and hover the result. What changed in the types without you touchingget?
Code & diagrams
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 docsThe 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-12Valid 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: []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
falseDomain, 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.
Break #2
Emit the wrong payload
At the end of the event bus example, add bus.emit("added", { name: "wrong shape" });.
Break #3
Mix up the IDs
At the end of the domain example, add the call find([task], "t-1");.
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:
taskIdthrows on a bad format, so aTaskIdanywhere 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 TaskIdforges one, and so would aunique symbolbrand, so keep theasin one function and flag every other one in review (Topic 17.4). - ▸
The event map pattern is the same idea as the DOM's
HTMLElementEventMapand React'suseReduceractions: 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
Resulttype 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 --noEmitin CI gives the same guarantee.
Remember this
- 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
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) ordone(with a finish date). Fields exist only in the state where they make sense, so atodotask cannot have a finish date. Arenderfunction with anassertNeverdefault means a new state is a compile error everywhere it matters. - 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) => ...)knowseis{ title: string }, andemit('added', { name: ... })oremit('removed', ...)are compile errors. The event map is the single source of truth, and the compiler derives everything else from it. - 4
Piece 3, validated storage (Topic 13.4, 13.6).
localStorageonly holds text, and anything could be in it: an old format, a hand-edited value, nothing. Soloadreadsunknown, parses it insidetry/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
Piece 4, the typed fetch client (Topic 13.3, 13.4, 7.5). A
Routesinterface maps each path to the type of its response, andget<P extends keyof Routes>(path)returnsPromise<Result<Routes[P]>>. Callingget('/tasks')gives a list,get('/tasks/1')gives one task, andget('/nope')is a compile error. The result is aResultunion, 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
Piece 5, put it together and render (Topic 4.6). The final file wires the pieces: the bus updates a
Mapof tasks and callsrender, which prints one line per task using an exhaustiveswitch. 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
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
Describe the architecture of a fully typed front-end app and where you validate data at runtime.
Why use branded IDs and a discriminated union for state?
How would you type an event bus so a wrong event name or payload fails to compile?
How do you keep a typed API client honest when the server can change?
Practice
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?
Add a "removed" event to the bus with payload { id: TaskId } and a listener that deletes the task. Show the call that must fail.
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
unknownand a type guard.I can type a client with a route map and a
Resultunion.I can add a new state or event and follow the compiler's errors to every place to update.