Build a Task Tracker CLI with TypeScript

Build a typed CLI task tracker using TypeScript interfaces, enums, and union types.

Introduction

30 Second Summary

Everyone has a to-do list. But when your code has bugs hiding in it, your to-do app crashes right when you need it most.

In this project, you will build a command-line task tracker using TypeScript. Along the way, you will see how TypeScript catches bugs before your code ever runs.

What You'll Build

A command-line task tracker that accepts arguments to add tasks, list them with their status, and mark them as complete, with all data persisted in a local JSON file.

By the end of this project, you'll have:

  • A TypeScript project configured with strict type checking and a tsconfig.json you understand.
  • A working CLI application that adds, lists, and completes tasks from the terminal.
  • Hands-on experience with TypeScript's type system catching real bugs at compile time instead of runtime.
  • Secret Mission: Add priority levels and filtering so you can view only high-priority tasks.

Are there any prerequisites?

You should have basic familiarity with JavaScript (variables, functions, arrays, objects). No TypeScript experience is needed.

Set Up Your TypeScript Development Environment

Before you can write TypeScript, you need a development environment that can compile it.

TypeScript code does not run directly. It compiles down to plain JavaScript first, and Node.js runs that JavaScript. In this step, you will install the tools that make this compilation workflow possible and configure how the TypeScript compiler behaves for this project.

In this step, get ready to:
  • Install Node.js and VS Code.
  • Create and initialize your project folder with TypeScript.
  • Configure the TypeScript compiler with tsconfig.json.
Install Node.js and VS Code

Node.js is the JavaScript runtime that will execute your compiled TypeScript code. This project requires version 24 (the current LTS release).

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Node.js version by running this command:
node -v

✔️ I see version 24 or higher

Your Node.js installation is ready. Move on to the next part of this substep.

ⓧ I see an older version

You have Node.js installed, but this project needs version 24 or higher.

  • Go to the Node.js download page.
  • Download the LTS installer for your operating system (.pkg for macOS, .msi for Windows).
  • Run the installer and follow the on-screen prompts. The new version will replace your existing installation.
  • Close and reopen your terminal, then run node -v again to confirm it now shows v24 or higher.

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Go to the Node.js download page.
  • Download the LTS installer for your operating system (.pkg for macOS, .msi for Windows).
  • Run the installer and follow the on-screen prompts.
  • Close and reopen your terminal, then run node -v to confirm it shows v24 or higher.
  • Confirm that npm (Node's package manager) is also available by running:
npm -v

You should see a version number printed. npm is bundled with Node.js, so if Node.js installed correctly, npm is already available.

Next, open VS Code. This is the code editor you will use throughout the project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

✔️ VS Code opened

VS Code is ready. Move on to the next substep.

ⓧ I don't have VS Code installed

  • Go to the VS Code download page.
  • Download the installer for your operating system.
  • Run the installer and follow the prompts.
  • Once installed, open VS Code using the search bar steps above.
Create and initialize your project

Every TypeScript project lives in its own folder with a package.json file that tracks dependencies. You will create this folder, initialize it, and install TypeScript as a development dependency.

  • In VS Code, open the integrated terminal by clicking Terminal in the top menu bar, then clicking New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir task-tracker
cd task-tracker
  • Confirm the folder was created by running:
pwd

You should see a path ending in task-tracker, confirming you are inside the new project folder.

  • Initialize your project with a package.json file by running:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. This file is the manifest for your project. It tracks your project name, version, and any packages you install.

You should see a confirmation message showing the contents of the new package.json file printed to your terminal.

  • Install TypeScript as a development dependency by running:
npm install typescript --save-dev

Why a dev dependency?

The --save-dev flag tells npm that TypeScript is only needed during development (for compiling). The compiled JavaScript output does not need TypeScript at runtime.

  • Verify TypeScript installed correctly by running:
npx tsc --version

You should see a version number like Version 6.0.3 printed to your terminal.

Not seeing a version number?

Make sure you ran npm install typescript --save-dev from inside the task-tracker folder. If you were still on the Desktop, TypeScript would have installed in the wrong location.

Run pwd to confirm your terminal is inside task-tracker. If not, run cd ~/Desktop/task-tracker and try the install again.

Help me troubleshoot this

  • Open the task-tracker folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the task-tracker folder on your Desktop, then click Open.

In the Explorer sidebar on the left, you should see package.json, package-lock.json, and a node_modules folder.

Configure the TypeScript compiler

TypeScript needs a configuration file to know how strictly to check your code, which JavaScript version to compile to, and where to put the output. This file is called tsconfig.json.

  • Create a new file by clicking the New File icon at the top of the Explorer sidebar (it looks like a page with a plus sign).
  • Name the file tsconfig.json and press Enter.
  • Paste the following configuration into the file:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

What do these settings do?

  • target: ES2022 tells TypeScript to compile to modern JavaScript that Node.js 24 fully supports.
  • module and moduleResolution: Node16 enables the correct module system for Node.js projects.
  • outDir: compiled JavaScript goes into a dist/ folder, keeping source and output separate.
  • rootDir: tells TypeScript that all source files live inside src/.
  • strict: enables all strict type-checking options at once. This is what makes TypeScript catch bugs early.
  • include: tells TypeScript to only compile files inside the src/ directory.
  • Save tsconfig.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Your Explorer sidebar should now show tsconfig.json alongside package.json and node_modules.

Seeing red squiggles in tsconfig.json?

If VS Code highlights an error, double-check that every key and value is wrapped in double quotes. JSON does not allow trailing commas after the last item in an object or array.

Help me fix my tsconfig.json

✔️ Awesome, I've got everything!

Great. Double check you have saved tsconfig.json and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete tsconfig.json file for comparison:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}

