TypeScript Fundamentals with Node.js
Build a type-safe CLI task manager and watch TypeScript catch real bugs.
Introduction
30 Second Summary
Ever had a bug that looked perfectly fine in your code but crashed everything when it ran? These invisible bugs are one of the biggest time sinks in software development.
In this project, you will build a command-line task manager in TypeScript that lets you add, complete, list, and filter tasks from the terminal. You will start by writing it in plain JavaScript, watch it crash from a subtle bug, then rebuild it with TypeScript types that catch the exact same bug before your code ever runs.
What You'll Build
Picture running a single command in your terminal and seeing your tasks neatly grouped by status, with the confidence that every typo and type mismatch was caught by the compiler before you hit Enter.
By the end of this project, you'll have:
- A working CLI task manager where you add tasks with a title, priority level, and due date, then mark them as complete and see them update in a formatted list.
- Type-safe filtering that groups tasks by status (pending, completed, overdue) and prevents invalid filter values at compile time.
- Hands-on proof of TypeScript's compiler catching a real runtime crash before your code ever runs, using interfaces, union types, and type narrowing.
- Secret Mission: Add a generic storage layer that persists any typed data to a JSON file.
Are there any prerequisites?
None. This project includes full setup instructions for Node.js, TypeScript, and all required tools. No prior TypeScript experience is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. You'll create a command-line task manager, first in plain JavaScript where a sneaky bug crashes your app, then in TypeScript where the compiler catches that same bug before your code ever runs.
Set Up Your TypeScript Project
Before you can write any typed code, you need a working TypeScript environment. That means a project folder, a compiler, and a way to run your TypeScript files directly.
In this step, you'll set up a Node.js project with TypeScript for type checking and tsx for running TypeScript files without a separate compile step.
In this step, get ready to:
- Initialize a Node.js project and install TypeScript dependencies.
- Configure the TypeScript compiler with a tsconfig.json file.
- Write and run your first TypeScript file.
Initialize the project and install dependencies
Every TypeScript project starts with a project folder, a package.json to track dependencies, and the TypeScript toolchain itself. You'll set all of that up now.
- Press Cmd+Space to open your search bar.
- Type Visual Studio Code and press Enter to open it.
Using Cursor instead?
Search for Cursor instead. Every instruction in this project works the same way in both editors.
- Open the integrated terminal by pressing Cmd+` (backtick, the key above Tab).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir task-manager
cd task-manager
- Open the task-manager folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the task-manager folder on your Desktop, then click Open.
Seeing a trust prompt?
VS Code may ask "Do you trust the authors of the files in this folder?" Click Yes, I trust the authors to continue. This is a standard security prompt that appears when opening a new folder.
VS Code now shows the empty task-manager folder in the Explorer sidebar on the left.
- Reopen the terminal by pressing Cmd+`. The terminal now defaults to the task-manager folder.
- Initialize a new Node.js project by running this command:
npm init -y
You should see a JSON summary of the new package.json printed in the terminal, with "name": "task-manager" near the top.
- Install TypeScript, Node.js type definitions, and tsx by running this command:
npm install -D typescript@7.0.2 @types/node@26.3.0 tsx@4.23.12
You should see output ending with a line like added 3 packages confirming the install succeeded.
What did you just install?
typescript@7.0.2 is the TypeScript compiler. It reads your code and checks for type errors before you ever run it.
@types/node@26.3.0 provides type definitions for Node.js built-in modules like fs and path. Without it, TypeScript would not know the shapes of those modules.
tsx@4.23.12 lets you run TypeScript files directly in Node.js without a separate compile step. It transpiles on the fly but does not type-check your code.
Install failed or timed out?
Make sure you have a working internet connection. npm needs to download the packages from the registry.
Check that your terminal is inside the task-manager folder by running pwd. It should end with /task-manager.
Help me debug my npm install error
Now you need to tell TypeScript how to compile your code. This is done through a configuration file called tsconfig.json.
- Create a new file by right-clicking in the Explorer sidebar and selecting New File.
- Name the file tsconfig.json and press Enter.
- Paste the following configuration into tsconfig.json:
{
"compilerOptions": {
"strict": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "./dist",
"rootDir": "./src",
"target": "es2025",
"skipLibCheck": true
},
"include": ["src"]
}
What does this configuration do?
strict: true turns on all of TypeScript's strictest type-checking rules. This is where the real bug-catching power comes from.
module: "nodenext" and moduleResolution: "nodenext" tell TypeScript to use Node.js's native module system. rootDir points to your source files and outDir sets where compiled JavaScript goes.
- Save tsconfig.json by pressing Cmd+S.
✔️ Awesome, I've got everything!
Great. Make sure you've saved tsconfig.json and move on to creating your first TypeScript file.
ⓧ I'd like to double check the full code
{
"compilerOptions": {
"strict": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "./dist",
"rootDir": "./src",
"target": "es2025",
"skipLibCheck": true
},
"include": ["src"]
}
Create the entry point
Your TypeScript toolchain is installed and configured. Time to make sure it actually works by writing a one-line test file.
- Right-click in the Explorer sidebar and select New File.
- Type src/index.ts as the filename and press Enter. VS Code creates the src folder and the index.ts file inside it automatically.
- Paste the following code into src/index.ts:
console.log("Hello, TypeScript!");
- Save src/index.ts by pressing Cmd+S.
Before you run this file, what do you expect to see in the terminal?
- Run your first TypeScript file by running this command in the terminal:
npx tsx src/index.ts
You should see Hello, TypeScript! printed in the terminal. That confirms tsx can run your TypeScript code.
Why tsx instead of running TypeScript directly?
Node.js can only run JavaScript. tsx bridges the gap by transpiling your TypeScript into JavaScript on the fly, so you can run .ts files as if they were native.
tsx does not type-check your code. You'll use the TypeScript compiler (tsc) separately for that. This separation is intentional and matches real-world TypeScript workflows.
Don't see Hello, TypeScript! in the terminal?
Make sure you saved src/index.ts before running the command. Unsaved changes will not be picked up by tsx.
Check that your file is at src/index.ts (inside the src folder), not at the root of task-manager. The path in the command must match the file location exactly.
Help me debug why npx tsx src/index.ts is not working
✔️ Awesome, I've got everything!
Great. src/index.ts is ready to go.
ⓧ I'd like to double check the full code
console.log("Hello, TypeScript!");
Add convenience scripts to package.json
Right now, running your code requires typing the full npx tsx src/index.ts command each time. You can add shortcut scripts to package.json and enable ES module support at the same time.
- Open package.json by clicking on it in the Explorer sidebar.
- Replace the entire contents of package.json with the following:
{
"name": "task-manager",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npx tsx watch src/index.ts",
"build": "npx tsc"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/node": "26.3.0",
"tsx": "4.23.12"
}
}
What changed in package.json?
"type": "module" tells Node.js to treat your project as an ES module project. This is required because the tsconfig.json uses module: "nodenext".
The dev script runs tsx in watch mode, so it re-runs your file every time you save. The build script runs the TypeScript compiler to type-check and compile your code.
The boilerplate fields from npm init (like description, main, and keywords) are removed because this project does not need them.
- Save package.json by pressing Cmd+S.
- Verify the scripts work by running this command in the terminal:
npx tsx src/index.ts
You should see Hello, TypeScript! again, confirming your project still works after the package.json changes.
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and you're ready for the next step.
ⓧ I'd like to double check the full code
{
"name": "task-manager",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npx tsx watch src/index.ts",
"build": "npx tsc"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/node": "26.3.0",
"tsx": "4.23.12"
}
}
Your TypeScript environment is ready to go. Next up, you'll write a task manager in plain JavaScript and watch it crash from a sneaky bug that looks perfectly fine to the eye.
Write the Task Manager in Plain JavaScript (and Watch It Break)
Your TypeScript environment is set up and working. Now it is time to build the task manager itself.
Here is the twist: you will write it in plain JavaScript first, with zero type annotations. The code will look perfectly correct to the eye. JavaScript will happily run it. Then a subtle bug will crash the whole program at runtime.
In this step, get ready to:
- Build createTask, addTask, and listTasks functions.
- Trigger a runtime crash from a subtle type bug that JavaScript cannot catch.
Build the initial task manager logic
The task manager needs three core functions. One creates task objects, one adds them to a list, and one prints them. You will write all three in plain JavaScript style, without any TypeScript types.
- Return to src/index.ts in your editor.
- Select all the existing code by pressing Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
- Add the task creation functions by pasting this code:
// Track the next available task ID
let nextId = 1;
// Store all tasks in a simple array
const tasks = [];
// Create a task object from the given details
function createTask(title, priority, dueDate) {
return {
id: nextId++,
title,
priority,
dueDate,
completed: false,
createdAt: new Date(),
};
}
// Add a new task to the array and log it
function addTask(title, priority, dueDate) {
const task = createTask(title, priority, dueDate);
tasks.push(task);
console.log(`Added task: "${task.title}" (ID: ${task.id})`);
return task;
}
What does this code do?
- nextId is a counter that gives each task a unique ID. Every time createTask runs, it uses the current value and bumps it up by one.
- createTask takes a title, priority, and due date, then returns a task object. It sets completed to false by default.
- addTask calls createTask, pushes the result into the tasks array, and prints a confirmation message.
- Below the addTask function, add the listTasks function by pasting this code:
// Print each task in a formatted list
function listTasks(taskList) {
if (taskList.length === 0) {
console.log("No tasks found.");
return;
}
console.log("\n--- Task List ---");
for (const task of taskList) {
const status = task.completed ? "[x]" : "[ ]";
// Format the due date as YYYY-MM-DD
const due = task.dueDate.toISOString().split("T")[0];
console.log(`${status} #${task.id} ${task.title} | Priority: ${task.priority} | Due: ${due}`);
}
console.log("");
}
What does this code do?
- listTasks loops through every task and prints it in a readable format with a checkbox, ID, title, priority, and due date.
- The key line is task.dueDate.toISOString(). It calls .toISOString() on the due date to format it as a clean YYYY-MM-DD string.
- Save src/index.ts.
Add tasks and trigger the bug
The functions are ready. Now you will add three sample tasks and print them.
- At the bottom of src/index.ts, below the listTasks function, add these task creation calls:
// Add sample tasks
addTask("Learn TypeScript basics", "high", new Date("2026-09-01"));
addTask("Build a CLI app", "medium", new Date("2026-09-15"));
addTask("Write unit tests", "low", "2026-09-15");
// Print all tasks
listTasks(tasks);
What does this code do?
- The first two tasks pass a new Date() object for the due date. The third task passes a plain string "2026-09-15" instead.
- To your eyes, all three calls look reasonable. JavaScript will not complain about any of them.
- listTasks(tasks) at the end loops through every task and prints it.
- Save src/index.ts.
All three addTask calls look perfectly valid. Before you run this, do you think all three tasks will print successfully?
- Run your task manager by running this command:
npx tsx src/index.ts
The first two tasks print fine. Then the program crashes with a TypeError:
TypeError: task.dueDate.toISOString is not a function
That crash is exactly what was supposed to happen. This is the pain point TypeScript exists to solve.
Why did this crash?
The third task received a plain string "2026-09-15" instead of a Date object. Strings do not have a .toISOString() method, so when listTasks tried to call it, the program blew up.
JavaScript never warned you. It accepted the string, stored it happily, and only exploded when the code tried to use it as a Date. This is the kind of bug that hides in production code for weeks.
Seeing a different error?
- If you see Cannot find module, make sure your file is saved at src/index.ts inside the task-manager folder.
- If you see a syntax error, check that the template literal backticks (`) in console.log lines are not replaced with regular quotes.
- If you do NOT see the crash and all three tasks print, check that the third addTask call uses a plain string "2026-09-15" without new Date() around it.
Help me debug my task manager crash.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/index.ts and that you see the TypeError crash in your terminal.
ⓧ I'd like to double check the full code
Your src/index.ts should contain these sections in order. Scroll up and confirm each one is present.
- The nextId variable and empty tasks array at the top.
- The createTask function that returns a task object with id, title, priority, dueDate, completed, and createdAt.
- The addTask function that pushes tasks into the array.
- The listTasks function that calls task.dueDate.toISOString() to format dates.
- Three addTask calls at the bottom, with the third one passing a plain string "2026-09-15" instead of new Date().
- A listTasks(tasks) call at the very end.
That crash is the exact problem TypeScript was designed to solve. In the next step, you will add types to this code and watch the compiler catch that same bug before it ever runs.
Add TypeScript Types and Catch the Bug at Compile Time
In the last step, JavaScript happily ran your code until toISOString() crashed at runtime on a string that looked like a perfectly valid date. The bug was invisible until someone actually ran the code.
TypeScript catches this exact category of bug before the code ever runs. In this step, you'll add type definitions and annotations to your task manager, then watch the compiler spot the date bug instantly.
In this step, get ready to:
- Define a Priority type and a Task interface.
- Run the TypeScript compiler and see it catch the string date bug.
- Fix the bug and verify all tasks print successfully.
Define a Task interface and a Priority type
Right now, every function in your task manager accepts any value for every parameter. TypeScript needs you to declare the exact shapes and values your code expects so it can check them at compile time.
- In src/index.ts, add the following type definitions at the very top of the file, above the let nextId = 1; line:
// --- Types ---
type Priority = "low" | "medium" | "high";
interface Task {
id: number;
title: string;
priority: Priority;
dueDate: Date;
completed: boolean;
createdAt: Date;
}
// --- Task Manager ---
What do these types do?
Priority is a union type. It restricts priority values to exactly "low", "medium", or "high". Any other string is a compiler error.
Task is an interface. It defines the exact shape of a task object, including that dueDate must be a Date object. Not a string. Not a number. A Date.
Now you need to tell each function which types it accepts and returns. This is where TypeScript connects your type definitions to the actual code.
- In the same file, find the tasks array declaration. Find this line:
const tasks = [];
- Replace it with this typed version:
const tasks: Task[] = [];
- Find the createTask function signature:
function createTask(title, priority, dueDate) {
- Replace it with this typed version, adding type annotations to each parameter and the return type:
function createTask(title: string, priority: Priority, dueDate: Date): Task {
- Find the addTask function signature:
function addTask(title, priority, dueDate) {
- Replace it with the same parameter types and return type:
function addTask(title: string, priority: Priority, dueDate: Date): Task {
- Find the listTasks function signature:
function listTasks(taskList) {
- Replace it with this typed version. The return type void means the function doesn't return a value:
function listTasks(taskList: Task[]): void {
- Save src/index.ts.
What do type annotations do?
Each annotation tells TypeScript the exact type a parameter or return value must be. title: string means only a string is allowed. priority: Priority means only "low", "medium", or "high" is allowed.
dueDate: Date means only a Date object is allowed. This is the annotation that will catch the bug from the last step.
Watch the compiler catch the bug
Your types are in place. The third addTask call still passes "2026-09-15" as a plain string for the dueDate parameter. You've declared dueDate as type Date. Before you run the compiler, what do you think it will say about that string?
- Run the TypeScript compiler in type-check-only mode by running this command:
npx tsc --noEmit
The compiler reports an error pointing directly at the third addTask call: Argument of type 'string' is not assignable to parameter of type 'Date'.
What does --noEmit do?
The --noEmit flag tells the TypeScript compiler to check your types without producing any output files. It validates every type annotation and reports errors, but skips generating JavaScript. This is useful when another tool (like tsx) handles the actual running.
This is the same bug that crashed your code in the last step. JavaScript waited until runtime to fail. TypeScript caught it without ever running a single line.
- In src/index.ts, scroll down to the three addTask calls at the bottom of the file.
- Find this line:
addTask("Write unit tests", "low", "2026-09-15");
- Replace "2026-09-15" with new Date("2026-09-15"), so the line now looks like this:
addTask("Write unit tests", "low", new Date("2026-09-15"));
- Save src/index.ts.
- Run the program again by running this command:
npx tsx src/index.ts
All three tasks now print successfully. No crash, no runtime error. The bug that took down your program in the last step is gone because the compiler forced you to fix it before the code ever ran.
Still seeing a compiler error?
- Make sure you saved src/index.ts after making the change. Unsaved changes won't be picked up by the compiler.
- Check that the third addTask call uses new Date("2026-09-15") with both new and Date capitalized.
- Verify that all four function signatures have type annotations. Missing annotations on even one function will produce additional errors.
Help me fix my TypeScript compiler errors in the task manager
Try passing an invalid priority value
Union types don't just catch wrong object types like strings vs Dates. They also restrict values to a specific set of allowed strings.
- In src/index.ts, temporarily add this line just above the listTasks(tasks); call at the bottom of the file:
addTask("Test", "urgent", new Date());
- Save the file and run the compiler by running this command:
npx tsc --noEmit
The compiler rejects "urgent" because it is not in the Priority union type. Only "low", "medium", and "high" are allowed.
Why do union types matter?
In plain JavaScript, passing "urgent" as a priority would silently succeed. The task would be created with an invalid priority, and you'd only discover the problem later when filtering or displaying broke in unexpected ways.
Union types prevent invalid data from entering the system in the first place. The bug never exists at runtime because the compiler blocks it.
- Delete the addTask("Test", "urgent", new Date()); line you just added. This was only a test.
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/index.ts and that the test line with "urgent" is removed.
ⓧ I'd like to double check the full code
Scroll through your src/index.ts and confirm these sections are present, in order:
- A // --- Types --- section at the top with type Priority and interface Task.
- A // --- Task Manager --- section with const tasks: Task[] = [];.
- createTask with parameters typed as string, Priority, Date and return type Task.
- addTask with the same parameter types and return type Task.
- listTasks with parameter typed as Task[] and return type void.
- Three addTask calls at the bottom, all using new Date(...) for the date parameter (no plain strings).
- No "urgent" test line remaining.
Your task manager now has a type-safe foundation. TypeScript knows exactly what shape a task is, what values a priority can hold, and what type every function expects. Next up, you'll build on these types to add status filtering and a task summary that groups tasks by their state.
Build Type-Safe Task Operations
Your types caught a real bug in the last step. That alone is valuable. But so far, each type annotation stands on its own.
The real power of TypeScript shows when you build functions that work together through shared types. In this step, you will create a status system that classifies tasks as pending, completed, or overdue. TypeScript will enforce that only valid statuses flow through your filtering logic.
In this step, get ready to:
- Create a TaskStatus type and a getTaskStatus function.
- Build type-safe filtering and summary functions.
- Wire everything together and verify the grouped output.
Create a TaskStatus type and a getTaskStatus function
A task can be in one of three states: pending, completed, or overdue. Just like your Priority type restricts priority to three valid values, a new TaskStatus union type will restrict status to three valid values.
- In src/index.ts, find the line type Priority = "low" | "medium" | "high";.
- Add this new type directly below it:
type TaskStatus = "pending" | "completed" | "overdue";
- Now scroll down past the closing } of the listTasks function. Add the following code below it:
// --- Status & Filtering ---
// Determine a task's status based on completion and due date
function getTaskStatus(task: Task): TaskStatus {
if (task.completed) {
return "completed";
}
if (task.dueDate < new Date()) {
return "overdue";
}
return "pending";
}
What does this code do?
- TaskStatus is a union type, just like Priority. TypeScript will reject any string that is not "pending", "completed", or "overdue".
- getTaskStatus checks task.completed first. If the task is not completed, it compares task.dueDate against the current date to determine if it is overdue or still pending.
- Save src/index.ts.
- Test the new function by temporarily adding this line at the very end of your file, after listTasks(tasks);:
console.log("First task status:", getTaskStatus(tasks[0]));
- Save the file and run your code by running:
npx tsx src/index.ts
You should see your three tasks listed, followed by First task status: pending. The function correctly computed the status.
- Delete the console.log("First task status:"...) test line you just added. The next functions will use getTaskStatus properly.
Seeing an error instead?
- Make sure type TaskStatus is placed after the Priority type and before the Task interface. It must be defined before getTaskStatus references it.
- Check that the getTaskStatus function is placed after the closing } of listTasks, not inside it.
- Help me debug my getTaskStatus function
Build type-safe filtering and summary functions
Now that you can compute a task's status, you need functions to filter tasks by that status and display a grouped summary. Both functions will rely on the TaskStatus type to guarantee only valid statuses are accepted.
- In src/index.ts, add this code directly below your getTaskStatus function:
// Filter tasks by their current status
function filterTasks(taskList: Task[], status: TaskStatus): Task[] {
return taskList.filter((task) => getTaskStatus(task) === status);
}
function printTaskSummary(taskList: Task[]): void {
// Group tasks into three status buckets
const pending = filterTasks(taskList, "pending");
const completed = filterTasks(taskList, "completed");
const overdue = filterTasks(taskList, "overdue");
console.log("\n=== Task Summary ===");
console.log(`Pending: ${pending.length}`);
console.log(`Completed: ${completed.length}`);
console.log(`Overdue: ${overdue.length}`);
if (overdue.length > 0) {
console.log("\nOverdue tasks:");
listTasks(overdue);
}
if (pending.length > 0) {
console.log("Pending tasks:");
listTasks(pending);
}
if (completed.length > 0) {
console.log("Completed tasks:");
listTasks(completed);
}
}
What does this code do?
- filterTasks takes a TaskStatus parameter. TypeScript ensures you can only pass "pending", "completed", or "overdue". Any other string is a compiler error.
- printTaskSummary calls filterTasks three times to split tasks into status groups. It then prints the count for each group and lists the individual tasks.
- Save src/index.ts.
Wire everything together in the main flow
Your filtering and summary functions are ready. Now you need to update the main section of your file to use them with a realistic set of tasks that includes past, future, and completed entries.
- In src/index.ts, find the three addTask calls and the listTasks(tasks) call at the bottom of your file.
- Select everything from the first addTask line to the end of the file and replace it with the following code:
// --- Main ---
console.log("Task Manager - TypeScript Edition\n");
// Add tasks with varying dates and priorities
addTask("Learn TypeScript basics", "high", new Date("2026-09-01"));
addTask("Build a CLI app", "medium", new Date("2026-09-15"));
addTask("Write unit tests", "low", new Date("2026-08-01"));
addTask("Deploy to production", "high", new Date("2026-10-01"));
// List all tasks
listTasks(tasks);
// Print grouped summary
printTaskSummary(tasks);
What changed here?
- The third task ("Write unit tests") now has a due date of 2026-08-01, which is in the past. This task will show up as overdue.
- A fourth task ("Deploy to production") was added with a future date, so it will show as pending.
- printTaskSummary(tasks) at the end groups and displays all tasks by their computed status.
- Save src/index.ts.
Before you run this, what do you think the summary will show? How many tasks will be pending, and how many overdue?
- Run your updated code by running:
npx tsx src/index.ts
You should see all four tasks listed, followed by a task summary showing counts for pending, completed, and overdue. The "Write unit tests" task appears under overdue because its due date is in the past.
Not seeing the summary?
- Make sure printTaskSummary(tasks); is the last line in your file. If you left the old listTasks(tasks); call as the final line, you will see the flat list but no grouped summary.
- Check that filterTasks and printTaskSummary are placed after getTaskStatus and before the // --- Main --- section. They depend on getTaskStatus being defined above them.
- Help me debug my task summary output
Now try passing an invalid status to see TypeScript catch it. Add this line temporarily at the very end of your file:
filterTasks(tasks, "urgent");
- Run the type checker by running:
npx tsc --noEmit
The compiler rejects "urgent" because it is not in the TaskStatus union type. Without types, this would silently return an empty array and you would never know why.
- Delete the filterTasks(tasks, "urgent"); line and save the file.
Why is this better than a runtime check?
In plain JavaScript, passing "urgent" to a filter function would not crash. It would silently return zero results, and you would spend time debugging why your list is empty.
TypeScript catches the typo before you ever run the code. The feedback loop is instant: write the wrong value, see the red underline, fix it.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts and that the filterTasks(tasks, "urgent"); test line has been removed.
ⓧ I'd like to double check the full code
Your src/index.ts is now quite long. Scroll through and confirm these sections appear in this order:
- // --- Types --- section with Priority, TaskStatus, and the Task interface.
- // --- Task Manager --- section with createTask, addTask, and listTasks.
- // --- Status & Filtering --- section with getTaskStatus, filterTasks, and printTaskSummary.
- // --- Main --- section with four addTask calls, listTasks(tasks), and printTaskSummary(tasks).
Your task manager can now classify, filter, and summarize tasks using type-safe operations. Next up, you will add a completeTask function that uses discriminated unions to handle success and failure cases explicitly.
Add a completeTask Function with Error Handling
Your task manager can now add tasks, list them, and filter them by status. The next thing any real application needs is a way to change data, and that means dealing with things that can go wrong.
What happens when you try to complete a task that does not exist? Or one that is already done? In plain JavaScript, these cases often fail silently or crash at runtime. TypeScript's return types force you to deal with errors explicitly, preventing silent failures before your code ever runs.
In this step, get ready to:
- Define a discriminated union type that represents success or failure.
- Build a completeTask function that returns typed results instead of crashing.
- Use type narrowing to safely handle both success and error outcomes.
Define a result type for operations that can fail
When a function can either succeed or fail, you need a return type that captures both possibilities. A discriminated union uses a shared field (like success) to let TypeScript tell the two cases apart at compile time.
- In src/index.ts, find the type TaskStatus line near the top of the file.
- Add the following TaskResult type between type TaskStatus and interface Task:
type TaskResult =
| { success: true; task: Task }
| { success: false; error: string };
- Now scroll down to the bottom of the printTaskSummary function (the closing } brace).
- Add a new section below it with the completeTask function by copying this code:
// --- Complete Task with Error Handling ---
function completeTask(taskList: Task[], id: number): TaskResult {
const task = taskList.find((t) => t.id === id);
if (!task) {
return { success: false, error: `Task with ID ${id} not found` };
}
if (task.completed) {
return {
success: false,
error: `Task "${task.title}" is already completed`,
};
}
task.completed = true;
return { success: true, task };
}
What does this code do?
TaskResult is a discriminated union with two variants. When success is true, the object carries a task field. When success is false, it carries an error string instead.
The completeTask function checks three scenarios: the task does not exist (returns a failure), the task is already completed (returns a failure), or the task is valid (marks it done and returns a success). Every path returns a typed result instead of throwing or silently failing.
- Save src/index.ts.
- Verify that the compiler is happy with your new types by running:
npx tsc --noEmit
You should see no output at all. Silence from the TypeScript compiler means everything type-checks correctly.
Seeing a compiler error?
- Check that TaskResult is placed before the interface Task definition. It references Task, so TypeScript needs that interface to be declared nearby.
- Make sure the return type annotation on completeTask says TaskResult (capital T, capital R).
- Still stuck? Help me fix this TypeScript compiler error in my completeTask function.
Why a discriminated union instead of throwing errors?
In JavaScript, functions often signal failure by throwing exceptions. The problem is that nothing in the function signature tells you it might throw, so callers can easily forget to wrap calls in try/catch.
A discriminated union makes failure part of the return type. The compiler forces every caller to handle both the success and failure cases before accessing the data inside.
Use type narrowing to handle the result
The completeTask function returns a TaskResult, but the caller does not know which variant it got. TypeScript's type narrowing lets you check result.success once, and the compiler automatically knows which fields are available inside each branch.
- Add the handleTaskResult function directly below completeTask by copying this code:
function handleTaskResult(result: TaskResult): void {
if (result.success) {
console.log(`Completed: "${result.task.title}"`);
} else {
console.log(`Error: ${result.error}`);
}
}
What does type narrowing do here?
Inside the if (result.success) branch, TypeScript narrows the type to { success: true; task: Task }. That means result.task is guaranteed to exist. In the else branch, TypeScript narrows to the failure variant, so only result.error is available.
If you tried to access result.task in the else branch, the compiler would stop you with an error. You physically cannot write code that reads a field that does not exist.
- Save src/index.ts.
Demonstrate the error handling
Time to put the error handling to work. You will call completeTask three times: once with a valid ID, once with an ID that does not exist, and once with a task that is already completed.
- Scroll down to the // --- Main --- section at the bottom of your file.
- Find this line:
printTaskSummary(tasks);
- Add the following code above that printTaskSummary(tasks); line:
// Complete a task
console.log("--- Completing Tasks ---");
const result1 = completeTask(tasks, 1);
handleTaskResult(result1);
const result2 = completeTask(tasks, 99);
handleTaskResult(result2);
const result3 = completeTask(tasks, 1);
handleTaskResult(result3);
What do these three calls test?
- result1 completes task ID 1 ("Learn TypeScript basics"). This should succeed.
- result2 tries to complete task ID 99, which does not exist. This should return a failure.
- result3 tries to complete task ID 1 again, but it was already completed. This should also return a failure.
- Save src/index.ts.
Before you run this, think about what you expect to see. One call uses a valid ID, one uses a nonexistent ID, and one retries an already-completed task. What should each one print?
- Run the program to see the results:
npx tsx src/index.ts
In the --- Completing Tasks --- section of the output, you should see three lines:
- Completed: "Learn TypeScript basics" for the valid completion.
- Error: Task with ID 99 not found for the invalid ID.
- Error: Task "Learn TypeScript basics" is already completed for the duplicate completion.
The task summary below should now show that "Learn TypeScript basics" moved from the pending or overdue group into the completed group.
Not seeing the expected output?
- Make sure the completing tasks code is above printTaskSummary(tasks), not below it. The summary needs to run after the completions.
- Check that handleTaskResult is defined after completeTask and before the // --- Main --- section.
- Verify that you are passing tasks (the array) as the first argument to completeTask, not just the ID.
- Still stuck? Help me debug why my completeTask output is not matching what I expected.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/index.ts.
ⓧ I'd like to double check the full code
Your src/index.ts should contain these sections in order. Scroll through your file and confirm each one is present.
- Types section: Priority type, TaskStatus type, TaskResult discriminated union, and Task interface.
- Task Manager section: createTask, addTask, and listTasks functions.
- Status & Filtering section: getTaskStatus, filterTasks, and printTaskSummary functions.
- Complete Task with Error Handling section: completeTask and handleTaskResult functions.
- Main section: four addTask calls, listTasks, three completeTask/handleTaskResult pairs, and printTaskSummary at the end.
Secret mission
Add a Generic Storage Layer
Your task manager forgets everything the moment you close the terminal. In this bonus challenge, you will build a generic Storage<T> layer using TypeScript generics that can persist any typed data to a JSON file. You will save tasks, load them back, and add an update method that finds items by ID.
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 or delete them.
Resources you used:
- task-manager project folder on your Desktop (includes source code, configuration, node_modules dependencies, and the generated tasks.json file).
Keep everything running
No action needed. Choose this if you want to revisit your TypeScript task manager or continue experimenting with the code.
- Your project files stay on your Desktop in the task-manager folder.
- Node.js remains installed on your system for future projects.
Pause - I'll come back to this later
No ongoing processes or costs to worry about. Your files stay on your Desktop, ready for when you return.
- If you left the dev server running with npm run dev, press Ctrl+C in the terminal to stop it.
Delete - I don't want to use this again
Remove all project files from your Desktop.
- If you have the dev server running, press Ctrl+C in the terminal to stop it.
- Delete the task-manager folder by running:
cd ~/Desktop
rm -rf task-manager
This removes all source files, configuration, node_modules, and the generated tasks.json file.
Nice Work!
Nice Work!
Nice work! You just built a fully type-safe task manager CLI with TypeScript, complete with compile-time bug catching, type-safe filtering, and structured error handling.
You've learned how to:
- Built a task manager CLI that adds, completes, lists, and filters tasks. You experienced a real JavaScript runtime crash and then watched TypeScript catch the exact same bug at compile time.
- Used interfaces and union types to model task data and priority levels. You also used type-safe filtering to group tasks by status.
- Used type narrowing with discriminated unions to handle success and failure cases safely, ensuring every code path deals with both outcomes.
- Secret Mission: Built a generic Storage<T> layer that persists typed data to JSON files.
Ready to quiz yourself?