総合演習プロジェクト
この章では、これまで学んだ知識を活かして、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: 型定義を作成
まず、タスクとその関連型を定義します。
ヒント
Priority型をリテラル型のUnionで定義Taskインターフェースを作成(id, title, completed, priority, createdAt)CreateTaskInput型を作成(id・createdAt・completed は不要 — システム側で自動設定)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クラスを実装
タスクの管理ロジックを実装します。
ヒント
- プライベートプロパティでタスク配列を保持
- addTask: 新しいタスクを作成して追加
- deleteTask: IDでタスクを削除
- toggleComplete: 完了状態を切り替え
- getTasks: フィルタに基づいてタスクを取得
- findById: IDでタスクを検索
- 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ファイルに保存・読み込みする機能を実装します。
ヒント
fs/promisesを使用して非同期I/O- 保存:
JSON.stringifyでJSON形式に変換 - 読み込み:
JSON.parseでオブジェクトに変換 - ファイルが存在しない場合のエラーハンドリング
- 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インターフェースを実装
コマンドライン操作のインターフェースを実装します。
ヒント
process.argvでコマンドライン引数を取得- コマンドをswitch文で分岐
- 各コマンドの引数をパース
- 結果を色付きで表示(任意)
- ヘルプメッセージを表示
回答と解説
// 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クラスのユニットテストを実装します。
ヒント
describeでテストグループを作成beforeEachで各テスト前にTaskManagerを初期化- 各メソッドに対してテストケースを作成
- 正常系と異常系の両方をテスト
- モックは使用せず、実際の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
発展課題
このプロジェクトをさらに拡張するアイデア:
- 期限の追加: タスクに期限を設定し、期限切れを警告
- カテゴリ/タグ: タスクを分類して管理
- 検索機能: タイトルで検索
- エクスポート機能: CSV/Markdownへエクスポート
- インタラクティブモード: 対話形式で操作
- 設定ファイル: 保存場所やデフォルト優先度をカスタマイズ
- リマインダー: 期限が近いタスクを通知
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
}
}
業界標準のライブラリとしては neverthrow や fp-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 等) を探す