メインコンテンツまでスキップ

総合演習プロジェクト

この章では、これまで学んだ知識を活かして、CLIタスク管理ツールを構築します。

この章で学ぶこと

  • 実践的なTypeScriptプロジェクトの構築
  • 型設計と実装のベストプラクティス
  • クラス設計とモジュール分割
  • ファイルI/Oとエラーハンドリング
  • ユニットテストの実装
備考

実際に手を動かしてアプリを構築すると、知識が定着しやすくなります。このプロジェクトでは本書で扱った TypeScript の機能を一通り使います。コードを写しながら、自分の興味に応じてアレンジを加えてみてください。

プロジェクト概要

機能

  • タスクの追加・削除・一覧表示
  • タスクの完了/未完了の切り替え
  • タスクの優先度設定(high/medium/low)
  • タスクのファイル保存(JSON形式)
  • コマンドライン操作

使用技術

  • TypeScript
  • Node.js
  • Jest(テスト。第18章で学んだ構成をそのまま実践するために採用。新規プロジェクトでは Vitest も第一候補 — 同章参照)

プロジェクト構成

task-cli/
├── src/
│ ├── types/
│ │ └── task.ts # 型定義
│ ├── services/
│ │ └── TaskManager.ts # タスク管理ロジック
│ ├── utils/
│ │ └── storage.ts # ファイルI/O
│ ├── cli.ts # CLIインターフェース
│ └── index.ts # エントリーポイント
├── tests/
│ └── TaskManager.test.ts # ユニットテスト
├── package.json
├── tsconfig.json
└── jest.config.js

ステップ1: プロジェクトのセットアップ

mkdir task-cli
cd task-cli
npm init -y

# 依存関係のインストール
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"]
}

package.json のscripts:

{
"scripts": {
"build": "tsc",
"start": "ts-node src/index.ts",
"dev": "ts-node src/index.ts",
"test": "jest",
"test:watch": "jest --watch"
}
}

ステップ2: 型定義を作成

まず、タスクとその関連型を定義します。

ヒント
  1. Priority型をリテラル型のUnionで定義
  2. Taskインターフェースを作成(id, title, completed, priority, createdAt)
  3. CreateTaskInput型を作成(id・createdAt・completed は不要 — システム側で自動設定)
  4. TaskFilter型を作成(表示フィルタ用)
回答と解説
// src/types/task.ts

// 優先度の型(リテラル型のUnion)
// 'high' | 'medium' | 'low' のいずれかのみ許可
export type Priority = 'high' | 'medium' | 'low';

// タスクのインターフェース
export interface Task {
id: string; // 一意の識別子(UUID)
title: string; // タスクのタイトル
completed: boolean; // 完了状態
priority: Priority; // 優先度
createdAt: Date; // 作成日時
}

// タスク作成時の入力型
// idとcreatedAtはシステムが自動生成するため不要
export interface CreateTaskInput {
title: string;
priority?: Priority; // オプショナル(デフォルトはmedium)
}

// タスク更新時の入力型
// すべてオプショナル
export interface UpdateTaskInput {
title?: string;
completed?: boolean;
priority?: Priority;
}

// フィルタの型
export type TaskFilter = 'all' | 'completed' | 'active';

// ソート順の型
export type SortOrder = 'priority' | 'date' | 'title';

解説:

  • Priorityはリテラル型のUnionで定義することで、有効な値のみを許可します
  • Taskインターフェースでタスクの完全な形を定義します
  • CreateTaskInputはユーザー入力用の型で、システムが生成するフィールドを除外しています
  • Partial<Task>を使う代わりに明示的にUpdateTaskInputを定義することで、意図を明確にしています

ステップ3: TaskManagerクラスを実装

タスクの管理ロジックを実装します。

ヒント
  1. プライベートプロパティでタスク配列を保持
  2. addTask: 新しいタスクを作成して追加
  3. deleteTask: IDでタスクを削除
  4. toggleComplete: 完了状態を切り替え
  5. getTasks: フィルタに基づいてタスクを取得
  6. findById: IDでタスクを検索
  7. UUID生成にはcrypto.randomUUID()を使用
