M Logo
๐Ÿ“ TypeScript from Zero ยท Part 26 of 50

TypeScript Project: Task Tracker CLI, Project Setup and a Discriminated Union Model

November 6, 2026

Welcome to episode 26 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This episode starts Part 3. For the next eight episodes we build one real program: a task tracker for the command line. We add tasks, list them, mark them done, and save them in a file. Today: the project, and the model of a task. Every code block below is the exact code and output from the video, ready to copy.

1. Project

We make a folder called tasks and go inside. npm init, the module type, TypeScript and the Node types as development dependencies, and tsc init. You know all of these steps from the earlier episodes.

text
$ npm init -y && npm pkg set type=module && npm install --save-dev typescript @types/node && npx tsc --init
Wrote to /home/mah/tasks/package.json:

{
  "name": "dry",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}

added 4 packages, and audited 5 packages in 599ms

found 0 vulnerabilities

Created a new tsconfig.json

You can learn more at https://aka.ms/tsconfig

2. tsconfig

We open the folder in VS Code and edit the T S config file. Root dir and out dir are switched on, so our code lives in the source folder. We tell tsc about the Node types. And we add the rewrite option from episode twenty four.

3. Model

Now the model. A task has an id, a title and a status. Instead of one flag, we use a union of three shapes. A task that is todo has nothing more. A task that is doing knows when it started. A task that is done knows when it finished. Each shape carries exactly what belongs to it. This is a discriminated union, from episode nineteen.

4. describe

A function describe turns a task into one line of text. The switch looks at the status, and in every case tsc knows which shape it has. In the doing case it can read started at, and in the done case, finished at.

5. index

In a second file we make a list of tasks, one of each status, and print them with describe. We run it with node, and the three lines appear.

src/index.ts:

ts
import { describe } from "./task.ts";
import type { Task } from "./task.ts";

const tasks: Task[] = [
  { id: 1, title: "Buy milk", status: "todo" },
  { id: 2, title: "Write code", status: "doing", startedAt: "2026-11-01" },
  { id: 3, title: "Fix bug", status: "done", finishedAt: "2026-10-30" },
];
for (const task of tasks) {
  console.log(describe(task));
}

src/task.ts:

ts
export type Task =
  | { id: number; title: string; status: "todo" }
  | { id: number; title: string; status: "doing"; startedAt: string }
  | { id: number; title: string; status: "done"; finishedAt: string };

export function describe(task: Task): string {
  switch (task.status) {
    case "todo":
      return `#${task.id} ${task.title} (not started)`;
    case "doing":
      return `#${task.id} ${task.title} (since ${task.startedAt})`;
    case "done":
      return `#${task.id} ${task.title} (finished ${task.finishedAt})`;
  }
}

tsconfig.json:

ts
{
  // Visit https://aka.ms/tsconfig to read more about this file
  "compilerOptions": {
    // File Layout
    "rootDir": "./src",
    "outDir": "./dist",

    // Environment Settings
    // See also https://aka.ms/tsconfig/module
    "module": "nodenext",
    "target": "esnext",
    "rewriteRelativeImportExtensions": true,
    "types": ["node"],
    // For nodejs:
    // "lib": ["esnext"],
    // "types": ["node"],
    // and npm install -D @types/node

    // Other Outputs
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,

    // Stricter Typechecking Options
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,

    // Style Options
    // "noImplicitReturns": true,
    // "noImplicitOverride": true,
    // "noUnusedLocals": true,
    // "noUnusedParameters": true,
    // "noFallthroughCasesInSwitch": true,
    // "noPropertyAccessFromIndexSignature": true,

    // Recommended Options
    "strict": true,
    "jsx": "react-jsx",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "noUncheckedSideEffectImports": true,
    "moduleDetection": "force",
    "skipLibCheck": true,
  }
}
text
$ node src/index.ts
#1 Buy milk (not started)
#2 Write code (since 2026-11-01)
#3 Fix bug (finished 2026-10-30)

6. Error

Now we add a fourth task that is done, but has no finish date. Node runs it, and prints: finished undefined. tsc is stricter: the object is not assignable to the task type, because it is missing the property finished at. TS2322. The model protects us from half finished data.

src/index.ts:

ts
import { describe } from "./task.ts";
import type { Task } from "./task.ts";

const tasks: Task[] = [
  { id: 1, title: "Buy milk", status: "todo" },
  { id: 2, title: "Write code", status: "doing", startedAt: "2026-11-01" },
  { id: 3, title: "Fix bug", status: "done", finishedAt: "2026-10-30" },
  { id: 4, title: "Test it", status: "done" },
];
for (const task of tasks) {
  console.log(describe(task));
}
text
$ node src/index.ts
#1 Buy milk (not started)
#2 Write code (since 2026-11-01)
#3 Fix bug (finished 2026-10-30)
#4 Test it (finished undefined)
text
$ npx tsc --noEmit
src/index.ts:8:3 - error TS2322: Type '{ id: number; title: string; status: "done"; }' is not assignable to type 'Task'.
  Property 'finishedAt' is missing in type '{ id: number; title: string; status: "done"; }' but required in type '{ id: number; title: string; status: "done"; finishedAt: string; }'.

8   { id: 4, title: "Test it", status: "done" },
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  src/task.ts:4:50 - 'finishedAt' is declared here.
    4   | { id: number; title: string; status: "done"; finishedAt: string };
                                                       ~~~~~~~~~~

Found 1 error in src/index.ts:8

7. Fix

We add the date, and tsc is quiet again.

src/index.ts:

ts
import { describe } from "./task.ts";
import type { Task } from "./task.ts";

const tasks: Task[] = [
  { id: 1, title: "Buy milk", status: "todo" },
  { id: 2, title: "Write code", status: "doing", startedAt: "2026-11-01" },
  { id: 3, title: "Fix bug", status: "done", finishedAt: "2026-10-30" },
  { id: 4, title: "Test it", status: "done", finishedAt: "2026-11-02" },
];
for (const task of tasks) {
  console.log(describe(task));
}
text
$ node src/index.ts
#1 Buy milk (not started)
#2 Write code (since 2026-11-01)
#3 Fix bug (finished 2026-10-30)
#4 Test it (finished 2026-11-02)
bash
$ npx tsc --noEmit

Recap

Today: the project setup in one go, and the model of the program, a task as a union of three shapes, with a switch that narrows. Next episode: reading the command line with parse args.

ยฉ 2026 All Rights Reserved By Mahmudul Alam