はじめに

フロントエンド開発でTypeScriptを使い始めると、バックエンドでも同じ型システムの恩恵を受けたいと感じるのは自然な流れです。Node.jsとTypeScriptを組み合わせることで、APIエンドポイントのリクエスト・レスポンスの型定義、環境変数の型安全な管理、データベースとのやり取りにおける型チェックなど、フルスタックで一貫した開発体験を実現できます。

本記事では、Node.jsプロジェクトでTypeScriptを設定する方法から始め、Expressフレームワークでの型定義、リクエストボディやクエリパラメータの型付け、そして環境変数を型安全に扱うテクニックまでを段階的に解説します。

この記事を読み終えると、以下のことができるようになります。

  • Node.jsプロジェクトにTypeScriptを導入し、適切なtsconfig.jsonを設定できる
  • Expressアプリケーションでリクエスト・レスポンスを型安全に扱える
  • 環境変数を型定義付きで管理し、実行時エラーを防止できる
  • バリデーションライブラリと連携した堅牢なAPI開発ができる

実行環境・前提条件

前提知識

  • TypeScriptの基本構文(型注釈、インターフェース、ジェネリクス)
  • Node.jsとnpmの基本操作
  • ExpressによるREST API開発の基礎

動作確認環境

ツール バージョン
Node.js 22.x LTS
npm 10.x以上
TypeScript 5.7以上
Express 4.21以上
VS Code 最新版

期待される結果

本記事の手順を完了すると、以下の状態になります。

  • TypeScriptで記述したExpressアプリケーションが動作する
  • APIエンドポイントのリクエスト・レスポンスが型安全になる
  • 環境変数の参照時に型チェックが働く
  • VS Codeでリクエストボディのプロパティ補完が動作する

TypeScript + Node.jsプロジェクトの初期設定

プロジェクトの作成

まず、新規プロジェクトを作成し、必要なパッケージをインストールします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# プロジェクトディレクトリの作成
mkdir ts-express-api
cd ts-express-api

# package.jsonの初期化
npm init -y

# TypeScriptと型定義のインストール
npm install typescript @types/node --save-dev

# Expressと型定義のインストール
npm install express
npm install @types/express --save-dev

tsconfig.jsonの設定

Node.jsバックエンド開発に最適化したTypeScript設定ファイルを作成します。

1
2
# tsconfig.jsonの生成
npx tsc --init