回答と解説
// src/services/TaskManager.ts
import { randomUUID } from 'crypto';
import {
Task,
CreateTaskInput,
UpdateTaskInput,
TaskFilter,
SortOrder,
Priority
} from '../types/task';

// TaskManagerクラス
// タスクの追加、削除、更新、取得などを管理
export class TaskManager {
// タスクの配列(プライベート)
private tasks: Task[] = [];

// コンストラクタ(初期タスクを受け取ることも可能)
constructor(initialTasks: Task[] = []) {
this.tasks = initialTasks;
}

// タスクを追加
addTask(input: CreateTaskInput): Task {
const newTask: Task = {
id: randomUUID(), // UUIDを生成
title: input.title,
completed: false, // デフォルトは未完了
priority: input.priority || 'medium', // デフォルトはmedium
createdAt: new Date(),
};

this.tasks.push(newTask);
return newTask;
}

// タスクを削除
deleteTask(id: string): boolean {
const index = this.tasks.findIndex(task => task.id === id);

if (index === -1) {
return false; // タスクが見つからない
}

this.tasks.splice(index, 1);
return true;
}

// 完了状態を切り替え
toggleComplete(id: string): Task | null {
const task = this.findById(id);

if (!task) {
return null;
}

task.completed = !task.completed;
return task;
}

// タスクを更新
updateTask(id: string, input: UpdateTaskInput): Task | null {
const task = this.findById(id);

if (!task) {
return null;
}

// 存在するプロパティのみ更新
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;
}

// IDでタスクを検索
findById(id: string): Task | undefined {
return this.tasks.find(task => task.id === id);
}

// フィルタに基づいてタスクを取得
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]; // コピーを返す
}
}

// ソートしてタスクを取得
getTasksSorted(sortOrder: SortOrder = 'date'): Task[] {
const sorted = [...this.tasks];

switch (sortOrder) {
case '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':
// タイトルのアルファベット順
return sorted.sort((a, b) => a.title.localeCompare(b.title));

case 'date':
default:
// 作成日順(新しい順)
return sorted.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
}

// 完了済みタスクをすべて削除
clearCompleted(): number {
const beforeCount = this.tasks.length;
this.tasks = this.tasks.filter(task => !task.completed);
return beforeCount - this.tasks.length;
}

// 統計情報を取得
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,
};
}

// すべてのタスクを取得(保存用)
getAllTasks(): Task[] {
return this.tasks;
}

// タスクを設定(読み込み用)
setTasks(tasks: Task[]): void {
this.tasks = tasks;
}
}

解説:

  • private tasks: Task[]でタスク配列をカプセル化し、外部からの直接アクセスを防いでいます
  • randomUUID()でユニークなIDを生成しています
  • getTasksはコピーを返すことで、外部からの意図しない変更を防いでいます
  • updateTaskではundefinedチェックで明示的に更新されたプロパティのみを反映します
  • getTasksSortedではRecord<Priority, number>で優先度の順序をマッピングしています

ステップ4: ファイルI/Oを実装

タスクをJSONファイルに保存・読み込みする機能を実装します。

ヒント
  1. fs/promisesを使用して非同期I/O
  2. 保存: JSON.stringifyでJSON形式に変換
  3. 読み込み: JSON.parseでオブジェクトに変換
  4. ファイルが存在しない場合のエラーハンドリング
  5. Date型の復元に注意(JSONではstring化される)
回答と解説
// src/utils/storage.ts
import { readFile, writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { Task } from '../types/task';

// デフォルトの保存ファイルパス
const DEFAULT_FILE_PATH = './tasks.json';

// ファイルが存在するかチェック
async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}

// タスクを保存
export async function saveTasks(
tasks: Task[],
filePath: string = DEFAULT_FILE_PATH
): Promise<void> {
try {
// JSON形式で保存(インデント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}`);
}
}

