TypeScript HTTP Errors as Data

Build a typed HTTP client that returns expected failures as Result data.

Introduction

30 Second Summary

When a service slows down or sends broken data, a teammate needs more than a confident answer. They need proof that the software handles each failure on purpose.

In this project, you will build a TypeScript client that treats expected HTTP failures as explicit data. The finished private GitHub release carries proof from normal operation plus a total outage.

What You'll Build

Your finished demo runs a 20-city weather client that reports 20 cities: 17 ok, 3 failed under normal conditions before recording a controlled total outage without crashing.

By the end of this project, you'll have:

  • A 20-city client that writes one ordered result per city to results.json.
  • Failure evidence from a normal run that ends with 20 cities: 17 ok, 3 failed. A total-outage run proves the client records every city as unreachable without crashing.
  • A private release that connects the teammate's question to inspectable engineering evidence.
  • Secret Mission: Review your two-minute handoff without reopening fetch.ts, weather.ts, or weather.test.ts. Replace the release asset if the handoff misses a required point.

Are there any prerequisites?

You need a Windows computer, Cursor, and a personal GitHub account. Step 1 checks PowerShell 7, Node.js 24, npm, Git, and GitHub CLI before installing only what is missing.

Before We Start

Before any hands-on work, commit to proving how a TypeScript client handles a slow API, a malformed response, a non-200 status, or a complete outage. Your answer will connect requirements, implementation, failure proof, and release evidence.

Set Up the Private Project

Your HTTP client needs a stable local foundation before its behavior can be designed or tested. This step separates machine setup problems from problems in your TypeScript code.

You will verify the Windows toolchain in Cursor. You will then install the pinned dependencies and push a minimal baseline to a private GitHub repository.

In this step, get ready to:
  • Verify PowerShell 7, Node.js 24, npm, Git, and GitHub CLI.
  • Create the project configuration and install its pinned dependencies.
  • Commit the setup and push it to a private GitHub repository.
Verify the Windows toolchain

The project uses PowerShell 7 so every command has one consistent shell. PowerShell 7 installs separately from Windows PowerShell 5.1.

  • Press the Windows key to open search.
  • Type Cursor and press Enter.
  • Select Terminal in Cursor's top menu.
  • Select New Terminal.
  • Check the installed PowerShell release by running this command:
pwsh -Version

✔️ I see PowerShell 7 or higher

PowerShell is ready. Keep this terminal open for the remaining checks.

ⓧ I see an older version

Install a current PowerShell 7 release from the official Microsoft guide. Open a new Cursor terminal after the installation finishes.

ⓧ Command not found

Install PowerShell 7 from the official Microsoft guide. Open a new Cursor terminal before repeating the check.

  • Check the required Node.js release by running this command:
node --version

✔️ I see version 24 or higher

Your Node.js release meets the project requirement.

ⓧ I see an older version

Install Node 24 LTS from the official Node.js download page. Open a new Cursor terminal before repeating the check.

ⓧ Command not found

Install Node 24 LTS from the official Node.js download page. Open a new Cursor terminal before repeating the check.

  • Check npm, Git, GitHub CLI, and your GitHub authentication by running these commands:
npm --version
git --version
gh --version
gh auth status

You should see version information for all three commands. The final check should name your authenticated GitHub.com account.

Is a command or sign-in missing?

gh auth login

Choose GitHub.com, HTTPS, and browser authentication when prompted. Help me finish the Windows toolchain setup.

Create the project configuration

The configuration pins every dependency used by the mock, client, checker, and tests. The lockfile makes later installs resolve the same package graph.

  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir typescript-and-http
cd typescript-and-http
  • Select File in Cursor's top menu.
  • Select Open Folder.
  • Choose the typescript-and-http folder on your Desktop.
  • Create package.json in Cursor's Explorer sidebar with this content:
{
  "name": "typescript-and-http",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@types/node": "24.19.2",
    "tsx": "4.23.15",
    "typescript": "7.0.2",
    "vitest": "5.0.4",
    "zod": "4.6.7"
  }
}

Why pin every dependency?

Exact versions keep the compiler, runner, schema library, and test runner consistent. This makes the acceptance evidence reproducible.

  • Create tsconfig.json beside package.json with this content:
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["*.ts"]
}
  • Create .gitignore beside package.json with this content:
node_modules/
results.json
*.log
  • Save all three configuration files.
  • Install the pinned dependencies and generate package-lock.json by running this command:
npm install

The first install can take a minute while npm downloads the packages. You should see a completed install summary and a new package-lock.json file.

Did the install fail?

Confirm the terminal path ends with typescript-and-http and that package.json contains valid JSON. Help me diagnose this npm install failure.

Push the private baseline

A small baseline gives every support file and client increment a traceable starting point. The private remote keeps that history available for the later issue, pull request, and release.

  • Create README.md beside package.json with this content:
# TypeScript and HTTP: Errors as Data

This private project proves how a TypeScript client handles expected HTTP failures.
  • Save README.md.
  • Initialize Git and create the baseline commit by running these commands:
git init
git branch -M main
git add .
git commit -m "Create the private project baseline"

You should see a commit summary containing the configuration, README, and dependency lockfile.

Did Git ask who you are?

Set your global Git author name and email, then repeat the commit. Help me configure my Git author safely.

  • Create and push the private repository by running this command:
gh repo create typescript-and-http --private --source=. --push
  • Confirm the remote visibility by running this command:
gh repo view --json visibility