Your TypeScript development environment is fully configured. Next up, you will define the data model for your task tracker using interfaces and enums.

Define Your Data Model with Interfaces and Enums

Your TypeScript project is configured and ready to go. Now it's time to define the data structures that will power your task tracker.

TypeScript's type system lets you describe the exact shape of your data before writing any logic. In this step, you'll use interfaces and enums to define what a task looks like, then write helper functions to save and load tasks from a file.

In this step, get ready to:
  • Define a data model for tasks using an enum and an interface.
  • Write helper functions to read and write tasks to a JSON file.
Create your data model with an enum and interface

Every task in your tracker needs a predictable shape. TypeScript gives you two tools for this: enums define a fixed set of allowed values, and interfaces describe the structure of an object.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Create the src directory by running this command:
mkdir src
  • Confirm the directory was created by running:
ls

You should see src listed alongside node_modules, package.json, and tsconfig.json.

  • In VS Code's Explorer sidebar on the left, right-click the src folder.
  • Select New File.
  • Name the file index.ts and press Enter.

The file opens in the editor. This is where all your task tracker logic will live.

  • Add the following code to src/index.ts to import the file system module and define your task status enum:
import { readFileSync, writeFileSync, existsSync } from "node:fs";

// --- Data Model ---

enum TaskStatus {
  Todo = "todo",
  Done = "done",
}

What does this code do?

The import line pulls in three functions from Node.js for reading, writing, and checking if files exist.

The TaskStatus enum defines the only two allowed states for a task: Todo and Done. Unlike a plain string, an enum restricts values to this exact set. If you accidentally type "todoo" somewhere later, the compiler catches the typo.

  • Save src/index.ts.
  • You won't see any output yet. The next chunk builds on this enum.
  • Add the Task interface directly below the enum in src/index.ts:
interface Task {
  id: number;
  title: string;
  status: TaskStatus;
  createdAt: string;
}

What does this code do?

The Task interface describes the exact shape every task object must follow. Every task needs an id (number), a title (string), a status (which must be one of the TaskStatus enum values), and a createdAt timestamp.

If you ever try to create a task object missing one of these properties, TypeScript flags the error before the code runs.

  • Save src/index.ts.
  • You won't see a change yet. The file helpers in the next substep use this data model.

What is the difference between an enum and an interface?

An enum defines a closed set of allowed values. Think of it as a dropdown menu with fixed options.