// タスクを読み込み
export async function loadTasks(
filePath: string = DEFAULT_FILE_PATH
): Promise<Task[]> {
try {
// ファイルが存在しない場合は空配列を返す
if (!(await fileExists(filePath))) {
return [];
}

const json = await readFile(filePath, 'utf-8');
const data = JSON.parse(json);

// Date型の復元
// JSONではDateがstringになるため、Dateオブジェクトに変換
return data.map((task: any) => ({
...task,
createdAt: new Date(task.createdAt),
}));
} catch (error) {
throw new Error(`Failed to load tasks: ${(error as Error).message}`);
}
}

// バックアップを作成
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}`);
}
}

解説:

  • fs/promisesを使用することで、非同期I/Oをasync/awaitで扱えます
  • fileExists関数でファイルの存在チェックを行い、存在しない場合は空配列を返します
  • JSONではDateオブジェクトが文字列に変換されるため、読み込み時にnew Date()で復元しています
  • エラーハンドリングでは、元のエラーメッセージを含めて新しいエラーをスローしています

ステップ5: CLIインターフェースを実装

コマンドライン操作のインターフェースを実装します。

ヒント
  1. process.argvでコマンドライン引数を取得
  2. コマンドをswitch文で分岐
  3. 各コマンドの引数をパース
  4. 結果を色付きで表示(任意)
  5. ヘルプメッセージを表示
回答と解説
// src/cli.ts
import { TaskManager } from './services/TaskManager';
import { saveTasks, loadTasks } from './utils/storage';
import { Priority, TaskFilter } from './types/task';

// ANSIカラーコード(ターミナルで色を付ける)
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
gray: '\x1b[90m',
};

// 優先度に応じた色を取得
function getPriorityColor(priority: Priority): string {
switch (priority) {
case 'high': return colors.red;
case 'medium': return colors.yellow;
case 'low': return colors.blue;
}
}

// ヘルプメッセージを表示
function showHelp(): void {
console.log(`
${colors.blue}Task CLI - タスク管理ツール${colors.reset}

使い方:
npm start <command> [options]

コマンド:
add <title> [--priority <high|medium|low>] タスクを追加
list [--filter <all|active|completed>] タスク一覧を表示
done <id> タスクを完了にする
delete <id> タスクを削除
clear 完了済みタスクを削除
stats 統計情報を表示
help このヘルプを表示

例:
npm start add "TypeScriptを学ぶ" --priority high
npm start list --filter active
npm start done abc123
`);
}

// タスク一覧を表示
function displayTasks(manager: TaskManager, filter: TaskFilter): void {
const tasks = manager.getTasks(filter);

if (tasks.length === 0) {
console.log(`${colors.gray}タスクがありません${colors.reset}`);
return;
}

console.log(`\n${colors.blue}=== タスク一覧 (${filter}) ===${colors.reset}\n`);

tasks.forEach(task => {
const status = task.completed
? `${colors.green}[完了]${colors.reset}`
: `${colors.gray}[未完了]${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();
}

// 引数をパース
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 };
}

// メイン関数
export async function main(): Promise<void> {
const args = process.argv.slice(2);
const { command, options } = parseArgs(args);

// タスクを読み込み
const tasks = await loadTasks();
const manager = new TaskManager(tasks);

try {
switch (command) {
case 'add': {
const title = options['_value'];
if (!title) {
console.log(`${colors.red}エラー: タイトルを指定してください${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}タスクを追加しました:${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}エラー: タスクIDを指定してください${colors.reset}`);
process.exit(1);
}
// IDの部分一致検索
const allTasks = manager.getAllTasks();
const matchingTask = allTasks.find(t => t.id.startsWith(id));
if (!matchingTask) {
console.log(`${colors.red}エラー: タスクが見つかりません${colors.reset}`);
process.exit(1);
}
const task = manager.toggleComplete(matchingTask.id);
await saveTasks(manager.getAllTasks());
const status = task?.completed ? '完了' : '未完了';
console.log(`${colors.green}タスクを${status}にしました:${colors.reset} ${task?.title}`);
break;
}

case 'delete': {
const id = options['_value'];
if (!id) {
console.log(`${colors.red}エラー: タスクIDを指定してください${colors.reset}`);
process.exit(1);
}
// IDの部分一致検索
const allTasks = manager.getAllTasks();
const matchingTask = allTasks.find(t => t.id.startsWith(id));
if (!matchingTask) {
console.log(`${colors.red}エラー: タスクが見つかりません${colors.reset}`);
process.exit(1);
}
manager.deleteTask(matchingTask.id);
await saveTasks(manager.getAllTasks());
console.log(`${colors.green}タスクを削除しました${colors.reset}`);
break;
}

case 'clear': {
const count = manager.clearCompleted();
await saveTasks(manager.getAllTasks());
console.log(`${colors.green}${count}件の完了済みタスクを削除しました${colors.reset}`);
break;
}

case 'stats': {
const stats = manager.getStats();
console.log(`\n${colors.blue}=== 統計情報 ===${colors.reset}\n`);
console.log(` 総タスク数: ${stats.total}`);
console.log(` 完了: ${colors.green}${stats.completed}${colors.reset}`);
console.log(` 未完了: ${colors.yellow}${stats.active}${colors.reset}`);
console.log();
break;
}

case 'help':
default:
showHelp();
break;
}
} catch (error) {
console.log(`${colors.red}エラー: ${(error as Error).message}${colors.reset}`);
process.exit(1);
}
}
// src/index.ts
import { main } from './cli';

