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

Express + ベストプラクティス

この章では、ExpressとTypeScriptを組み合わせた型安全なバックエンド開発について学びます。

この章で学ぶこと

  • Express + TypeScriptのセットアップ
  • 型安全なリクエスト/レスポンス
  • エラーハンドリング
  • バリデーション(Zod)
  • 型設計のベストプラクティス
備考

TypeScriptをExpressで使うことで、APIの型安全性が向上し、リクエスト/レスポンスの構造を明確に定義できます。

Express + TypeScriptのセットアップ

本書では Express 5(2024 年 9 月 GA) を前提とします。Express 5 では async 関数のエラーが自動的に next に伝播される改善、より厳密な path-to-regexp 構文、require() のドロップ(ESM 推奨)など、いくつかの重要な改善が入っています。

# プロジェクトディレクトリを作成
mkdir express-api
cd express-api
npm init -y

# 依存関係のインストール(Express 5 を明示)
npm install express@^5 cors helmet
npm install --save-dev typescript @types/node @types/express @types/cors tsx
備考

開発時の実行には、ESM対応で高速な tsx を使用します。旧来の ts-node-dev は CommonJS 主体で、ESM プロジェクトで不安定になりがちです。Node.js 22+ であれば node --watch --experimental-strip-types src/index.ts でも代替できます。

package.json("type": "module" を明示):

{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}

tsconfig.json(ESM + NodeNext 推奨):

{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
備考

CommonJS プロジェクトの場合

既存の CommonJS プロジェクトを維持したい場合は、"module": "commonjs" / "moduleResolution": "node" とし、package.json"type": "module" を外します。ただし、2026年の新規プロジェクトでは Express 5 と合わせて ESM("type": "module" + "module": "NodeNext" を選ぶのが推奨です。

Expressアプリケーションの基本構造

// src/index.ts
// Expressと関連する型をインポート
import express, { Application, Request, Response, NextFunction } from 'express';
import cors from 'cors'; // CORSミドルウェア
import helmet from 'helmet'; // セキュリティヘッダー設定
import { userRouter } from './routes/userRoutes';
import { errorHandler } from './middleware/errorHandler';

// Expressアプリケーションを作成
const app: Application = express();

// ポート番号(環境変数または3000)
const PORT = process.env.PORT || 3000;

// ===== ミドルウェアの設定 =====
app.use(helmet()); // セキュリティヘッダーを設定
app.use(cors()); // CORS(クロスオリジン)を許可
app.use(express.json()); // JSONボディをパース

// ===== ルートの設定 =====
// ルートパス
app.get('/', (req: Request, res: Response) => {
res.json({ message: 'Welcome to the API' });
});

// ユーザー関連のルート(/api/users/*)
app.use('/api/users', userRouter);

// ===== エラーハンドリング(最後に配置) =====
app.use(errorHandler);

// サーバー起動
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

型安全なリクエスト/レスポンス

まず、使用する型を定義します。

// src/types/index.ts

// ユーザーの型
export interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}

// ユーザー作成時のDTO(Data Transfer Object)
// 作成時にはidやcreatedAtは不要
export interface CreateUserDto {
name: string;
email: string;
}

// ユーザー更新時のDTO
// すべてのフィールドがオプショナル
export interface UpdateUserDto {
name?: string;
email?: string;
}

// APIレスポンスの型(ジェネリック)
// 成功時はdata、失敗時はerrorを含む
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}

次に、型安全なルートを作成します。

// src/routes/userRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { User, CreateUserDto, UpdateUserDto, ApiResponse } from '../types';

const router = Router();

// インメモリデータストア(実際はDBを使用)
let users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', createdAt: new Date() },
{ id: 2, name: 'Bob', email: 'bob@example.com', createdAt: new Date() },
];
let nextId = 3;

// パラメータの型定義
interface UserParams {
id: string; // URLパラメータは常にstring
}

