Skip to editor content
learningtypescript.orglesson 19 of 25

Capstone Project: Type-Safe Learning Tracker

This is the last lesson of the core arc, and it is the one where everything meets. You will build a type-safe learning tracker: a small program that models learning activities, stores them in a generic service, parses untrusted input into the domain model, and prints a report — all in strict-mode TypeScript, all headless. No browser, no network, no framework. Every piece runs right here on the page.

What You'll Build

  • A domain model — a discriminated union of Activity types (lesson, quiz, practice), each tagged by a literal kind (interfaces and unions from Interfaces and Types, enums/const-unions from Enums And Constants, the arrays that hold them from Data Structures).
  • A tracker service — a generic in-memory service over any record with an id, exposing typed CRUD and query (generics from Generics, Partial/Pick DTOs from Utility Types, one valid class shape from Classes And OOP).
  • A validation layer — hand-rolled parse-and-guard functions that turn unknown input into domain types, surfacing failures as a Result value (the Result pattern from Error Handling; the production version uses Zod, from Zod And Validation).
  • A reporting layer — a narrowing-driven console report that switches on the discriminant and proves it is exhaustive with a never check (narrowing from Type Narrowing, exhaustiveness from Debugging TypeScript).

How this lesson runs

Every step runs in the embedded playground on this page — the tracker is pure logic with no DOM and no network, so there is nothing to save to a file and nothing to open in a browser. Run each fence, read its console output, and move on. The final build fence assembles the whole thing and grades itself.

One thing to keep in mind throughout: the runner transpiles and runs your code — it strips the types and executes the JavaScript underneath. It does not typecheck. So the type discipline in this lesson (narrowing before access, exhaustiveness, parsing unknown) is what keeps the runtime honest: a value you never narrowed is undefined at run time, and an input you never parsed crashes when you touch a field that isn't there. The types are how you stop that before it happens.

The Config This Project Assumes

A real version of this tracker ships a tsconfig.json, and the setting that matters is "strict": true. One member of that bundle is doing most of the work here: strictNullChecks. It is the reason get(id: string): T | undefined in Milestone 2 is an honest signature rather than a decorative one — with strictNullChecks on, tsc --strict refuses a found.title on the result of a lookup that can miss, reporting error TS18048: 'found' is possibly 'undefined'. (measured on tsc 6.0.2). Switch that one flag off and the same file typechecks clean, because undefined becomes assignable to every type again and T | undefined collapses to T. The existing === undefined guard in update is then unenforced — still correct, but nothing makes you write it.

The other flag the project leans on is noImplicitAny, also in that bundle. It is what forces the value: unknown annotation on the isRecord guard in Milestone 3: drop the annotation and tsc --strict stops with error TS7006: Parameter 'value' implicitly has an 'any' type., while with noImplicitAny off the same unannotated guard compiles clean and every caller silently hands it an any (both measured on tsc 6.0.2). A validation layer whose parameter is any is not validating anything the compiler knows about.

Be clear about what this means on this page, though: the Run button does not read a tsconfig.json at all. The runner transpiles and executes, so every flag above is something you would get from tsc in a real project and get from nothing here. That is why the fences below pair a Predict or a no-run fence with the compiler's actual diagnostic whenever the type-level and run-time answers differ — the configuration is the part of this project you have to reason about rather than observe.

Milestone 1: The Domain Model

Every tracker starts with the shape of the thing it tracks. A learning activity is not one shape but several — a lesson has minutes spent, a quiz has a score, a practice attempt has a solved flag. Model that as a discriminated union: each member is its own object type carrying a literal kind, and Activity is the union of them. The literal kind is the discriminant that later layers narrow on.

// Each activity kind is a literal-type tag on its own object shape.
type LessonActivity = {
  kind: "lesson";
  id: string;
  title: string;
  minutesSpent: number;
};

type QuizActivity = {
  kind: "quiz";
  id: string;
  title: string;
  score: number; // 0..100
};

type PracticeActivity = {
  kind: "practice";
  id: string;
  title: string;
  attempts: number;
  solved: boolean;
};

// The domain model: a discriminated union over the literal `kind`.
type Activity = LessonActivity | QuizActivity | PracticeActivity;