An interface defines the structure of an object. It lists which properties must exist and what type each one holds. Together, they give TypeScript everything it needs to validate your data at compile time.

Write file helper functions

Your task tracker needs to persist data between runs. You'll store tasks in a tasks.json file and use two helper functions to read from and write to it.

  • Add the following code below the Task interface in src/index.ts:
// --- File Helpers ---

const TASKS_FILE = "tasks.json";

function readTasks(): Task[] {
  if (!existsSync(TASKS_FILE)) {
    return [];
  }
  const data = readFileSync(TASKS_FILE, "utf-8");
  return JSON.parse(data) as Task[];
}

function writeTasks(tasks: Task[]): void {
  writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
}

What does this code do?

TASKS_FILE is a constant holding the filename where tasks are saved. Defining it once means you never accidentally misspell the path in multiple places.

readTasks() checks if the file exists first. If it doesn't (like on the first run), it returns an empty array. Otherwise it reads the file and parses the JSON back into an array of Task objects.

writeTasks() takes an array of tasks and writes them to the file as nicely formatted JSON. The : void return type annotation tells TypeScript this function doesn't return anything.

  • Save src/index.ts.
  • Compile your code by running this command in the terminal:
npx tsc

The command completes silently with no output. This means TypeScript found zero type errors in your code.

Seeing errors after running npx tsc?

  • Check that your file is saved at src/index.ts (inside the src folder, not in the project root). The tsconfig.json expects code to live in src/.
  • Make sure every opening brace { has a matching closing brace }. A missing brace causes cascading errors.
  • Confirm the import line uses "node:fs" with the node: prefix. Without it, TypeScript may not resolve the module correctly.

Help me debug my TypeScript compilation error

✔️ Awesome, I've got everything!

Great. Double check you've saved src/index.ts and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete src/index.ts file at this point:

import { readFileSync, writeFileSync, existsSync } from "node:fs";

// --- Data Model ---

enum TaskStatus {
  Todo = "todo",
  Done = "done",
}

interface Task {
  id: number;
  title: string;
  status: TaskStatus;
  createdAt: string;
}

// --- File Helpers ---

const TASKS_FILE = "tasks.json";

function readTasks(): Task[] {
  if (!existsSync(TASKS_FILE)) {
    return [];
  }
  const data = readFileSync(TASKS_FILE, "utf-8");
  return JSON.parse(data) as Task[];
}

function writeTasks(tasks: Task[]): void {
  writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
}

Your data model is locked in. TypeScript now knows exactly what a task looks like. Next up, you'll build the command handler that uses these types to add, list, and complete tasks from the terminal.

Build the Command Handler with Union Types and Type Narrowing

Your data model and file helpers are in place. Now it is time to build the part of your app that actually does things: the command handler.

A command-line app needs to figure out what the user wants (add a task? list tasks? mark one done?) and then call the right function. TypeScript gives you two powerful features for this: union types to define the exact set of valid commands, and type narrowing to guarantee you handle every one.

In this step, get ready to:
  • Define a union type that restricts commands to only valid options.
  • Write handler functions for adding, listing, and completing tasks.
  • Wire up a switch statement that dispatches commands and test the full app.
Define the Command union type

In plain JavaScript, a user could type anything as a command and your app would have to guess what to do. A union type lets you tell TypeScript exactly which string values are valid.

  • In src/index.ts, scroll to the bottom of the file (below the writeTasks function).
  • Add the Command Handlers section with the union type by pasting this code:
// --- Command Handlers ---

type Command = "add" | "list" | "done";

What is a union type?

A union type uses the | operator to say "this value can be one of these specific options." Here, Command can only ever be "add", "list", or "done".

If you accidentally write code that passes "remove" where a Command is expected, TypeScript catches it at compile time.

Write handler functions for each command

Each command needs its own function. You will write three: one to add a task, one to list all tasks, and one to mark a task as done.

  • In src/index.ts, directly below the type Command line you just added, paste the addTask function:
