【TypeScript】Express で型安全なREST API 完全ガイド|Request型・ミドルウェア・バリデーション・認証まで徹底解説

【TypeScript】Express で型安全なREST API 完全ガイド|Request型・ミドルウェア・バリデーション・認証まで徹底解説 TypeScript

ExpressはNode.jsで最も広く使われているWebフレームワークですが、TypeScriptで使う場合に「req.body の型が any になる」「ミドルウェアでプロパティを追加すると型エラーが出る」といった問題に直面します。

本記事では、TypeScript + Expressの環境構築から始め、Request / Response のジェネリクス型引数による型付け、ミドルウェアの型定義、Requestオブジェクトの拡張、Zodを使ったリクエストバリデーション、JWT認証・CRUD APIの実務パターンまでを体系的に解説します。

この記事でわかること

  • TypeScript + Expressのセットアップ(@types/expressts-nodetsx
  • 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の RequestResponse はジェネリクス型引数を受け取ります。正確に指定することで req.bodyreq.paramsreq.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 型チェックあり
  }
);
URLパラメータは常に string
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);
  }
);
req.body はランタイム型保証なし
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);
  }
);
QueryString は常に string | string[] | ParsedQs
厳密には req.query の型は ParsedQsqsライブラリの型)です。同名のパラメータが複数送られると配列になります。ジェネリクスで型を絞り込んでも、実際の値は文字列であることに注意してください。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) でエラーハンドラーに渡す
ルートハンドラー内で next(err) を呼ぶと、Express は自動的にエラーハンドリングミドルウェアに処理を渡します。asyncルートハンドラーでの try/catch の中で next(err) を呼ぶのが定番パターンです。Express 5 では async エラーが自動的にキャッチされますが、Express 4 では明示的に next(err) が必要です。

Request オブジェクトの型拡張

認証ミドルウェアでデコードしたユーザー情報を req.user に追加するパターンはよく使われますが、TypeScriptでは req.userRequest の型定義に存在しないためエラーになります。これを解決するには 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.bodyany 型になる 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.Filefilesプロパティを追加するDeclaration Mergingを内部で行っています。req.filereq.filesはmulterを使うだけで型が付きます。

Qテストはどう書けばよいですか?

Asupertest@types/supertest)を使うとHTTPリクエストのテストが書けます。const res = await request(app).post("/users").send(body) で型安全にリクエストできます。Expressアプリとサーバー起動を分離(app.tsindex.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設計ができるようになります。