ExpressはNode.jsで最も広く使われているWebフレームワークですが、TypeScriptで使う場合に「req.body の型が any になる」「ミドルウェアでプロパティを追加すると型エラーが出る」といった問題に直面します。
本記事では、TypeScript + Expressの環境構築から始め、Request / Response のジェネリクス型引数による型付け、ミドルウェアの型定義、Requestオブジェクトの拡張、Zodを使ったリクエストバリデーション、JWT認証・CRUD APIの実務パターンまでを体系的に解説します。
- TypeScript + Expressのセットアップ(
@types/express・ts-node・tsx) Request<Params, ResBody, ReqBody, Query>の4つの型引数の使い方- 通常ミドルウェア・エラーハンドリングミドルウェアの型定義
- Declaration Merging で
Requestオブジェクトに独自プロパティを追加する方法 - Zodとの連携でリクエストボディを実行時バリデーション+型安全化
- 型安全な JWT認証ミドルウェアの実装
- 型安全なCRUD REST API(ユーザー管理)の実装
- 統一エラーレスポンス型の設計
この記事は TypeScript の基本構文と Node.js の基礎知識を前提とします。TypeScript + Node.js のセットアップ方法はNode.js + TypeScript 完全ガイドを参照してください。ジェネリクスの基礎は ジェネリクス完全ガイドで解説しています。
セットアップ
必要パッケージのインストール
# プロジェクト初期化 mkdir my-api && cd my-api npm init -y # Express と TypeScript 本体 npm install express npm install -D typescript @types/express @types/node npm install -D tsx # 開発用: tsをそのまま実行 # 追加でよく使うパッケージ npm install zod # バリデーション npm install cors helmet # セキュリティ npm install -D @types/cors
tsconfig.json の設定
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
エントリーポイント(src/index.ts)
import express from "express";
import cors from "cors";
import helmet from "helmet";
const app = express();
const PORT = Number(process.env.PORT) || 3000;
app.use(helmet());
app.use(cors());
app.use(express.json()); // req.body を JSON パース
app.use(express.urlencoded({ extended: true }));
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
export default app;
# 開発時の起動コマンド (package.json の scripts に追加) # "dev": "tsx watch src/index.ts" # "build": "tsc" # "start": "node dist/index.js" npx tsx watch src/index.ts
Request と Response の型定義
Expressの Request と Response はジェネリクス型引数を受け取ります。正確に指定することで req.body・req.params・req.query が型安全になります。
Request の4つの型引数
// Request<P, ResBody, ReqBody, Query>
// P : URLパラメータ ({ id: string } など)
// ResBody : レスポンスボディの型 (res.json() に渡す型)
// ReqBody : リクエストボディの型 (req.body の型)
// Query : クエリ文字列の型 (req.query の型)
import { Request, Response } from "express";
// 例: GET /users/:id
type GetUserParams = { id: string };
type GetUserResBody = { id: number; name: string; email: string };
app.get(
"/users/:id",
(req: Request<GetUserParams>, res: Response<GetUserResBody>) => {
const id = Number(req.params.id); // string → number 変換
const user = findUser(id);
if (!user) {
res.status(404).json({ id: 0, name: "", email: "" }); // 型に合わせる
return;
}
res.json(user); // GetUserResBody 型チェックあり
}
);
req.params.id の型は string です(URLは文字列のため)。P の型は { id: string } であり { id: number } とは指定できません。数値として使う場合は必ず Number(req.params.id) または parseInt() で変換してください。リクエストボディ(ReqBody)の型定義
// POST /users のリクエストボディ型
type CreateUserBody = {
name: string;
email: string;
age?: number;
};
type CreateUserResBody = {
id: number;
name: string;
email: string;
createdAt: string;
};
app.post(
"/users",
(
req: Request<{}, CreateUserResBody, CreateUserBody>,
res: Response<CreateUserResBody>
) => {
// req.body は CreateUserBody 型として扱える
const { name, email, age } = req.body;
const newUser = createUser({ name, email, age });
res.status(201).json(newUser);
}
);
Request<{}, {}, CreateUserBody> と指定しても、TypeScript の型チェックはコンパイル時のみです。実際に送られてきた JSON が型と一致する保証はありません。後述の Zod バリデーションで実行時チェックを行うことが必須です。クエリパラメータ(Query)の型定義
// GET /users?page=1&limit=20&sort=name
type UsersQuery = {
page?: string; // クエリパラメータは常に string
limit?: string;
sort?: "name" | "createdAt";
};
app.get(
"/users",
(req: Request<{}, {}, {}, UsersQuery>, res: Response) => {
const page = Number(req.query.page ?? "1");
const limit = Number(req.query.limit ?? "20");
const sort = req.query.sort ?? "createdAt";
const users = getUserList({ page, limit, sort });
res.json(users);
}
);
厳密には
req.query の型は ParsedQs(qsライブラリの型)です。同名のパラメータが複数送られると配列になります。ジェネリクスで型を絞り込んでも、実際の値は文字列であることに注意してください。Zodの z.string().transform(Number) で安全に数値変換できます。RequestHandler 型でルートハンドラーを分離する
ルートハンドラーをインラインで書かずに関数として分離する場合は、RequestHandler 型を使うと型引数をまとめて管理できます。
import { RequestHandler } from "express";
// RequestHandler<Params, ResBody, ReqBody, Query>
type GetUserHandler = RequestHandler<
{ id: string }, // Params
UserResponse, // ResBody
never, // ReqBody (GETなので不要)
never // Query
>;
const getUser: GetUserHandler = async (req, res, next) => {
try {
const user = await userService.findById(Number(req.params.id));
if (!user) {
res.status(404).json({ error: "User not found" } as any);
return;
}
res.json(user);
} catch (err) {
next(err); // エラーをエラーハンドリングミドルウェアに渡す
}
};
app.get("/users/:id", getUser);
// 型を省略する簡潔な書き方(型推論を活用)
const listUsers: RequestHandler = async (_req, res, next) => {
try {
const users = await userService.findAll();
res.json(users);
} catch (err) {
next(err);
}
};
ミドルウェアの型定義
ミドルウェアは (req, res, next) => void の形の関数です。通常ミドルウェアは RequestHandler 型、エラーハンドリングミドルウェアは専用の ErrorRequestHandler 型を使います。
通常ミドルウェアの型
import { Request, Response, NextFunction, RequestHandler } from "express";
// リクエストロガーミドルウェア
const requestLogger: RequestHandler = (req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const ms = Date.now() - start;
console.log(`[${req.method}] ${req.path} - ${res.statusCode} (${ms}ms)`);
});
next();
};
// レート制限ミドルウェア(シンプル版)
function rateLimit(maxRequests: number, windowMs: number): RequestHandler {
const requests = new Map<string, number[]>();
return (req: Request, res: Response, next: NextFunction): void => {
const ip = req.ip ?? "unknown";
const now = Date.now();
const windowStart = now - windowMs;
const times = (requests.get(ip) ?? []).filter((t) => t > windowStart);
if (times.length >= maxRequests) {
res.status(429).json({ error: "Too Many Requests" });
return; // void を返すことで next() を呼ばない
}
times.push(now);
requests.set(ip, times);
next();
};
}
app.use(requestLogger);
app.use("/api", rateLimit(100, 60_000)); // 1分間に100リクエスト
エラーハンドリングミドルウェアの型
エラーハンドリングミドルウェアは引数が4つ(err, req, res, next)である点が特徴です。Express はこの4引数の関数をエラーハンドラーとして認識します。
import { ErrorRequestHandler } from "express";
// アプリ独自のエラークラス
class AppError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly code?: string
) {
super(message);
this.name = "AppError";
}
}
// 統一エラーレスポンス型
interface ErrorResponse {
error: {
message: string;
code: string;
statusCode: number;
};
}
import { ZodError } from "zod";
// エラーハンドリングミドルウェア(必ず4引数)
const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
console.error(err);
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: {
message: err.message,
code: err.code ?? "APP_ERROR",
statusCode: err.statusCode,
},
} satisfies ErrorResponse);
return;
}
// Zodバリデーションエラー
if (err instanceof ZodError) {
res.status(400).json({
error: {
message: "バリデーションエラー",
code: "VALIDATION_ERROR",
statusCode: 400,
},
fieldErrors: err.flatten().fieldErrors,
});
return;
}
// 未知のエラー
res.status(500).json({
error: {
message: "Internal Server Error",
code: "INTERNAL_ERROR",
statusCode: 500,
},
});
};
// ルート定義の最後に登録
app.use(errorHandler);
ルートハンドラー内で
next(err) を呼ぶと、Express は自動的にエラーハンドリングミドルウェアに処理を渡します。asyncルートハンドラーでの try/catch の中で next(err) を呼ぶのが定番パターンです。Express 5 では async エラーが自動的にキャッチされますが、Express 4 では明示的に next(err) が必要です。Request オブジェクトの型拡張
認証ミドルウェアでデコードしたユーザー情報を req.user に追加するパターンはよく使われますが、TypeScriptでは req.user が Request の型定義に存在しないためエラーになります。これを解決するには Declaration Merging(宣言マージ)で型を拡張します。
// src/types/express.d.ts
import "express";
// ログイン済みユーザー情報
interface AuthUser {
id: number;
email: string;
role: "admin" | "user";
}
// Express の Request インターフェースを拡張
declare global {
namespace Express {
interface Request {
user?: AuthUser; // 認証ユーザー(オプション)
requestId?: string; // リクエストID
startTime?: number; // 処理開始時刻(ロギング用)
}
}
}
// src/middlewares/auth.ts
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
interface JwtPayload {
sub: number;
email: string;
role: "admin" | "user";
}
export function authenticate(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({ error: { message: "認証が必要です", code: "UNAUTHORIZED", statusCode: 401 } });
return;
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(
token,
process.env.JWT_SECRET ?? "secret"
) as JwtPayload;
// req.user に型安全に代入(src/types/express.d.ts で拡張済み)
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
next();
} catch {
res.status(401).json({ error: { message: "無効なトークンです", code: "INVALID_TOKEN", statusCode: 401 } });
}
}
// 管理者のみ許可するミドルウェア
export function requireAdmin(
req: Request,
res: Response,
next: NextFunction
): void {
if (req.user?.role !== "admin") {
res.status(403).json({ error: { message: "権限がありません", code: "FORBIDDEN", statusCode: 403 } });
return;
}
next();
}
Declaration Mergingの詳細は型定義ファイル(.d.ts)完全ガイドで解説しています。
Zod でリクエストバリデーション
req.body の型はコンパイル時にしか検証されません。Zodを使うことで実行時にも型検証でき、型安全なAPIを構築できます。
バリデーションミドルウェアファクトリー
// src/middlewares/validate.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import { z, ZodSchema, ZodError } from "zod";
interface ValidateSchemas {
body?: ZodSchema;
params?: ZodSchema;
query?: ZodSchema;
}
// バリデーションミドルウェアを生成するファクトリー関数
export function validate(schemas: ValidateSchemas): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
try {
if (schemas.body) req.body = schemas.body.parse(req.body);
if (schemas.params) req.params = schemas.params.parse(req.params);
if (schemas.query) req.query = schemas.query.parse(req.query);
next();
} catch (err) {
if (err instanceof ZodError) {
res.status(400).json({
error: {
message: "リクエストの形式が正しくありません",
code: "VALIDATION_ERROR",
statusCode: 400,
},
fieldErrors: err.flatten().fieldErrors,
});
return;
}
next(err);
}
};
}
スキーマ定義とルートへの適用
// src/schemas/user.schema.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z.string().min(1, "名前は必須").max(50, "50文字以内"),
email: z.string().email("有効なメールアドレスを入力してください"),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["admin", "user"]).default("user"),
});
export const updateUserSchema = createUserSchema.partial().refine(
(data) => Object.keys(data).length > 0,
{ message: "少なくとも1つのフィールドを指定してください" }
);
export const userIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, "IDは数値である必要があります"),
});
export const getUsersQuerySchema = z.object({
page: z.string().transform(Number).pipe(z.number().int().min(1)).optional(),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).optional(),
search: z.string().max(100).optional(),
});
// スキーマから型を自動生成
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type GetUsersQuery = z.infer<typeof getUsersQuerySchema>;
// src/routes/users.ts
import { Router } from "express";
import { validate } from "../middlewares/validate";
import { authenticate } from "../middlewares/auth";
import * as schemas from "../schemas/user.schema";
import * as controller from "../controllers/user.controller";
const router = Router();
router.get(
"/",
validate({ query: schemas.getUsersQuerySchema }),
controller.listUsers
);
router.get(
"/:id",
validate({ params: schemas.userIdParamSchema }),
controller.getUser
);
router.post(
"/",
authenticate,
validate({ body: schemas.createUserSchema }),
controller.createUser
);
router.patch(
"/:id",
authenticate,
validate({ params: schemas.userIdParamSchema, body: schemas.updateUserSchema }),
controller.updateUser
);
router.delete(
"/:id",
authenticate,
validate({ params: schemas.userIdParamSchema }),
controller.deleteUser
);
export default router;
型安全なCRUD APIコントローラーの実装
バリデーション・認証ミドルウェアを組み合わせた、実務的なCRUD APIコントローラーの実装例です。
// src/controllers/user.controller.ts
import { Request, Response, NextFunction, RequestHandler } from "express";
import type {
CreateUserInput,
UpdateUserInput,
GetUsersQuery,
} from "../schemas/user.schema";
// インメモリDB(実際はPrismaやMySQLなどに置き換え)
interface User {
id: number;
name: string;
email: string;
role: "admin" | "user";
createdAt: Date;
updatedAt: Date;
}
let users: User[] = [];
let nextId = 1;
// GET /users
export const listUsers: RequestHandler = (req, res) => {
// validate ミドルウェアで変換済みなので型キャスト
const { page = 1, limit = 20, search } = req.query as GetUsersQuery;
let result = [...users];
if (search) {
result = result.filter(
(u) => u.name.includes(search) || u.email.includes(search)
);
}
const total = result.length;
const start = (page - 1) * limit;
const data = result.slice(start, start + limit);
res.json({ data, total, page, limit });
};
// GET /users/:id
export const getUser: RequestHandler = (req, res) => {
const id = Number(req.params.id);
const user = users.find((u) => u.id === id);
if (!user) {
res.status(404).json({
error: { message: "ユーザーが見つかりません", code: "NOT_FOUND", statusCode: 404 },
});
return;
}
res.json(user);
};
// POST /users
export const createUser: RequestHandler = (req, res) => {
const input = req.body as CreateUserInput;
// メール重複チェック
if (users.some((u) => u.email === input.email)) {
res.status(409).json({
error: { message: "このメールアドレスは既に登録されています", code: "CONFLICT", statusCode: 409 },
});
return;
}
const now = new Date();
const user: User = {
id: nextId++,
name: input.name,
email: input.email,
role: input.role,
createdAt: now,
updatedAt: now,
};
users.push(user);
res.status(201).json(user);
};
// PATCH /users/:id
export const updateUser: RequestHandler = (req, res) => {
const id = Number(req.params.id);
const input = req.body as UpdateUserInput;
const index = users.findIndex((u) => u.id === id);
if (index === -1) {
res.status(404).json({
error: { message: "ユーザーが見つかりません", code: "NOT_FOUND", statusCode: 404 },
});
return;
}
users[index] = { ...users[index], ...input, updatedAt: new Date() };
res.json(users[index]);
};
// DELETE /users/:id
export const deleteUser: RequestHandler = (req, res) => {
const id = Number(req.params.id);
const index = users.findIndex((u) => u.id === id);
if (index === -1) {
res.status(404).json({
error: { message: "ユーザーが見つかりません", code: "NOT_FOUND", statusCode: 404 },
});
return;
}
users.splice(index, 1);
res.status(204).send();
};
推奨プロジェクト構成
src/
├── index.ts # エントリーポイント(app設定・ルート登録・サーバー起動)
├── app.ts # Expressアプリ設定(ミドルウェア登録のみ)
├── types/
│ └── express.d.ts # Request拡張(req.userなど)
├── routes/
│ ├── index.ts # ルートをまとめて登録
│ ├── users.ts # /users ルート
│ └── auth.ts # /auth ルート
├── controllers/
│ ├── user.controller.ts
│ └── auth.controller.ts
├── middlewares/
│ ├── auth.ts # JWT認証ミドルウェア
│ ├── validate.ts # Zodバリデーションミドルウェア
│ └── errorHandler.ts # エラーハンドリングミドルウェア
├── schemas/
│ ├── user.schema.ts # ZodスキーマとInput型
│ └── auth.schema.ts
├── services/
│ ├── user.service.ts # ビジネスロジック
│ └── auth.service.ts
└── utils/
└── response.ts # APIレスポンスのユーティリティ
routes: URLマッピングとミドルウェアの組み合わせのみ。controllers: リクエスト/レスポンス処理のみ。ビジネスロジックを持たない。services: データベースアクセスやビジネスロジック。テストしやすい純粋関数が理想。この分離により、ユニットテスト・結合テストが書きやすくなります。
型安全なレスポンスユーティリティ
APIレスポンスの形式を統一するユーティリティを作ることで、フロントエンドとバックエンドの型共有が容易になります。
// src/utils/response.ts
import { Response } from "express";
// 成功レスポンス型
interface SuccessResponse<T> {
success: true;
data: T;
}
// リスト取得用の成功レスポンス型
interface ListResponse<T> {
success: true;
data: T[];
meta: {
total: number;
page: number;
limit: number;
hasNext: boolean;
};
}
// エラーレスポンス型
interface FailResponse {
success: false;
error: {
message: string;
code: string;
statusCode: number;
details?: unknown;
};
}
// API全体のレスポンス型(Union)
export type ApiResponse<T> = SuccessResponse<T> | FailResponse;
// ユーティリティ関数
export const ok = <T>(res: Response, data: T, status = 200): void => {
res.status(status).json({ success: true, data } satisfies SuccessResponse<T>);
};
export const list = <T>(res: Response, data: T[], meta: ListResponse<T>["meta"]): void => {
res.json({ success: true, data, meta } satisfies ListResponse<T>);
};
export const fail = (
res: Response,
statusCode: number,
message: string,
code: string,
details?: unknown
): void => {
res.status(statusCode).json({
success: false,
error: { message, code, statusCode, details },
} satisfies FailResponse);
};
// 使用例
// import { ok, list, fail } from "../utils/response";
// ok(res, user); // 200
// ok(res, newUser, 201); // 201 Created
// list(res, users, { total, page, limit, hasNext }) // リスト
// fail(res, 404, "Not Found", "NOT_FOUND"); // エラー
404ハンドラーと環境変数の型安全な設定
404(Not Found)ハンドラー
定義したどのルートにもマッチしないリクエストへの対処は、全ルート登録後・エラーハンドラーの直前に設定します。
// src/index.ts(全ルート登録後に追加)
// ルートを登録
app.use("/users", usersRouter);
app.use("/auth", authRouter);
// 404ハンドラー(すべてのルートにマッチしなかった場合)
app.use((_req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
message: "指定されたエンドポイントが見つかりません",
code: "NOT_FOUND",
statusCode: 404,
},
});
});
// エラーハンドラー(必ず最後)
app.use(errorHandler);
環境変数の型安全な管理
ExpressアプリではDBのURL・JWTシークレット・ポート番号などを環境変数で管理します。Zodで起動時にバリデーションすることで、設定漏れを本番稼働前に検出できます。
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)).default("3000" as any),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWTシークレットは32文字以上"),
CORS_ORIGIN: z.string().default("http://localhost:5173"),
});
const _result = envSchema.safeParse(process.env);
if (!_result.success) {
console.error("環境変数の設定が不正です:");
console.error(_result.error.flatten().fieldErrors);
process.exit(1);
}
export const env = _result.data;
// src/index.ts で使用
// import { env } from "./config/env";
// app.listen(env.PORT, ...);
// cors({ origin: env.CORS_ORIGIN })
よくあるエラーと解決策
| エラー・問題 | 原因 | 解決策 |
|---|---|---|
Property 'user' does not exist on type 'Request' |
req.userが型定義にない |
src/types/express.d.tsでDeclaration Merging |
req.body が any 型になる |
express.json()未設定または型引数未指定 |
app.use(express.json())追加。ジェネリクス型引数で型を指定 |
| 非同期ルートハンドラーのエラーが捕捉されない | async関数内のエラーがExpressに伝達されない(Express 4) |
try/catch内で next(err) を呼ぶ |
| エラーハンドラーが呼ばれない | 引数が3つしかない(errの引数が抜けている) |
必ず (err, req, res, next) の4引数に。使わない引数も省略不可 |
Cannot find module '@types/express' |
@types/express未インストール |
npm install -D @types/express @types/node |
res.status(...).json() で型エラー |
Response の型引数と実際に返す型が不一致 |
ResBodyの型引数と .json() の引数の型を合わせる |
| CORS エラー | cors()ミドルウェア未設定 |
npm install cors @types/corsして app.use(cors())追加 |
よくある質問
QExpress 4 と Express 5 の型の違いは?
AExpress 5(2024年正式リリース)ではasync ルートハンドラーのエラーが自動的にキャッチされます。next(err) を手動で呼ばなくてもエラーハンドラーに渡ります。型定義は @types/express(v4系)と express(v5は内蔵型定義)で異なります。2024年現在、多くのプロジェクトはまだExpress 4を使用しているため、本記事はExpress 4ベースで解説しています。
QFastify や Hono と比べてExpressはTypeScript対応が劣りますか?
AFastifyはfastify-pluginのスキーマから型を生成できるなどTypeScript統合が強力です。Honoは最初からTypeScript-firstで設計されており型推論が優れています。ただしExpressはエコシステムの豊富さと情報量で依然として強みがあります。本記事のパターン(型引数・Declaration Merging・Zodバリデーション)を使えば、Expressでも十分な型安全性を確保できます。
Qreq.body の型を自動推論させることはできますか?
AExpress 4 単体では困難ですが、tRPC を使うとクライアント・サーバー間で型が完全に共有され、req.body型の問題自体が解消されます。REST APIのままで解決したい場合は、本記事のZodミドルウェアパターンが実用的なアプローチです。
QファイルアップロードのMultipart型はどう定義しますか?
Amulter(@types/multer)を使うのが一般的です。multerはRequestにfile: Express.Multer.Fileやfilesプロパティを追加するDeclaration Mergingを内部で行っています。req.file・req.filesはmulterを使うだけで型が付きます。
Qテストはどう書けばよいですか?
Asupertest(@types/supertest)を使うとHTTPリクエストのテストが書けます。const res = await request(app).post("/users").send(body) で型安全にリクエストできます。Expressアプリとサーバー起動を分離(app.tsとindex.tsを分ける)することで、テスト時にポートを使わずにアプリをテストできます。TypeScript + Vitestのテスト手法はTypeScript テスト完全ガイドで解説しています。
まとめ
TypeScript + Expressで型安全なAPIを構築するための主要ポイントをまとめます。
| 課題 | 解決方法 |
|---|---|
req.bodyの型付け |
Request<P, ResBody, ReqBody, Query>の型引数 + Zodで実行時バリデーション |
req.user等の独自プロパティ |
src/types/express.d.tsでDeclaration Merging |
| ミドルウェアの型 | RequestHandler(通常)/ ErrorRequestHandler(エラー) |
| エラーの統一管理 | AppErrorクラス + 4引数エラーハンドラー + next(err)パターン |
| バリデーション | validate()ミドルウェアファクトリー + Zodスキーマでルートに組み込む |
| レスポンスの型統一 | ok/list/failユーティリティ + ApiResponse<T>型 |
TypeScript + Expressの組み合わせに慣れたら、Zodバリデーションやエラーハンドリングパターンも深く学ぶと、さらに堅牢なAPI設計ができるようになります。