function addTask(title: string): void {
  const tasks = readTasks();
  const newTask: Task = {
    id: tasks.length + 1,
    title,
    status: TaskStatus.Todo,
    createdAt: new Date().toISOString(),
  };
  tasks.push(newTask);
  writeTasks(tasks);
  console.log(`Added task #${newTask.id}: "${newTask.title}"`);
}

What does this code do?

The title: string parameter annotation means TypeScript will reject any call that passes a non-string value. The return type : void tells TypeScript this function does not return anything.

Inside, it reads existing tasks, creates a new Task object matching your interface (id, title, status, createdAt), appends it to the array, writes it back to disk, and logs a confirmation.

  • Below addTask, paste the listTasks function:
function listTasks(): void {
  const tasks: Array<Task> = readTasks();

  if (tasks.length === 0) {
    console.log("No tasks yet. Add one with: add \"task title\"");
    return;
  }

  console.log("\n Your Tasks:\n");
  for (const task of tasks) {
    const icon = task.status === TaskStatus.Done ? "[x]" : "[ ]";
    console.log(`  ${icon} #${task.id} - ${task.title}`);
  }

  const todoCount = tasks.filter((t) => t.status === TaskStatus.Todo).length;
  const doneCount = tasks.filter((t) => t.status === TaskStatus.Done).length;
  console.log(`\n  Summary: ${todoCount} pending, ${doneCount} completed\n`);
}

What does this code do?

The Array<Task> type annotation uses generics to tell TypeScript the array contains only Task objects. This gives you autocomplete on every item inside the loop.

The function prints each task with a [x] or [ ] icon depending on status, then uses .filter() to count how many are pending vs completed for a summary line.

  • Below listTasks, paste the completeTask function:
function completeTask(id: number): void {
  const tasks = readTasks();
  const task = tasks.find((t) => t.id === id);

  if (!task) {
    console.log(`Task #${id} not found.`);
    return;
  }

  task.status = TaskStatus.Done;
  writeTasks(tasks);
  console.log(`Completed task #${id}: "${task.title}"`);
}

What does this code do?

The id: number annotation ensures this function only accepts numeric IDs. Inside, .find() returns Task | undefined because the search might fail.

The if (!task) check is type narrowing in action. After this check, TypeScript knows task is definitely a Task (not undefined), so you can safely access task.status and task.title.

  • Save src/index.ts.

Seeing red squiggles in VS Code?

Make sure each function is placed after the type Command line and before any other code you might add later. Check that your curly braces are balanced. help me fix syntax errors in my handler functions

Wire up the switch statement and test your commands

Your handlers are ready, but nothing calls them yet. You need a main block that reads what the user typed and dispatches to the correct function.

  • At the very bottom of src/index.ts (below completeTask), paste the main dispatch block:
// --- Main ---

const args = process.argv.slice(2);
const command = args[0] as Command | undefined;

switch (command) {
  case "add": {
    const title = args[1];
    if (!title) {
      console.log("Usage: add \"task title\"");
      break;
    }
    addTask(title);
    break;
  }
  case "list":
    listTasks();
    break;
  case "done": {
    const id = parseInt(args[1], 10);
    if (isNaN(id)) {
      console.log("Usage: done <task-id>");
      break;
    }
    completeTask(id);
    break;
  }
  default:
    console.log("Commands: add, list, done");
}

What is type narrowing in this switch?

When TypeScript enters case "add", it knows command is exactly "add". Inside case "done", it knows command is "done". This is type narrowing. Each branch of the switch reduces the union type to a single specific value.

The default case catches anything that does not match the union, including undefined (when the user runs the script with no arguments).

  • Save src/index.ts.
  • Compile your project by running:
npx tsc
  • You should see no errors. If the command completes silently, your code is valid.
  • Test adding a task by running:
node dist/index.js add "Buy groceries"
  • You should see: Added task #1: "Buy groceries".
  • Test listing tasks by running:
node dist/index.js list
  • You should see your task displayed with a [ ] icon, plus a summary line showing 1 pending and 0 completed.
  • Test completing a task by running:
node dist/index.js done 1
  • You should see: Completed task #1: "Buy groceries".
  • Run node dist/index.js list one more time to confirm the task now shows [x] and the summary reads 0 pending, 1 completed.