// CLIを実行
main().catch(error => {
console.error('予期しないエラー:', error);
process.exit(1);
});

解説:

  • process.argv.slice(2)でNode.jsとファイルパスを除いたコマンドライン引数を取得しています
  • ANSIエスケープコードを使ってターミナルに色を付けています
  • parseArgs関数で引数をコマンドとオプションに分解しています
  • 各コマンドはswitch文で分岐し、適切な処理を実行します
  • エラー時はprocess.exit(1)でエラーコードを返しています

ステップ6: テストを追加

TaskManagerクラスのユニットテストを実装します。

ヒント
  1. describeでテストグループを作成
  2. beforeEachで各テスト前にTaskManagerを初期化
  3. 各メソッドに対してテストケースを作成
  4. 正常系と異常系の両方をテスト
  5. モックは使用せず、実際のTaskManagerをテスト
回答と解説
// tests/TaskManager.test.ts
import { TaskManager } from '../src/services/TaskManager';
import { Task, CreateTaskInput, Priority } from '../src/types/task';

describe('TaskManager', () => {
let manager: TaskManager;

// 各テスト前に新しいインスタンスを作成
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'); // デフォルト
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(() => {
// テスト用のタスクを追加
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);
});
});
});

解説:

  • beforeEachで各テスト前にTaskManagerを初期化し、テスト間の影響を防いでいます
  • 各メソッドに対して正常系と異常系(存在しないIDなど)をテストしています
  • toEqualでオブジェクトの深い比較、toBeでプリミティブ値の比較をしています
  • toBeInstanceOf(Date)でDate型のチェックを行っています

テスト実行:

npm test

動作確認

# ビルド(任意)
npm run build

# ヘルプを表示
npm start help

# タスクを追加
npm start add "TypeScriptを学ぶ" --priority high
npm start add "Jestでテストを書く"
npm start add "READMEを書く" --priority low

# タスク一覧
npm start list

# 未完了のみ表示
npm start list --filter active

# タスクを完了
npm start done abc # IDの先頭数文字でOK

# 統計情報
npm start stats

# 完了済みを削除
npm start clear

発展課題

このプロジェクトをさらに拡張するアイデア:

  1. 期限の追加: タスクに期限を設定し、期限切れを警告
  2. カテゴリ/タグ: タスクを分類して管理
  3. 検索機能: タイトルで検索
  4. エクスポート機能: CSV/Markdownへエクスポート
  5. インタラクティブモード: 対話形式で操作
  6. 設定ファイル: 保存場所やデフォルト優先度をカスタマイズ
  7. リマインダー: 期限が近いタスクを通知

npmパッケージとして公開する

作成したCLIツールをnpmに公開する手順を紹介します。

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"
}

CLIエントリポイント

#!/usr/bin/env node
// src/cli.ts の先頭に追加

import { main } from './index';
main();

公開手順

# npmにログイン
npm login

# ビルドと公開
npm publish