// ===== GET /api/users - 全ユーザー取得 =====
// Response<ApiResponse<User[]>>でレスポンスの型を指定
router.get('/', (req: Request, res: Response<ApiResponse<User[]>>) => {
res.json({
success: true,
data: users,
});
});

// ===== GET /api/users/:id - 特定ユーザー取得 =====
// Request<UserParams>でパラメータの型を指定
router.get('/:id', (
req: Request<UserParams>,
res: Response<ApiResponse<User>>
) => {
// パラメータを数値に変換
const id = parseInt(req.params.id, 10);
// ユーザーを検索
const user = users.find(u => u.id === id);

if (!user) {
// 404エラー
return res.status(404).json({
success: false,
error: 'User not found',
});
}

res.json({
success: true,
data: user,
});
});

// ===== POST /api/users - ユーザー作成 =====
// Request<Params, ResBody, ReqBody>の順で型を指定
router.post('/', (
req: Request<{}, ApiResponse<User>, CreateUserDto>,
res: Response<ApiResponse<User>>
) => {
const { name, email } = req.body;

// バリデーション
if (!name || !email) {
return res.status(400).json({
success: false,
error: 'Name and email are required',
});
}

// 新しいユーザーを作成
const newUser: User = {
id: nextId++,
name,
email,
createdAt: new Date(),
};

users.push(newUser);

// 201 Created
res.status(201).json({
success: true,
data: newUser,
message: 'User created successfully',
});
});

// ===== PUT /api/users/:id - ユーザー更新 =====
router.put('/:id', (
req: Request<UserParams, ApiResponse<User>, UpdateUserDto>,
res: Response<ApiResponse<User>>
) => {
const id = parseInt(req.params.id, 10);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return res.status(404).json({
success: false,
error: 'User not found',
});
}

const { name, email } = req.body;

// 存在するフィールドのみ更新
if (name) users[userIndex].name = name;
if (email) users[userIndex].email = email;

res.json({
success: true,
data: users[userIndex],
message: 'User updated successfully',
});
});

// ===== DELETE /api/users/:id - ユーザー削除 =====
router.delete('/:id', (
req: Request<UserParams>,
res: Response<ApiResponse<null>>
) => {
const id = parseInt(req.params.id, 10);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return res.status(404).json({
success: false,
error: 'User not found',
});
}

// 配列から削除
users.splice(userIndex, 1);

res.json({
success: true,
message: 'User deleted successfully',
});
});

export { router as userRouter };

エラーハンドリング

カスタムエラークラスとエラーハンドリングミドルウェアを作成します。

備考

Express 5 では async エラーが自動で next に渡される

Express 4 では async 関数内で throw された例外は Express に捕捉されず、手動で try/catch + next(err) が必要でした。Express 5(2024 GA)からは async 関数が返した rejected Promise が自動的にエラーミドルウェアへ伝播します。そのため、以下のような冗長な try/catch が不要になりました。

// Express 4 で必要だった書き方
app.get('/users/:id', async (req, res, next) => {
try {
const user = await fetchUser(req.params.id)
res.json(user)
} catch (err) {
next(err) // 必須
}
})

// Express 5 では素直に書ける
app.get('/users/:id', async (req, res) => {
const user = await fetchUser(req.params.id)
res.json(user) // 例外は自動で errorHandler に流れる
})

それでもエラーメッセージを整形したいなどの理由で try/catch を使うのは OK です。意図的に next(err) する場合と、自動伝播させる場合を使い分けてください。

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ApiResponse } from '../types';

// カスタムエラークラス
export class AppError extends Error {
statusCode: number; // HTTPステータスコード
isOperational: boolean; // 運用上のエラーかどうか

constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // アプリケーションが想定するエラー

// スタックトレースを正しく取得
Error.captureStackTrace(this, this.constructor);
}
}

// エラーハンドリングミドルウェア
// 4つの引数を持つ関数はExpressでエラーハンドラとして認識される
export function errorHandler(
err: Error | AppError,
req: Request,
res: Response<ApiResponse<null>>,
next: NextFunction
): void {
console.error('Error:', err);

// AppErrorの場合は適切なステータスコードを返す
if (err instanceof AppError) {
res.status(err.statusCode).json({
success: false,
error: err.message,
});
return;
}

// 予期しないエラー(500 Internal Server Error)
res.status(500).json({
success: false,
error: 'Internal server error',
});
}