生成されたファイルを以下のように編集します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
  "compilerOptions": {
    // 出力設定
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    
    // 厳格な型チェック
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    
    // モジュール関連
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    
    // 開発体験
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

各設定項目の意味は以下の通りです。

設定項目 説明
target: "ES2022" Node.js 22がネイティブサポートするES2022機能を使用
module: "NodeNext" Node.jsのESM/CJS自動判定に対応
noUncheckedIndexedAccess 配列やオブジェクトのインデックスアクセスをundefined可能として扱う
resolveJsonModule JSONファイルのインポートを許可

ディレクトリ構成

以下のディレクトリ構成でプロジェクトを整理します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
ts-express-api/
├── src/
│   ├── index.ts          # エントリーポイント
│   ├── app.ts            # Expressアプリケーション設定
│   ├── routes/           # ルートハンドラ
│   │   └── users.ts
│   ├── controllers/      # コントローラ層
│   │   └── userController.ts
│   ├── types/            # 型定義
│   │   ├── express.d.ts  # Express拡張型定義
│   │   └── env.d.ts      # 環境変数型定義
│   ├── middlewares/      # ミドルウェア
│   │   └── validation.ts
│   └── config/           # 設定ファイル
│       └── env.ts
├── dist/                 # コンパイル出力
├── package.json
└── tsconfig.json

package.jsonのスクリプト設定

開発と本番環境用のスクリプトを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "name": "ts-express-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "express": "^4.21.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

開発時にはtsxを使用してTypeScriptを直接実行します。

1
npm install tsx --save-dev

Expressアプリケーションの型付け

基本的なExpressアプリの作成

まず、型付けされたExpressアプリケーションの基本形を作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app.ts
import express, { Application, Request, Response, NextFunction } from 'express';

const app: Application = express();

// JSONボディのパース
app.use(express.json());

// ヘルスチェックエンドポイント
app.get('/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 404ハンドラ
app.use((_req: Request, res: Response) => {
  res.status(404).json({ error: 'Not Found' });
});

// エラーハンドラ
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

export default app;
1
2
3
4
5
6
7
8
// src/index.ts
import app from './app.js';

const PORT = process.env.PORT ?? 3000;

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Request・Responseの型パラメータ

ExpressのRequestResponseはジェネリック型として定義されており、型パラメータを指定することでリクエストボディやレスポンスの型を定義できます。

1
2
3
4
5
// Requestの型パラメータ
// Request<Params, ResBody, ReqBody, ReqQuery, Locals>

// Responseの型パラメータ
// Response<ResBody, Locals>

実際のAPIエンドポイントで使用する例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: string;
}

export interface CreateUserRequest {
  name: string;
  email: string;
}

export interface UpdateUserRequest {
  name?: string;
  email?: string;
}

export interface UserParams {
  id: string;
}

export interface UserListQuery {
  page?: string;
  limit?: string;
  sort?: 'name' | 'createdAt';
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
// src/controllers/userController.ts
import { Request, Response } from 'express';
import {
  User,
  CreateUserRequest,
  UpdateUserRequest,
  UserParams,
  UserListQuery
} from '../types/user.js';

// 仮のデータストア
const users: User[] = [
  { id: 1, name: '山田太郎', email: 'yamada@example.com', createdAt: '2026-01-01T00:00:00Z' },
  { id: 2, name: '鈴木花子', email: 'suzuki@example.com', createdAt: '2026-01-02T00:00:00Z' },
];

// ユーザー一覧取得
export const getUsers = (
  req: Request<object, User[], never, UserListQuery>,
  res: Response<User[]>
): void => {
  const { page = '1', limit = '10', sort } = req.query;
  
  let result = [...users];
  
  if (sort) {
    result.sort((a, b) => a[sort].localeCompare(b[sort]));
  }
  
  const startIndex = (parseInt(page) - 1) * parseInt(limit);
  const endIndex = startIndex + parseInt(limit);
  
  res.json(result.slice(startIndex, endIndex));
};

// ユーザー詳細取得
export const getUserById = (
  req: Request<UserParams, User | { error: string }>,
  res: Response<User | { error: string }>
): void => {
  const id = parseInt(req.params.id);
  const user = users.find(u => u.id === id);
  
  if (!user) {
    res.status(404).json({ error: 'User not found' });
    return;
  }
  
  res.json(user);
};

// ユーザー作成
export const createUser = (
  req: Request<object, User, CreateUserRequest>,
  res: Response<User>
): void => {
  const { name, email } = req.body;
  
  const newUser: User = {
    id: users.length + 1,
    name,
    email,
    createdAt: new Date().toISOString()
  };
  
  users.push(newUser);
  res.status(201).json(newUser);
};

// ユーザー更新
export const updateUser = (
  req: Request<UserParams, User | { error: string }, UpdateUserRequest>,
  res: Response<User | { error: string }>
): void => {
  const id = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === id);
  
  if (userIndex === -1) {
    res.status(404).json({ error: 'User not found' });
    return;
  }
  
  const { name, email } = req.body;
  const updatedUser: User = {
    ...users[userIndex],
    ...(name && { name }),
    ...(email && { email })
  };
  
  users[userIndex] = updatedUser;
  res.json(updatedUser);
};

// ユーザー削除
export const deleteUser = (
  req: Request<UserParams, { message: string } | { error: string }>,
  res: Response<{ message: string } | { error: string }>
): void => {
  const id = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === id);
  
  if (userIndex === -1) {
    res.status(404).json({ error: 'User not found' });
    return;
  }
  
  users.splice(userIndex, 1);
  res.json({ message: 'User deleted successfully' });
};

ルーターの型付け

ルーターを分離して、コントローラと組み合わせます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/routes/users.ts
import { Router } from 'express';
import {
  getUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser
} from '../controllers/userController.js';

const router = Router();