# グローバルインストールでテスト
npm install -g my-task-cli
task add "テストタスク"

GitHub ActionsでCI/CD

自動テストと公開を設定します。

# .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 }}
備考

NPM_TOKENはnpmアカウントで発行し、GitHubリポジトリのSecretsに登録してください。

発展的なトピック(2026年実務向け)

本章のベースは学習用の CLI プロジェクトですが、実務で同様のパッケージを公開する場合は以下の知識が重要です。

ESM での __dirname / __filename

CommonJS では __dirname / __filename がグローバルに利用できましたが、ESM ではこれらは利用できません。代わりに import.meta から取得します。

// ❌ ESM では未定義
// console.log(__dirname)

// ✅ Node.js 20.11+ / 21.2+ : import.meta.dirname が使える
console.log(import.meta.dirname)
console.log(import.meta.filename)

// ✅ 互換性が必要なら URL から変換
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

ESM 環境では import.meta.dirname が最短で安全なので、Node.js 20.11 以上を対象にする場合はこちらを推奨します。

Result 型 / Either 型でエラーハンドリングする

throw によるエラーは型として表現されないため、呼び出し側がどんなエラーが起こりうるかをコードから読み取れません。関数型プログラミング風の Result 型(または Either 型) を使うと、エラーを値として返し、型システムで扱えます。

// シンプルな Result 型の定義
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 }
}

// ユースケース: ファイル読み込みを 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
}
}

// 呼び出し側: 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
}
}

業界標準のライブラリとしては neverthrowfp-ts があります。チーム内で方針を統一して採用するのが推奨です。

バージョン管理: Changesets

npm パッケージを公開するときは SemVer(セマンティックバージョニング) に従ってバージョンを上げる必要があります。複数人で開発している場合、「誰がどの変更を入れたか」「そのバージョンは patch / minor / major のどれか」を記録するツールとして Changesets が広く使われています。

# 初期セットアップ
npm install -D @changesets/cli
npx changeset init

# 変更を加えたら changeset を作成
npx changeset
# → 対話的に変更の種類(patch / minor / major)と説明を記録

# バージョンを反映
npx changeset version # package.json と CHANGELOG.md が更新される

# リリース
npx changeset publish # npm publish を実行

npm provenance(サプライチェーンの信頼性)

npm 9.5+ では、パッケージの出所を証明する provenance(SLSA 準拠) を付与できます。GitHub Actions から発行した証明書を npm に登録し、「このパッケージはこの GitHub リポジトリの、このコミットからビルドされた」ことが検証可能になります。

# .github/workflows/release.yml
- run: npm publish --provenance --access public
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

GitHub Actions の id-token: write 権限が必要です。サプライチェーン攻撃対策として、公開ライブラリでは有効化を推奨します。

まとめ

本書を通じて、以下のTypeScript開発スキルを習得しました。

基礎

  • 型注釈と型推論
  • プリミティブ型、オブジェクト型、配列型
  • Union型、リテラル型、タプル型
  • インターフェースと型エイリアス

関数とクラス

  • 関数の型定義、オプショナル引数、デフォルト値
  • クラス、継承、アクセス修飾子
  • ジェネリクス

高度な型システム

  • Mapped Types、Conditional Types
  • Utility Types(Partial、Readonly、Pick など)
  • 型ガード、never型

開発環境

  • tsconfig.jsonの設定
  • ESLint、Prettier
  • Jestによるテスト

実践開発

  • React + TypeScript
  • Express + TypeScript
  • 型安全なAPI設計

これらの知識を組み合わせると、TypeScript で動くアプリを実装できます。


ここまで読んでいただきありがとうございました。本書では TypeScript の基礎から実践までを扱いました。次は手元のプロジェクトで TypeScript を使ってみてください。

次に読む

読了です。次のステップとして以下を推奨します。

  • React 入門 — TS で型を学んだ次は React で UI を学ぶ (全 29 章 / Vite + TypeScript + Tailwind CSS)
  • Clean Code (TypeScript 版) — 設計規範のベストプラクティスを学ぶ
  • Reinvent Notes Docs — 他のトピック (Markdown 記法 / Getting Started 等) を探す