Skip to main content

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
info

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
  1. Define the Priority type as a union of literal types
  2. Create the Task interface (id, title, completed, priority, createdAt)
  3. Create the CreateTaskInput type (no id, createdAt, or completed — they are set automatically by the system)
  4. Create the TaskFilter type (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 Priority as a union of literal types allows only valid values
  • The Task interface defines the complete shape of a task
  • CreateTaskInput is the type for user input, excluding the fields the system generates
  • Instead of using Partial<Task>, defining UpdateTaskInput explicitly makes the intent clear

Step 3: Implement the TaskManager class

Implement the task management logic.

Hint
  1. Hold the task array in a private property
  2. addTask: create and add a new task
  3. deleteTask: delete a task by ID
  4. toggleComplete: toggle the completed state
  5. getTasks: get tasks based on a filter
  6. findById: find a task by ID
  7. 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 outside
  • randomUUID() generates a unique ID
  • getTasks returns a copy, which prevents unintended changes from the outside
  • updateTask reflects only the explicitly updated properties via an undefined check
  • getTasksSorted maps the priority order with Record<Priority, number>

Step 4: Implement file I/O

Implement the feature to save and load tasks to a JSON file.

Hint
  1. Use fs/promises for async I/O
  2. Save: convert to JSON format with JSON.stringify
  3. Load: convert to objects with JSON.parse
  4. Error handling for when the file does not exist
  5. 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/promises lets you handle async I/O with async/await
  • The fileExists function 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
  1. Get the command-line arguments with process.argv
  2. Branch on the command with a switch statement
  3. Parse the arguments of each command
  4. Display the result with color (optional)
  5. 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 parseArgs function 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
  1. Create a test group with describe
  2. Initialize the TaskManager before each test with beforeEach
  3. Create test cases for each method
  4. Test both the normal and abnormal paths
  5. 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:

  • beforeEach initializes 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)
  • toEqual does a deep comparison of objects, and toBe compares primitive values
  • toBeInstanceOf(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:

  1. Add due dates: set a due date on a task and warn about overdue ones
  2. Categories/tags: classify and manage tasks
  3. Search feature: search by title
  4. Export feature: export to CSV/Markdown
  5. Interactive mode: operate it in a dialog style
  6. Config file: customize the save location and default priority
  7. 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 }}
info

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.

You have finished. As your next steps, we recommend the following.