Capstone: Study Planner

The Course
•
6 min read

You now have enough TypeScript to convert a small JavaScript application. The capstone is the console core of the beginner JavaScript study planner: import saved sessions and summarize completed work. A webpage is deliberately outside this beginner TypeScript project, so the new learning stays focused on types and boundaries.

Read the program in three layers. Session and Report name trusted data. parseSessions accepts text, parses to unknown, validates every row, and constructs new Session records. summarize accepts trusted readonly records and calculates the report without changing them. The caller narrows the import result before using its sessions.

The report preserves the JavaScript project’s rule that incomplete rows do not contribute completed minutes. The importer is a new, deliberately smaller contract: it accepts zero and finite nonnegative decimals, whereas the JavaScript beginner form required whole minutes from 1 through 240. Reuse the JavaScript validation policy instead if you want an exact migration of that form. TypeScript does not choose either rule for you. For this course’s importer, zero is valid; negative and non-finite minutes are not. Topics are trimmed and must not become blank. A bad row rejects the entire import rather than silently disappearing.

Array.isArray uses any[] in TypeScript’s standard library. The loop immediately binds each element to unknown so its properties still require guards. This is an intentional boundary discipline, not an assertion. Notice that the program never casts unknown data into Session.

The full runnable version is below. Read and predict it before running. It is longer than earlier examples because it connects the pieces, not because the type syntax is more advanced. You can download the modular project; the local-running lesson explains its files and commands.

Predict, check, then run

Before running, write down the output and explain which information the checker knows. Click Check types first, then Check & run.

Predict, then runTypeScript
type Session = {
  readonly topic: string;

  readonly minutes: number;

  readonly done: boolean;
};

type ImportResult =
  | {
      kind: "ok";

      sessions: readonly Session[];
    }
  | {
      kind: "error";

      message: string;
    };

// JSON syntax, field shape and domain rules are separate checks.
function parseSessions(json: string): ImportResult {
  let value: unknown;

  try {
    value = JSON.parse(json);
  } catch {
    return { kind: "error", message: "Invalid JSON" };
  }

  if (!Array.isArray(value)) {
    return { kind: "error", message: "Expected a list" };
  }

  const sessions: Session[] = [];

  // Array.isArray narrows to any[] in TypeScript's library. Explicitly keep
  // each external element unknown so no unchecked properties slip through.
  for (const element of value) {
    const item: unknown = element;

    if (
      typeof item !== "object" ||
      item === null ||
      !("topic" in item) ||
      typeof item.topic !== "string" ||
      item.topic.trim() === "" ||
      !("minutes" in item) ||
      typeof item.minutes !== "number" ||
      !Number.isFinite(item.minutes) ||
      item.minutes < 0 ||
      !("done" in item) ||
      typeof item.done !== "boolean"
    ) {
      return { kind: "error", message: "Invalid session" };
    }

    sessions.push({
      topic: item.topic.trim(),
      minutes: item.minutes,
      done: item.done
    });
  }

  return { kind: "ok", sessions };
}

type Report = {
  completedCount: number;

  completedMinutes: number;
};

function summarize(sessions: readonly Session[]): Report {
  let completedCount = 0;
  let completedMinutes = 0;

  for (const session of sessions) {
    if (session.done) {
      completedCount += 1;
      completedMinutes += session.minutes;
    }
  }

  return { completedCount, completedMinutes };
}

const saved =
  '[{"topic":" Types ","minutes":25,"done":true},{"topic":"DOM","minutes":40,"done":false}]';
const result = parseSessions(saved);

if (result.kind === "error") {
  console.log(result.message);
} else {
  const report = summarize(result.sessions);

  console.log(
    `${report.completedCount} complete, ${report.completedMinutes} min`
  );
}

Enable JavaScript to edit and type-check this example.

Check your prediction
1 complete, 25 min

Trace input into trusted data and then a report

parseSessions accepts text and must justify a typed success. summarize accepts already trusted sessions and should not parse JSON again. Trace one completed and one incomplete record with unequal durations. A completed zero-minute session still counts as a completed record, so count and duration need independent expectations.

Boundary or caseWhat to inspect
SyntaxMalformed text fails before field decoding
DecoderReject wrong fields and normalize accepted topics
ReportCount completed records and sum their minutes without mutation

Find both functions in model.ts and their caller in main.ts. Change only the fixture: no sessions, valid zero, text minutes or a blank topic. Predict which stage handles each case. Verify source records survive summary unchanged.

Your turn

Exercise

Capstone: Study Planner: practise

Recreate the pure summarize part from memory. Define readonly Session fields and a Report. For completed Types/25 and incomplete DOM/40, print 1 complete, 25 min. Also print 0 complete, 0 min for an empty list.

Your workspaceTypeScript
// Rebuild the report function before adding import validation.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Start with the trusted-data calculation. It should not parse JSON itself.

Hint 2: a first step

Use local counters and return a named report object.

Show one solution
One possible solutionTypeScript
type Session = {
  readonly topic: string;

  readonly minutes: number;

  readonly done: boolean;
};

type Report = {
  completedCount: number;

  completedMinutes: number;
};

function summarize(sessions: readonly Session[]): Report {
  let completedCount = 0;
  let completedMinutes = 0;

  for (const session of sessions) {
    if (session.done) {
      completedCount += 1;
      completedMinutes += session.minutes;
    }
  }

  return { completedCount, completedMinutes };
}

function display(report: Report): string {
  return `${report.completedCount} complete, ${report.completedMinutes} min`;
}

console.log(
  display(
    summarize([
      { topic: "Types", minutes: 25, done: true },
      { topic: "DOM", minutes: 40, done: false }
    ])
  )
);
console.log(display(summarize([])));

Enable JavaScript to edit and type-check this example.

1 complete, 25 min
0 complete, 0 min

Explain the types at the function boundary and the runtime checks, if any. Compare your approach before copying this answer.

Change it and explain it

Change the saved JSON in the full example to an empty list, malformed JSON, a blank topic and text minutes. Trace exactly which branch handles each. Identify which annotations could be inferred and which ones communicate a useful boundary. Do not weaken types to make invalid data pass.

Was this article helpful?

Your response is saved on this device.