Seeing a compiler error?

Check that you placed the Main section at the very bottom of the file, after all function definitions. TypeScript needs the functions to be defined before the switch calls them.

If you see a "cannot find module" error, make sure you ran npx tsc before trying node dist/index.js. The dist/ folder only exists after compilation. help me fix my TypeScript compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts and that npx tsc compiles without errors.

ⓧ I'd like to double check the full code

Here is the complete src/index.ts file at this point:

import { readFileSync, writeFileSync, existsSync } from "node:fs";

// --- Data Model ---

enum TaskStatus {
  Todo = "todo",
  Done = "done",
}

interface Task {
  id: number;
  title: string;
  status: TaskStatus;
  createdAt: string;
}

// --- File Helpers ---

const TASKS_FILE = "tasks.json";

function readTasks(): Task[] {
  if (!existsSync(TASKS_FILE)) {
    return [];
  }
  const data = readFileSync(TASKS_FILE, "utf-8");
  return JSON.parse(data) as Task[];
}

function writeTasks(tasks: Task[]): void {
  writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
}

// --- Command Handlers ---

type Command = "add" | "list" | "done";

function addTask(title: string): void {
  const tasks = readTasks();
  const newTask: Task = {
    id: tasks.length + 1,
    title,
    status: TaskStatus.Todo,
    createdAt: new Date().toISOString(),
  };
  tasks.push(newTask);
  writeTasks(tasks);
  console.log(`Added task #${newTask.id}: "${newTask.title}"`);
}

function listTasks(): void {
  const tasks: Array<Task> = readTasks();

  if (tasks.length === 0) {
    console.log("No tasks yet. Add one with: add \"task title\"");
    return;
  }

  console.log("\n Your Tasks:\n");
  for (const task of tasks) {
    const icon = task.status === TaskStatus.Done ? "[x]" : "[ ]";
    console.log(`  ${icon} #${task.id} - ${task.title}`);
  }

  const todoCount = tasks.filter((t) => t.status === TaskStatus.Todo).length;
  const doneCount = tasks.filter((t) => t.status === TaskStatus.Done).length;
  console.log(`\n  Summary: ${todoCount} pending, ${doneCount} completed\n`);
}

function completeTask(id: number): void {
  const tasks = readTasks();
  const task = tasks.find((t) => t.id === id);

  if (!task) {
    console.log(`Task #${id} not found.`);
    return;
  }

  task.status = TaskStatus.Done;
  writeTasks(tasks);
  console.log(`Completed task #${id}: "${task.title}"`);
}

// --- Main ---

const args = process.argv.slice(2);
const command = args[0] as Command | undefined;

switch (command) {
  case "add": {
    const title = args[1];
    if (!title) {
      console.log("Usage: add \"task title\"");
      break;
    }
    addTask(title);
    break;
  }
  case "list":
    listTasks();
    break;
  case "done": {
    const id = parseInt(args[1], 10);
    if (isNaN(id)) {
      console.log("Usage: done <task-id>");
      break;
    }
    completeTask(id);
    break;
  }
  default:
    console.log("Commands: add, list, done");
}

Your task tracker is fully functional. You can add tasks, list them, and mark them complete. Next up, you will push TypeScript's type system to its limits by intentionally breaking things to see how the compiler protects you from bugs.

Experience TypeScript's Safety Net

Your task tracker compiles and runs. But what makes TypeScript worth the extra setup over plain JavaScript? The answer is safety.

In this step, you'll deliberately break your code to watch the compiler stop you. You'll also see how generics give you protection when working with arrays of data.

In this step, get ready to:
  • Introduce type errors and observe TypeScript's compiler messages.
  • Explore how Array<Task> generics protect your data transformations.
Introduce intentional type errors

The best way to understand TypeScript's value is to see it catch a real mistake. You'll make two deliberate errors and watch the compiler refuse to let them through.

  • In src/index.ts, scroll down to the case "add" block in your switch statement.
  • Find this line:
    addTask(title);
  • Change title to the number 123, so the line looks like this:
    addTask(123);
  • Try compiling by running this command:
