TypeScript Survival Guide

Course manual

15 - Capstone: Lead the Trip

Chapter 15 - Capstone: Lead the Trip

Scouts performing calm first aid triage at a campsite with an open first aid kit

The last outdoor progression is Adventure Out: plan a trip for several days, make every arrangement yourself, then teach and inspire others about the outdoors. The capstone is that trip. Two tracks, one shared core, five-minute demos, and the review checklist as the grading rubric. Pick the track that stretches you; both end with strangers handled and proof shown in front of the room.

The shared core

Both tracks build on src/shared/patrol.ts in the capstone-starter packet: the Scout shape, the SAMPLE_PATROL fixture, and the isScout, findByBadge, and countByRole guards. It is finished and owned by both tracks — read it first, rewrite nothing in it. It shows the bar: strangers arrive as unknown, guards prove them, fallbacks never throw, no any, no bare as. Your work is graded against that file’s discipline, whichever track you pick.

Get the packet from static/labs/capstone-starter/. It is a starter, not a lab kit: a brief plus starter code under src/shared/, src/track-a/, and src/track-b/, with no red tests to satisfy. The proof is your demo.

Track A: refactor weak-to-strong

src/track-a/lineup.ts runs and answers correctly on good input — parseEntry, totalMiles, leaders, plus a loadBoard fixture loader. Every seam is weak: any at each boundary, no guards, blind trust in the caller. A null board, a string where miles belong, a stranger count — each one crashes or lies instead of falling back.

Refactor it without changing its answers on good input, and decide every stranger case on purpose: null for unprovable entries, 0 for untotalable lists, [] for unservable leader requests. Suggested halfway proof: copy the day’s refactor-weak-strong red tests beside the file and make them pass against your rewrite. Suggested finish: npm run typecheck clean, grep for any empty outside comments, and a before-and-after diff you can walk in ninety seconds.

Track A suits the refactorer’s temperament: the shape exists, the behavior exists, and the work is proof. If the testing chapter felt like home, start here.

New here: refactor under green, stranger-case decisions. Docs: TypeScript Handbook — Narrowing. Watch: Video 158 — TypeScript Makes You A Faster Developer (Kyle’s velocity rebuttal, argued keystroke for keystroke: typed parameters restore full autocomplete inside every function, so one added word pays back every call site — strictness as speed, the compounded version of this track’s rewrite).

Track B: build a small MCP server

src/track-b/server.ts is a runnable patrol server over the Model Context Protocol, shaped after the SDK’s own server examples (examples/ in the typescript-sdk repository): a createServer factory, tools registered with zod schemas, served over stdio. The find-scout tool is finished — badge number in, "Amara #3 - Lookout" out, strangers answered with an isError result instead of a throw. Use it as the model.

Your work: implement count-by-role (currently a stub that throws), then add ONE tool of your own design — a role listing, a badge-availability check, a patrol summary, your call — with the same return shape, { content: [{ type: "text", text }] }, and the same guard-first habit from the shared patrol module. The zod schema is the tool’s stranger check: declare the inputs narrowly (z.number(), string codes with descriptions) so malformed calls fail at the schema, never in your handler.

Pinned dependency: @modelcontextprotocol/server 2.0.0-alpha.4 (the v2 alpha line; the 2.0.0-alpha.0 tag was never published, so the starter pins the newest alpha instead). Zod v4 ships the input schemas. Run it with npm install && npm run build && npm start, then connect any MCP client over stdio and call find-scout with badge 3.

Track B suits the builder’s temperament: a live server, a real protocol, a tool with your name on it. If the async chapter felt like home, start here.

New here: MCP tools, zod input schemas, isError stranger answers. Docs: Model Context Protocol docs, TypeScript SDK examples, Zod v4 docs. Watch: Video 067 — Is tRPC The End Of REST/GraphQL? (the typed-boundaries argument across a network: REST cannot promise shapes, GraphQL charges query-language complexity, and the typed middle path keeps both ends honest — the same promise this track’s tool schemas make to every caller).

The demo