router.get('/', getUsers);
router.get('/:id', getUserById);
router.post('/', createUser);
router.put('/:id', updateUser);
router.delete('/:id', deleteUser);

export default router;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app.ts(更新版)
import express, { Application, Request, Response, NextFunction } from 'express';
import userRouter from './routes/users.js';

const app: Application = express();

app.use(express.json());

// ルートのマウント
app.use('/api/users', userRouter);

app.get('/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.use((_req: Request, res: Response) => {
  res.status(404).json({ error: 'Not Found' });
});

app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
});

export default app;

リクエストバリデーションと型の連携

Zodを使った型安全なバリデーション

実行時のバリデーションと型定義を統合するために、Zodライブラリを使用します。Zodはスキーマ定義から自動的にTypeScript型を生成できます。

1
npm install zod
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/schemas/userSchema.ts
import { z } from 'zod';

// ユーザー作成スキーマ
export const createUserSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
  email: z.string().email('Invalid email format')
});

// ユーザー更新スキーマ
export const updateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  email: z.string().email().optional()
}).refine(data => data.name ?? data.email, {
  message: 'At least one field must be provided'
});

// クエリパラメータスキーマ
export const userListQuerySchema = z.object({
  page: z.string().regex(/^\d+$/).optional().default('1'),
  limit: z.string().regex(/^\d+$/).optional().default('10'),
  sort: z.enum(['name', 'createdAt']).optional()
});

// IDパラメータスキーマ
export const userIdParamSchema = z.object({
  id: z.string().regex(/^\d+$/, 'ID must be a number')
});

// スキーマから型を生成
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type UserListQueryInput = z.infer<typeof userListQuerySchema>;
export type UserIdParamInput = z.infer<typeof userIdParamSchema>;

バリデーションミドルウェアの作成

Zodスキーマを使った汎用的なバリデーションミドルウェアを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/middlewares/validation.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodError, ZodSchema } from 'zod';

interface ValidationSchemas {
  body?: ZodSchema;
  query?: ZodSchema;
  params?: ZodSchema;
}

interface ValidationErrorResponse {
  error: string;
  details: Array<{
    path: string;
    message: string;
  }>;
}

export const validate = (schemas: ValidationSchemas) => {
  return (req: Request, res: Response<ValidationErrorResponse>, next: NextFunction): void => {
    try {
      if (schemas.body) {
        req.body = schemas.body.parse(req.body);
      }
      if (schemas.query) {
        req.query = schemas.query.parse(req.query);
      }
      if (schemas.params) {
        req.params = schemas.params.parse(req.params);
      }
      next();
    } catch (error) {
      if (error instanceof ZodError) {
        res.status(400).json({
          error: 'Validation failed',
          details: error.errors.map(err => ({
            path: err.path.join('.'),
            message: err.message
          }))
        });
        return;
      }
      next(error);
    }
  };
};

バリデーション付きルートの実装

バリデーションミドルウェアをルートに組み込みます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// src/routes/users.ts(更新版)
import { Router } from 'express';
import {
  getUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser
} from '../controllers/userController.js';
import { validate } from '../middlewares/validation.js';
import {
  createUserSchema,
  updateUserSchema,
  userListQuerySchema,
  userIdParamSchema
} from '../schemas/userSchema.js';

const router = Router();

router.get(
  '/',
  validate({ query: userListQuerySchema }),
  getUsers
);

router.get(
  '/:id',
  validate({ params: userIdParamSchema }),
  getUserById
);

router.post(
  '/',
  validate({ body: createUserSchema }),
  createUser
);

router.put(
  '/:id',
  validate({ params: userIdParamSchema, body: updateUserSchema }),
  updateUser
);

router.delete(
  '/:id',
  validate({ params: userIdParamSchema }),
  deleteUser
);

export default router;
flowchart LR
    A[HTTPリクエスト] --> B[バリデーションミドルウェア]
    B -->|成功| C[コントローラ]
    B -->|失敗| D[400エラーレスポンス]
    C --> E[レスポンス]

環境変数の型安全な管理

環境変数の型定義