npx tsc

The compiler refuses to compile. You should see an error telling you that a number is not assignable to a parameter of type string.

What just happened?

Your addTask function declares its parameter as title: string. When you passed 123 (a number), TypeScript caught the mismatch instantly.

In plain JavaScript, this would silently succeed. The number 123 would get coerced to a string at some point, or worse, cause a runtime crash deep inside your logic. TypeScript catches it before your code ever runs.

Now try a second type of error. Still in src/index.ts, find the for loop inside listTasks(). Find this line:

    console.log(`  ${icon} #${task.id} - ${task.title}`);
  • Add this line directly below it:
    console.log(task.description);
  • Try compiling again by running:
npx tsc

The compiler refuses again. This time the error tells you that the property description does not exist on type Task.

Why is this useful?

Your Task interface defines exactly four properties: id, title, status, and createdAt. Trying to access description is a typo that TypeScript flags immediately.

In a larger codebase, this kind of error is incredibly common. You rename a field, forget to update one reference, and plain JavaScript happily returns undefined at runtime. TypeScript makes these bugs impossible.

  • Revert both changes now. Change addTask(123) back to addTask(title) and delete the console.log(task.description) line.
  • Save src/index.ts.
Explore how generics protect your data transformations

Your listTasks() function already uses a powerful TypeScript feature called generics. Generics tell TypeScript exactly what type of data lives inside a collection.

  • In src/index.ts, find this line at the top of listTasks():
  const tasks: Array<Task> = readTasks();

What does Array<Task> do?

The Array<Task> annotation tells TypeScript that tasks is an array where every element is a Task object. This unlocks two benefits.

First, when you call .filter() or .find() on the array, TypeScript knows the callback parameter t is a Task. It gives you autocomplete for t.id, t.title, t.status, and t.createdAt.

Second, if you try to access a property that does not exist (like t.descrption with a typo), the compiler catches it immediately. The generic type flows through every array method automatically.

  • Now look at the summary logic near the end of listTasks():
  const todoCount = tasks.filter((t) => t.status === TaskStatus.Todo).length;
  const doneCount = tasks.filter((t) => t.status === TaskStatus.Done).length;
  console.log(`\n  Summary: ${todoCount} pending, ${doneCount} completed\n`);

How do generics help here?

Because tasks is typed as Array<Task>, TypeScript knows that t inside the .filter() callback is a Task. This means t.status is guaranteed to be a TaskStatus value.

If you accidentally wrote t.staus (a typo) or compared against a string like "pending" instead of TaskStatus.Todo, the compiler would catch it. Generics make every step in the data transformation type-safe.

  • Confirm your code compiles cleanly by running:
npx tsc

You should see no output at all. Silence from the TypeScript compiler means zero errors.

  • Test your task tracker by running:
node dist/index.js list

You should see your tasks listed with [ ] or [x] icons, followed by a summary line showing how many are pending and how many are completed.

Not seeing a summary line?

Make sure you reverted both experimental changes. The addTask(123) line should be addTask(title) and the console.log(task.description) line should be deleted entirely.

If you see no tasks at all, add one first with node dist/index.js add "Test task" then run list again.

help me fix my TypeScript compilation errors

✔️ Awesome, I've got everything!

Great. Double check you've saved src/index.ts with all experimental errors removed.

ⓧ I'd like to double check the full code

Here is the complete src/index.ts as it should look after this step:

import { readFileSync, writeFileSync, existsSync } from "node:fs";

// --- Data Model ---

enum TaskStatus {
  Todo = "todo",
  Done = "done",
}

interface Task {
  id: number;
  title: string;
  status: TaskStatus;
  createdAt: string;
}

// --- File Helpers ---

const TASKS_FILE = "tasks.json";

function readTasks(): Task[] {
  if (!existsSync(TASKS_FILE)) {
    return [];
  }
  const data = readFileSync(TASKS_FILE, "utf-8");
  return JSON.parse(data) as Task[];
}

function writeTasks(tasks: Task[]): void {
  writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
}