const activities: Activity[] = [
  { kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 },
  { kind: "quiz", id: "q1", title: "Union quiz", score: 90 },
  { kind: "practice", id: "p1", title: "Narrowing drills", attempts: 3, solved: true },
];

for (const activity of activities) {
  console.log(`${activity.kind}: ${activity.title}`);
}

The literal kind field does the load-bearing work: "lesson" is not the type string, it is the single value "lesson". That is what lets the compiler tell the three members apart later — and it is why you cannot yet reach for activity.score in the loop above. Inside the loop activity is the whole union, and only one member has a score.

Capstone milestone

Milestone 1 — the domain model. You have a discriminated union Activity with three members (lesson, quiz, practice), each tagged by a literal kind and carrying its own fields. This is the single shape every later layer stores, validates, and reports on.

Hint: This is the same domain-model milestone that Data Structures, Interfaces and Types, and Enums And Constants build toward — typed collections, interfaces/type aliases, and literal-union tags. Here they combine into the real thing.

  • Each member (LessonActivity, QuizActivity, PracticeActivity) has a literal kind field, not a string
  • Activity is the union of the three members
  • The shared fields (id, title) plus each member's own fields (minutesSpent / score / attempts + solved) are all present

Milestone 2: The Tracker Service

The service owns the activities. Make it generic over any record that carries a string id, so the same service works for activities today and anything else with an id tomorrow. Back it with a Map for identity lookups, and expose typed operations: add, get, all, a where query that takes a predicate, and a count. A class is one valid shape here; a factory over a Map would work equally well.

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type PracticeActivity = { kind: "practice"; id: string; title: string; attempts: number; solved: boolean };
type Activity = LessonActivity | QuizActivity | PracticeActivity;

// A generic in-memory service over any record that carries a string `id`.
class Tracker<T extends { id: string }> {
  private items = new Map<string, T>();

  add(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  get(id: string): T | undefined {
    return this.items.get(id);
  }

  all(): T[] {
    return Array.from(this.items.values());
  }

  // A typed query: hand in a predicate over T, get the matching subset back.
  where(predicate: (item: T) => boolean): T[] {
    return this.all().filter(predicate);
  }

  count(): number {
    return this.items.size;
  }

  // The update DTO. A caller editing one field should not have to restate the
  // whole record — but it must still say *which* record, so `id` stays
  // required while everything else becomes optional. `Pick<T, "id">` keeps the
  // one field; `Partial<T>` relaxes the rest.
  update(patch: Pick<T, "id"> & Partial<T>): T | undefined {
    const existing = this.items.get(patch.id);
    if (existing === undefined) {
      return undefined;
    }
    const merged = { ...existing, ...patch };
    this.items.set(merged.id, merged);
    return merged;
  }
}

const tracker = new Tracker<Activity>();
tracker.add({ kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 });
tracker.add({ kind: "quiz", id: "q1", title: "Union quiz", score: 90 });
tracker.add({ kind: "practice", id: "p1", title: "Narrowing drills", attempts: 3, solved: true });

console.log("Tracked:", tracker.count());
console.log("Quizzes:", tracker.where((a) => a.kind === "quiz").map((a) => a.title).join(", "));
console.log("Has l1:", tracker.get("l1")?.title);

// Rename one activity by handing over only the id and the field that changed.
const renamed = tracker.update({ id: "l1", title: "Intro to TypeScript types" });
console.log("Renamed:", renamed?.title);
console.log("Unknown id returns:", tracker.update({ id: "nope", title: "x" }));

The T extends { id: string } constraint is what makes add and get safe: add can read item.id because every T is guaranteed to have one, and get returns T | undefined — the undefined is honest, because a lookup can miss. where hands the whole T to your predicate, so a.kind === "quiz" inside it narrows against the real union, not against any. If you built the service with Map<string, any> instead, all of that safety would evaporate silently and the runner would never tell you.

update is where the utility types earn their place (Utility Types). Its parameter is a DTO — a data-transfer shape derived from T rather than declared alongside it, so it cannot drift out of step when Activity changes. Pick<T, "id"> keeps id required, because a patch that does not say which record it edits is meaningless; Partial<T> makes every other field optional, because a caller renaming an activity should not have to restate its score. Intersecting the two gives exactly "the id, plus any subset of the rest". Writing that shape out by hand would mean maintaining a second copy of every member of the union; deriving it means there is only ever one.

Capstone milestone

Milestone 2 — the tracker service. A generic service (class or factory) over a Map holds the activities behind a string id, and exposes typed add / get / all / where / count. The generic parameter is constrained to records with an id, so identity operations are type-safe.

Hint: This fuses the tracker-service milestone from Generics, Utility Types (Partial/Pick DTOs), and Classes And OOP (a class-based service is one valid shape). A functional factory over the same Map works just as well.