Node.jsのprocess.envはデフォルトでstring | undefined型です。型安全に環境変数を扱うために、専用のモジュールを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/config/env.ts
import { z } from 'zod';

// 環境変数スキーマの定義
const envSchema = z.object({
  // サーバー設定
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().regex(/^\d+$/).default('3000').transform(Number),
  HOST: z.string().default('localhost'),
  
  // データベース設定
  DATABASE_URL: z.string().url(),
  DATABASE_POOL_SIZE: z.string().regex(/^\d+$/).default('10').transform(Number),
  
  // 認証設定
  JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('1h'),
  
  // 外部API設定
  API_KEY: z.string().optional(),
  API_TIMEOUT_MS: z.string().regex(/^\d+$/).default('5000').transform(Number),
});

// 環境変数の型を生成
export type Env = z.infer<typeof envSchema>;

// 環境変数のパースと検証
const parseEnv = (): Env => {
  const result = envSchema.safeParse(process.env);
  
  if (!result.success) {
    console.error('Environment validation failed:');
    result.error.errors.forEach(err => {
      console.error(`  ${err.path.join('.')}: ${err.message}`);
    });
    process.exit(1);
  }
  
  return result.data;
};

// 型安全な環境変数オブジェクトをエクスポート
export const env = parseEnv();

TypeScriptの型定義ファイルでの補完

process.envに対する型補完を有効にするため、型定義ファイルを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/types/env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      PORT: string;
      HOST: string;
      DATABASE_URL: string;
      DATABASE_POOL_SIZE: string;
      JWT_SECRET: string;
      JWT_EXPIRES_IN: string;
      API_KEY?: string;
      API_TIMEOUT_MS: string;
    }
  }
}

export {};

環境変数の使用

型安全な環境変数を使用するようにアプリケーションを更新します。

1
2
3
4
5
6
7
8
// src/index.ts(更新版)
import app from './app.js';
import { env } from './config/env.js';

app.listen(env.PORT, () => {
  console.log(`Server is running on http://${env.HOST}:${env.PORT}`);
  console.log(`Environment: ${env.NODE_ENV}`);
});
1
2
3
4
5
6
7
8
// src/config/database.ts
import { env } from './env.js';

export const databaseConfig = {
  connectionString: env.DATABASE_URL,
  poolSize: env.DATABASE_POOL_SIZE,
  ssl: env.NODE_ENV === 'production'
};

.envファイルの読み込み

開発環境では.envファイルから環境変数を読み込みます。

1
npm install dotenv
1
2
3
4
5
6
7
8
// src/index.ts(dotenv対応版)
import 'dotenv/config';
import app from './app.js';
import { env } from './config/env.js';

app.listen(env.PORT, () => {
  console.log(`Server is running on http://${env.HOST}:${env.PORT}`);
});
1
2
3
4
5
6
7
8
9
# .env.example
NODE_ENV=development
PORT=3000
HOST=localhost
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DATABASE_POOL_SIZE=10
JWT_SECRET=your-super-secret-key-at-least-32-chars
JWT_EXPIRES_IN=1h
API_TIMEOUT_MS=5000

Expressの型拡張

カスタムリクエストプロパティの型定義

認証ミドルウェアでユーザー情報をreqオブジェクトに追加するような場合、Express の型を拡張する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// src/types/express.d.ts
import { User } from './user.js';

declare global {
  namespace Express {
    interface Request {
      user?: User;
      requestId?: string;
    }
    
    interface Locals {
      startTime: number;
    }
  }
}

export {};

認証ミドルウェアの実装

型拡張を活用した認証ミドルウェアを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// src/middlewares/auth.ts
import { Request, Response, NextFunction } from 'express';
import { User } from '../types/user.js';
import { env } from '../config/env.js';

interface JWTPayload {
  userId: number;
  email: string;
  iat: number;
  exp: number;
}

// JWTの検証(簡略化した例)
const verifyToken = (token: string): JWTPayload | null => {
  // 実際の実装ではjsonwebtokenライブラリを使用
  try {
    // トークン検証ロジック
    return { userId: 1, email: 'user@example.com', iat: Date.now(), exp: Date.now() + 3600000 };
  } catch {
    return null;
  }
};