// --- Command Handlers ---

type Command = "add" | "list" | "done";

function addTask(title: string): void {
  const tasks = readTasks();
  const newTask: Task = {
    id: tasks.length + 1,
    title,
    status: TaskStatus.Todo,
    createdAt: new Date().toISOString(),
  };
  tasks.push(newTask);
  writeTasks(tasks);
  console.log(`Added task #${newTask.id}: "${newTask.title}"`);
}

function listTasks(): void {
  const tasks: Array<Task> = readTasks();

  if (tasks.length === 0) {
    console.log("No tasks yet. Add one with: add \"task title\"");
    return;
  }

  console.log("\n Your Tasks:\n");
  for (const task of tasks) {
    const icon = task.status === TaskStatus.Done ? "[x]" : "[ ]";
    console.log(`  ${icon} #${task.id} - ${task.title}`);
  }

  const todoCount = tasks.filter((t) => t.status === TaskStatus.Todo).length;
  const doneCount = tasks.filter((t) => t.status === TaskStatus.Done).length;
  console.log(`\n  Summary: ${todoCount} pending, ${doneCount} completed\n`);
}

function completeTask(id: number): void {
  const tasks = readTasks();
  const task = tasks.find((t) => t.id === id);

  if (!task) {
    console.log(`Task #${id} not found.`);
    return;
  }

  task.status = TaskStatus.Done;
  writeTasks(tasks);
  console.log(`Completed task #${id}: "${task.title}"`);
}

// --- Main ---

const args = process.argv.slice(2);
const command = args[0] as Command | undefined;

switch (command) {
  case "add": {
    const title = args[1];
    if (!title) {
      console.log("Usage: add \"task title\"");
      break;
    }
    addTask(title);
    break;
  }
  case "list":
    listTasks();
    break;
  case "done": {
    const id = parseInt(args[1], 10);
    if (isNaN(id)) {
      console.log("Usage: done <task-id>");
      break;
    }
    completeTask(id);
    break;
  }
  default:
    console.log("Commands: add, list, done");
}

You've just experienced TypeScript's core promise firsthand. Bugs that would have silently corrupted data at runtime got caught before your code even compiled. Next up, you'll take on a Secret Mission to extend your task tracker with priority levels and filtering.

Secret mission

Add Priority Levels and Filtering

Your task tracker treats every task equally. What if some tasks are more urgent than others? Add a priority system that lets you tag tasks as low, medium, or high priority, then filter your list to focus on what matters most.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for future use or delete them entirely.

Resources you used:

  • Project folder on your Desktop (contains src/, dist/, tsconfig.json, package.json, and tasks.json).
  • node_modules/ directory with TypeScript installed (inside your project folder).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your task tracker for daily use.

  • Your project folder stays on your Desktop with all files intact.
  • You can continue adding tasks with node dist/index.js add "task title" at any time.
  • If you modify src/index.ts, remember to recompile with npx tsc before running.

Pause - I'll come back to this later

Since this project is entirely local with no servers or services, there is nothing to shut down. Your files remain on your Desktop for when you return.

  • Close VS Code to free up system memory.
  • All source code, compiled output, and task data stay on disk until you reopen the folder.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • Close VS Code.
  • Open your terminal.
  • Delete the project folder from your Desktop by running the command for your operating system:

macOS

rm -rf ~/Desktop/[[PROJECT_FOLDER="your project folder name"]]

Windows

Remove-Item -Recurse -Force "$HOME\Desktop\[[PROJECT_FOLDER="your project folder name"]]"

This removes all source code, compiled output, dependencies, and your tasks.json data file in one step.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully typed command-line task tracker in TypeScript from scratch.

You've learned how to:

  • Built a working CLI task tracker with add, list, and done commands that persist data to a local JSON file.
  • Configured a TypeScript project from scratch with strict mode, and used interfaces, enums, and union types to model your data and catch bugs at compile time.
  • Used type narrowing for safe command dispatch and generics with array methods for type-safe data transformations.
  • Secret Mission: Added a priority system with enum-based filtering to view only high-priority tasks.

Ready to quiz yourself?