// 非同期エラーをキャッチするラッパー
// async関数のエラーを自動的にnext()に渡す
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return (req: Request, res: Response, next: NextFunction) => {
// Promiseのエラーをキャッチしてnextに渡す
Promise.resolve(fn(req, res, next)).catch(next);
};
}

使用例:

// asyncHandlerとAppErrorを使用
import { asyncHandler, AppError } from '../middleware/errorHandler';

router.get('/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id, 10);
const user = await userService.findById(id);

if (!user) {
// AppErrorをthrowすると、errorHandlerがキャッチして処理
throw new AppError('User not found', 404);
}

res.json({ success: true, data: user });
}));

バリデーション(Zod)

Zodを使用した型安全なバリデーションを実装します。

npm install zod
// src/validation/userValidation.ts
import { z } from 'zod';

// スキーマ定義
// z.object()でオブジェクトスキーマを作成
export const createUserSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters') // 最小2文字
.max(50, 'Name must be at most 50 characters'), // 最大50文字
email: z.email('Invalid email format'), // メール形式をチェック (v4 はトップレベル API)
});

export const updateUserSchema = z.object({
name: z.string()
.min(2)
.max(50)
.optional(), // オプショナル
email: z.email()
.optional(),
});

// スキーマから型を生成
// z.infer<typeof スキーマ>でスキーマから型を抽出
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;

バリデーションミドルウェア:

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
import { ApiResponse } from '../types';

// ジェネリックなバリデーションミドルウェア
export function validate(schema: ZodSchema) {
return (req: Request, res: Response<ApiResponse<null>>, next: NextFunction) => {
// safeParse: エラーをthrowせず、結果オブジェクトを返す
const result = schema.safeParse(req.body);

if (!result.success) {
// バリデーションエラー
const errors = result.error.issues.map(e => ({
field: e.path.join('.'), // エラーのフィールドパス
message: e.message, // エラーメッセージ
}));

return res.status(400).json({
success: false,
error: 'Validation failed',
message: JSON.stringify(errors),
});
}

// バリデーション済みのデータで置き換え
req.body = result.data;
next();
};
}

ルートで使用:

// src/routes/userRoutes.ts
import { validate } from '../middleware/validate';
import { createUserSchema } from '../validation/userValidation';

// validateミドルウェアをルートに追加
router.post('/', validate(createUserSchema), (req, res) => {
// req.bodyはCreateUserInput型として安全に使用可能
const { name, email } = req.body;
// ...
});

型設計のベストプラクティス

1. DTOパターン

// リクエスト/レスポンスごとに適切な型を定義

// エンティティ(データベースの形)
interface User {
id: number;
name: string;
email: string;
passwordHash: string; // 内部でのみ使用
createdAt: Date;
updatedAt: Date;
}

// 作成時のDTO(必要なフィールドのみ)
interface CreateUserDto {
name: string;
email: string;
password: string; // パスワード(ハッシュ化前)
}

// レスポンス用DTO(パスワードを除外)
interface UserResponseDto {
id: number;
name: string;
email: string;
createdAt: Date;
}

// エンティティをレスポンス用に変換
function toUserResponse(user: User): UserResponseDto {
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
};
}

2. 統一されたAPIレスポンス

// すべてのAPIで統一された形式を使用
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
meta?: {
total?: number;
page?: number;
limit?: number;
};
}

// 成功レスポンスを作成するヘルパー
function successResponse<T>(data: T, message?: string): ApiResponse<T> {
return {
success: true,
data,
message,
};
}

// エラーレスポンスを作成するヘルパー
function errorResponse(error: string, message?: string): ApiResponse<null> {
return {
success: false,
error,
message,
};
}

3. 型ガード

