Comprehensive exercise project
In this chapter, you build a CLI task management tool using the knowledge you have learned so far.
What you learn in this chapter
- Building a practical TypeScript project
- Best practices for type design and implementation
- Class design and module splitting
- File I/O and error handling
- Implementing unit tests
Building an app hands-on helps the knowledge stick. This project uses a broad range of the TypeScript features covered in this book. As you copy the code, feel free to add your own twists based on your interests.
Project overview
Features
- Add, delete, and list tasks
- Toggle a task's completed/incomplete state
- Set a task's priority (high/medium/low)
- Save tasks to a file (JSON format)
- Command-line operation
Technology used
- TypeScript
- Node.js
- Jest (for testing. Adopted to practice the setup learned in Chapter 18 as is. For new projects, Vitest is also a top choice — see the same chapter)
Project structure
task-cli/
├── src/
│ ├── types/
│ │ └── task.ts # type definitions
│ ├── services/
│ │ └── TaskManager.ts # task management logic
│ ├── utils/
│ │ └── storage.ts # file I/O
│ ├── cli.ts # the CLI interface
│ └── index.ts # the entry point
├── tests/
│ └── TaskManager.test.ts # unit tests
├── package.json
├── tsconfig.json
└── jest.config.js
Step 1: Set up the project
mkdir task-cli
cd task-cli
npm init -y
# Install the dependencies
npm install --save-dev typescript @types/node ts-node jest ts-jest @types/jest
tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
The scripts in package.json:
{
"scripts": {
"build": "tsc",
"start": "ts-node src/index.ts",
"dev": "ts-node src/index.ts",
"test": "jest",
"test:watch": "jest --watch"
}
}
Step 2: Create the type definitions
First, define the task and its related types.
Hint
- Define the
Prioritytype as a union of literal types - Create the
Taskinterface (id, title, completed, priority, createdAt) - Create the
CreateTaskInputtype (no id, createdAt, or completed — they are set automatically by the system) - Create the
TaskFiltertype (for the display filter)
Answer and explanation
// src/types/task.ts
// The priority type (a union of literal types)
// Only one of 'high' | 'medium' | 'low' is allowed
export type Priority = 'high' | 'medium' | 'low';
// The task interface
export interface Task {
id: string; // a unique identifier (UUID)
title: string; // the task title
completed: boolean; // the completed state
priority: Priority; // the priority
createdAt: Date; // the creation time
}
// The input type for creating a task
// id and createdAt are generated automatically by the system, so they are not needed
export interface CreateTaskInput {
title: string;
priority?: Priority; // optional (the default is medium)
}
// The input type for updating a task
// Everything is optional
export interface UpdateTaskInput {
title?: string;
completed?: boolean;
priority?: Priority;
}
// The filter type
export type TaskFilter = 'all' | 'completed' | 'active';
// The sort order type
export type SortOrder = 'priority' | 'date' | 'title';
Explanation:
- Defining
Priorityas a union of literal types allows only valid values - The
Taskinterface defines the complete shape of a task CreateTaskInputis the type for user input, excluding the fields the system generates- Instead of using
Partial<Task>, definingUpdateTaskInputexplicitly makes the intent clear
Step 3: Implement the TaskManager class
Implement the task management logic.
Hint
- Hold the task array in a private property
- addTask: create and add a new task
- deleteTask: delete a task by ID
- toggleComplete: toggle the completed state
- getTasks: get tasks based on a filter
- findById: find a task by ID
- Use crypto.randomUUID() to generate a UUID
Answer and explanation
// src/services/TaskManager.ts
import { randomUUID } from 'crypto';
import {
Task,
CreateTaskInput,
UpdateTaskInput,
TaskFilter,
SortOrder,
Priority
} from '../types/task';
// The TaskManager class
// Manages adding, deleting, updating, and retrieving tasks
export class TaskManager {
// The task array (private)
private tasks: Task[] = [];
// The constructor (can also receive initial tasks)
constructor(initialTasks: Task[] = []) {
this.tasks = initialTasks;
}
// Add a task
addTask(input: CreateTaskInput): Task {
const newTask: Task = {
id: randomUUID(), // generate a UUID
title: input.title,
completed: false, // the default is incomplete
priority: input.priority || 'medium', // the default is medium
createdAt: new Date(),
};
this.tasks.push(newTask);
return newTask;
}
// Delete a task
deleteTask(id: string): boolean {
const index = this.tasks.findIndex(task => task.id === id);
if (index === -1) {
return false; // the task was not found
}
this.tasks.splice(index, 1);
return true;
}
// Toggle the completed state
toggleComplete(id: string): Task | null {
const task = this.findById(id);
if (!task) {
return null;
}
task.completed = !task.completed;
return task;
}
// Update a task
updateTask(id: string, input: UpdateTaskInput): Task | null {
const task = this.findById(id);
if (!task) {
return null;
}
// Update only the properties that exist
if (input.title !== undefined) {
task.title = input.title;
}
if (input.completed !== undefined) {
task.completed = input.completed;
}
if (input.priority !== undefined) {
task.priority = input.priority;
}
return task;
}
// Find a task by ID
findById(id: string): Task | undefined {
return this.tasks.find(task => task.id === id);
}
// Get tasks based on a filter
getTasks(filter: TaskFilter = 'all'): Task[] {
switch (filter) {
case 'completed':
return this.tasks.filter(task => task.completed);
case 'active':
return this.tasks.filter(task => !task.completed);
case 'all':
default:
return [...this.tasks]; // return a copy
}
}
// Get tasks sorted
getTasksSorted(sortOrder: SortOrder = 'date'): Task[] {
const sorted = [...this.tasks];
switch (sortOrder) {
case 'priority':
// By priority (high > medium > low)
const priorityOrder: Record<Priority, number> = {
high: 0,
medium: 1,
low: 2,
};
return sorted.sort((a, b) =>
priorityOrder[a.priority] - priorityOrder[b.priority]
);
case 'title':
// Alphabetical order by title
return sorted.sort((a, b) => a.title.localeCompare(b.title));
case 'date':
default:
// By creation date (newest first)
return sorted.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
}
// Delete all completed tasks
clearCompleted(): number {
const beforeCount = this.tasks.length;
this.tasks = this.tasks.filter(task => !task.completed);
return beforeCount - this.tasks.length;
}
// Get statistics
getStats(): { total: number; completed: number; active: number } {
const completed = this.tasks.filter(t => t.completed).length;
return {
total: this.tasks.length,
completed,
active: this.tasks.length - completed,
};
}
// Get all tasks (for saving)
getAllTasks(): Task[] {
return this.tasks;
}
// Set the tasks (for loading)
setTasks(tasks: Task[]): void {
this.tasks = tasks;
}
}
Explanation:
private tasks: Task[]encapsulates the task array and prevents direct access from the outsiderandomUUID()generates a unique IDgetTasksreturns a copy, which prevents unintended changes from the outsideupdateTaskreflects only the explicitly updated properties via anundefinedcheckgetTasksSortedmaps the priority order withRecord<Priority, number>
Step 4: Implement file I/O
Implement the feature to save and load tasks to a JSON file.
Hint
- Use
fs/promisesfor async I/O - Save: convert to JSON format with
JSON.stringify - Load: convert to objects with
JSON.parse - Error handling for when the file does not exist
- Be careful about restoring the Date type (it becomes a string in JSON)
Answer and explanation
// src/utils/storage.ts
import { readFile, writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { Task } from '../types/task';
// The default save file path
const DEFAULT_FILE_PATH = './tasks.json';
// Check whether a file exists
async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
// Save tasks
export async function saveTasks(
tasks: Task[],
filePath: string = DEFAULT_FILE_PATH
): Promise<void> {
try {
// Save in JSON format (formatted with indent 2)
const json = JSON.stringify(tasks, null, 2);
await writeFile(filePath, json, 'utf-8');
} catch (error) {
throw new Error(`Failed to save tasks: ${(error as Error).message}`);
}
}
// Load tasks
export async function loadTasks(
filePath: string = DEFAULT_FILE_PATH
): Promise<Task[]> {
try {
// If the file does not exist, return an empty array
if (!(await fileExists(filePath))) {
return [];
}
const json = await readFile(filePath, 'utf-8');
const data = JSON.parse(json);
// Restore the Date type
// In JSON, a Date becomes a string, so convert it to a Date object
return data.map((task: any) => ({
...task,
createdAt: new Date(task.createdAt),
}));
} catch (error) {
throw new Error(`Failed to load tasks: ${(error as Error).message}`);
}
}
// Create a backup
export async function createBackup(
filePath: string = DEFAULT_FILE_PATH
): Promise<string> {
try {
if (!(await fileExists(filePath))) {
throw new Error('No tasks file to backup');
}
const backupPath = `${filePath}.backup.${Date.now()}`;
const content = await readFile(filePath, 'utf-8');
await writeFile(backupPath, content, 'utf-8');
return backupPath;
} catch (error) {
throw new Error(`Failed to create backup: ${(error as Error).message}`);
}
}
Explanation:
- Using
fs/promiseslets you handle async I/O with async/await - The
fileExistsfunction checks for the file's existence and returns an empty array if it does not exist - Since a Date object becomes a string in JSON, it is restored with
new Date()on load - In the error handling, a new error is thrown that includes the original error message
Step 5: Implement the CLI interface
Implement the interface for command-line operation.
Hint
- Get the command-line arguments with
process.argv - Branch on the command with a switch statement
- Parse the arguments of each command
- Display the result with color (optional)
- Show a help message
Answer and explanation
// src/cli.ts
import { TaskManager } from './services/TaskManager';
import { saveTasks, loadTasks } from './utils/storage';
import { Priority, TaskFilter } from './types/task';
// ANSI color codes (to add color in the terminal)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
gray: '\x1b[90m',
};
// Get the color for a priority
function getPriorityColor(priority: Priority): string {
switch (priority) {
case 'high': return colors.red;
case 'medium': return colors.yellow;
case 'low': return colors.blue;
}
}
// Show the help message
function showHelp(): void {
console.log(`
${colors.blue}Task CLI - task management tool${colors.reset}
Usage:
npm start <command> [options]
Commands:
add <title> [--priority <high|medium|low>] Add a task
list [--filter <all|active|completed>] Show the task list
done <id> Mark a task as done
delete <id> Delete a task
clear Delete completed tasks
stats Show statistics
help Show this help
Examples:
npm start add "Learn TypeScript" --priority high
npm start list --filter active
npm start done abc123
`);
}
// Display the task list
function displayTasks(manager: TaskManager, filter: TaskFilter): void {
const tasks = manager.getTasks(filter);
if (tasks.length === 0) {
console.log(`${colors.gray}No tasks${colors.reset}`);
return;
}
console.log(`\n${colors.blue}=== Task list (${filter}) ===${colors.reset}\n`);
tasks.forEach(task => {
const status = task.completed
? `${colors.green}[Done]${colors.reset}`
: `${colors.gray}[Not done]${colors.reset}`;
const priorityColor = getPriorityColor(task.priority);
const priority = `${priorityColor}[${task.priority}]${colors.reset}`;
console.log(` ${status} ${priority} ${task.title}`);
console.log(` ${colors.gray}ID: ${task.id.slice(0, 8)}...${colors.reset}`);
});
console.log();
}
// Parse the arguments
function parseArgs(args: string[]): { command: string; options: Record<string, string> } {
const command = args[0] || 'help';
const options: Record<string, string> = {};
for (let i = 1; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2);
const value = args[i + 1] || '';
options[key] = value;
i++;
} else if (!options['_value']) {
options['_value'] = args[i];
}
}
return { command, options };
}
// The main function
export async function main(): Promise<void> {
const args = process.argv.slice(2);
const { command, options } = parseArgs(args);
// Load the tasks
const tasks = await loadTasks();
const manager = new TaskManager(tasks);
try {
switch (command) {
case 'add': {
const title = options['_value'];
if (!title) {
console.log(`${colors.red}Error: Please specify a title${colors.reset}`);
process.exit(1);
}
const priority = (options['priority'] as Priority) || 'medium';
const task = manager.addTask({ title, priority });
await saveTasks(manager.getAllTasks());
console.log(`${colors.green}Task added:${colors.reset} ${task.title}`);
break;
}
case 'list': {
const filter = (options['filter'] as TaskFilter) || 'all';
displayTasks(manager, filter);
break;
}
case 'done': {
const id = options['_value'];
if (!id) {
console.log(`${colors.red}Error: Please specify a task ID${colors.reset}`);
process.exit(1);
}
// Partial-match search by ID
const allTasks = manager.getAllTasks();
const matchingTask = allTasks.find(t => t.id.startsWith(id));
if (!matchingTask) {
console.log(`${colors.red}Error: Task not found${colors.reset}`);
process.exit(1);
}
const task = manager.toggleComplete(matchingTask.id);
await saveTasks(manager.getAllTasks());
const status = task?.completed ? 'done' : 'not done';
console.log(`${colors.green}Marked the task as ${status}:${colors.reset} ${task?.title}`);
break;
}
case 'delete': {
const id = options['_value'];
if (!id) {
console.log(`${colors.red}Error: Please specify a task ID${colors.reset}`);
process.exit(1);
}
// Partial-match search by ID
const allTasks = manager.getAllTasks();
const matchingTask = allTasks.find(t => t.id.startsWith(id));
if (!matchingTask) {
console.log(`${colors.red}Error: Task not found${colors.reset}`);
process.exit(1);
}
manager.deleteTask(matchingTask.id);
await saveTasks(manager.getAllTasks());
console.log(`${colors.green}Task deleted${colors.reset}`);
break;
}
case 'clear': {
const count = manager.clearCompleted();
await saveTasks(manager.getAllTasks());
console.log(`${colors.green}Deleted ${count} completed task(s)${colors.reset}`);
break;
}
case 'stats': {
const stats = manager.getStats();
console.log(`\n${colors.blue}=== Statistics ===${colors.reset}\n`);
console.log(` Total tasks: ${stats.total}`);
console.log(` Completed: ${colors.green}${stats.completed}${colors.reset}`);
console.log(` Active: ${colors.yellow}${stats.active}${colors.reset}`);
console.log();
break;
}
case 'help':
default:
showHelp();
break;
}
} catch (error) {
console.log(`${colors.red}Error: ${(error as Error).message}${colors.reset}`);
process.exit(1);
}
}
// src/index.ts
import { main } from './cli';
// Run the CLI
main().catch(error => {
console.error('Unexpected error:', error);
process.exit(1);
});
Explanation:
process.argv.slice(2)gets the command-line arguments excluding Node.js and the file path- ANSI escape codes are used to add color in the terminal
- The
parseArgsfunction breaks the arguments into a command and options - Each command branches with a switch statement and runs the appropriate logic
- On error,
process.exit(1)returns an error code
Step 6: Add tests
Implement unit tests for the TaskManager class.
Hint
- Create a test group with
describe - Initialize the TaskManager before each test with
beforeEach - Create test cases for each method
- Test both the normal and abnormal paths
- Do not use mocks; test the real TaskManager
Answer and explanation
// tests/TaskManager.test.ts
import { TaskManager } from '../src/services/TaskManager';
import { Task, CreateTaskInput, Priority } from '../src/types/task';
describe('TaskManager', () => {
let manager: TaskManager;
// Create a new instance before each test
beforeEach(() => {
manager = new TaskManager();
});
// ===== addTask =====
describe('addTask', () => {
it('should add a new task with default priority', () => {
const input: CreateTaskInput = { title: 'Test Task' };
const task = manager.addTask(input);
expect(task.title).toBe('Test Task');
expect(task.completed).toBe(false);
expect(task.priority).toBe('medium'); // default
expect(task.id).toBeDefined();
expect(task.createdAt).toBeInstanceOf(Date);
});
it('should add a new task with specified priority', () => {
const input: CreateTaskInput = { title: 'High Priority', priority: 'high' };
const task = manager.addTask(input);
expect(task.priority).toBe('high');
});
it('should increase tasks count after adding', () => {
manager.addTask({ title: 'Task 1' });
manager.addTask({ title: 'Task 2' });
expect(manager.getTasks().length).toBe(2);
});
});
// ===== deleteTask =====
describe('deleteTask', () => {
it('should delete an existing task', () => {
const task = manager.addTask({ title: 'To Delete' });
const result = manager.deleteTask(task.id);
expect(result).toBe(true);
expect(manager.getTasks().length).toBe(0);
});
it('should return false for non-existing task', () => {
const result = manager.deleteTask('non-existing-id');
expect(result).toBe(false);
});
});
// ===== toggleComplete =====
describe('toggleComplete', () => {
it('should toggle completed to true', () => {
const task = manager.addTask({ title: 'Toggle Test' });
const updated = manager.toggleComplete(task.id);
expect(updated?.completed).toBe(true);
});
it('should toggle completed back to false', () => {
const task = manager.addTask({ title: 'Toggle Test' });
manager.toggleComplete(task.id);
const updated = manager.toggleComplete(task.id);
expect(updated?.completed).toBe(false);
});
it('should return null for non-existing task', () => {
const result = manager.toggleComplete('non-existing-id');
expect(result).toBeNull();
});
});
// ===== updateTask =====
describe('updateTask', () => {
it('should update task title', () => {
const task = manager.addTask({ title: 'Original' });
const updated = manager.updateTask(task.id, { title: 'Updated' });
expect(updated?.title).toBe('Updated');
});
it('should update task priority', () => {
const task = manager.addTask({ title: 'Test', priority: 'low' });
const updated = manager.updateTask(task.id, { priority: 'high' });
expect(updated?.priority).toBe('high');
});
it('should return null for non-existing task', () => {
const result = manager.updateTask('non-existing-id', { title: 'New' });
expect(result).toBeNull();
});
});
// ===== findById =====
describe('findById', () => {
it('should find an existing task', () => {
const task = manager.addTask({ title: 'Find Me' });
const found = manager.findById(task.id);
expect(found).toEqual(task);
});
it('should return undefined for non-existing task', () => {
const found = manager.findById('non-existing-id');
expect(found).toBeUndefined();
});
});
// ===== getTasks =====
describe('getTasks', () => {
beforeEach(() => {
// Add tasks for testing
const task1 = manager.addTask({ title: 'Active 1' });
const task2 = manager.addTask({ title: 'Active 2' });
const task3 = manager.addTask({ title: 'Completed 1' });
manager.toggleComplete(task3.id);
});
it('should return all tasks with filter "all"', () => {
const tasks = manager.getTasks('all');
expect(tasks.length).toBe(3);
});
it('should return only active tasks with filter "active"', () => {
const tasks = manager.getTasks('active');
expect(tasks.length).toBe(2);
expect(tasks.every(t => !t.completed)).toBe(true);
});
it('should return only completed tasks with filter "completed"', () => {
const tasks = manager.getTasks('completed');
expect(tasks.length).toBe(1);
expect(tasks.every(t => t.completed)).toBe(true);
});
});
// ===== getTasksSorted =====
describe('getTasksSorted', () => {
beforeEach(() => {
manager.addTask({ title: 'B Task', priority: 'low' });
manager.addTask({ title: 'A Task', priority: 'high' });
manager.addTask({ title: 'C Task', priority: 'medium' });
});
it('should sort by priority', () => {
const tasks = manager.getTasksSorted('priority');
expect(tasks[0].priority).toBe('high');
expect(tasks[1].priority).toBe('medium');
expect(tasks[2].priority).toBe('low');
});
it('should sort by title', () => {
const tasks = manager.getTasksSorted('title');
expect(tasks[0].title).toBe('A Task');
expect(tasks[1].title).toBe('B Task');
expect(tasks[2].title).toBe('C Task');
});
});
// ===== clearCompleted =====
describe('clearCompleted', () => {
it('should clear all completed tasks', () => {
const task1 = manager.addTask({ title: 'Active' });
const task2 = manager.addTask({ title: 'Completed 1' });
const task3 = manager.addTask({ title: 'Completed 2' });
manager.toggleComplete(task2.id);
manager.toggleComplete(task3.id);
const count = manager.clearCompleted();
expect(count).toBe(2);
expect(manager.getTasks().length).toBe(1);
});
});
// ===== getStats =====
describe('getStats', () => {
it('should return correct statistics', () => {
const task1 = manager.addTask({ title: 'Active 1' });
const task2 = manager.addTask({ title: 'Active 2' });
const task3 = manager.addTask({ title: 'Completed' });
manager.toggleComplete(task3.id);
const stats = manager.getStats();
expect(stats.total).toBe(3);
expect(stats.completed).toBe(1);
expect(stats.active).toBe(2);
});
});
});
Explanation:
beforeEachinitializes the TaskManager before each test to prevent tests from affecting one another- Each method is tested for both the normal path and the abnormal path (such as a non-existent ID)
toEqualdoes a deep comparison of objects, andtoBecompares primitive valuestoBeInstanceOf(Date)checks the Date type
Run the tests:
npm test
Verifying it works
# Build (optional)
npm run build
# Show the help
npm start help
# Add tasks
npm start add "Learn TypeScript" --priority high
npm start add "Write tests with Jest"
npm start add "Write the README" --priority low
# List the tasks
npm start list
# Show only incomplete ones
npm start list --filter active
# Complete a task
npm start done abc # the first few characters of the ID are OK
# Show statistics
npm start stats
# Delete the completed ones
npm start clear
Extension challenges
Ideas for extending this project further:
- Add due dates: set a due date on a task and warn about overdue ones
- Categories/tags: classify and manage tasks
- Search feature: search by title
- Export feature: export to CSV/Markdown
- Interactive mode: operate it in a dialog style
- Config file: customize the save location and default priority
- Reminders: notify about tasks with an approaching deadline
Publishing it as an npm package
Here is the procedure to publish the CLI tool you created to npm.
Configuring package.json
{
"name": "my-task-cli",
"version": "1.0.0",
"description": "A type-safe CLI task manager",
"main": "dist/index.js",
"bin": {
"task": "dist/cli.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": ["cli", "task", "typescript"],
"license": "MIT"
}
The CLI entry point
#!/usr/bin/env node
// Add to the top of src/cli.ts
import { main } from './index';
main();
Publishing procedure
# Log in to npm
npm login
# Build and publish
npm publish
# Test with a global install
npm install -g my-task-cli
task add "Test task"
CI/CD with GitHub Actions
Set up automated testing and publishing.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
- run: npm ci
- run: npm run build
- run: npm test
publish:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Issue NPM_TOKEN from your npm account and register it in the GitHub repository's Secrets.
Advanced topics (for 2026 practice)
The basis of this chapter is a learning-oriented CLI project, but if you publish a similar package in practice, the following knowledge is important.
__dirname / __filename in ESM
In CommonJS, __dirname / __filename were globally available, but in ESM they are not available. Instead, get them from import.meta.
// ❌ Undefined in ESM
// console.log(__dirname)
// ✅ Node.js 20.11+ / 21.2+ : import.meta.dirname is available
console.log(import.meta.dirname)
console.log(import.meta.filename)
// ✅ If you need compatibility, convert from the URL
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
In an ESM environment, import.meta.dirname is the shortest and safest, so it is recommended when you target Node.js 20.11 or higher.
Error handling with a Result type / Either type
An error thrown by throw is not represented as a type, so the caller cannot tell from the code what errors can occur. Using a functional-programming-style Result type (or Either type) lets you return errors as values and handle them with the type system.
// A simple Result type definition
type Ok<T> = { ok: true; value: T }
type Err<E> = { ok: false; error: E }
type Result<T, E> = Ok<T> | Err<E>
function ok<T>(value: T): Ok<T> {
return { ok: true, value }
}
function err<E>(error: E): Err<E> {
return { ok: false, error }
}
// Use case: represent file reading with a Result
import { readFile } from 'node:fs/promises'
type ReadError = { type: 'NOT_FOUND' } | { type: 'PERMISSION_DENIED' }
async function safeReadFile(path: string): Promise<Result<string, ReadError>> {
try {
const content = await readFile(path, 'utf-8')
return ok(content)
} catch (e) {
const error = e as NodeJS.ErrnoException
if (error.code === 'ENOENT') return err({ type: 'NOT_FOUND' })
if (error.code === 'EACCES') return err({ type: 'PERMISSION_DENIED' })
throw e // throw an unexpected error
}
}
// The caller: handle it with an exhaustive check
const result = await safeReadFile('/tmp/data.txt')
if (result.ok) {
console.log(result.value)
} else {
switch (result.error.type) {
case 'NOT_FOUND':
console.error('File not found')
break
case 'PERMISSION_DENIED':
console.error('Access denied')
break
}
}
As industry-standard libraries, there are neverthrow and fp-ts. It is recommended to unify the approach within your team and adopt one.
Version management: Changesets
When you publish an npm package, you need to bump the version following SemVer (semantic versioning). When developing with multiple people, Changesets is widely used as a tool to record "who made which change" and "whether that version is a patch / minor / major".
# Initial setup
npm install -D @changesets/cli
npx changeset init
# Create a changeset when you make a change
npx changeset
# → interactively record the kind of change (patch / minor / major) and a description
# Apply the version
npx changeset version # package.json and CHANGELOG.md are updated
# Release
npx changeset publish # runs npm publish
npm provenance (supply chain trust)
In npm 9.5+, you can attach provenance (SLSA-compliant) that proves the package's origin. By registering a certificate issued from GitHub Actions with npm, it becomes verifiable that "this package was built from this commit of this GitHub repository."
# .github/workflows/release.yml
- run: npm publish --provenance --access public
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
It requires the id-token: write permission in GitHub Actions. As a defense against supply chain attacks, enabling it is recommended for public libraries.
Summary
Throughout this book, you have acquired the following TypeScript development skills.
Basics
- Type annotations and type inference
- Primitive types, object types, array types
- Union types, literal types, tuple types
- Interfaces and type aliases
Functions and classes
- Function type definitions, optional parameters, default values
- Classes, inheritance, access modifiers
- Generics
The advanced type system
- Mapped Types, Conditional Types
- Utility Types (Partial, Readonly, Pick, and so on)
- Type guards, the never type
The development environment
- Configuring tsconfig.json
- ESLint, Prettier
- Testing with Jest
Practical development
- React + TypeScript
- Express + TypeScript
- Type-safe API design
By combining this knowledge, you can build apps that run on TypeScript.
Thank you for reading this far. This book covered TypeScript from the basics to practice. Next, try using TypeScript in your own project.
What to read next
You have finished. As your next steps, we recommend the following.
- Introduction to React — after learning types with TS, learn UI with React (all 29 chapters / Vite + TypeScript + Tailwind CSS)
- Clean Code (TypeScript edition) — learn the best practices of design standards
- Reinvent Notes Docs — explore other topics (Markdown notation / Getting Started, etc.)