  • The service is generic (Tracker<T extends { id: string }>) — it is not hardcoded to Activity
  • It is backed by a Map keyed on id, and get returns T | undefined
  • where takes a predicate (item: T) => boolean and returns the matching subset
  • Adding three activities and counting returns 3
  • update takes a derived DTO (Pick<T, "id"> & Partial<T>) so a patch names its record and may omit every other field

Milestone 3: The Validation Layer

The tracker so far trusts its input — every value handed to add was already an Activity. Real data is not so kind: it arrives as unknown from a file, an API, or a message, and you cannot assume any of its shape. The rule is parse, don't validate: write functions that take unknown and either return a proper domain value or report why they couldn't, so that past the parse boundary everything is typed.

Surface the failure as a Result value rather than a thrown exception, so the caller has to reckon with it.

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type PracticeActivity = { kind: "practice"; id: string; title: string; attempts: number; solved: boolean };
type Activity = LessonActivity | QuizActivity | PracticeActivity;

// A tiny Result type so a parse failure is a value, not a thrown exception.
type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };

// Guards over `unknown`: nothing below trusts the shape of its input.
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseActivity(input: unknown): ParseResult<Activity> {
  if (!isRecord(input)) {
    return { ok: false, error: "expected an object" };
  }
  const { kind, id, title } = input;
  if (typeof id !== "string" || typeof title !== "string") {
    return { ok: false, error: "id and title must be strings" };
  }
  if (kind === "lesson") {
    if (typeof input.minutesSpent !== "number") {
      return { ok: false, error: "lesson.minutesSpent must be a number" };
    }
    return { ok: true, value: { kind, id, title, minutesSpent: input.minutesSpent } };
  }
  if (kind === "quiz") {
    if (typeof input.score !== "number") {
      return { ok: false, error: "quiz.score must be a number" };
    }
    return { ok: true, value: { kind, id, title, score: input.score } };
  }
  if (kind === "practice") {
    if (typeof input.attempts !== "number" || typeof input.solved !== "boolean") {
      return { ok: false, error: "practice needs numeric attempts and boolean solved" };
    }
    return { ok: true, value: { kind, id, title, attempts: input.attempts, solved: input.solved } };
  }
  return { ok: false, error: `unknown kind: ${String(kind)}` };
}

// Untrusted input — the shape a JSON payload might arrive in.
const good: unknown = { kind: "quiz", id: "q9", title: "Guards quiz", score: 88 };
const bad: unknown = { kind: "quiz", id: "q9", title: "Guards quiz" };

const parsedGood = parseActivity(good);
const parsedBad = parseActivity(bad);

console.log("good:", parsedGood.ok ? parsedGood.value.title : parsedGood.error);
console.log("bad:", parsedBad.ok ? parsedBad.value.title : parsedBad.error);

The isRecord guard is a type predicate (value is Record<string, unknown>): once it returns true, the compiler lets you index input by key, and each typeof check that follows narrows one field from unknown to a real type before it lands in the returned object. That is the whole discipline — every field crosses the boundary through a check, so the Activity you hand back is genuinely an Activity, not a hopeful as cast.

In production you would reach for Zod (Zod And Validation): z.discriminatedUnion expresses exactly this parse with a schema instead of hand-rolled guards, and gives you the inferred type for free. Hand-rolling it once, as here, is how you understand what Zod is doing under the hood — and the tracker's runner has no node_modules, so this milestone stays hand-rolled.

The reason to spend a whole layer on this is easiest to see by watching what happens without it. Before you read on, commit to an answer:

Predict

