Build a Type-Safe Task Manager
Build a CLI task manager using TypeScript's type system and generics.
Introduction
30 Second Summary
Imagine writing code where typos and wrong values get caught instantly, before you ever hit "run." That's what a type system gives you.
In this project, you will build a command-line task manager in TypeScript that progressively uses more advanced type features. You'll start from basic type annotations and work your way up to generics and discriminated unions.
What You'll Build
A type-safe task management CLI that models different task types (Bug, Feature, Story) using TypeScript's type system, a generic data store, and utility types for safe operations.
By the end of this project, you'll have:
- A working TypeScript project with strict configuration that compiles and runs without errors.
- A generic Repository class that provides type-safe CRUD operations for any entity you pass to it.
- A discriminated union model with type guards that let you safely handle different task variants without runtime errors.
- Secret Mission: a priority-based filtering system using mapped types, intersection types, and custom type predicates.
Are there any prerequisites?
You should be comfortable with basic JavaScript concepts like variables, functions, arrays, and objects. No prior TypeScript experience is required.
Before We Start
Before We Start...
Set Up Your TypeScript Environment
Before you can build a type-safe task manager, you need a working TypeScript development environment. TypeScript code does not run directly. It compiles down to JavaScript, which Node.js then executes.
In this step, you will install Node.js (which also gives you npm for managing packages), add TypeScript as a project dependency, and configure the TypeScript compiler so it knows how to process your code.
In this step, get ready to:
- Install and verify Node.js on your machine.
- Create a project folder and initialize it with npm and TypeScript.
- Configure the TypeScript compiler and verify your setup compiles successfully.
Install Node.js and verify your installation
Node.js provides the JavaScript runtime that executes your compiled TypeScript. It also bundles npm, the package manager you will use to install TypeScript itself.
- 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 --version
✔️ I see version 24 or higher
Your terminal shows something like v24.16.0 or higher. Node.js is ready to go.
- Confirm npm is also available by running:
npm --version
You should see a version number printed. Both tools are installed and working.
ⓧ I see a version lower than 24
You have an older version of Node.js installed. This project requires version 24 or higher (the current LTS release).
- Visit the official Node.js download page at nodejs.org/en/download.
- Download the LTS version (v24) for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
Why version 24 specifically?
Node.js 24 (codenamed Krypton) is the current Active LTS release. LTS means Long Term Support, so it receives security patches and stability fixes for years.
ⓧ Command not found
Node.js is not installed on your machine yet.
- Visit the official Node.js download page at nodejs.org/en/download.
- Download the LTS version (v24) for your operating system.
- Run the installer and follow the prompts. Accept the default settings.
- Close and reopen your terminal after installation completes.
- Run node --version again to confirm it shows v24 or higher.
- Run npm --version to verify npm was also installed.
Still not seeing a version number?
Make sure you completely closed and reopened your terminal after installing. The terminal needs to reload its PATH to find the new node and npm commands.
help me troubleshoot my Node.js installation
Create a project folder and initialize it
Every TypeScript project needs a folder with a package.json file (for npm to track dependencies) and a tsconfig.json file (for the compiler to know your settings).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir typescript-task-manager
cd typescript-task-manager
- Initialize a new npm project, install TypeScript, and generate a TypeScript config by running these commands:
npm init -y
npm install -D typescript@6.0
npx tsc --init
What do these commands do?
- npm init -y creates a package.json with default settings. The -y flag accepts all defaults so you skip the interactive prompts.
- npm install -D typescript@6.0 installs TypeScript 6.0 as a development dependency. The -D flag means it is only needed during development, not at runtime.
- npx tsc --init generates a default tsconfig.json file with commented-out options you can customize.
- Confirm the files were created by running:
ls
You should see node_modules, package.json, package-lock.json, and tsconfig.json in the output.
Seeing permission errors or command not found?
If npm commands fail, make sure your terminal is in the typescript-task-manager folder. Run pwd to check your current location.
help me fix npm errors in my TypeScript project setup
Configure your TypeScript compiler and verify the setup
The generated tsconfig.json has dozens of commented-out options. You will replace it with a clean configuration tailored to this project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the typescript-task-manager folder on your Desktop, then click Open.
- Open tsconfig.json from the Explorer sidebar on the left.
- Select all the content in the file (Cmd+A on macOS or Ctrl+A on Windows) and replace it with this configuration:
{
"compilerOptions": {
"target": "es2025",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
What do these compiler options mean?
- target: "es2025" tells TypeScript to output modern JavaScript compatible with current runtimes.
- module: "nodenext" uses Node.js native ES module resolution. This is the recommended setting for Node.js projects.
- strict: true enables all strict type-checking rules. This catches more bugs at compile time.
- outDir: "./dist" puts compiled JavaScript files in a dist folder, keeping source and output separate.
- rootDir: "./src" tells the compiler that all source files live in the src folder.
- Save tsconfig.json (Cmd+S on macOS or Ctrl+S on Windows).
- Now open package.json from the Explorer sidebar.
- Select all the content and replace it with this configuration:
{
"name": "typescript-task-manager",
"version": "1.0.0",
"description": "A type-safe task management system built with TypeScript",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"devDependencies": {
"typescript": "6.0"
}
}
Why add "type": "module"?
Setting "type": "module" tells Node.js to treat your JavaScript files as ES modules. This pairs with the nodenext module setting in tsconfig.json and allows you to use modern import/export syntax.
- Save package.json.
Now create the source folder and a test file to confirm everything compiles.
- Create the src folder and a test file by running these commands in your VS Code terminal (open it with Ctrl+` or Terminal > New Terminal from the menu):
mkdir src
- In the Explorer sidebar, expand the new src folder. Right-click it and select New File.
- Name the file index.ts and press Enter.
- Add this code to src/index.ts:
// placeholder entry point
console.log("TypeScript is working!");
- Save src/index.ts.
- Compile your TypeScript project by running this command in the terminal:
npx tsc
What does a successful compile look like?
If the TypeScript compiler finds no errors, it produces no output at all. Silence means success. It creates a dist/index.js file containing the compiled JavaScript.
- Verify the compiled output exists by running:
node dist/index.js
You should see TypeScript is working! printed in your terminal.
Seeing a compilation error?
Make sure your tsconfig.json has "rootDir": "./src" and that your index.ts file is inside the src folder, not the project root.
Also check that package.json has "type": "module" set. Without this, Node.js will not recognize ES module imports in later steps.
help me fix TypeScript compilation errors in my project
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here are all three files as they should look at this point.
{
"compilerOptions": {
"target": "es2025",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
{
"name": "typescript-task-manager",
"version": "1.0.0",
"description": "A type-safe task management system built with TypeScript",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"devDependencies": {
"typescript": "6.0"
}
}
// placeholder entry point
console.log("TypeScript is working!");
Your TypeScript environment is locked in. Next up, you will define the type system that powers your task manager.
Define Your Type Foundation
Your TypeScript environment is ready to go. Now it's time to define the data models that will power your task manager.
In any application, well-defined types act like a contract for your data. They describe what shape your data takes, so the compiler can catch mistakes before your code ever runs. In this step, you'll build a type foundation using interfaces, literal unions, discriminated unions, and utility types.
In this step, get ready to:
- Define a base task interface and status type.
- Create task variants using discriminated unions.
- Derive utility types for create, update, and read-only operations.
Create the types file with basic annotations
Every task in your system needs a status. Instead of using a plain string (which could be misspelled at runtime), TypeScript lets you define a literal union type that restricts a value to a specific set of strings.
- Create a new file called src/types.ts in your project's src folder.
- Add the following code to define a TaskStatus type and a BaseTask interface:
// === Basic Types and Literal Unions ===
export type TaskStatus = "todo" | "in-progress" | "done";
// === Discriminated Union: Task Variants ===
interface BaseTask {
id: string;
title: string;
description: string;
status: TaskStatus;
createdAt: Date;
}
What does this code do?
TaskStatus is a literal union type. A variable of this type can only ever hold one of the three exact strings: "todo", "in-progress", or "done". Any other string causes a compile error.
BaseTask is an interface that describes the shape every task must have. It uses TaskStatus for its status property, so the type system enforces valid statuses everywhere a task appears.
- Save src/types.ts.
- Compile your project by running:
npx tsc
- You should see no output. The terminal returns to the prompt with no errors, which means your types are valid.
Seeing a compile error?
- Check that src/types.ts is saved inside the src folder, not the project root. Your tsconfig.json expects source files in src/.
- Verify you haven't accidentally modified tsconfig.json since Step 1.
Why won't my types.ts file compile?
Extend the model with discriminated unions
A task manager handles different kinds of tasks. Bugs have severity levels. Features have estimated hours. Stories have point values. A discriminated union lets you model these variants in one type, with a shared kind property that tells TypeScript which variant it's looking at.
- Add the following code below the BaseTask interface in src/types.ts:
export interface BugTask extends BaseTask {
kind: "bug";
severity: "low" | "medium" | "high" | "critical";
stepsToReproduce: string[];
}
export interface FeatureTask extends BaseTask {
kind: "feature";
estimatedHours: number;
acceptanceCriteria: string[];
}
export interface StoryTask extends BaseTask {
kind: "story";
points: number;
userRole: string;
}
// Discriminated union type
export type TaskItem = BugTask | FeatureTask | StoryTask;
What does this code do?
Each interface extends BaseTask (so it inherits id, title, status, etc.) and adds a kind property with a specific string literal value. That kind property is the discriminant.
When you combine them into TaskItem, TypeScript can look at the kind value and automatically narrow the type. If kind is "bug", TypeScript knows the task has a severity property. You'll see this in action in later steps.
Why discriminated unions over a single interface?
You could put all properties on one big Task interface and make most of them optional. But that means you'd have to check for undefined everywhere and get no help from the compiler if you forget.
Discriminated unions make impossible states unrepresentable. A BugTask always has severity. A FeatureTask never does. The compiler enforces this for you.
- Save src/types.ts and compile again:
npx tsc
- You should see no output again. A clean compile confirms your discriminated union is properly defined.
Getting an error about duplicate identifiers?
- Make sure you added the new interfaces below the closing brace of BaseTask, not inside it.
- Check that each kind value is a string in quotes ("bug", "feature", "story"), not a bare identifier.
Help me fix my discriminated union definition
Add utility types for safe operations
TypeScript's built-in utility types let you derive new types from existing ones. This is powerful because you define your data shape once, then create specialized versions for different operations without duplicating code.
- Add the following code at the bottom of src/types.ts, below the TaskItem type:
// === Utility Types ===
export type CreateTaskInput = Omit<TaskItem, "id" | "createdAt">;
export type UpdateTaskInput = Partial<Pick<TaskItem, "title" | "description" | "status">>;
export type ReadonlyTask = Readonly<TaskItem>;
What do these utility types do?
- Omit<TaskItem, "id" | "createdAt"> creates a type that has everything from TaskItem except id and createdAt. When creating a task, the system generates these automatically.
- Partial<Pick<TaskItem, "title" | "description" | "status">> first picks only those three fields, then makes each one optional. Perfect for updates where you might change the title but not the description.
- Readonly<TaskItem> makes all properties read-only. Any attempt to reassign a property on a ReadonlyTask causes a compile error.
- Save src/types.ts and compile the project one more time:
npx tsc
- A clean compile (no output) confirms your entire type foundation is valid. TypeScript resolved all type references across your interfaces, unions, and utility types.
Seeing an error mentioning TaskItem?
- Make sure the TaskItem type definition appears before the utility types. TypeScript reads top to bottom, so TaskItem must be defined before you reference it in Omit, Pick, or Readonly.
- Check for typos in property names inside the angle brackets. They must match the exact property names from your interfaces ("title", "description", "status").
Help me fix my utility type definitions
✔️ Awesome, I've got everything!
Great. Double check you've saved src/types.ts and that npx tsc compiles cleanly with no output.
ⓧ I'd like to double check the full code
Here's the complete src/types.ts file. Compare yours against this:
// === Basic Types and Literal Unions ===
export type TaskStatus = "todo" | "in-progress" | "done";
// === Discriminated Union: Task Variants ===
interface BaseTask {
id: string;
title: string;
description: string;
status: TaskStatus;
createdAt: Date;
}
export interface BugTask extends BaseTask {
kind: "bug";
severity: "low" | "medium" | "high" | "critical";
stepsToReproduce: string[];
}
export interface FeatureTask extends BaseTask {
kind: "feature";
estimatedHours: number;
acceptanceCriteria: string[];
}
export interface StoryTask extends BaseTask {
kind: "story";
points: number;
userRole: string;
}
// Discriminated union type
export type TaskItem = BugTask | FeatureTask | StoryTask;
// === Utility Types ===
export type CreateTaskInput = Omit<TaskItem, "id" | "createdAt">;
export type UpdateTaskInput = Partial<Pick<TaskItem, "title" | "description" | "status">>;
export type ReadonlyTask = Readonly<TaskItem>;
Your type foundation is solid. You've defined exactly what a task looks like, what variants exist, and what shapes are valid for different operations. Next up, you'll build a generic repository class that uses these types to provide type-safe CRUD operations.
Build a Generic Repository
Your type system is defined. You have interfaces for three task variants and utility types for creating and updating them. Now you need somewhere to store and manage these tasks.
In this step, you'll build a generic Repository class. Generics let you write one data-management class that works with any type while keeping full type safety.
In this step, get ready to:
- Build a generic Repository<T> class with CRUD methods.
- Add flexible query methods using predicates.
- Create a TaskRepository that extends the generic class with type-safe filtering.
Create the generic Repository class
Your task manager needs a place to store tasks. Instead of writing a task-specific store, you'll build a generic one that works with any object that has an id property.
- Create a new file called src/repository.ts in VS Code.
- Add the following code to define the Repository class with its initial methods:
export class Repository<T extends { id: string }> {
private items: Map<string, T> = new Map();
add(item: T): T {
this.items.set(item.id, item);
return item;
}
getById(id: string): T | undefined {
return this.items.get(id);
}
getAll(): T[] {
return Array.from(this.items.values());
}
delete(id: string): boolean {
return this.items.delete(id);
}
}
What does this code do?
The <T extends { id: string }> syntax makes this class generic. It works with any type T as long as that type has a string id property.
- private items: Map<string, T> stores data in a key-value map, using each item's id as the key.
- add() saves an item and returns it.
- getById() returns the item or undefined if not found.
- getAll() converts the Map values into an array.
- delete() removes an item and returns true if it existed.
- Save src/repository.ts.
- Compile the project by running:
npx tsc
You should see no errors in the terminal. The dist/ folder now contains the compiled JavaScript.
Seeing a compilation error?
- Check that the file is saved inside the src/ folder. The tsconfig.json has rootDir set to ./src, so files outside that folder won't compile.
- Make sure you have export before class Repository. Without it, other files won't be able to import the class.
Help me fix my TypeScript compilation error in repository.ts.
Add update and findBy methods
The repository needs two more capabilities. An update method that merges partial changes into existing items. A findBy method that accepts any filter function.
- In src/repository.ts, find the getAll() method.
- Add the following update method directly below getAll(), above the existing delete method:
update(id: string, changes: Partial<T>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...changes };
this.items.set(id, updated);
return updated;
}
- Now find the delete() method. Add this findBy method directly below it, still inside the class (before the closing }):
findBy(predicate: (item: T) => boolean): T[] {
return this.getAll().filter(predicate);
}
What do these methods do?
- update() takes an id and a Partial<T> object. Partial<T> means every property of T becomes optional, so you can update just one field without providing the entire object.
- findBy() accepts a predicate function. Any function that takes an item and returns true or false works here. This keeps the repository flexible without hardcoding specific filters.
- Save src/repository.ts.
- Compile again by running:
npx tsc
You should see no errors. Your generic repository now has all six methods.
Getting an error about Partial or the spread operator?
- Make sure update is inside the class body (between the opening { after the class declaration and the final closing }).
- Check that findBy is also inside the class. A method placed after the closing brace becomes a standalone function and loses access to this.
Help me fix my Repository class after adding update and findBy methods.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/repository.ts before moving on.
ⓧ I'd like to double check the full code
export class Repository<T extends { id: string }> {
private items: Map<string, T> = new Map();
add(item: T): T {
this.items.set(item.id, item);
return item;
}
getById(id: string): T | undefined {
return this.items.get(id);
}
getAll(): T[] {
return Array.from(this.items.values());
}
update(id: string, changes: Partial<T>): T | undefined {
const existing = this.items.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...changes };
this.items.set(id, updated);
return updated;
}
delete(id: string): boolean {
return this.items.delete(id);
}
findBy(predicate: (item: T) => boolean): T[] {
return this.getAll().filter(predicate);
}
}
Build the task-specific repository
The generic Repository handles storage for any type. Now you need a layer that understands your specific task types and can filter them by kind.
What is a type guard?
A type guard is a function that returns a special task is BugTask type annotation. When you call this function inside a conditional, TypeScript narrows the type automatically.
Without type guards, TypeScript only knows you have a TaskItem. With them, it knows the exact variant and gives you access to variant-specific properties like severity or points.
- Create a new file called src/task-repository.ts.
- Add the imports and type guard functions:
import { Repository } from "./repository.js";
import { TaskItem, BugTask, FeatureTask, StoryTask } from "./types.js";
// === Type Guards ===
export function isBugTask(task: TaskItem): task is BugTask {
return task.kind === "bug";
}
export function isFeatureTask(task: TaskItem): task is FeatureTask {
return task.kind === "feature";
}
export function isStoryTask(task: TaskItem): task is StoryTask {
return task.kind === "story";
}
Why do imports use .js extensions?
With nodenext module resolution, TypeScript requires explicit file extensions in import paths. You write ./repository.js even though the source file is .ts. TypeScript resolves the .ts file during compilation but the output JavaScript needs the .js path at runtime.
- Below the type guards, add the TaskRepository class:
// === Task Repository with type-specific methods ===
export class TaskRepository extends Repository<TaskItem> {
getBugs(): BugTask[] {
return this.getAll().filter(isBugTask);
}
getFeatures(): FeatureTask[] {
return this.getAll().filter(isFeatureTask);
}
getStories(): StoryTask[] {
return this.getAll().filter(isStoryTask);
}
getByStatus(status: TaskItem["status"]): TaskItem[] {
return this.findBy((task) => task.status === status);
}
}
What does this class do?
- TaskRepository extends Repository<TaskItem> inherits all six generic methods (add, getById, getAll, update, delete, findBy) with TaskItem locked in as the type.
- getBugs() uses the isBugTask type guard as a filter. TypeScript knows the returned array is BugTask[], not just TaskItem[].
- getByStatus() uses TaskItem["status"] as the parameter type. This is an indexed access type that resolves to TaskStatus, keeping the method in sync with your type definitions.
- Save src/task-repository.ts.
- Compile the full project by running:
npx tsc
You should see no errors. All three source files compile together successfully.
Seeing a module not found error?
- Check that your import paths use .js extensions, not .ts. With nodenext module resolution, TypeScript requires explicit .js extensions in imports.
- Make sure repository.ts and types.ts both live inside src/ alongside task-repository.ts.
- Verify that Repository is exported from repository.ts (the export keyword before class).
Help me fix my import errors in task-repository.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/task-repository.ts before moving on.
ⓧ I'd like to double check the full code
import { Repository } from "./repository.js";
import { TaskItem, BugTask, FeatureTask, StoryTask } from "./types.js";
// === Type Guards ===
export function isBugTask(task: TaskItem): task is BugTask {
return task.kind === "bug";
}
export function isFeatureTask(task: TaskItem): task is FeatureTask {
return task.kind === "feature";
}
export function isStoryTask(task: TaskItem): task is StoryTask {
return task.kind === "story";
}
// === Task Repository with type-specific methods ===
export class TaskRepository extends Repository<TaskItem> {
getBugs(): BugTask[] {
return this.getAll().filter(isBugTask);
}
getFeatures(): FeatureTask[] {
return this.getAll().filter(isFeatureTask);
}
getStories(): StoryTask[] {
return this.getAll().filter(isStoryTask);
}
getByStatus(status: TaskItem["status"]): TaskItem[] {
return this.findBy((task) => task.status === status);
}
}
Your data layer is complete. You have a generic repository that works with any type, and a task-specific repository that adds type-safe filtering. Next up, you'll wire everything together in a CLI that creates tasks, queries them, and proves the type safety works at runtime.
Wire Up the CLI and Run It
You've built a complete type system and a generic repository. Now it's time to bring everything together into a working program.
In this step, you'll create the main entry point that uses all your types and classes to manage tasks. You'll see TypeScript's type narrowing in action as it automatically infers the correct properties inside each switch case.
In this step, get ready to:
- Create sample tasks and demonstrate type narrowing with a switch statement.
- Add type-safe update and query operations.
- Compile and run the project to see TypeScript catch errors before runtime.
Create the repository and sample tasks
The entry point file is where your types, repository, and task-specific logic come together. Each sample task uses a different variant of the discriminated union, so TypeScript can prove at compile time that your switch statement handles every case correctly.
- Open src/index.ts in VS Code and replace the entire contents with the following code:
import { TaskItem, BugTask, FeatureTask, StoryTask, UpdateTaskInput } from "./types.js";
import { TaskRepository } from "./task-repository.js";
// === Create the repository ===
const repo = new TaskRepository();
// === Add sample tasks ===
const bug: BugTask = {
id: "task-1",
kind: "bug",
title: "Login button unresponsive",
description: "The login button does not respond on mobile devices",
status: "todo",
createdAt: new Date("2026-06-01"),
severity: "high",
stepsToReproduce: ["Open app on mobile", "Tap login button", "Nothing happens"],
};
const feature: FeatureTask = {
id: "task-2",
kind: "feature",
title: "Add dark mode",
description: "Implement a dark mode toggle in settings",
status: "in-progress",
createdAt: new Date("2026-05-28"),
estimatedHours: 12,
acceptanceCriteria: ["Toggle in settings", "Persists across sessions", "Applies to all pages"],
};
const story: StoryTask = {
id: "task-3",
kind: "story",
title: "User can export data",
description: "As a user, I want to export my data as CSV",
status: "todo",
createdAt: new Date("2026-05-30"),
points: 5,
userRole: "end-user",
};
repo.add(bug);
repo.add(feature);
repo.add(story);
// === Demonstrate type narrowing with switch ===
console.log("=== All Tasks ===\n");
for (const task of repo.getAll()) {
console.log(`[${task.kind.toUpperCase()}] ${task.title} (${task.status})`);
switch (task.kind) {
case "bug":
console.log(` Severity: ${task.severity}`);
console.log(` Steps: ${task.stepsToReproduce.join(" -> ")}`);
break;
case "feature":
console.log(` Estimated: ${task.estimatedHours}h`);
console.log(` Criteria: ${task.acceptanceCriteria.length} items`);
break;
case "story":
console.log(` Points: ${task.points}`);
console.log(` Role: ${task.userRole}`);
break;
}
console.log();
}
What does this code do?
The imports pull in your type definitions and the TaskRepository class. Each sample task object is typed explicitly (BugTask, FeatureTask, StoryTask), so TypeScript verifies every property matches the interface.
Inside the switch (task.kind) block, TypeScript automatically narrows the type. In the "bug" case, it knows task is a BugTask and gives you access to severity and stepsToReproduce without any casting.
Notice the .js extensions on imports. When using nodenext module resolution, TypeScript requires you to import the compiled output path, not the .ts source path.
- Save src/index.ts.
- You won't see output yet. The compile and run step is coming after you add the remaining operations.
Seeing red squiggles in VS Code?
- Check that your import paths end with .js (not .ts). This is required by nodenext module resolution.
- Make sure every property in your task objects matches the interface. For example, BugTask requires both severity and stepsToReproduce.
- Verify kind values are exact literal strings: "bug", "feature", or "story".
Help me fix a TypeScript error in my src/index.ts file where I'm creating sample tasks for a discriminated union
Add update and query operations
Now you'll add operations that demonstrate how UpdateTaskInput (built from utility types) constrains what fields can be changed, and how type-guard-powered methods return correctly-typed arrays.
- In src/index.ts, add the following code below the closing } of the for loop (after the final console.log() inside it):
// === Demonstrate type-safe updates ===
console.log("=== Updating Task ===\n");
const updateData: UpdateTaskInput = {
status: "done",
title: "Login button unresponsive (FIXED)",
};
const updated = repo.update("task-1", updateData);
if (updated) {
console.log(`Updated: ${updated.title} -> ${updated.status}`);
}
// === Demonstrate filtering with type guards ===
console.log("\n=== Bugs Only ===\n");
const bugs = repo.getBugs();
for (const b of bugs) {
// TypeScript knows 'b' is BugTask here
console.log(`${b.title} - Severity: ${b.severity}`);
}
console.log("\n=== Todo Tasks ===\n");
const todoTasks = repo.getByStatus("todo");
for (const t of todoTasks) {
console.log(`${t.title} (${t.kind})`);
}
// === Demonstrate findBy with custom predicate ===
console.log("\n=== Custom Query: Tasks created after May 29 ===\n");
const recentTasks = repo.findBy(
(task) => task.createdAt > new Date("2026-05-29")
);
for (const t of recentTasks) {
console.log(`${t.title} - ${t.createdAt.toISOString().split("T")[0]}`);
}
console.log("\n✅ All operations completed with full type safety!");
What does this code do?
The updateData variable is typed as UpdateTaskInput, which is Partial<Pick<TaskItem, "title" | "description" | "status">>. This means you can only update title, description, or status. Try adding id or kind to the object and TypeScript will refuse.
The getBugs() call returns BugTask[] (not TaskItem[]), so inside the loop, b.severity is accessible without any type assertion. The findBy method takes any predicate function and returns matching items from the repository.
- Save src/index.ts.
- You won't see output yet. The next substep compiles and runs everything.
Getting a type error on updateData?
- Make sure you haven't added properties that aren't in the UpdateTaskInput type. Only title, description, and status are allowed.
- Check that the status value is one of the valid literals: "todo", "in-progress", or "done".
Help me understand why TypeScript won't let me add certain fields to my update object
✔️ Awesome, I've got everything!
Great. Double check you've saved src/index.ts before moving on to compile.
ⓧ I'd like to double check the full code
import { TaskItem, BugTask, FeatureTask, StoryTask, UpdateTaskInput } from "./types.js";
import { TaskRepository } from "./task-repository.js";
// === Create the repository ===
const repo = new TaskRepository();
// === Add sample tasks ===
const bug: BugTask = {
id: "task-1",
kind: "bug",
title: "Login button unresponsive",
description: "The login button does not respond on mobile devices",
status: "todo",
createdAt: new Date("2026-06-01"),
severity: "high",
stepsToReproduce: ["Open app on mobile", "Tap login button", "Nothing happens"],
};
const feature: FeatureTask = {
id: "task-2",
kind: "feature",
title: "Add dark mode",
description: "Implement a dark mode toggle in settings",
status: "in-progress",
createdAt: new Date("2026-05-28"),
estimatedHours: 12,
acceptanceCriteria: ["Toggle in settings", "Persists across sessions", "Applies to all pages"],
};
const story: StoryTask = {
id: "task-3",
kind: "story",
title: "User can export data",
description: "As a user, I want to export my data as CSV",
status: "todo",
createdAt: new Date("2026-05-30"),
points: 5,
userRole: "end-user",
};
repo.add(bug);
repo.add(feature);
repo.add(story);
// === Demonstrate type narrowing with switch ===
console.log("=== All Tasks ===\n");
for (const task of repo.getAll()) {
console.log(`[${task.kind.toUpperCase()}] ${task.title} (${task.status})`);
switch (task.kind) {
case "bug":
console.log(` Severity: ${task.severity}`);
console.log(` Steps: ${task.stepsToReproduce.join(" -> ")}`);
break;
case "feature":
console.log(` Estimated: ${task.estimatedHours}h`);
console.log(` Criteria: ${task.acceptanceCriteria.length} items`);
break;
case "story":
console.log(` Points: ${task.points}`);
console.log(` Role: ${task.userRole}`);
break;
}
console.log();
}
// === Demonstrate type-safe updates ===
console.log("=== Updating Task ===\n");
const updateData: UpdateTaskInput = {
status: "done",
title: "Login button unresponsive (FIXED)",
};
const updated = repo.update("task-1", updateData);
if (updated) {
console.log(`Updated: ${updated.title} -> ${updated.status}`);
}
// === Demonstrate filtering with type guards ===
console.log("\n=== Bugs Only ===\n");
const bugs = repo.getBugs();
for (const b of bugs) {
// TypeScript knows 'b' is BugTask here
console.log(`${b.title} - Severity: ${b.severity}`);
}
console.log("\n=== Todo Tasks ===\n");
const todoTasks = repo.getByStatus("todo");
for (const t of todoTasks) {
console.log(`${t.title} (${t.kind})`);
}
// === Demonstrate findBy with custom predicate ===
console.log("\n=== Custom Query: Tasks created after May 29 ===\n");
const recentTasks = repo.findBy(
(task) => task.createdAt > new Date("2026-05-29")
);
for (const t of recentTasks) {
console.log(`${t.title} - ${t.createdAt.toISOString().split("T")[0]}`);
}
console.log("\n✅ All operations completed with full type safety!");
Compile and run the project
TypeScript code doesn't run directly. The compiler (tsc) transforms your .ts files into JavaScript, catching type errors along the way.
- Compile the project by running this command:
npx tsc
What should I see?
If there are no errors, the command returns silently with no output. That silence means every type in your project checks out. A dist/ folder now contains the compiled JavaScript files.
- Run the compiled program by running:
node dist/index.js
- You should see all your tasks printed with kind-specific details, the update confirmation, filtered bug and todo lists, and the custom date query results.
Seeing a compilation error?
- Check that your import paths use .js extensions ("./types.js", not "./types" or "./types.ts").
- Make sure package.json still has "type": "module".
- Verify that every task object includes all required properties for its interface.
Help me fix a TypeScript compilation error in my task manager project
Now let's prove that TypeScript actually catches mistakes at compile time.
- In src/index.ts, find the bug object near the top of the file.
- Find this line:
kind: "bug",
- Change "bug" to "invalid", so the line looks like this:
kind: "invalid",
- Save the file and try to compile again by running:
npx tsc
TypeScript refuses to compile. The error tells you that "invalid" is not assignable to the expected literal types. This is the compiler catching a bug before your code ever runs.
- Change kind back to "bug" and save the file.
- Compile and run one final time to confirm everything works:
npx tsc && node dist/index.js
- You should see the full output ending with ✅ All operations completed with full type safety!.
Why is this powerful?
In plain JavaScript, assigning kind: "invalid" would create a silent bug. Your switch statement would fall through without printing anything, and you'd only discover the problem at runtime (if at all). TypeScript catches it instantly at compile time.
Secret mission
Priority Filtering with Mapped Types
Your task manager handles different task types, but it can't tell you which tasks are most urgent. Build a priority-based filtering system using mapped types, intersection types, and custom type predicates to take your TypeScript skills to the next level.
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 on your machine or delete them entirely.
Resources you used:
- typescript-task-manager project folder on your Desktop.
- Node.js (installed system-wide in Step 1).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your TypeScript task manager or refer back to the code later.
- Your project folder stays on your Desktop at ~/Desktop/typescript-task-manager.
- Node.js remains installed and available for future projects.
- The compiled output lives in the dist/ folder inside your project.
Pause - I'll come back to this later
There are no running processes or background services to stop. This project only runs when you explicitly compile and execute it.
- Your project is already paused by default. No action required.
- To resume later, open a terminal in your typescript-task-manager folder and run npm run dev to compile and execute again.
Delete - I don't want to use this again
Remove all project files and optionally uninstall Node.js if you no longer need it.
- Delete the project folder by running these commands in your terminal:
cd ~/Desktop
rm -rf typescript-task-manager
Windows users
On Windows, open PowerShell and run:
cd ~/Desktop
Remove-Item -Recurse -Force typescript-task-manager
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see typescript-task-manager in the output.
- To uninstall Node.js, follow the official uninstall guide for your platform at https://nodejs.org. On Windows, use Add or remove programs in Settings. On macOS, if you installed via a version manager like nvm, run nvm uninstall <version>.
Nice Work!
Nice Work!
You just built a complete type-safe task management system in TypeScript from scratch. Every piece of data flowing through your application is checked at compile time, catching bugs before they ever reach runtime.
You've learned how to:
- Define a rich type model using interfaces, literal types, and discriminated unions to safely represent different task variants with type narrowing.
- Build a generic Repository class that provides type-safe CRUD operations for any entity, using constraints and utility types like Partial, Pick, Omit, and Readonly.
- Write custom type guards with the is keyword to narrow union types and filter arrays into correctly-typed results.
- Secret Mission: Build a priority filtering system using mapped types, intersection types, and a generic sortBy utility constrained with keyof.
Ready to quiz yourself?