// ユーザー取得(簡略化した例)
const getUserById = async (id: number): Promise<User | null> => {
  // 実際の実装ではデータベースから取得
  return {
    id,
    name: 'テストユーザー',
    email: 'user@example.com',
    createdAt: new Date().toISOString()
  };
};

export const authenticate = async (
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Authorization header missing or invalid' });
    return;
  }
  
  const token = authHeader.slice(7);
  const payload = verifyToken(token);
  
  if (!payload) {
    res.status(401).json({ error: 'Invalid or expired token' });
    return;
  }
  
  const user = await getUserById(payload.userId);
  
  if (!user) {
    res.status(401).json({ error: 'User not found' });
    return;
  }
  
  // 型拡張によりreq.userへの代入が型安全になる
  req.user = user;
  next();
};

// 認証済みリクエスト用のヘルパー型
export interface AuthenticatedRequest extends Request {
  user: User;  // undefinedではなく必須
}

// 認証が必要なハンドラで使用
export const requireAuth = (
  handler: (req: AuthenticatedRequest, res: Response, next: NextFunction) => void | Promise<void>
) => {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }
    handler(req as AuthenticatedRequest, res, next);
  };
};

認証が必要なエンドポイントの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/controllers/profileController.ts
import { Response } from 'express';
import { AuthenticatedRequest, requireAuth } from '../middlewares/auth.js';
import { User } from '../types/user.js';

export const getProfile = requireAuth(
  (req: AuthenticatedRequest, res: Response<User>): void => {
    // req.userは必ず存在することが型で保証される
    res.json(req.user);
  }
);

export const updateProfile = requireAuth(
  (req: AuthenticatedRequest, res: Response<User>): void => {
    const { name, email } = req.body;
    
    const updatedUser: User = {
      ...req.user,
      ...(name && { name }),
      ...(email && { email })
    };
    
    res.json(updatedUser);
  }
);

エラーハンドリングの型付け

カスタムエラークラス

型安全なエラーハンドリングのために、カスタムエラークラスを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/errors/AppError.ts
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly code: string,
    public readonly details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
  
  static badRequest(message: string, code = 'BAD_REQUEST', details?: Record<string, unknown>): AppError {
    return new AppError(400, message, code, details);
  }
  
  static unauthorized(message = 'Unauthorized', code = 'UNAUTHORIZED'): AppError {
    return new AppError(401, message, code);
  }
  
  static forbidden(message = 'Forbidden', code = 'FORBIDDEN'): AppError {
    return new AppError(403, message, code);
  }
  
  static notFound(resource: string, code = 'NOT_FOUND'): AppError {
    return new AppError(404, `${resource} not found`, code);
  }
  
  static conflict(message: string, code = 'CONFLICT'): AppError {
    return new AppError(409, message, code);
  }
  
  static internal(message = 'Internal server error', code = 'INTERNAL_ERROR'): AppError {
    return new AppError(500, message, code);
  }
}

型安全なエラーハンドラ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// src/middlewares/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError.js';
import { env } from '../config/env.js';

interface ErrorResponse {
  error: {
    message: string;
    code: string;
    details?: Record<string, unknown>;
    stack?: string;
  };
}

export const errorHandler = (
  err: Error,
  _req: Request,
  res: Response<ErrorResponse>,
  _next: NextFunction
): void => {
  // AppErrorの場合
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: {
        message: err.message,
        code: err.code,
        details: err.details,
        ...(env.NODE_ENV === 'development' && { stack: err.stack })
      }
    });
    return;
  }
  
  // 予期しないエラー
  console.error('Unexpected error:', err);
  
  res.status(500).json({
    error: {
      message: env.NODE_ENV === 'production' 
        ? 'Internal server error' 
        : err.message,
      code: 'INTERNAL_ERROR',
      ...(env.NODE_ENV === 'development' && { stack: err.stack })
    }
  });
};

エラーハンドリングの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/controllers/userController.ts(更新版)
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/AppError.js';
import { User, UserParams, CreateUserRequest } from '../types/user.js';

const users: User[] = [];