A feed value arrives as unknown and is missing its score. Instead of routing it through parseActivity, this code asserts the shape with a cast and reads the field. Two different tools get a say: the compiler, and the runner that actually executes the program. What happens?

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type Activity = LessonActivity | QuizActivity;

const feed: unknown = { kind: "quiz", id: "q2", title: "Bad quiz" };

// Skipping the parse boundary entirely: assert the shape instead of checking it.
const activity = feed as Activity;

console.log("kind:", activity.kind);
console.log("score + 10:", (activity as QuizActivity).score + 10);

Capstone milestone

Milestone 3 — the validation layer. A parseActivity function takes unknown input and returns a ParseResult<Activity>: it narrows every field through a guard before trusting it, and reports failures as a value rather than throwing. Past this boundary, everything is a real Activity.

Hint: This is the validation-layer milestone from Error Handling (the Result pattern) and Zod And Validation (Zod as the production version). Here it is hand-rolled so you can see exactly what a schema library automates.

  • isRecord is a type predicate (value is Record<string, unknown>) guarding object shape
  • parseActivity narrows id, title, and each member's own fields from unknown before returning
  • Failures come back as { ok: false, error } — no throw, no as cast to force the type
  • A quiz missing its numeric score parses as { ok: false }

Milestone 4: The Reporting Layer

The last layer reads the tracked activities back out and renders a report. Because Activity is a discriminated union, the report narrows on kind: a switch on the discriminant, one case per member, and inside each case the compiler has narrowed activity to exactly that member — so activity.score is legal in the quiz case and nowhere else.

The default branch does something subtler: it assigns activity to a variable of type never. That only compiles if every member has already been handled, so the moment someone adds a fourth activity kind, this line fails to compile — an exhaustiveness check that turns a forgotten case into a compile error instead of a silent gap in the report.

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type PracticeActivity = { kind: "practice"; id: string; title: string; attempts: number; solved: boolean };
type Activity = LessonActivity | QuizActivity | PracticeActivity;

// Narrow on the discriminant; each branch sees exactly one member's fields.
function describe(activity: Activity): string {
  switch (activity.kind) {
    case "lesson":
      return `Lesson "${activity.title}": ${activity.minutesSpent} min`;
    case "quiz":
      return `Quiz "${activity.title}": scored ${activity.score}`;
    case "practice":
      return `Practice "${activity.title}": ${activity.attempts} attempts, ${activity.solved ? "solved" : "unsolved"}`;
    default: {
      // If a new member is added to Activity, this line stops compiling —
      // the compiler proves the switch is exhaustive.
      const unreachable: never = activity;
      return unreachable;
    }
  }
}

const activities: Activity[] = [
  { kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 },
  { kind: "quiz", id: "q1", title: "Union quiz", score: 90 },
  { kind: "practice", id: "p1", title: "Narrowing drills", attempts: 3, solved: true },
];

console.log("=== Learning report ===");
for (const activity of activities) {
  console.log(describe(activity));
}

Notice that no branch casts and no branch guesses: inside case "quiz", activity.score is a number because the compiler narrowed the union down to QuizActivity on the discriminant. This is the payoff of the literal kind you chose back in Milestone 1 — it is a discriminant precisely so the report can fan out on it safely.

When the compiler blocks you (a no-run demo)

The discipline above is not optional decoration. Skip the narrowing and reach for a member field directly on the union, and the compiler stops you. This fence is marked no-run because it is meant to fail to compile — the runner strips types and would happily run it, which would contradict the lesson:

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type Activity = LessonActivity | QuizActivity;

function report(activity: Activity): string {
  // No narrowing yet: `score` only exists on the quiz member.
  return `scored ${activity.score}`;
}

console.log(report({ kind: "quiz", id: "q1", title: "x", score: 90 }));

Under tsc --strict this fence fails with:

error TS2339: Property 'score' does not exist on type 'Activity'.
  Property 'score' does not exist on type 'LessonActivity'.

That error is the compiler doing your bug-hunting up front: score genuinely does not exist on a LessonActivity, so reading it on the un-narrowed union would be undefined at run time. Narrowing on kind first — the switch above — is what makes the access legal, because inside each case the union has collapsed to a single member.