// 型ガード関数でランタイムでも型を保証

// unknownからUserかどうかをチェック
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
);
}

// 使用例
const data: unknown = await fetchData();
if (isUser(data)) {
// この中ではdataはUser型として扱える
console.log(data.name);
}

4. 環境変数の型付け

// src/config/env.ts
import { z } from 'zod';

// 環境変数のスキーマ
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
PORT: z.string().transform(Number), // 文字列を数値に変換
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32),
});

// 環境変数をパースして型付け
export const env = envSchema.parse(process.env);

// env.PORT は number 型
// env.DATABASE_URL は string 型

5. サービス層の型付け

// src/services/userService.ts

// サービスの戻り値型
interface ServiceResult<T> {
success: boolean;
data?: T;
error?: string;
}

class UserService {
// ユーザー検索
async findById(id: number): Promise<ServiceResult<User>> {
try {
const user = await db.users.findUnique({ where: { id } });
if (!user) {
return { success: false, error: 'User not found' };
}
return { success: true, data: user };
} catch (error) {
return { success: false, error: 'Database error' };
}
}

// ユーザー作成
async create(dto: CreateUserDto): Promise<ServiceResult<User>> {
try {
const user = await db.users.create({ data: dto });
return { success: true, data: user };
} catch (error) {
return { success: false, error: 'Failed to create user' };
}
}
}

試してみよう: 型安全なAPIエンドポイントを作ろう ★★★

以下の要件を満たすTodo APIを作成してください。

要件:

  1. GET /api/todos - 全Todoを取得
  2. POST /api/todos - Todoを作成(title必須)
  3. PATCH /api/todos/:id/toggle - 完了状態を切り替え
  4. Zodでバリデーション
  5. エラーハンドリング
ヒント
  1. Todo型を定義(id, title, completed, createdAt)
  2. CreateTodoSchemaをZodで作成
  3. validateミドルウェアを使用
  4. asyncHandlerで非同期エラーをキャッチ
  5. AppErrorで適切なエラーを返す
回答と解説
// src/types/todo.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}

export interface CreateTodoDto {
title: string;
}

export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
// src/validation/todoValidation.ts
import { z } from 'zod';

// タイトル必須、1〜100文字
export const createTodoSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be at most 100 characters'),
});

export type CreateTodoInput = z.infer<typeof createTodoSchema>;
// src/routes/todoRoutes.ts
import { Router, Request, Response } from 'express';
import { Todo, CreateTodoDto, ApiResponse } from '../types/todo';
import { asyncHandler, AppError } from '../middleware/errorHandler';
import { validate } from '../middleware/validate';
import { createTodoSchema } from '../validation/todoValidation';

const router = Router();

// インメモリデータストア
let todos: Todo[] = [];
let nextId = 1;

// パラメータの型
interface TodoParams {
id: string;
}

// GET /api/todos - 全Todo取得
router.get('/', (req: Request, res: Response<ApiResponse<Todo[]>>) => {
res.json({
success: true,
data: todos,
});
});

// POST /api/todos - Todo作成
router.post('/',
// バリデーションミドルウェア
validate(createTodoSchema),
(req: Request<{}, ApiResponse<Todo>, CreateTodoDto>, res: Response<ApiResponse<Todo>>) => {
const { title } = req.body;

const newTodo: Todo = {
id: nextId++,
title,
completed: false,
createdAt: new Date(),
};

todos.push(newTodo);

res.status(201).json({
success: true,
data: newTodo,
message: 'Todo created successfully',
});
}
);

// PATCH /api/todos/:id/toggle - 完了状態切り替え
router.patch('/:id/toggle',
asyncHandler(async (req: Request<TodoParams>, res: Response<ApiResponse<Todo>>) => {
const id = parseInt(req.params.id, 10);

// 無効なIDチェック
if (isNaN(id)) {
throw new AppError('Invalid todo ID', 400);
}

const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
throw new AppError('Todo not found', 404);
}

// 完了状態を反転
todos[todoIndex].completed = !todos[todoIndex].completed;

res.json({
success: true,
data: todos[todoIndex],
message: `Todo marked as ${todos[todoIndex].completed ? 'completed' : 'incomplete'}`,
});
})
);

