Skip to lesson

learningtypescript.org / advanced / 15-capstone-project · lesson 19 of 25

TL;DR

Build a headless, strict-mode learning tracker across four milestones: a discriminated-union domain model, a generics-backed service, a hand-rolled validation layer that parses unknown input, and a narrowing-driven console report with an exhaustiveness check.

Key concepts

  • TypeScript project
  • TypeScript capstone
  • type-safe learning tracker
  • discriminated union
  • exhaustiveness check
  • parse don't validate

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.

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
Continue learning

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;
  }
}

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);

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.

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
Continue learning

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.

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 }
Continue learning

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.

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
Continue learning

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.

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

Continue learning

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.

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 -->

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.