That is what the compiler does when you let it. The next block is what happens when you talk it out of the objection — the same mistake, but silenced with a cast, so it reaches the runner and the runner has no opinion about it either:

Debug

This is the reporting layer with its narrowing removed and a cast put in its place, so it compiles. It also runs - and then fails its own check with a line that reads wrong. Commit a hypothesis about what report() is actually holding when it is handed a lesson, then fix it so both lines name a real field.

import assert from "node:assert";

type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type Activity = LessonActivity | QuizActivity;

function report(activity: Activity): string {
const quiz = activity as QuizActivity;
return `scored ${quiz.score}`;
}

const lines = [
report({ kind: "quiz", id: "q1", title: "Union quiz", score: 90 }),
report({ kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 }),
];

assert.ok(
!lines.some((line) => line.includes("undefined")),
"every report line should name a real field, got: " + lines.join(" | "),
);
console.log(lines.join("\n"));

Expected output:

scored 90
spent 25 min

Capstone milestone

Milestone 4 — the reporting layer. A describe function switches on the activity's discriminant, renders one line per member with only that member's fields, and includes a never-typed default branch so adding a new kind fails to compile. That is a narrowing-driven, exhaustive report.

Hint: This is the reporting milestone from Type Narrowing (narrowing per variant) and Debugging TypeScript (exhaustiveness checks harden the reporter). The never check is what makes the report future-proof.

  • describe switches on activity.kind with one case per member
  • Each case reads only the fields of its narrowed member (no cross-member access, no as)
  • The default branch assigns activity to a never variable as an exhaustiveness check
  • Reaching for a member field on the un-narrowed union is a compile error, not a runtime undefined

Before You Assemble

Four layers are on the table and the build fence below asks for three of them at once. Two short checks first — one that reaches back across the whole arc without letting you re-read it, and one on the order the layers have to run in.

Recall

Answer from memory, without scrolling back. Each of the four layers you just built leans on one specific mechanism, and getting them confused is what produces a tracker that compiles and lies. Which pairing of layer to mechanism is right in every one of its four parts?

Arrange the code

These five lines take one untrusted feed value, run it through the parse boundary, unwrap the result, narrow it to one member of the union before reading a field only that member has, and print the report line. They are shuffled. Put them back in an order that runs - and then answer the question the puzzle is really asking: which line could not have moved, and what does the runner do if it does?

  1. const raw: unknown = { kind: "quiz", id: "q1", title: "Union quiz", score: 90 };
  2. const parsed = parseActivity(raw);
  3. const line = activity === null ? "rejected" : activity.kind === "quiz" ? `Quiz "${activity.title}": scored ${activity.score}` : `Other: ${activity.title}`;
  4. const activity = parsed.ok ? parsed.value : null;
  5. console.log(line);

The Complete Tracker

Now assemble all four layers into one program — and prove it works. This is a build task: the domain model, the ParseResult type, and the generic Tracker service are given and locked; you finish three functions that wire the layers together. parseActivity parses one untrusted value, loadFeed runs the whole untrusted feed through it and reports how many were rejected, and describe renders a report line by narrowing on the discriminant.

Run it as-is and it fails immediately, telling you which check failed first. Implement each function until every check passes and it prints All checks passed.

What "finished" means here, precisely: every check passes and the program prints its four closing lines — All checks passed., then Tracked activities: 3, Rejected from feed: 1, and Sample report: Lesson "Intro to types": 25 min. That is a stronger completion signal than a program that merely runs, because the checks pin exact values rather than the absence of a crash: a well-formed quiz parses, a quiz missing its numeric score is rejected, exactly one of the four feed values fails, exactly three reach the tracker, and describe renders the quiz and practice lines character for character. A tracker that gets any one of those wrong stops on the check that names it. The three stubs are the four layers with their seams showing — parseActivity is Milestone 3, loadFeed is Milestone 3 driving Milestone 2, and describe is Milestone 4.

Build

Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The domain model and service are given. Do NOT change these.
type LessonActivity = { kind: "lesson"; id: string; title: string; minutesSpent: number };
type QuizActivity = { kind: "quiz"; id: string; title: string; score: number };
type PracticeActivity = { kind: "practice"; id: string; title: string; attempts: number; solved: boolean };
type Activity = LessonActivity | QuizActivity | PracticeActivity;