export const getUserById = async (
  req: Request<UserParams>,
  res: Response<User>,
  next: NextFunction
): Promise<void> => {
  try {
    const id = parseInt(req.params.id);
    const user = users.find(u => u.id === id);
    
    if (!user) {
      throw AppError.notFound('User');
    }
    
    res.json(user);
  } catch (error) {
    next(error);
  }
};

export const createUser = async (
  req: Request<object, User, CreateUserRequest>,
  res: Response<User>,
  next: NextFunction
): Promise<void> => {
  try {
    const { name, email } = req.body;
    
    // メールアドレスの重複チェック
    const existingUser = users.find(u => u.email === email);
    if (existingUser) {
      throw AppError.conflict('Email already exists', 'EMAIL_DUPLICATE');
    }
    
    const newUser: User = {
      id: users.length + 1,
      name,
      email,
      createdAt: new Date().toISOString()
    };
    
    users.push(newUser);
    res.status(201).json(newUser);
  } catch (error) {
    next(error);
  }
};

実践的なプロジェクト構成

完成版のアプリケーション構成

ここまでの内容を統合した完成版のアプリケーション構成を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/app.ts(完成版)
import express, { Application } from 'express';
import { env } from './config/env.js';
import userRouter from './routes/users.js';
import profileRouter from './routes/profile.js';
import { authenticate } from './middlewares/auth.js';
import { errorHandler } from './middlewares/errorHandler.js';
import { requestLogger } from './middlewares/requestLogger.js';

const app: Application = express();

// 共通ミドルウェア
app.use(express.json());
app.use(requestLogger);

// ヘルスチェック
app.get('/health', (_req, res) => {
  res.json({ 
    status: 'ok', 
    environment: env.NODE_ENV,
    timestamp: new Date().toISOString() 
  });
});

// 公開API
app.use('/api/users', userRouter);

// 認証が必要なAPI
app.use('/api/profile', authenticate, profileRouter);

// 404ハンドラ
app.use((_req, res) => {
  res.status(404).json({ error: 'Not Found' });
});

// エラーハンドラ
app.use(errorHandler);

export default app;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// src/middlewares/requestLogger.ts
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

export const requestLogger = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  req.requestId = randomUUID();
  res.locals.startTime = Date.now();
  
  res.on('finish', () => {
    const duration = Date.now() - res.locals.startTime;
    console.log(
      `[${req.requestId}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`
    );
  });
  
  next();
};

ビルドと実行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 開発モード(ホットリロード)
npm run dev

# 型チェック
npm run typecheck

# 本番ビルド
npm run build

# 本番実行
npm start

開発モードで起動すると、以下のようなログが出力されます。

1
2
Server is running on http://localhost:3000
Environment: development

APIのテスト

curlを使ってAPIをテストします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ヘルスチェック
curl http://localhost:3000/health

# ユーザー一覧取得
curl http://localhost:3000/api/users

# ユーザー作成
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "山田太郎", "email": "yamada@example.com"}'

# バリデーションエラーの確認
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "invalid-email"}'

まとめ

本記事では、TypeScriptを使ったNode.js/Expressバックエンド開発の型付けについて解説しました。

重要なポイントを振り返ります。

  • tsconfig.json: strictモードとnoUncheckedIndexedAccessを有効にすることで、より厳密な型チェックを実現
  • リクエスト・レスポンスの型付け: Expressのジェネリック型パラメータを活用し、APIの入出力を型定義
  • Zodによるバリデーション: ランタイムバリデーションとTypeScript型を統合し、型の二重定義を回避
  • 環境変数の型安全な管理: Zodスキーマで環境変数を検証し、起動時にエラーを検出
  • Express型拡張: declare globalを使ってreq.userなどのカスタムプロパティを型安全に追加
  • エラーハンドリング: カスタムエラークラスで型安全かつ一貫したエラーレスポンスを実現

TypeScriptをバックエンド開発に導入することで、フロントエンドとの型定義の共有、APIの契約の明確化、リファクタリング時の安全性向上など、多くのメリットを得られます。本記事のコードをベースに、実際のプロジェクトで型安全なAPIを構築してください。

参考リンク