The compiler and runtime

Build a typed task list

Combine object types, literal unions, functions, narrowing, generics, and runtime parsing in one small program.

Time to put the whole course into one small program: a command-line task list that reads and writes a JSON file. Every lesson so far shows up somewhere in it.

Start with a state model that makes impossible combinations impossible:

type TaskStatus = 'open' | 'completed'

type Task = {
  id: number
  title: string
  status: TaskStatus
}

The literal union means a task can never be 'done' or 'Completed' by accident. The object type means every task has all three fields, always.

The commands

Support four commands: add, list, complete, and remove. Read the command from process.argv and put each one in its own function with a clear input contract:

function addTask(tasks: Task[], title: string): Task[] {
  const id = tasks.length === 0 ? 1 : Math.max(...tasks.map(t => t.id)) + 1
  return [...tasks, { id, title, status: 'open' }]
}

Annotate the parameters, because they are boundaries. Let local return types infer unless a function is exported and promises a contract to other files. addTask() is exported by the module, so I wrote : Task[] on purpose.

Parse the file, do not assert it

The JSON file is data from outside the program. Treat what JSON.parse() returns as unknown. Check that it is an array, then run every element through the isTask() guard from the previous lesson, extended with the status field. Only then return Task[].

Writing JSON.parse(text) as Task[] does not count as parsing. It compiles, it validates nothing, and a hand-edited file with "id": "3" will crash complete somewhere far from the read.

A generic that earns its place

Three commands need to find a task by id. A generic helper keeps the caller’s item type:

function findById<T extends { id: number }>(items: T[], id: number) {
  return items.find(item => item.id === id)
}

The result is T | undefined: the caller’s exact type when a match exists, and undefined when it does not. That undefined is the point. It forces the complete and remove commands to handle a missing task instead of crashing on it.

Run it and break it

Add "outDir": "dist" to compilerOptions in your tsconfig.json, then compile and run one round trip:

npx tsc
node dist/tasks.js add "Write the TypeScript course"
node dist/tasks.js list

You should see one line: 1 [open] Write the TypeScript course.

Now test the failure paths on purpose:

  • the file does not exist
  • the JSON is malformed
  • one task has a string id
  • an unknown command is passed
  • a requested task id does not exist

Each one should print a clear message and exit with a non-zero code. None should print a stack trace.

Keep strict enabled and add a repeatable check:

npx tsc --noEmit

Finish by removing every any, every unjustified as, and every non-null !. The goal is not a quiet checker. The goal is a program where every uncertain boundary and every missing value is explicit in the types. If you can read the signatures and know where the data can go wrong, you are done.

Lesson completed