type ParseResult<T> = { ok: true; value: T } | { ok: false; error: string };

class Tracker<T extends { id: string }> {
private items = new Map<string, T>();
add(item: T): T { this.items.set(item.id, item); return item; }
all(): T[] { return Array.from(this.items.values()); }
count(): number { return this.items.size; }
}

// The raw feed: untrusted `unknown` values, exactly as JSON would deliver them.
// Do NOT change this array.
const feed: unknown[] = [
{ kind: "lesson", id: "l1", title: "Intro to types", minutesSpent: 25 },
{ kind: "quiz", id: "q1", title: "Union quiz", score: 90 },
{ kind: "practice", id: "p1", title: "Narrowing drills", attempts: 3, solved: true },
{ kind: "quiz", id: "q2", title: "Bad quiz" },
];

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

// TODO 1: parse one untrusted value into an Activity.
//   Reject non-records, non-string id/title, and any member missing its own
//   fields (lesson.minutesSpent:number, quiz.score:number,
//   practice.attempts:number + solved:boolean). Return a ParseResult<Activity>.
//   parseActivity({ kind:"quiz", id:"q1", title:"x", score:90 }).ok -> true
//   parseActivity({ kind:"quiz", id:"q2", title:"y" }).ok           -> false
function parseActivity(input: unknown): ParseResult<Activity> {
// your code here
return { ok: false, error: "not implemented" };
}

// TODO 2: load the feed. Parse each raw value; add only the ones that parse
//   into the tracker; return the count of values that FAILED to parse.
//   loadFeed(new Tracker<Activity>(), feed) -> 1   (the malformed quiz)
function loadFeed(tracker: Tracker<Activity>, raw: unknown[]): number {
// your code here
return 0;
}

