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.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
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.
$ 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/tsconfig2. 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:
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:
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:
{
// 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,
}
}$ 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:
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));
}$ 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)$ 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:87. Fix
We add the date, and tsc is quiet again.
src/index.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));
}$ 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)$ npx tsc --noEmitRecap
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.