// DELETE /api/todos/:id - Todo削除
router.delete('/:id',
asyncHandler(async (req: Request<TodoParams>, res: Response<ApiResponse<null>>) => {
const id = parseInt(req.params.id, 10);

if (isNaN(id)) {
throw new AppError('Invalid todo ID', 400);
}

const todoIndex = todos.findIndex(t => t.id === id);

if (todoIndex === -1) {
throw new AppError('Todo not found', 404);
}

todos.splice(todoIndex, 1);

res.json({
success: true,
message: 'Todo deleted successfully',
});
})
);

export { router as todoRouter };
// src/index.ts(ルートを追加)
import { todoRouter } from './routes/todoRoutes';

// ...
app.use('/api/todos', todoRouter);
// ...

解説:

  • 型定義: TodoインターフェースでTodoの構造を定義し、CreateTodoDtoで作成時の入力を定義しています
  • Zodバリデーション: createTodoSchemaでタイトルの必須チェックと長さ制限を設定しています
  • validateミドルウェア: ルートに追加することで、ハンドラに到達する前にバリデーションが実行されます
  • asyncHandler: 非同期処理のエラーを自動的にキャッチし、エラーハンドラに渡します
  • AppError: 適切なHTTPステータスコードとメッセージでエラーを返します
  • 型安全なレスポンス: Response<ApiResponse<T>>で統一された形式を保証しています

テスト方法:

# サーバー起動
npm run dev

# Todo作成
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn TypeScript"}'

# 全Todo取得
curl http://localhost:3000/api/todos

# 完了状態切り替え
curl -X PATCH http://localhost:3000/api/todos/1/toggle

# Todo削除
curl -X DELETE http://localhost:3000/api/todos/1

Express 以外の選択肢(2026年)

Express は依然として最も普及している Node.js フレームワークですが、2026 年時点では用途に応じて以下の代替を検討できます。

フレームワーク特徴向いているケース
Express 5安定性・実績・豊富なエコシステム既存の Express 資産を活かす、学習目的
FastifyExpress 互換の API で高速、スキーマ駆動パフォーマンス重視の REST API
Hono軽量・Web 標準 API ベース、エッジ環境で動くCloudflare Workers / Deno Deploy などのエッジデプロイ
ElysiaBun に最適化、型推論が効きやすいBun を採用している新規プロジェクト
NestJSDecorator ベースの大規模向け FWエンタープライズ向け、DDD / Clean Architecture
備考

SPA のバックエンドとしては Hono が特に人気を伸ばしています。Web 標準(Fetch API / Web Streams)をベースにしており、Cloudflare Workers・Deno Deploy・Node.js・Bun など様々なランタイムで同じコードが動きます。本書では Express を題材にしますが、新規プロジェクトでは用途に応じて使い分けを検討してください。

さらに学ぶべきトピック

本章で基本を学んだ後、実際のプロダクション開発では以下のトピックが重要です。

ORM(Prisma)

データベース操作には型安全なORM「Prisma」が人気です。

// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
}

// 自動生成される型を使用
import { PrismaClient, User } from '@prisma/client';

const prisma = new PrismaClient();

// 完全に型安全なクエリ
async function getUser(id: number): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}

// リレーションも型安全
async function getUserWithPosts(id: number) {
return prisma.user.findUnique({
where: { id },
include: { posts: true }, // 型推論される
});
}

JWT認証

認証にはJWT(JSON Web Token)がよく使われます。

import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';

// JWTペイロードの型
interface JwtPayload {
userId: number;
email: string;
}

// 認証済みリクエストの型
interface AuthRequest extends Request {
user?: JwtPayload;
}

// トークン生成
function generateToken(payload: JwtPayload): string {
return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '24h' });
}

// 認証ミドルウェア
function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.replace('Bearer ', '');