You should see PRIVATE in the response. Your local setup now has a committed private home.

Your toolchain and private repository are ready. Next, you will add the deterministic support files that make every client result testable.

Create the Support Baseline

Your private repository now has a pinned toolchain and a clean baseline. The client still needs a deterministic service, acceptance checkers, and design documents before you write its three learner-owned source files.

This step adds that support layer. The local mock produces the same 17 successes plus three planned failures on every normal run.

In this step, get ready to:
  • Create the city fixture, result contract, and deterministic mock API.
  • Add the requirements, architecture, test plan, and decision records.
  • Add the acceptance checkers and release templates before pushing the baseline.
Create the deterministic runtime support

The mock uses a real local HTTP server. Timeouts, status failures, malformed data, and closed-port failures therefore cross the same Fetch boundary as a real service.

  • Create cities.csv inside the typescript-and-http folder with this content:
city
Lisbon
London
Paris
Berlin
Madrid
Rome
Vienna
Prague
Warsaw
Dublin
Athens
Helsinki
Stockholm
Copenhagen
Brussels
Zurich
Reykjavik
Mumbai
Oslo
Tokyo
  • Save cities.csv.
  • Create result.ts beside it with this content:
// Represent expected success and failure through one typed contract.
export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: "timeout" | "http" | "bad_shape" | "unreachable"; status?: number };

Why use a Result contract?

A discriminated union gives every expected outcome an ok field. Callers can narrow the result without relying on exceptions for normal failure cases.

  • Create mock-api.ts beside result.ts with this content:
// Serve deterministic success and failure cases through a real local HTTP boundary.
import { createServer, type Server } from "node:http";
import { fileURLToPath } from "node:url";
export type MockHandle = { baseUrl: string; stop: () => Promise<void> };
const weather: Record<string, { tempC: number; condition: string }> = {
  Lisbon: { tempC: 21, condition: "sunny" }, London: { tempC: 14, condition: "cloudy" },
  Paris: { tempC: 18, condition: "clear" }, Berlin: { tempC: 15, condition: "windy" },
  Madrid: { tempC: 25, condition: "sunny" }, Rome: { tempC: 23, condition: "clear" },
  Vienna: { tempC: 16, condition: "cloudy" }, Prague: { tempC: 13, condition: "rain" },
  Warsaw: { tempC: 12, condition: "windy" }, Dublin: { tempC: 11, condition: "rain" },
  Athens: { tempC: 27, condition: "sunny" }, Helsinki: { tempC: 8, condition: "cloudy" },
  Stockholm: { tempC: 9, condition: "clear" }, Copenhagen: { tempC: 10, condition: "windy" },
  Brussels: { tempC: 14, condition: "rain" }, Zurich: { tempC: 15, condition: "clear" },
  Tokyo: { tempC: 20, condition: "cloudy" }
};
export async function startMock(port = 4321): Promise<MockHandle> {
  const server: Server = createServer((req, res) => {
    const city = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("city") ?? "";
    if (city === "Reykjavik") return void setTimeout(() => { if (!res.writableEnded) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ city, tempC: 4, condition: "snow" })); } }, 5000);
    if (city === "Mumbai") { res.writeHead(503); return void res.end("unavailable"); }
    res.writeHead(200, { "content-type": "application/json" });
    if (city === "Oslo") return void res.end(JSON.stringify({ city, tempC: "cold", condition: "snow" }));
    return void res.end(JSON.stringify({ city, ...(weather[city] ?? { tempC: 17, condition: "clear" }) }));
  });
  await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
  const address = server.address(); const actualPort = typeof address === "object" && address ? address.port : port;
  return { baseUrl: `http://127.0.0.1:${actualPort}`, stop: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) };
}
if (fileURLToPath(import.meta.url) === process.argv[1]) { const mock = await startMock(); console.log(`mock weather API on ${mock.baseUrl}  (stop it with Ctrl+C)`); }

What cases does the mock provide?

  • Lisbon and sixteen other cities return valid weather data.
  • Reykjavik responds after five seconds, which exceeds the client's two-second timeout.
  • Mumbai returns status 503.
  • Oslo returns the wrong data shape.
  • Save mock-api.ts.
  • Start the support service by running this command:
npx tsx mock-api.ts

You should see mock weather API on http://127.0.0.1:4321 (stop it with Ctrl+C).

  • Press Ctrl+C to stop the verification run.
Add the design packet

The design packet makes the build reviewable before learner-owned source code exists. Each decision now includes a measurable reason to reverse it.

  • Create the document folders by running these commands:
mkdir docs
mkdir .github
  • Create docs/requirements.md with this content:
# Requirements

## Teammate question
What happens when the API is slow, malformed, non-200, or completely unavailable?

## Functional requirements
- FR-1: Read 20 ordered city names from `cities.csv`.
- FR-2: Process one city at a time.
- FR-3: Accept only status 200 and validate every response at runtime.
- FR-4: Return `timeout`, `http`, `bad_shape`, and `unreachable` failures as `Result` data.
- FR-5: Write one ordered row per city to `results.json` and exit 1 when any city fails.
- FR-6: Support the prescribed Windows and PowerShell 7 environment.

## Normal result
`20 cities: 17 ok, 3 failed`.

## Total-outage result
All 20 cities are `unreachable`, the process exits 1, and the process does not crash.
  • Create docs/architecture.md with this content:
# Architecture