// TODO 3: render one report line by narrowing on the discriminant. Include an
//   exhaustiveness `never` check in the default branch so adding a new member
//   later fails to compile.
//   describe({ kind:"quiz", id:"q1", title:"Union quiz", score:90 })
//     -> 'Quiz "Union quiz": scored 90'
function describe(activity: Activity): string {
// your code here
return "";
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const okParse = parseActivity({ kind: "quiz", id: "q1", title: "Union quiz", score: 90 });
assert.strictEqual(okParse.ok, true, "TODO 1: parseActivity should accept a well-formed quiz");
assert.strictEqual(
parseActivity({ kind: "quiz", id: "q2", title: "Bad quiz" }).ok,
false,
"TODO 1: parseActivity should reject a quiz missing its numeric score",
);

const tracker = new Tracker<Activity>();
const rejected = loadFeed(tracker, feed);
assert.strictEqual(rejected, 1, "TODO 2: loadFeed should report exactly one rejected (malformed) value");
assert.strictEqual(tracker.count(), 3, "TODO 2: loadFeed should add only the three values that parsed");

assert.strictEqual(
describe({ kind: "quiz", id: "q1", title: "Union quiz", score: 90 }),
'Quiz "Union quiz": scored 90',
"TODO 3: describe should narrow on kind and render the quiz line",
);
assert.strictEqual(
describe({ kind: "practice", id: "p1", title: "Narrowing drills", attempts: 3, solved: true }),
'Practice "Narrowing drills": 3 attempts, solved',
"TODO 3: describe should narrow on kind and render the practice line",
);

console.log("All checks passed.");
console.log("Tracked activities:", tracker.count());
console.log("Rejected from feed:", rejected);
console.log("Sample report:", describe(tracker.all()[0]));

Expected output:

All checks passed.
Tracked activities: 3
Rejected from feed: 1
Sample report: Lesson "Intro to types": 25 min

Once it passes, try two variations and predict each before running:

  1. Break the parse boundary. In parseActivity, delete the quiz branch's typeof input.score check so it returns { ok: true, value: { kind, id, title, score: input.score } } unconditionally. Predict which assert fails first before running. The malformed q2 now parses "successfully", so the very first check that touches it — TODO 1's parseActivity({ kind: "quiz", … no score }).ok === false — no longer holds, and the run stops there with AssertionError: TODO 1: parseActivity should reject a quiz missing its numeric score before loadFeed or the count check ever runs. That first failure is the whole point: the runner doesn't typecheck, so at run time only the guard rejects bad data — tsc --strict would separately reject this edit (input.score is still unknown), but the runner never sees that.
  2. Drop the exhaustiveness check. In describe, delete the default branch entirely, remove its : string return annotation (change function describe(activity: Activity): string { to function describe(activity: Activity) {), and add a fourth member type StreakActivity = { kind: "streak"; id: string; title: string; days: number } to the Activity union. Predict what describe does with a streak activity before running. With the annotation gone there is nothing to force streak to be handled, so the switch falls through and describe returns undefined — the runner happily prints undefined for that activity, a silent gap in the report. Restore the : string annotation (still without the default) and tsc --strict catches it a different way — error TS2366: Function lacks ending return statement and return type does not include 'undefined'. — and restoring the never default makes the non-exhaustive switch itself the compile error. Three ways the type system can close the gap; leaving all three off is what turns it silent.

One last thing, before you call it done. The tracker is built around one domain model: three activity kinds, each tagged by a literal kind. Suppose the requirement changed — a fourth kind, a field that only some kinds carry, or activities arriving from two sources with different field names. Which layers would you have to touch, and which would not notice at all? Work through it honestly, because the answer audits the design you just assembled: describe should announce the gap as a compile error rather than a missing line, parseActivity should absorb the whole shape change, and Tracker should not care at all, since it only ever promised T extends { id: string }. Anywhere that turns out not to be true is a seam that leaked. Then answer the harder half: which of those choices would you make differently if you were starting again? The hand-rolled parse boundary is the honest candidate — it is the layer you wrote the most code for and the one a schema library would have given you in a few lines, and deciding when that trade is worth making is a judgement this project has now given you the evidence to make. And one structural question the single file deliberately hid from you: this whole tracker lives in one fence because the runner executes one file, but the four layers are exactly the seams a real project would cut along. Say where the import and export lines would fall (Modules) — which layer becomes which module, which of them needs only import type because it consumes a shape without ever calling into it, and which single module every other one would end up importing. If a layer you named earlier as "would not notice" turns out to need an import from a layer that changed, that is the same leaked seam showing up a second time, in a form the compiler would have caught for you.

Transfer

The tracker's rule is that untrusted input crosses exactly one boundary, and past that boundary every value is a real domain type. Rust programs reach for the same rule with serde and a Result, and Python programs with Pydantic. None of the three shares a type system with the others. Which statement names what actually transfers?

Key Takeaways

  • Discriminated unions model "one of several shapes." A literal kind on each member is the discriminant the whole program narrows on — model, service, and report all lean on it.
  • Generics with a constraint keep a service reusable and safe. Tracker<T extends { id: string }> works for any identified record, and the constraint is what makes add/get type-safe rather than any.
  • Parse, don't validate. Untrusted input is unknown; guard every field across the boundary with type predicates and typeof checks, and past that line everything is a real domain type — no as casts papering over the gap.
  • Report by narrowing, and prove exhaustiveness. Switch on the discriminant so each case sees one member; a never-typed default turns a forgotten case into a compile error instead of a silent hole.
  • The runner strips types, so type discipline is what keeps runtime honest. An un-narrowed access is undefined at run time; an unparsed field crashes. The compiler catches both first — which is the point of thinking in types.

Pro Tip: Build from the domain model outward. Get the Activity union and its literal discriminants right first, then the generic service on top, then the parse boundary, then the report — each layer depends only on the ones before it, so each stays independently runnable and testable in a plain console. That layering, not any single feature, is what makes a type-safe program stay type-safe as it grows.

Next Steps

The core arc ends here — everything past this point is the optional extension tail: deeper type-system features you can reach for when a project calls for them. First up is mapped types, the machinery behind the utility types (Partial, Pick) your tracker's DTOs relied on — you will learn to build your own transformations over a type's keys.

Continue to Mapped Types -->

Next lesson

Mapped Types

Learn TypeScript mapped types to transform, filter, and reshape types programmatically. Build your own Partial, Required, and Readonly.

25 min