if (!token) {
res.status(401).json({ error: 'No token provided' });
return;
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}

セッション管理

セッションベースの認証にはexpress-sessionを使います。

import session from 'express-session';

// セッションの型を拡張
declare module 'express-session' {
interface SessionData {
userId: number;
isLoggedIn: boolean;
}
}

app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
}));

// 使用例
app.post('/login', (req, res) => {
req.session.userId = 1;
req.session.isLoggedIn = true;
res.json({ message: 'Logged in' });
});

まとめ

この章で学んだこと:

  • Express + TypeScriptのセットアップ: プロジェクト構成と設定ファイル
  • 型安全なリクエスト/レスポンス: Request<Params, ResBody, ReqBody>の活用
  • エラーハンドリング: AppErrorクラスとerrorHandlerミドルウェア
  • asyncHandler: 非同期エラーの自動キャッチ
  • Zodバリデーション: スキーマ定義と型生成
  • validateミドルウェア: ルートへのバリデーション適用
  • 型設計のベストプラクティス: DTO、統一レスポンス、型ガード

次の章では、総合演習プロジェクトとしてCLIタスク管理ツールを作成します。

初学者がつまずきやすいポイント

警告

よくある間違い

❌ Requestの型パラメータの順序を間違える

// Request<Params, ResBody, ReqBody, Query>の順序

// ❌ 順序を間違える
router.post('/', (req: Request<CreateUserDto>) => { // 第1引数はParams
// ...
});

// ✅ 正しい順序
router.post('/', (
req: Request<{}, ApiResponse<User>, CreateUserDto>, // 第3引数がReqBody
res: Response<ApiResponse<User>>
) => {
const { name } = req.body; // CreateUserDto型
});

原因: Requestの型パラメータは<Params, ResBody, ReqBody, Query>の順序です。 解決策: ReqBodyは第3引数、空のパラメータには{}を指定しましょう。

❌ 非同期エラーがキャッチされない

// ❌ asyncルートのエラーがExpressに伝わらない
router.get('/', async (req, res) => {
const data = await someAsyncOperation(); // エラーが発生すると...
res.json(data); // クラッシュまたはハング
});

// ✅ asyncHandlerでラップ
router.get('/', asyncHandler(async (req, res) => {
const data = await someAsyncOperation();
res.json(data);
}));

// asyncHandlerの実装
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

原因: Expressは非同期関数のエラーを自動的にキャッチしません。 解決策: asyncHandlerでラップするか、express-async-errorsパッケージを使いましょう。

❌ URLパラメータが常にstringであることを忘れる

// ❌ パラメータを数値として直接使用
router.get('/:id', (req, res) => {
const id = req.params.id; // string型
const user = users.find(u => u.id === id); // 比較が常にfalseになる可能性
});

// ✅ 明示的に変換
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid ID' });
}
const user = users.find(u => u.id === id);
});

原因: URLパラメータは常にstring型であり、数値への変換が必要です。 解決策: parseIntで変換し、isNaNでバリデーションしましょう。

❌ Zodのsafeparseを使わずにthrowする

// ❌ parseはエラーをthrowする
try {
const data = schema.parse(req.body);
} catch (e) {
// ZodErrorの処理が複雑
}

// ✅ safeParseは結果オブジェクトを返す
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues,
});
}
const data = result.data; // 型安全なデータ

原因: parseはエラー時に例外をスローし、適切なエラーレスポンスを返しにくくなります。 解決策: safeParseを使い、successフラグで分岐しましょう。

❌ エラーハンドリングミドルウェアの引数が3つ

// ❌ 引数が3つだとエラーハンドラとして認識されない
app.use((req, res, next) => {
// 通常のミドルウェアとして扱われる
});

// ✅ 4つの引数でエラーハンドラとして認識
app.use((err, req, res, next) => { // 4つの引数が必須
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});

原因: Expressは引数が4つの関数をエラーハンドリングミドルウェアとして認識します。 解決策: 必ず4つの引数(err, req, res, next)を持たせましょう。


次に読む