`cities.csv` -> `fetch.ts` -> `getWeather()` -> local HTTP mock -> `Result<Weather>` -> `results.json`.

## Responsibilities
- `fetch.ts` normalizes input, runs the sequential loop, writes evidence, and sets the final exit code.
- `weather.ts` owns HTTP status handling, runtime validation, and failure classification.
- `result.ts` defines the shared success and failure contract.
- `mock-api.ts` supplies deterministic boundary behavior.
- `weather.test.ts`, `check.ts`, and `mustfail.ts` prove the contract.

## Limits
The project uses one local endpoint, one process, sequential requests, and no retries.
  • Create docs/test-plan.md with this content:
# Test Plan

- AC-1: `npx tsc --noEmit` returns without a diagnostic.
- AC-2: `npx tsx check.ts` reports the required source contracts.
- AC-3: `npx vitest run` reports one passing test.
- AC-4: `npx tsx mustfail.ts` reports `MUST-FAIL OK` after the mock stops.
- AC-5: The source checker reports that no empty catch block exists.
- AC-6: Pull request #2 is merged, issue #1 is closed, and the Project card is Done.
- AC-7: `README.md` records the date, Windows environment, elapsed minutes, test evidence, decisions, AI use, and talk track.

Record stable output lines instead of copying an entire colored terminal transcript.
  • Create docs/decisions.md with this content:
# Architecture Decision Records

## ADR-001: Return expected failures as data
Use `Result<T>` for timeout, HTTP, shape, and connection failures.
Reverse when callers require exception propagation for failures they cannot recover from locally.

## ADR-002: Validate at the HTTP boundary
Use Zod before application code reads response fields.
Reverse when the provider supplies a generated client that performs equivalent runtime validation.

## ADR-003: Keep the client sequential
Process one city at a time for deterministic ordering and evidence.
Reverse when measured runtime becomes unacceptable and ordering can be preserved under bounded concurrency.

## ADR-004: Preserve partial results
Write every city result before setting exit code 1 when any failure occurs.
Reverse when a consumer contract requires all-or-nothing output.
  • Save all four files in docs.
  • Confirm each file appears in Cursor's Explorer sidebar.
Add the acceptance and release support

The final support files check the learner-owned implementation without replacing it. They also reserve a structured place for the evidence collected after testing and merging.

  • Create check.ts with this content:
