Build a Task Tracker CLI in TypeScript
Build a typed CLI task tracker and learn TypeScript's compile-time safety.
Introduction
30 Second Summary
Imagine writing code that catches your mistakes before you even run it. That is the core promise of TypeScript. It adds a type system on top of JavaScript that acts like a spell-checker for your logic.
In this project, you will build a command-line task tracker application in TypeScript. You will define typed data structures, compile your code with tsc, and experience first-hand how the compiler catches bugs that would slip through in Python.
What You'll Build
A CLI application that manages tasks with typed properties like priority levels and statuses, stored in a local JSON file, with compile-time type safety enforced by the TypeScript compiler.
By the end of this project, you'll have:
- A fully configured TypeScript project with tsconfig.json and strict mode enabled by default.
- Typed interfaces and union types that enforce the shape of your data at compile time.
- A working CLI tool that adds, lists, completes, and filters tasks stored in a JSON file.
- Secret Mission: Add a priority-based sorting feature using generics.
Are there any prerequisites?
You should have basic programming knowledge (variables, functions, loops, conditionals) and familiarity with a terminal. No prior TypeScript experience is needed.
Set Up Your TypeScript Project
Every TypeScript project starts with a compilation pipeline. You write .ts files, and the TypeScript compiler transforms them into .js files that Node.js can run.
In this step, you will create the project folder, install TypeScript, and configure the compiler so it knows where your source code lives and where to put the compiled output.
In this step, get ready to:
- Initialize the project with npm and install TypeScript.
- Configure the TypeScript compiler with tsconfig.json.
- Create the project file structure.
Initialize the project and install TypeScript
You need a project folder with npm's dependency management before you can install TypeScript as a dev dependency.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- 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 exists by running:
ls
You should see the task-tracker folder listed in the output.
- Initialize npm to create a package.json file by running:
npm init -y
- Install TypeScript and Node.js type definitions as dev dependencies by running these commands:
npm install -D typescript@6.0.3
npm install -D @types/node
What do these commands do?
- npm install -D typescript@6.0.3 installs the TypeScript compiler as a development dependency pinned to version 6.0.3.
- npm install -D @types/node installs type definitions for Node.js built-in modules like fs and process.
- The -D flag means these packages are only needed during development, not at runtime.
- Verify TypeScript is installed correctly by running:
npx tsc --version
You should see Version 6.0.3 in the output.
Don't see the version number?
- Make sure you ran both npm install commands from inside the task-tracker folder. If you are in a different directory, npx will not find the locally installed TypeScript.
- Check that a node_modules folder exists inside task-tracker. If it does not, re-run the install commands.
- Why isn't npx tsc --version working after I installed TypeScript?
Now you need to open this project in VS Code so you can create and edit files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the task-tracker folder, and click Open.
The npm init -y command generated a default package.json with placeholder values. You need to update it with settings that tell Node.js to use ES modules and add helpful build scripts.
- In the VS Code Explorer sidebar on the left, click package.json to open it.
- Select all the text in the file and replace it with this code:
{
"name": "task-tracker",
"version": "1.0.0",
"description": "A TypeScript CLI task tracker",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"devDependencies": {
"@types/node": "^26.0.0",
"typescript": "^6.0.3"
}
}
What does this configuration do?
- "type": "module" tells Node.js to treat .js files as ES modules. This is required because TypeScript's nodenext module system outputs ES module syntax.
- The build script runs the TypeScript compiler. The start script runs the compiled output.
- devDependencies lists packages needed only during development. TypeScript compiles your code, but the final .js files run without it.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red squiggly lines in package.json?
- Check that every opening brace { has a matching closing brace }. JSON is strict about commas and brackets.
- Make sure there are no trailing commas after the last item in any object or array.
- Help me fix a JSON syntax error in my package.json file.
✔️ Awesome, I've got everything!
Great. Make sure you have saved package.json before moving on.
ⓧ I'd like to double check the full code
{
"name": "task-tracker",
"version": "1.0.0",
"description": "A TypeScript CLI task tracker",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"devDependencies": {
"@types/node": "^26.0.0",
"typescript": "^6.0.3"
}
}
Configure tsconfig.json
The TypeScript compiler needs a configuration file to know how to process your code. This file controls where it looks for source files, where it puts compiled output, and what module system to use.
- In the VS Code Explorer sidebar, right-click in the empty space below package.json.
- Click New File.
- Name it tsconfig.json and press Enter.
- Paste the following configuration into the file:
{
"compilerOptions": {
"module": "nodenext",
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*"]
}
What do these compiler options mean?
- "module": "nodenext" tells TypeScript to use Node.js-native ES modules. This is the recommended setting for modern Node.js projects.
- "outDir": "./dist" puts all compiled .js files into a dist/ folder, keeping them separate from your source code.
- "rootDir": "./src" tells the compiler that all your TypeScript source files live inside src/.
- "types": ["node"] loads Node.js type definitions. TypeScript 6.0 defaults this to an empty array, so you must add "node" explicitly.
TypeScript 6.0 also enables strict mode by default. This means the compiler will catch more type errors without you needing to configure anything extra.
- Save tsconfig.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing an error about unexpected tokens?
- Make sure you named the file exactly tsconfig.json (not tsconfig.json.txt or ts.config.json).
- Check that there are no trailing commas after "types": ["node"] or after the include array.
- Help me fix my tsconfig.json file.
✔️ Awesome, I've got everything!
Great. Make sure you have saved tsconfig.json before moving on.
ⓧ I'd like to double check the full code
{
"compilerOptions": {
"module": "nodenext",
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*"]
}
Create the project structure
The compiler expects a src/ folder for source files and a tasks.json file to store task data. You will create both now.
- In the VS Code Explorer sidebar, right-click in the empty space and click New Folder.
- Name it src and press Enter.
- Right-click in the empty space again and click New File.
- Name it tasks.json and press Enter.
- Paste the following content into tasks.json:
[]
What is tasks.json for?
This file will store your tasks as a JSON array. Starting with an empty array [] means the app has no tasks yet. Your TypeScript code will read from and write to this file as you add, list, and complete tasks.
- Save tasks.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now verify that the TypeScript compiler can read your configuration and run without errors.
- Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Run the TypeScript compiler by entering this command:
npx tsc
The command should complete with no output and no errors. Since the src/ folder has no .ts files yet, there is nothing to compile, but the compiler successfully reads your tsconfig.json without complaints.
Seeing an error when running npx tsc?
- If you see an error about rootDir, make sure you created the src/ folder at the same level as tsconfig.json (not nested inside another folder).
- If you see a file-not-found error, make sure your terminal is in the task-tracker directory. Run pwd to check your current location.
- Help me fix the TypeScript compiler error I'm seeing.
Your TypeScript project is fully configured. Next up, you will define the type system that makes your task tracker type-safe.
Define Your Task Types
Your TypeScript project is configured and ready to compile. Now you need to define the shape of the data your task tracker will work with.
In TypeScript, you define the structure of your data using types and interfaces. The compiler then enforces those structures everywhere in your code, catching mistakes before you ever run the program.
In this step, get ready to:
- Define type aliases and a Task interface in a new file.
- Trigger a compile-time error to see TypeScript catch a bug.
- Observe type inference in VS Code.
Create src/types.ts with the Task interface
Every task in your tracker will have an id, a title, a priority level, a status, and a creation timestamp. You will define these shapes once, and TypeScript will enforce them everywhere.
- In VS Code, create a new file called types.ts inside the src/ folder.
- Add the following code to src/types.ts:
// Define the allowed priority levels as a union type
export type Priority = "low" | "medium" | "high";
// Define the allowed statuses as a union type
export type Status = "todo" | "in-progress" | "done";
// Define the shape of a single task
export interface Task {
id: number;
title: string;
priority: Priority;
status: Status;
createdAt: string;
}
What does this code do?
A type alias like Priority restricts a value to one of a fixed set of strings. If you try to use any string outside that set, TypeScript flags it at compile time.
The Task interface defines exactly which fields a task object must have and what type each field must be. Any object that claims to be a Task but is missing a field or has the wrong type will trigger a compile error.
- Save src/types.ts.
- Run the following command in your terminal to compile:
npx tsc
The command should complete with no output, meaning your types compiled successfully.
Seeing an error when you compile?
Make sure your file is saved inside the src/ directory, not in the project root. The tsconfig.json has rootDir set to ./src, so TypeScript only looks for source files there.
Also confirm that every string in the union types is wrapped in double quotes and separated by | characters.
help me fix a TypeScript compile error in my types.ts file
✔️ Awesome, I've got everything!
Great. Double check you have saved src/types.ts and move on to the next substep.
ⓧ I'd like to double check the full code
export type Priority = "low" | "medium" | "high";
export type Status = "todo" | "in-progress" | "done";
export interface Task {
id: number;
title: string;
priority: Priority;
status: Status;
createdAt: string;
}
Test the type system by introducing an intentional error
TypeScript's real power shows up when you make a mistake. Instead of discovering bugs at runtime (like you would in Python), TypeScript catches them the moment you compile.
- At the bottom of src/types.ts, add this temporary test line:
// Temporary test - try an invalid priority value
const testPriority: Priority = "urgent";
- Save the file, then run the compiler:
npx tsc
TypeScript refuses to compile. You should see an error message stating that "urgent" is not assignable to type Priority.
Why does this matter?
In Python, this kind of mistake would only surface when the code actually runs and hits the bad value. TypeScript catches it before the program ever executes.
This is the core trade-off: you spend a few extra seconds writing type annotations upfront, but you eliminate entire categories of bugs that would otherwise slip into production.
- Delete the two temporary test lines you just added (the comment and the const testPriority line).
- Save src/types.ts.
Observe type inference in VS Code
TypeScript does not always require you to write explicit type annotations. It can infer the type of a variable from the value you assign to it.
- At the bottom of src/types.ts, temporarily add this line:
let myStatus = "todo";
- Hover your cursor over myStatus in VS Code.
VS Code displays the inferred type in a tooltip. TypeScript figured out that myStatus is a string without you writing a single annotation.
Inference vs explicit annotation
TypeScript infers string here because let allows reassignment to any string. Your Status type is narrower. It only allows "todo", "in-progress", or "done".
Inference is convenient for local variables. Explicit annotations like your Priority and Status types are essential for enforcing rules across your codebase.
- Delete the temporary let myStatus line from src/types.ts.
- Save the file and confirm clean compilation by running:
npx tsc
The command completes with no output. Your types are defined and compiling cleanly.
Still seeing a compile error?
Make sure you removed both temporary lines (the const testPriority line from earlier and the let myStatus line). Your src/types.ts should only contain the Priority type, the Status type, and the Task interface.
help me get npx tsc to compile without errors
Your data types are locked in. Next up, you will build a storage module that reads and writes tasks to a JSON file, with TypeScript enforcing the Task shape on every read and write.
Build the Storage Module
Your Task interface and type aliases are in place. TypeScript now knows exactly what shape a task should have.
Without a way to save tasks to disk, everything disappears the moment the program exits. In this step, you will build a storage module that reads and writes tasks to a JSON file using Node.js file system APIs.
In this step, get ready to:
- Create a storage module with typed load and save functions.
- Add a helper function that generates unique task IDs.
- Compile and verify the output in dist/.
Create the storage module with load and save functions
Your task tracker needs a dedicated module for reading from and writing to the JSON file. By typing these functions with the Task interface, TypeScript catches any mismatch between what you store and what you read back.
- Create a new file called storage.ts inside the src/ folder.
- Add the following code to src/storage.ts:
import { readFileSync, writeFileSync } from "node:fs";
import { Task } from "./types.ts";
// Path to the JSON file that stores all tasks
const DATA_FILE = "tasks.json";
// Read the JSON file and return its contents as a typed array
export function loadTasks(): Task[] {
try {
const data = readFileSync(DATA_FILE, "utf-8");
return JSON.parse(data) as Task[];
} catch {
return [];
}
}
// Write the full task array back to the JSON file
export function saveTasks(tasks: Task[]): void {
writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2), "utf-8");
}
What does this code do?
The imports pull readFileSync and writeFileSync from Node's built-in file system module. The Task import brings in the interface you defined in types.ts so the return types are enforced.
- DATA_FILE is a constant pointing to tasks.json. Centralizing the path here means you only change it in one place if the filename ever changes.
- loadTasks() reads the file, parses the JSON string into an object, and casts it as Task[]. The try/catch returns an empty array if the file is missing or corrupted.
- saveTasks() converts the array to a formatted JSON string (indented with 2 spaces) and writes it back to disk.
- Save src/storage.ts.
- Compile your project by running this command:
npx tsc
The command completes silently with no output. That silence means success.
Seeing a compile error?
- Check that your import path includes the .ts extension: "./types.ts". With module: "nodenext", TypeScript requires file extensions in import statements.
- Make sure storage.ts is inside the src/ folder, not in the project root. The rootDir in tsconfig.json only looks at src/.
- Confirm src/types.ts still exports Task. A missing export keyword on the interface will cause an import error.
Why won't my storage module compile?
Add a helper to generate unique task IDs
Each task needs a unique numeric ID. The getNextId function scans existing tasks for the highest ID and returns the next number up.
- Add this function below saveTasks in src/storage.ts:
// Find the highest existing ID and return the next one
export function getNextId(): number {
const tasks = loadTasks();
if (tasks.length === 0) return 1;
return Math.max(...tasks.map((t) => t.id)) + 1;
}
What does this code do?
getNextId() calls loadTasks() to get all current tasks. If the array is empty, it returns 1 as the first ID.
Otherwise, it maps every task to its id field, finds the maximum with Math.max(), and adds 1. TypeScript ensures t.id is always a number because the Task interface says so.
- Save src/storage.ts.
- Compile your project by running this command:
npx tsc
- Confirm the compiled output exists by running:
ls dist/
You should see storage.js and types.js listed in the output.
Don't see storage.js in dist/?
- Make sure you saved src/storage.ts before running npx tsc. Unsaved changes are invisible to the compiler.
- Run npx tsc from the task-tracker directory (where tsconfig.json lives). Running it from a different folder means it cannot find the config.
- Check that outDir in tsconfig.json is set to "./dist". A typo here sends compiled files to the wrong folder.
Why isn't my compiled output appearing?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/storage.ts and that npx tsc compiles without errors.
ⓧ I'd like to double check the full code
import { readFileSync, writeFileSync } from "node:fs";
import { Task } from "./types.ts";
const DATA_FILE = "tasks.json";
export function loadTasks(): Task[] {
try {
const data = readFileSync(DATA_FILE, "utf-8");
return JSON.parse(data) as Task[];
} catch {
return [];
}
}
export function saveTasks(tasks: Task[]): void {
writeFileSync(DATA_FILE, JSON.stringify(tasks, null, 2), "utf-8");
}
export function getNextId(): number {
const tasks = loadTasks();
if (tasks.length === 0) return 1;
return Math.max(...tasks.map((t) => t.id)) + 1;
}
Your storage layer is complete. Next up, you will build the CLI commands that use these functions to add, list, and complete tasks.
Build the CLI Commands
Your storage layer can read and write tasks to a JSON file. Now you need the functions that actually create, display, and update those tasks.
In this step, you will build the command functions that manipulate tasks and wire them up to a CLI entry point. The entry point parses what the user typed and dispatches to the right function. TypeScript's type guards will ensure invalid inputs are caught before they reach your core logic.
In this step, get ready to:
- Create task command functions (add, list, complete).
- Build a CLI entry point that parses arguments and dispatches commands.
- Compile and test the full application end to end.
Create the task command functions
Each CLI command maps to a single function. You will write three functions that handle adding a task, listing tasks, and marking a task as done.
- Create a new file called src/commands.ts in VS Code.
- Add the imports and the addTask function by pasting this code:
import { Priority, Status, Task } from "./types.ts";
import { loadTasks, saveTasks, getNextId } from "./storage.ts";
export function addTask(title: string, priority: Priority): void {
// Build a new task object with a unique id and current timestamp
const newTask: Task = {
id: getNextId(),
title,
priority,
status: "todo",
createdAt: new Date().toISOString(),
};
// Load existing tasks, add the new one, and save
const tasks = loadTasks();
tasks.push(newTask);
saveTasks(tasks);
console.log(`Added task #${newTask.id}: "${title}" [${priority}]`);
}
What does this code do?
The imports pull in the types you defined earlier and the storage functions that handle file I/O.
The addTask function creates a new Task object that satisfies the Task interface. TypeScript will reject this code if any required field is missing or has the wrong type.
You won't see a change yet. The next chunk adds the list function to the same file.
- Add the listTasks function below addTask in src/commands.ts:
export function listTasks(filterStatus?: Status): void {
const tasks = loadTasks();
// Filter tasks by status if a filter was provided
const filtered = filterStatus
? tasks.filter((t) => t.status === filterStatus)
: tasks;
if (filtered.length === 0) {
console.log("No tasks found.");
return;
}
// Print a formatted table header and rows
console.log("\n ID | Status | Priority | Title");
console.log("----|-------------|----------|------");
for (const task of filtered) {
const id = String(task.id).padStart(3);
const status = task.status.padEnd(11);
const priority = task.priority.padEnd(8);
console.log(` ${id} | ${status} | ${priority} | ${task.title}`);
}
console.log("");
}
What does this code do?
The ? after filterStatus makes the parameter optional. If the caller passes a status, only matching tasks are shown. If they pass nothing, all tasks are shown.
TypeScript knows that filterStatus can only be one of your three Status union values or undefined. Passing any other string would be a compile error.
You won't see a change yet. The next chunk adds the complete function.
- Add the completeTask function below listTasks in src/commands.ts:
export function completeTask(id: number): void {
const tasks = loadTasks();
// Find the task matching the given id
const task = tasks.find((t) => t.id === id);
if (!task) {
console.log(`Task #${id} not found.`);
return;
}
// Update the status and persist the change
task.status = "done";
saveTasks(tasks);
console.log(`Task #${id} marked as done: "${task.title}"`);
}
What does this code do?
This function loads all tasks, finds the one with a matching id, and sets its status to "done". Because task.status is typed as Status, TypeScript only allows "todo", "in-progress", or "done" as the value.
- Save src/commands.ts.
- Compile the project by running:
npx tsc
- You should see no errors. The dist/ folder now contains commands.js alongside storage.js and types.js.
Seeing a compile error?
- Check that the import paths use the .ts extension. With module: "nodenext", file extensions are mandatory in import paths.
- Verify that the function signatures match the types exactly. For example, addTask must accept Priority (not string) for the second parameter.
- Make sure the file is saved inside the src/ directory, not the project root.
Why is my commands.ts not compiling?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/commands.ts before moving on.
ⓧ I'd like to double check the full code
import { Priority, Status, Task } from "./types.ts";
import { loadTasks, saveTasks, getNextId } from "./storage.ts";
export function addTask(title: string, priority: Priority): void {
const newTask: Task = {
id: getNextId(),
title,
priority,
status: "todo",
createdAt: new Date().toISOString(),
};
const tasks = loadTasks();
tasks.push(newTask);
saveTasks(tasks);
console.log(`Added task #${newTask.id}: "${title}" [${priority}]`);
}
export function listTasks(filterStatus?: Status): void {
const tasks = loadTasks();
const filtered = filterStatus
? tasks.filter((t) => t.status === filterStatus)
: tasks;
if (filtered.length === 0) {
console.log("No tasks found.");
return;
}
console.log("\n ID | Status | Priority | Title");
console.log("----|-------------|----------|------");
for (const task of filtered) {
const id = String(task.id).padStart(3);
const status = task.status.padEnd(11);
const priority = task.priority.padEnd(8);
console.log(` ${id} | ${status} | ${priority} | ${task.title}`);
}
console.log("");
}
export function completeTask(id: number): void {
const tasks = loadTasks();
const task = tasks.find((t) => t.id === id);
if (!task) {
console.log(`Task #${id} not found.`);
return;
}
task.status = "done";
saveTasks(tasks);
console.log(`Task #${id} marked as done: "${task.title}"`);
}
Create the CLI entry point
The entry point is the file that runs when someone types a command. It reads what the user typed, validates the input, and calls the right function.
- Create a new file called src/index.ts in VS Code.
- Add the imports, argument parsing, and type guard functions by pasting this code:
import { addTask, listTasks, completeTask } from "./commands.ts";
import { Priority, Status } from "./types.ts";
// Slice off the first two elements (node path and script path)
const args = process.argv.slice(2);
const command = args[0];
// Type guard: narrows a string to the Priority union type
function isValidPriority(value: string): value is Priority {
return ["low", "medium", "high"].includes(value);
}
// Type guard: narrows a string to the Status union type
function isValidStatus(value: string): value is Status {
return ["todo", "in-progress", "done"].includes(value);
}
What are type guards?
A type guard is a function that tells TypeScript "if this returns true, the value is guaranteed to be this specific type." The value is Priority return type annotation is what makes it a type guard instead of a plain boolean check.
After calling isValidPriority(priority) and getting true, TypeScript narrows the variable from string to Priority. This is called type narrowing.
You won't see a change yet. The next chunk adds the switch statement that dispatches commands.
- Add the command dispatch logic below the type guards in src/index.ts:
switch (command) {
case "add": {
const title = args[1];
const priority = args[2] || "medium";
if (!title) {
console.log("Usage: node dist/index.js add <title> [priority]");
console.log("Priority: low | medium | high");
process.exit(1);
}
if (!isValidPriority(priority)) {
console.log(`Invalid priority: "${priority}". Use low, medium, or high.`);
process.exit(1);
}
addTask(title, priority);
break;
}
case "list": {
const statusFilter = args[1];
if (statusFilter && !isValidStatus(statusFilter)) {
console.log(`Invalid status: "${statusFilter}". Use todo, in-progress, or done.`);
process.exit(1);
}
listTasks(statusFilter as Status | undefined);
break;
}
case "complete": {
const id = parseInt(args[1], 10);
if (isNaN(id)) {
console.log("Usage: node dist/index.js complete <id>");
process.exit(1);
}
completeTask(id);
break;
}
default:
console.log("Task Tracker - TypeScript CLI");
console.log("");
console.log("Commands:");
console.log(" add <title> [priority] Add a new task (priority: low|medium|high)");
console.log(" list [status] List tasks (status: todo|in-progress|done)");
console.log(" complete <id> Mark a task as done");
break;
}
What does this code do?
The switch statement reads the first argument (the command name) and routes to the matching case. Each case validates its own arguments before calling the command function.
Notice how isValidPriority(priority) does double duty. It both validates the input AND narrows the type. After the check passes, TypeScript knows priority is a Priority, so passing it to addTask is type-safe.
The default case prints a help message when the user runs the tool without a recognized command.
- Save src/index.ts.
- Compile the project by running:
npx tsc
- You should see no errors. The dist/ folder now contains index.js and commands.js alongside the earlier files.
Seeing a type error in the switch statement?
- Make sure each case uses curly braces { } to create a block scope. Without them, variable declarations leak between cases.
- Check that your type guard functions are defined above the switch statement, not inside it.
- Verify the import path uses ./commands.ts with the .ts extension.
Why won't my index.ts compile?
✔️ Awesome, I've got everything!
Great. Make sure src/index.ts is saved before testing.
ⓧ I'd like to double check the full code
import { addTask, listTasks, completeTask } from "./commands.ts";
import { Priority, Status } from "./types.ts";
const args = process.argv.slice(2);
const command = args[0];
function isValidPriority(value: string): value is Priority {
return ["low", "medium", "high"].includes(value);
}
function isValidStatus(value: string): value is Status {
return ["todo", "in-progress", "done"].includes(value);
}
switch (command) {
case "add": {
const title = args[1];
const priority = args[2] || "medium";
if (!title) {
console.log("Usage: node dist/index.js add <title> [priority]");
console.log("Priority: low | medium | high");
process.exit(1);
}
if (!isValidPriority(priority)) {
console.log(`Invalid priority: "${priority}". Use low, medium, or high.`);
process.exit(1);
}
addTask(title, priority);
break;
}
case "list": {
const statusFilter = args[1];
if (statusFilter && !isValidStatus(statusFilter)) {
console.log(`Invalid status: "${statusFilter}". Use todo, in-progress, or done.`);
process.exit(1);
}
listTasks(statusFilter as Status | undefined);
break;
}
case "complete": {
const id = parseInt(args[1], 10);
if (isNaN(id)) {
console.log("Usage: node dist/index.js complete <id>");
process.exit(1);
}
completeTask(id);
break;
}
default:
console.log("Task Tracker - TypeScript CLI");
console.log("");
console.log("Commands:");
console.log(" add <title> [priority] Add a new task (priority: low|medium|high)");
console.log(" list [status] List tasks (status: todo|in-progress|done)");
console.log(" complete <id> Mark a task as done");
break;
}
Compile and test the full application
Time to run your task tracker for real. You will add a task, list all tasks, mark one as done, and list again to confirm the status change.
- Add your first task by running:
node dist/index.js add "Learn TypeScript" high
- You should see: Added task #1: "Learn TypeScript" [high].
- List all tasks by running:
node dist/index.js list
- You should see a table with your task listed as todo status and high priority.
- Mark task #1 as complete by running:
node dist/index.js complete 1
- You should see: Task #1 marked as done: "Learn TypeScript".
- List tasks one more time to confirm the status update by running:
node dist/index.js list
- The task's status column should now show done instead of todo.
Seeing an error when running the commands?
- Make sure you run npx tsc before running node dist/index.js. You always need to compile TypeScript before running the output JavaScript.
- Check that tasks.json exists in the project root (not inside src/). The storage module looks for it relative to where you run the command.
- If you see a module-not-found error, verify that "type": "module" is in your package.json. Without it, Node.js treats your files as CommonJS and the ESM imports fail.
My task tracker commands are not working. Help me debug.
Why compile before running?
TypeScript files cannot run directly in Node.js. The npx tsc command translates your .ts files into plain .js files inside dist/. You always edit src/, compile, then run from dist/.
This compile step is where TypeScript catches bugs. If your code has a type error, it fails here instead of crashing at runtime.
Secret mission
Add Priority Sorting with Generics
Your task tracker lists tasks in the order they were added. But what if you want to see your highest-priority tasks first? In this mission, you'll build a generic sorting utility that works with any array type, then wire it into your CLI with a --sort flag.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, pause for later, or delete them entirely.
Resources you used:
- The task-tracker project directory on your Desktop (includes source files, compiled output, and node_modules).
- The tasks.json data file storing your tasks (inside the project directory).
Keep everything running
No action needed. Choose this if you want to keep building on your task tracker or reference the code later.
- Your project takes minimal disk space.
- You can continue adding features like due dates, categories, or search.
- Run npx tsc any time to recompile after making changes.
Pause - I'll come back to this later
There are no running processes to stop. Your project is already paused by default since it only runs when you explicitly execute a command.
- Your source files, compiled output, and task data will remain in the task-tracker folder on your Desktop.
- When you return, navigate back to the folder and run npx tsc to recompile if you make changes.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes your source code, compiled output, dependencies, and task data.
- Delete the entire project directory by running these commands in your terminal:
cd ~/Desktop
rm -rf task-tracker
On Windows, run this instead:
cd ~\Desktop
Remove-Item -Recurse -Force task-tracker
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see task-tracker in the output.
Nice Work!
Nice Work!
You just built a fully typed command-line task tracker from scratch using TypeScript. Every piece of data flowing through your app is checked at compile time, before a single line of code ever runs.
You've learned how to:
- Set up a TypeScript project from scratch with tsconfig.json and strict mode, compiling .ts files to JavaScript with npx tsc.
- Defined interfaces and union types that enforce data shape at compile time, catching invalid values like "urgent" before your code ever runs.
- Built a working CLI application with typed file I/O using the Node.js fs module, reading and writing a tasks.json file with full type safety.
- Secret Mission: Implemented a generic utility function using keyof and generics for reusable, type-safe priority sorting.
Ready to quiz yourself?