Five minutes, three beats, no slides required.

  1. Show the before: the weak file answering loosely, or the stub tool throwing. Name what is weak about it, precisely.
  2. Show the after: the same call, strong — or the new tool, live over stdio. Same input, proven output.
  3. Name one stranger case and the fallback you chose for it. “A string badge gives null” beats “it handles errors.”

Demos run in track order — all of A, then all of B — with the checklist below as the shared rubric. Questions from the room should target the stranger cases; if nobody asks, volunteer yours.

The review checklist

Both tracks, same six checks. This is the grading rubric; clear all six and the capstone passes.

  1. No any, no bare as — grep reads empty outside comments.
  2. Every boundary input is unknown until a guard proves it (track B: zod schemas count as the tool-boundary guard).
  3. Strangers get fallbacks (null, 0, [], isError results), never a throw.
  4. npm run typecheck passes with zero errors.
  5. (B only) each tool answers { content: [{ type, text }] } and serves live over stdio in the demo.
  6. You can name the stranger case you are proudest of, and why.

DIY simulator drills

You are the simulator. Cover the answers, predict, then verify. Log each miss in one sentence. One drill per file — Chapter 03 showed why.

Drill 1 — Be the count checker

Which line errors, and with which code?

interface Entry {
	name: string;
	miles: number;
}
declare function leaders(board: Entry[], count: number): Entry[];
declare const board: Entry[];

const top = leaders(board, "two");

Verify with npx tsc --noEmit --strict --target es2020. (The stranger count is a string where a number belongs — the call errors with TS2345. Track A’s rewrite settles this exact case on purpose: [] for unservable leader requests. Say the code before running.)

Drill 2 — Be the shape checker

Which line errors, and with which code?

interface ToolText {
	type: "text";
	text: string;
}
interface ToolResult {
	content: readonly [ToolText, ...ToolText[]];
	isError?: boolean;
}
declare function runTool(name: string): ToolResult;
const out: ToolResult = { content: [{ text: "Amara #3" }] };

Verify with npx tsc --noEmit --strict --target es2020. (The content item is missing its type tag — the literal errors with TS2741, naming the exact missing property. Track B’s return shape is a contract, and contracts list every field.)

Drill 3 — Be the honest responder

Which declaration errors, and with which code?

interface ToolText {
	type: "text";
	text: string;
}
interface ToolResult {
	content: readonly [ToolText, ...ToolText[]];
	isError?: boolean;
}
const a: ToolResult = {
	content: [{ type: "text", text: "no such badge" }],
	isError: true,
};
const b: ToolResult = { error: "no such badge" };

Verify with npx tsc --noEmit --strict --target es2020. (The first passes — strangers ride inside the shape, flagged honest. The second errors with TS2561: error is not a known property, and the checker even suggests isError. Unknown keys stay out; known flags carry the news.)

Drill 4 — Be the refactor

What prints on each line?

function parseEntry(value) {
	if (typeof value !== "object" || value === null) return null;
	if (typeof value.name !== "string") return null;
	if (typeof value.miles !== "number") return null;
	return { name: value.name, miles: value.miles };
}
function totalMiles(entries) {
	if (!Array.isArray(entries)) return 0;
	let total = 0;
	for (const e of entries) {
		const p = parseEntry(e);
		if (p !== null) total += p.miles;
	}
	return total;
}
console.log(JSON.stringify(parseEntry({ name: "Amara", miles: 12 })));
console.log(JSON.stringify(parseEntry({ name: "Amara", miles: "12" })));
console.log(totalMiles([{ name: "Amara", miles: 12 }, { name: "Ghost", miles: "many" }, null]));

Verify with node. ({"name":"Amara","miles":12}, then null, then 12 — the proven row passes, the string mileage fails its typeof check, and the mixed board sums only what parses. Same answers as the weak file on good input; deliberate fallbacks everywhere else. Say all three lines before running.)

Closing the day

Day 5 ends where day 1 began: a stranger arrives, and the code decides. The season guard decided with strict equality; the patrol suite decided with mocks and fixtures behind it; the production suites decide nightly in CI; the capstone decides live in the demo. Five chapters, one instinct: prove it before you trust it. Keep the checklist. It fits in a review comment, and it catches everything the day taught.