// Check the final learner-owned source for the project contracts named in the instructions.
import { readFile } from "node:fs/promises";
const files = { fetch: await readFile("fetch.ts", "utf8"), weather: await readFile("weather.ts", "utf8"), test: await readFile("weather.test.ts", "utf8") };
const required = [
  [files.fetch, "for (const city of cities)"], [files.fetch, "writeFile("],
  [files.fetch, "process.exitCode = 1"], [files.fetch, "${rows.length} cities: ${okCount} ok, ${failedCount} failed"],
  [files.weather, "AbortSignal.timeout(2000)"], [files.weather, "response.status !== 200"],
  [files.weather, "WeatherSchema.safeParse(body)"], [files.weather, "error.name === \"TimeoutError\""],
  [files.weather, "Promise<Result<Weather>>"], [files.test, "startMock(0)"],
  [files.test, "getWeather(\"Lisbon\""], [files.test, "getWeather(\"Mumbai\""], [files.test, "getWeather(\"Oslo\""]
] as const;
for (const [source, fragment] of required) if (!source.includes(fragment)) throw new Error(`CHECK FAIL: missing ${fragment}`);
const testCalls = files.test.match(/\btest\s*\(/g)?.length ?? 0;
const expectCalls = files.test.match(/\bexpect\s*\(/g)?.length ?? 0;
if (testCalls !== 1 || expectCalls !== 3) throw new Error("CHECK FAIL: expected one test and three expect calls");
const combined = Object.values(files).join("\n");
if (/catch\s*(\([^)]*\))?\s*\{\s*\}/s.test(combined)) throw new Error("EMPTY-CATCH FAIL: empty catch block found");
console.log("CHECK PASS: required source contracts are present.");
console.log("EMPTY-CATCH PASS: no empty catch block was found.");
  • Create mustfail.ts with this content:
// Prove the complete outage path with fresh output from the real client process.
import { spawn } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
async function lisbonAnswers(): Promise<boolean> { try { await fetch("http://127.0.0.1:4321/weather?city=Lisbon"); return true; } catch { return false; } }
if (await lisbonAnswers()) throw new Error("Stop mock-api.ts before running this check.");
await rm("results.json", { force: true });
const child = spawn(process.execPath, ["--import=tsx", "fetch.ts"], { stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stderr = "";
child.stdout.on("data", (chunk) => stdout += chunk); child.stderr.on("data", (chunk) => stderr += chunk);
const exitCode = await new Promise<number | null>((resolve, reject) => { child.on("error", reject); child.on("close", resolve); });
process.stdout.write(stdout); process.stderr.write(stderr);
const rows = JSON.parse(await readFile("results.json", "utf8")) as Array<{ ok: boolean; error?: string }>;
const unreachable = rows.filter((row) => !row.ok && row.error === "unreachable").length;
const crashed = stderr.split(/\r?\n/).some((line) => /^Node\.js v\d/.test(line.trim()));
console.log(`fetch.ts exit code: ${exitCode}`); console.log(`cities recorded unreachable: ${unreachable} of ${rows.length}`); console.log(`crashed: ${crashed ? "yes" : "no"}`);
if (exitCode !== 1 || rows.length !== 20 || unreachable !== 20 || crashed) throw new Error("MUST-FAIL CHECK FAILED");
console.log("MUST-FAIL OK: every city was unreachable, the exit code was 1, and nothing crashed.");
  • Replace README.md with this evidence template:
# TypeScript and HTTP: Errors as Data

## What it is
TODO: Describe the client and the teammate's API-down question.

## The five steps
TODO: Summarize each runnable client increment.

## The four TypeScript ideas
TODO: Explain CSV normalization, typed functions, Zod validation, and discriminated unions.

## What it proves
TODO: Record AC-1 through AC-7, the date, Windows, and elapsed minutes.

## The four decisions
TODO: Summarize ADR-001 through ADR-004 with their reversal triggers.

## How I used AI
TODO: Name what AI drafted and the test or source that verified it.

## Talk track
TODO: Cover the ideas, errors as data, both run results, two decisions, and one production change.

## Interview question
How would you make this client production-ready?
  • Create .github/pull_request_template.md with this content:
## What

Built the sequential TypeScript weather client and returned expected HTTP failures as Result data.

## How it was tested

- `npx tsc --noEmit`.
- `npx tsx check.ts`.
- `npx vitest run`.
- `npx tsx mustfail.ts`.

## API-down answer

Every city is `unreachable`. The child exits 1 after writing 20 rows, without a standalone Node.js crash line.

Closes #1
  • Save the acceptance and release files.
  • Confirm the complete baseline type-checks by running this command:
npx tsc --noEmit

The PowerShell prompt should return without a diagnostic.

Did the baseline fail to type-check?

Use the filename and line number in the compiler diagnostic to find the mismatch. Help me diagnose the support-baseline type error.

  • Commit and push the support baseline by running these commands:
git add .
git commit -m "Add the deterministic support baseline"
git push

You should see the new commit pushed to main. The repository now contains every support file needed for the design and build.

Your deterministic support layer is ready. Next, you will turn its requirements and decisions into a reviewable unit of work.

Design the Client Contract

Your private repository now contains the deterministic support baseline. The next goal is to turn its requirements, architecture, test plan, and decisions into a reviewable unit of work before you write the learner-owned TypeScript files.

The teammate's HTTP failure question becomes readable requirements. The architecture then shows where each responsibility belongs.

The test plan defines proof for every requirement. The decision records preserve alternatives plus reversal triggers.

In this step, get ready to:
  • Review the five documents that define the client contract.
  • Publish the requirements as issue #1.
  • Track issue #1 in a linked private Project.
Read the design documents

Each document answers a different review question. Reading them in sequence connects the requested behavior to its eventual release evidence.

  • Read FR-1 through FR-6 in docs/requirements.md by selecting the file in Cursor's Explorer sidebar.
  • Read the flowchart plus limits in docs/architecture.md by selecting the file in Cursor's Explorer sidebar.
  • Read AC-1 through AC-7 in docs/test-plan.md by selecting the file in Cursor's Explorer sidebar.
  • Read ADR-001 through ADR-004 in docs/decisions.md by selecting the file in Cursor's Explorer sidebar.
  • Read the unfinished evidence fields in README.md by selecting the file in Cursor's Explorer sidebar.

How the design packet fits together

  • The requirements define the behavior that must exist.
  • The architecture assigns that behavior to specific files.
  • The test plan defines the evidence that proves the behavior.
  • The decision records capture each trade-off plus its reversal trigger.
  • The README reserves a place for the final evidence plus handoff.

Good work. You can now trace the teammate's question from requirement to proof without opening a learner-written source file.

Create the requirements issue

A GitHub issue gives the client contract a reviewable home. Using the requirements file as its body keeps the issue aligned with the committed document.

  • Switch back to the PowerShell terminal in Cursor.
  • Use docs/requirements.md as the issue body by running this command:
gh issue create --title "TypeScript and HTTP: errors as data" --body-file docs/requirements.md

What does this command do?

The gh command creates an issue in the repository connected to the current folder.

  • The --title flag sets the reviewable question for issue #1.
  • The --body-file flag copies the committed requirements into the issue body.
  • Record the URL printed by GitHub CLI as https://github.com/your-account/typescript-and-http/issues/1.
  • Paste https://github.com/your-account/typescript-and-http/issues/1 into your browser's address bar to open issue #1.

You should see issue #1 with the title TypeScript and HTTP: errors as data. Its body should begin with the functional requirements from docs/requirements.md.

Issue not created?

  • Confirm the Cursor terminal is still inside the typescript-and-http folder.
  • Complete any GitHub authentication prompt that interrupted the command.
  • Check that the private repository still appears as the current remote.

Still stuck? Help me troubleshoot why GitHub CLI did not create issue #1 from docs/requirements.md.

Build the private Project board

A GitHub Project turns issue #1 into a visible unit of work. The board shows whether the client contract is waiting or active.

  • Select your profile picture on GitHub.com.
  • Select Your profile.
  • Select Projects.
  • Click New project.
  • Select Start from scratch.
  • Select Board.

GitHub now shows the setup fields for a new board.

  • Enter typescript-and-http in the Project name field.
  • Click Create project.

The new Project opens as a board with status columns.

  • Select Settings from the Project's menu.
  • Set Visibility to Private.

The Project visibility should now read Private.

Linking the Project makes the board available from the repository. This keeps planning evidence beside the code history.

  • Return to the typescript-and-http repository on GitHub.
  • Select the repository's Projects tab.
  • Select Link a project.
  • Search for typescript-and-http in the search field.
  • Select the matching typescript-and-http Project.

You should now see typescript-and-http listed in the repository's Projects area. This confirms the link.

The board's add-item row sits at the bottom, so it is easy to miss. That row accepts the issue URL you recorded earlier.

  • Switch back to the private typescript-and-http Project board.
  • Select the add-item row at the bottom of the board.
  • Paste https://github.com/your-account/typescript-and-http/issues/1 into the row.
  • Press Return to add issue #1.
  • Drag issue #1 into In Progress.

Before you refresh, which status column do you expect to hold issue #1?

  • Refresh the Project board to check issue #1's status.

You should see TypeScript and HTTP: errors as data as an In Progress card. This proves the private planning board is tracking the requirements issue.

Card missing or in the wrong column?

  • Confirm you pasted the URL for issue #1 from the private typescript-and-http repository.
  • Check that the card was added as an issue instead of plain draft text.
  • Move the card into the In Progress column if it remained in another status.

Need a hand? Help me fix the Project card without creating another issue.

That planning work now has a home in GitHub. Next, you'll turn the client contract into five runnable increments.

Build the Result-Based Client

Your requirements now have a private issue and Project card. The support baseline also gives every increment a deterministic service plus acceptance checks.

An HTTP boundary can return valid data or several expected failures. In this step, you will build the TypeScript client in five runnable commits so each new idea proves itself before the next change.

In this step, get ready to:
  • Create the fetch branch and run the deterministic mock.
  • Build five runnable client increments with one commit per increment.
  • Run the focused behavior test before committing the finished client.
Start the branch and mock service

A Git branch separates the five client commits from the support baseline on main. The mock keeps normal and failure results stable across every run.

  • Return to Cursor with the typescript-and-http folder open.
  • Create and switch to the feature branch by running this command:
git switch -c fetch

You should see confirmation that Git switched to fetch.

  • Select Terminal in Cursor's top menu.
  • Select New Terminal to create a second terminal.
  • Start the mock in the second terminal by running this command:
npx tsx mock-api.ts

You should see mock weather API on http://127.0.0.1:4321 (stop it with Ctrl+C). Keep this terminal running.

Did the mock fail to start?

Keep one mock process on port 4321 and stop any extra copy with Ctrl+C. Help me fix the local mock process.

Build the first three proofs

The first three increments move from normalized CSV input to a validated response boundary. Save, run, and commit each increment before adding the next one.

Increment 1: Read and normalize the cities.

  • Create fetch.ts inside typescript-and-http from Cursor's Explorer sidebar.
  • Import readFile from node:fs/promises.
  • Implement parseCities() to split CRLF or LF lines.
  • Trim each row and remove blank rows.
  • Drop the first non-blank city header.
  • Read cities.csv and print 20 cities read from main().
  • Add a final handler that reports unexpected failures and sets process.exitCode to 1.
  • Save fetch.ts.

Before you run it, do you expect the normalized count to include the header or blank rows?

  • Type-check and run the first increment with these commands:
npx tsc --noEmit
npx tsx fetch.ts

The type check should stay silent. The client should then print 20 cities read.

  • Commit the working first increment by running these commands:
git add fetch.ts
git commit -m "Step 1: read the cities"

Increment 2: Fetch typed weather for Lisbon.

  • Create weather.ts beside fetch.ts.
  • Define a Weather type with city, tempC, and condition fields.
  • Implement getWeather(city, baseUrl) with typed string parameters.
  • Build the request URL with encodeURIComponent(city).
  • Pass AbortSignal.timeout(2000) to Fetch.
  • Treat any status other than 200 as a failure.
  • Update fetch.ts to print Lisbon: ok after the valid response.
  • Save both files.
  • Type-check, run, and commit the second increment with these commands:
npx tsc --noEmit
npx tsx fetch.ts
git add fetch.ts weather.ts
git commit -m "Step 2: fetch typed weather"

You should see 20 cities read followed by Lisbon: ok.

Increment 3: Validate the response shape.

  • Import Zod as z in weather.ts.
  • Define WeatherSchema with string city, number tempC, and string condition fields.
  • Infer the exported Weather type from the schema.
  • Read the response body as unknown data.
  • Validate that body with the exact expression WeatherSchema.safeParse(body).
  • Return the parsed data only when validation succeeds.
  • Save weather.ts.
  • Type-check, run, and commit the validated boundary with these commands:
npx tsc --noEmit
npx tsx fetch.ts
git add weather.ts
git commit -m "Step 3: validate the response"

Lisbon should still report ok. The response now crosses a runtime-validated boundary.

Did an increment fail?

Use the compiler filename and line number to find a type mismatch. Keep the strict configuration unchanged. Help me diagnose this increment.

Return failures as data and test them

The last two increments replace expected exceptions with one explicit Result contract. They then scale the same boundary across all 20 cities.

Increment 4: Return every expected failure as Result data.

  • Import Result from ./result.ts.
  • Set the exact return type to Promise<Result<Weather>>.
  • Return http with the response status when response.status !== 200.
  • Return bad_shape when JSON parsing or WeatherSchema.safeParse(body) fails.
  • Return timeout when an Error satisfies error.name === "TimeoutError".
  • Return unreachable for other connection failures.
  • Update fetch.ts to print the Lisbon, Mumbai, and Oslo outcomes.
  • Save both files.
  • Type-check, run, and commit the Result boundary with these commands:
npx tsc --noEmit
npx tsx fetch.ts
git add fetch.ts weather.ts
git commit -m "Step 4: return failures as data"

You should see Lisbon succeed, Mumbai return http with status 503, and Oslo return bad_shape.

Increment 5: Process and test every city.

  • Loop sequentially with the exact header for (const city of cities).
  • Store one ordered result row for every city.
  • Write the rows with writeFile( to results.json.
  • Print the exact summary template ${rows.length} cities: ${okCount} ok, ${failedCount} failed.
  • Set process.exitCode = 1 when failedCount is greater than zero.
  • Create weather.test.ts with one Vitest test( call.
  • Start its isolated mock with startMock(0) so the operating system selects a free port instead of competing with port 4321.
  • Add exactly three expect( calls for Lisbon success, Mumbai status 503, and Oslo bad_shape.

Before you run the final increment, do you expect the focused test to use the already-running mock or its own isolated port?

  • Run the final source, behavior, and normal-result checks with these commands:
npx tsc --noEmit
npx tsx check.ts
npx vitest run
npx tsx fetch.ts

You should see both checker PASS lines, Tests 1 passed (1), and 20 cities: 17 ok, 3 failed.

Did the final checks fail?

  • Confirm weather.test.ts uses startMock(0) if the test reports that port 4321 is busy.
  • Use the missing fragment named by check.ts to align the final source with the disclosed contract.
  • Keep one test and three expectations instead of weakening the checker.

Need help tracing a mismatch? Help me trace the failed final check.

  • Commit the tested fifth increment by running these commands:
git add fetch.ts weather.ts weather.test.ts
git commit -m "Step 5: process and test every city"

That is the client complete. Five runnable commits now connect CSV input to ordered success and failure evidence.

Your five client increments now pass their local checks. Next, you will run the complete acceptance plan and prove the total-outage behavior.

Run the Test Plan

Your five TypeScript increments now turn the API-down question into working behavior at an HTTP boundary. This step proves that the full project still matches that promise.

The test plan turns each requirement into an acceptance criterion. It checks type safety, source contracts, unit behavior, empty catches, and a total outage.

In this step, get ready to:
  • Run the normal checks for AC-1 through AC-3.
  • Stop the mock to prove the total-outage behavior for AC-4.
  • Confirm AC-5 and keep the evidence ready for the README.
Run the normal acceptance checks

AC-1 through AC-3 check the project under normal test conditions. The mock from the Build step stays running while you perform these checks.

  • Switch back to the second Cursor terminal from the Build step.
  • Confirm its last line shows mock weather API on http://127.0.0.1:4321 (stop it with Ctrl+C).
  • Return to the primary Cursor terminal on branch fetch.

AC-1 checks every TypeScript file against the strict project configuration. A successful check prints no output.

  • Run the AC-1 type check with this command:
npx tsc --noEmit

What does the type check prove?

The TypeScript compiler checks the project for type errors. The --noEmit flag prevents it from writing JavaScript files.

You should see the PowerShell prompt return without a diagnostic. That silent result proves AC-1 passed.

Did the type check print a diagnostic?

  • Use the reported filename and line number to find the failing source.
  • Fix the type error without changing the acceptance criterion.
  • Rerun the type check after saving the file.

Need help reading the diagnostic? Help me understand this TypeScript type-check failure.

AC-2 checks that the required source contracts are present. It also searches the project for empty catch blocks.

  • Run the project checker for AC-2 by using this command:
npx tsx check.ts

What does the project checker prove?

The checker reads the final source files and verifies their required contracts. It also confirms that expected failures are handled instead of disappearing inside an empty catch block.

You should see CHECK PASS: required source contracts are present..

The next line should read EMPTY-CATCH PASS: no empty catch block was found..

Did the source checker fail?

  • Read the checker output to identify the named source file.
  • Restore the required contract in that source file.
  • Keep check.ts unchanged while fixing the implementation.

Need help locating the contract mismatch? Help me diagnose the failed source check.

AC-3 runs the focused Vitest test. Its three expectations cover a valid Lisbon response, Mumbai's status failure, and Oslo's invalid shape.

  • Run the focused AC-3 test with this command:
npx vitest run

What does the focused test prove?

The test starts its own mock on an available local port. It checks that success and expected HTTP failures return through the same typed contract.

You should see the key line Tests 1 passed (1).

Did the focused test fail?

  • Read the failed expectation to identify Lisbon, Mumbai, or Oslo.
  • Compare that city's actual Result with the expected Result shown by the test.
  • Fix the matching behavior in weather.ts without changing the expectation.

Need help interpreting the failure? Help me trace this Vitest result mismatch.

Good progress. The normal acceptance path now proves the project compiles, preserves its source contracts, and passes its focused behavior test.

Prove the total-outage behavior

A negative test proves that the client fails in the required way. AC-4 removes the mock service so every request reaches a closed port.

Stopping the local mock is safe. It affects only the process running in the second Cursor terminal.

  • Switch back to the second Cursor terminal from the Build step.
  • Press Ctrl+C to stop the mock.
  • Return to the primary Cursor terminal.

You should see the mock terminal return to its PowerShell prompt. The service no longer answers on port 4321.

The outage checker replaces only the ignored results.json file. This gives the check fresh evidence from the outage.

Before you run this, do you think the client will crash or record every failure as data?

  • Run the AC-4 outage check with this command:
npx tsx mustfail.ts

What does the outage checker prove?

  • It confirms that Lisbon no longer answers on port 4321.
  • It runs fetch.ts as a child process against the stopped mock.
  • It reads the fresh results.json file to count unreachable rows.
  • It checks for exit code 1 without a process crash.

You should see every city reported as unreachable. The summary should report 20 failed cities.

Near the end, you should see fetch.ts exit code: 1.

You should also see cities recorded unreachable: 20 of 20.

The crash check should read crashed: no.

The final line should read MUST-FAIL OK: every city was unreachable, the exit code was 1, and nothing crashed..

That is the hardest failure proven. Your client records all 20 outages as Results before it exits with the required failure code.

Did the outage check refuse to run?

  • Return to the second Cursor terminal if the checker reports that something still answers on port 4321.
  • Press Ctrl+C in that terminal.
  • Rerun the outage checker from the primary terminal.

Still missing the must-fail result? Help me troubleshoot the total-outage check.

Map the evidence to AC-1 through AC-5

Each acceptance criterion now has a concrete terminal signal. This mapping keeps the evidence readable when you fill the README during close-out.

Your AC-1 through AC-5 evidence

  • AC-1 is the type check returning to the prompt without output.
  • AC-2 is the source-contract PASS line from check.ts.
  • AC-3 is Tests 1 passed (1) from the focused test.
  • AC-4 is the final MUST-FAIL OK line from the stopped-mock run.
  • AC-5 is EMPTY-CATCH PASS: no empty catch block was found. from AC-2.
  • Scroll through the primary Cursor terminal to confirm all five acceptance signals remain in its history.
  • Leave the successful outputs available for the README work in the next step.

Your source files remain unchanged. The ignored results.json now contains 20 fresh unreachable rows from the required outage test.

Solid work. AC-1 through AC-5 now have terminal proof, so next you can package that evidence into the private release.

Release the Evidence

Your client has passed AC-1 through AC-5. Terminal output can disappear before a teammate reviews it.

This step turns that proof into a private GitHub release. The sequence collects merge evidence after the merge, so the released README contains observed results instead of predictions.

In this step, get ready to:
  • Complete and push the pre-merge evidence.
  • Merge pull request #2 and record AC-6 from the completed state.
  • Publish release v0.1.0 with the final README and two-minute recording.
Prepare the pre-merge evidence

The pull request template already belongs to the committed support baseline. This step verifies it instead of creating the same file twice.

  • Select .github/pull_request_template.md in Cursor's Explorer sidebar.
  • Confirm the file begins with ## What.
  • Confirm the file ends with Closes #1.

The template gives the review a stable summary, test list, outage answer, and closing link to issue #1.

  • Select README.md in Cursor's Explorer sidebar.
  • Replace the client-description TODO with the finished client and teammate question.
  • Replace the five-increment TODO with one sentence per commit.
  • Replace the TypeScript-ideas TODO with CSV normalization, typed functions, Zod validation, and discriminated unions.
  • Record AC-1 through AC-5 with their stable terminal lines.
  • Write AC-6: pending the merge of pull request #2 as a temporary close-out marker.
  • Record the date, Windows environment, and elapsed minutes.
  • Complete the decision, AI-use, talk-track, and interview sections.

Every section should now contain project-specific prose. The only unfinished statement should be the clearly named AC-6 merge marker.

  • Save README.md.
  • Stage and commit the pre-merge evidence by running these commands:
git add README.md
git commit -m "Fill the pre-merge evidence"
git push -u origin fetch

You should see a commit summary for README.md and confirmation that fetch tracks the remote branch.

Was the push rejected?

Confirm the terminal is inside typescript-and-http and that GitHub CLI still has access to the private repository. Help me troubleshoot the rejected push.

Merge the review and record AC-6

The merge closes the implementation boundary between fetch and main. Only after that state exists can the README record real AC-6 evidence.

  • Open the private typescript-and-http repository on GitHub.
  • Select Compare & pull request for branch fetch.
  • Set the base branch to main.
  • Set the compare branch to fetch.
  • Enter TypeScript and HTTP: errors as data as the title.
  • Confirm the populated body contains Closes #1.
  • Select Create pull request.

You should see pull request #2 with main as its base and fetch as its compare branch.

  • Select Merge pull request.
  • Select Confirm merge.
  • Confirm pull request #2 shows a merged state.
  • Confirm issue #1 shows a closed state.

The linked Project may move the issue automatically. If its default workflow is disabled, you will make the final status explicit.

  • Open the linked private GitHub Project.
  • Confirm the issue #1 card is in Done.
  • Move the issue #1 card to Done if it remains in another column.
  • Return to Cursor.
  • Switch to the merged branch and download its state by running these commands:
git switch main
git pull --ff-only

You should see main update to the merge commit.

  • Open README.md.
  • Replace the AC-6 marker with the observed merged pull request, closed issue, and Done card states.
  • Confirm no TODO or pending marker remains.
  • Save README.md.
  • Commit the observed close-out evidence to main by running these commands:
git add README.md
git commit -m "Record the observed close-out evidence"
git push origin main

You should see the evidence commit pushed to main. The branch now contains AC-1 through AC-7 before the release tag is created.

  • Verify the closed issue and merged review by running these commands:
gh issue view 1 --json state
gh pr list --state merged

You should see issue state CLOSED and pull request #2 in the merged list.

Publish the reviewed release

The release tag must point at the final main branch, including the observed close-out evidence. The recording turns that evidence into a two-minute engineering handoff.

Plan the two-minute handoff

  • Open with the teammate's API-down question.
  • Explain CSV normalization, typed functions, Zod validation, and discriminated unions.
  • Quote 20 cities: 17 ok, 3 failed.
  • Quote the final MUST-FAIL OK result.
  • Defend two architecture decisions with their reversal triggers.
  • Name one production change without implementing it.
  • Record the two-minute talk track with your laptop or phone.
  • Copy the recording to your laptop.
  • Open Releases in the private repository.
  • Select Draft a new release.
  • Select Choose a tag.
  • Enter v0.1.0.
  • Select Create new tag.
  • Set Target to main.
  • Enter TypeScript HTTP Errors as Data v0.1.0 in Release title.
  • Add the recording through the binaries box.
  • Wait until the recording appears as an attached asset.
  • Select Publish release.

You should see release v0.1.0 targeting main with one recording asset. Open the tag view and confirm the final README contains no unfinished marker.

Is the recording missing?

Wait for the file to finish uploading before selecting Publish release. If an upload stalls, remove the incomplete asset and add the local recording again. Help me finish the release asset upload.

That completes the release. Your private repository now connects the original requirement to tested source, observed close-out evidence, and a reviewable handoff.

Secret mission

Review the Handoff

A working release can still leave a teammate with unanswered questions. Review the published recording without reopening the learner-written source files, then replace the asset if any required part of the handoff is missing.

Clean Up Your Resources

Clean Up Your Resources

Keeping this project costs 0.00 USD because its files are local and GitHub Free holds the private remote evidence. You can keep the evidence, pause the mock, or delete every resource.

Resources you used:

  • Your local Desktop/typescript-and-http folder contains the pinned dependencies, support baseline, learner-owned source files, documents, and generated evidence.
  • Your private GitHub repository contains closed issue #1, branch fetch, merged pull request #2, the final evidence commit, and release v0.1.0 with its recording.
  • Your linked private GitHub Project named typescript-and-http contains the completed issue card.

Keep everything running

No action is needed. Choose this if you want to keep the requirements-to-release evidence available for review.

  • Keep the local Desktop/typescript-and-http folder.
  • Keep the private repository with issue #1, pull request #2, and release v0.1.0.
  • Keep the linked private GitHub Project and its completed card.

Pause - I'll come back to this later

Pausing stops the only possible local process. Every file and GitHub record stays intact without incurring an ongoing charge.

  • Return to the Cursor terminal that ran mock-api.ts if it is still open.
  • Press Ctrl+C if the mock is still holding the terminal.
  • Confirm the terminal returns to a PowerShell prompt.
  • Leave the local folder, private repository, release, and Project in place.

Delete - I don't want to use this again

Deletion permanently removes the local project and its remote evidence. Pause here long enough to confirm you no longer need the release recording or review history.

The release is the first remote resource to remove. GitHub documents a release-specific delete command, so this action does not rely on a guessed dashboard path.

  • Return to the typescript-and-http folder in Cursor.
  • Delete release v0.1.0 by running this command:
gh release delete v0.1.0 -y
  • Open the repository's Releases page.
  • Confirm v0.1.0 no longer appears.

The linked Project is a separate resource. Deleting the repository does not delete this board.

  • Open the private typescript-and-http Project.
  • Open the Project menu in the top-right corner.
  • Select Settings.
  • Scroll to Danger zone.
  • Select Delete this project beside Delete project.
  • Enter typescript-and-http when GitHub asks for the project name.
  • Select I understand the consequences, delete this project.

Deleting the repository removes issue #1, the branches, pull request #2, and the remaining remote history.

  • Open the private typescript-and-http repository.
  • Select Settings under the repository name.
  • Stay on the General settings page.
  • Scroll to Danger Zone.
  • Select Delete this repository.
  • Select I want to delete this repository.
  • Select I have read and understand these effects.
  • Enter the repository name and select the final Delete this repository control.

The last resource is the local folder on your Desktop. Closing Cursor first prevents Windows from keeping one of its files open.

  • Close Cursor.
  • Press the Windows key to open search.
  • Type File Explorer and press Enter.
  • Open your Desktop folder.
  • Right-click typescript-and-http.
  • Select Delete.
  • Open the Recycle Bin.
  • Delete the typescript-and-http folder permanently.

Cleanup is complete when the release, Project, repository, and Desktop folder are no longer available.

Nice Work!

Nice Work!

Mission complete! You shipped a private TypeScript project with a typed HTTP boundary as release v0.1.0.

You've learned how to:

  • Turned a teammate's API-down question into requirements and decisions across five project documents. Each architecture decision record captures a reversible engineering choice.
  • Built a sequential 20-city client from normalized CSV input. The typed boundary returns expected failures through the Result contract. Zod validation rejects malformed data. Discriminated unions make each outcome explicit.
  • Proved normal behavior with the 17/3 run. Proved outage behavior with the total-outage MUST-FAIL case. The focused Vitest proof covered status failures plus malformed shapes. Published the evidence in the private GitHub repository through merged pull request #2 and release v0.1.0.
  • Completed the Secret Mission by reviewing the two-minute handoff without reopening fetch.ts, weather.ts, or weather.test.ts. The recording connects the API-down requirement to the Result contract. It defends ADR-001 through ADR-004 with reversal triggers. It quotes the normal result plus the total-outage result. It ends with one production change.

Ready to quiz yourself?