T-CREATOR

<div />

TypeScriptでミドルウェアを設計する ExpressとNestJSの応用例で型安全に整理する

2026年1月7日
TypeScriptでミドルウェアを設計する ExpressとNestJSの応用例で型安全に整理する

TypeScript でミドルウェアを設計する際、Express と NestJS のどちらを選ぶべきか、型安全性をどう担保するか、実務では常に判断を迫られます。本記事では、ミドルウェア設計における Express と NestJS の違いを比較し、型安全な実装パターン実務での判断基準を整理します。

リクエスト拡張や共通コンテキストの扱い、インターフェース設計、ユニオン型の活用など、拡張しやすく壊れにくい設計を、実際の検証結果と失敗経験を交えて解説します。

Express と NestJS のミドルウェア設計比較

#観点ExpressNestJS実務での選択基準
1設計思想手続き型・柔軟性重視宣言型・構造化重視小規模は Express、大規模は NestJS
2型安全性手動で型定義が必要デコレーターで型推論が効く型安全重視なら NestJS
3Request拡張インターフェース拡張が必要デコレーターで自動注入拡張頻度が高いなら NestJS
4学習コスト低い中〜高いチームの習熟度に依存
5保守性設計次第で変動大高い(DI・デコレーター)長期運用なら NestJS

この表は即答用です。詳細な理由と実装例は後段で解説します。

検証環境

  • OS: macOS 15.2
  • Node.js: 22.13.1
  • TypeScript: 5.7.2
  • 主要パッケージ:
    • express: 4.21.2
    • @nestjs/core: 10.4.15
    • @nestjs/common: 10.4.15
  • 検証日: 2026年1月7日

TypeScript ミドルウェア設計における背景と実務での課題

この章でわかること:

  • なぜ TypeScript でミドルウェアを設計するのか
  • Express と NestJS の設計思想の違い
  • 実務で直面する型安全性の課題

TypeScript によるミドルウェア設計が求められる理由

モダンな Web アプリケーション開発において、ミドルウェアは認証・ログ・バリデーションといった横断的関心事を担います。JavaScript だけで実装すると、リクエストオブジェクトへの動的なプロパティ追加により、実行時エラーが頻発します。

TypeScript を導入することで、型安全性が向上し、コンパイル時にエラーを検出できるようになります。しかし、Express と NestJS では型定義のアプローチが大きく異なり、実務では どちらを選ぶべきか という判断が求められます。

実務で直面した型安全性の課題

実際に業務で Express プロジェクトを TypeScript 化した際、以下の問題に遭遇しました。

  • req.user に型定義がなく、実行時に undefined エラーが発生
  • ミドルウェアチェーンでの型推論が効かず、手動で型アサーションが必要
  • インターフェース拡張が複数ファイルに散らばり、保守困難

この経験から、型安全性を担保する設計パターンExpress / NestJS の使い分け基準の重要性を痛感しました。

つまずきポイント

  • Express では Request 型の拡張を declare global で行う必要があり、初学者には難易度が高い
  • NestJS はデコレーター記法が独特で、学習コストが高い

Express と NestJS の設計思想と型安全性の違い

この章でわかること:

  • Express の柔軟性と型定義の課題
  • NestJS の構造化アプローチと型推論の強み
  • 実務での選択基準

以下の図は、Express と NestJS のミドルウェア処理フローの違いを示しています。Express は手続き型でミドルウェアを順次実行するのに対し、NestJS はガード・インターセプター・パイプといった役割別のレイヤーで処理を分離します。

mermaidflowchart LR
  reqE["リクエスト"] --> mw1["認証MW"]
  mw1 --> mw2["ログMW"]
  mw2 --> handler["ハンドラ"]
  handler --> resE["レスポンス"]

  reqN["リクエスト"] --> guard["ガード"]
  guard --> interceptor["インターセプター"]
  interceptor --> pipe["パイプ"]
  pipe --> handlerN["ハンドラ"]
  handlerN --> filter["フィルター"]
  filter --> resN["レスポンス"]

Express は左側のシンプルなフロー、NestJS は右側の構造化されたフローです。

Express における型安全性の実現と課題

Express は軽量で柔軟ですが、TypeScript の型安全性を活かすには工夫が必要です。

Request オブジェクトの型拡張

Express で req.user を型安全に扱うには、以下のようにインターフェースを拡張します。

typescriptimport { Request, Response, NextFunction } from "express";

interface AuthUser {
  id: string;
  email: string;
  role: "admin" | "user";
}

declare global {
  namespace Express {
    interface Request {
      user?: AuthUser;
    }
  }
}

const authMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction,
): void => {
  // 検証の結果、トークンが有効なら user を設定
  req.user = {
    id: "123",
    email: "test@example.com",
    role: "user",
  };
  next();
};

動作確認済み(Node.js 22.13.1、TypeScript 5.7.2)

この方法により、以降のミドルウェアやハンドラで req.user が型安全に扱えます。ただし、declare global を使うため、型定義の管理が散らばりやすい課題があります。

実際に試したところの結果

業務で Express の大規模プロジェクトに TypeScript を導入した際、declare global の型定義が複数ファイルに分散し、どこで req に何が追加されたか追跡困難になりました。この経験から、型定義を一元管理するファイル構成が重要だと学びました。

NestJS における型安全性の実現と強み

NestJS は、デコレーターと依存性注入(DI)により、型推論が自動で効きます。

デコレーターによる型安全な Request 拡張

typescriptimport { createParamDecorator, ExecutionContext } from "@nestjs/common";

interface AuthUser {
  id: string;
  email: string;
  role: "admin" | "user";
}

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): AuthUser => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

// コントローラーでの使用
import { Controller, Get } from "@nestjs/common";

@Controller("profile")
export class ProfileController {
  @Get()
  getProfile(@CurrentUser() user: AuthUser) {
    // user は型推論が効いている
    return { id: user.id, email: user.email };
  }
}

動作確認済み(NestJS 10.4.15、TypeScript 5.7.2)

NestJS では、デコレーターが型情報を保持するため、declare global が不要です。型定義が局所化され、保守性が向上します。

実際に試したところの結果

NestJS プロジェクトでは、デコレーターにより型推論が自動で効き、IDE の補完機能が強力に働きました。ただし、デコレーターの仕組みを理解するまでの学習コストが高く、チーム全体で習得するまで時間を要しました。

つまずきポイント

  • Express の declare global は名前空間汚染のリスクがあり、大規模プロジェクトでは管理が困難
  • NestJS のデコレーターは強力だが、初学者には抽象度が高く感じられる

Express におけるミドルウェア設計パターンと型安全性

この章でわかること:

  • 認証ミドルウェアの型安全な実装
  • エラーハンドリングの統一パターン
  • 実務での運用ノウハウ

認証ミドルウェアの設計と型安全性

Express で JWT 認証を実装する際、型安全性を保つには以下のパターンを用います。

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

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

const authenticateToken = (
  req: Request,
  res: Response,
  next: NextFunction,
): void => {
  const authHeader = req.headers["authorization"];
  const token = authHeader?.split(" ")[1];

  if (!token) {
    res.status(401).json({
      error: "Access token required",
      code: "AUTH_TOKEN_MISSING",
    });
    return;
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
    req.user = {
      id: decoded.userId,
      email: decoded.email,
      role: "user",
    };
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      res.status(401).json({
        error: "Token expired",
        code: "AUTH_TOKEN_EXPIRED",
      });
      return;
    }
    res.status(403).json({
      error: "Invalid token",
      code: "AUTH_TOKEN_INVALID",
    });
  }
};

動作確認済み(express 4.21.2、jsonwebtoken 9.0.2)

このパターンでは、エラーコード(AUTH_TOKEN_MISSINGAUTH_TOKEN_EXPIREDAUTH_TOKEN_INVALID)を明示することで、フロントエンド側でのエラーハンドリングが容易になります。

検証の結果

実際の業務で、エラーコードを統一することで、フロントエンドとバックエンドのエラーハンドリング実装が効率化されました。ただし、初期にコード体系を設計せず、後から追加した結果、命名規則が不統一になり、修正に時間を要しました。

エラーハンドリングミドルウェアの統一パターン

Express のエラーミドルウェアは、4つの引数を持つ特殊な形式です。

typescriptinterface ApiError extends Error {
  statusCode?: number;
  code?: string;
  details?: any;
}

const globalErrorHandler = (
  error: ApiError,
  req: Request,
  res: Response,
  next: NextFunction,
): void => {
  const statusCode = error.statusCode || 500;
  const errorCode = error.code || "INTERNAL_SERVER_ERROR";

  console.error(
    JSON.stringify({
      level: "error",
      message: error.message,
      code: errorCode,
      stack: error.stack,
      timestamp: new Date().toISOString(),
    }),
  );

  res.status(statusCode).json({
    error: {
      code: errorCode,
      message: error.message,
      ...(process.env.NODE_ENV === "development" && {
        stack: error.stack,
        details: error.details,
      }),
    },
  });
};

// app.js での使用
app.use(globalErrorHandler);

動作確認済み(express 4.21.2)

開発環境ではスタックトレースを含め、本番環境では除外することで、セキュリティと開発効率を両立します。

つまずきポイント

  • エラーミドルウェアは必ず4つの引数を取る必要があり、3つにすると動作しない
  • next() を呼ばないと処理が止まるため、明示的に return する

NestJS におけるミドルウェア設計パターンと型安全性

この章でわかること:

  • ガード・インターセプター・パイプ・フィルターの使い分け
  • デコレーターによる型推論の活用
  • 実務での設計判断

ガードによる認証・認可の実装

NestJS のガードは、CanActivate インターフェースを実装し、認証・認可を担います。

typescriptimport {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException({
        error: "Authentication required",
        code: "AUTH_TOKEN_MISSING",
      });
    }

    try {
      const payload = await this.jwtService.verifyAsync(token);
      request.user = payload;
      return true;
    } catch (error) {
      if (error.name === "TokenExpiredError") {
        throw new UnauthorizedException({
          error: "Token expired",
          code: "AUTH_TOKEN_EXPIRED",
        });
      }
      throw new UnauthorizedException({
        error: "Invalid token",
        code: "AUTH_TOKEN_INVALID",
      });
    }
  }

  private extractTokenFromHeader(request: any): string | undefined {
    const [type, token] = request.headers.authorization?.split(" ") ?? [];
    return type === "Bearer" ? token : undefined;
  }
}

動作確認済み(@nestjs/jwt 10.2.0)

ガードは依存性注入により JwtService を利用でき、コードが簡潔になります。

検証の結果

業務で NestJS のガードを採用した結果、認証ロジックが一箇所に集約され、テストが容易になりました。ただし、カスタムデコレーターとの組み合わせ方を理解するまで、試行錯誤が必要でした。

インターセプターによるログとレスポンス変換

インターセプターは、リクエスト・レスポンスの前後処理を担います。

typescriptimport {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap, map } from "rxjs/operators";

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const method = request.method;
    const url = request.url;
    const timestamp = new Date().toISOString();

    console.log(`[${timestamp}] ${method} ${url} - START`);

    const startTime = Date.now();

    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime;
        console.log(`[${timestamp}] ${method} ${url} - END (${duration}ms)`);
      }),
      map((data) => ({
        ...data,
        meta: {
          timestamp,
          duration: Date.now() - startTime,
        },
      })),
    );
  }
}

動作確認済み(@nestjs/common 10.4.15、rxjs 7.8.1)

RxJS の演算子により、非同期処理を宣言的に記述できます。

パイプによるバリデーションと型変換

パイプは、入力データの検証・変換を担います。

typescriptimport {
  PipeTransform,
  Injectable,
  ArgumentMetadata,
  BadRequestException,
} from "@nestjs/common";

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, metadata: ArgumentMetadata) {
    if (!value) {
      throw new BadRequestException({
        error: "Validation failed",
        code: "VALIDATION_EMPTY_VALUE",
        field: metadata.data,
      });
    }

    if (metadata.type === "param" && metadata.data === "id") {
      return this.validateId(value);
    }

    return value;
  }

  private validateId(value: string): string {
    const uuidRegex =
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (!uuidRegex.test(value)) {
      throw new BadRequestException({
        error: "ID must be a valid UUID",
        code: "VALIDATION_UUID_REQUIRED",
      });
    }
    return value;
  }
}

動作確認済み(@nestjs/common 10.4.15)

パイプにより、バリデーションロジックが再利用可能になります。

つまずきポイント

  • インターセプターの RxJS 記法は、非同期処理に不慣れだと理解が難しい
  • パイプは PipeTransform を実装する必要があり、型定義を忘れるとエラーになる

Express vs NestJS の判断基準と比較まとめ(詳細版)

この章でわかること:

  • どのような条件で Express / NestJS を選ぶか
  • 型安全性・保守性・学習コストの観点での比較
  • 実務での採用・不採用の理由

設計思想と型安全性の比較

#観点ExpressNestJS実務判断のポイント
1型定義方式declare global で手動拡張デコレーターで自動型推論型推論重視なら NestJS
2ミドルウェア構造手続き型(関数チェーン)宣言型(デコレーター・DI)柔軟性重視なら Express
3Request拡張インターフェース拡張が必要カスタムデコレーターで注入拡張頻度が高いなら NestJS
4エラーハンドリンググローバルエラーミドルウェアフィルター・例外レイヤー統一性重視なら NestJS
5テスト容易性モック・スタブが必要DI でテストが容易テスト重視なら NestJS

向いているケース・向かないケース

Express が向いているケース

  • 小規模 API(エンドポイント数 10〜30 程度)
  • プロトタイプ・MVP 開発でスピード重視
  • チームに Express 経験者が多い
  • 既存の Express プロジェクトへの段階的 TypeScript 導入

NestJS が向いているケース

  • 大規模 API(エンドポイント数 50 以上)
  • 長期運用・保守性重視
  • 複雑なビジネスロジック・ドメイン駆動設計
  • TypeScript ネイティブなチーム

実際に採用した・採用しなかった理由

Express を採用したケース: 業務で既存の Node.js プロジェクトに TypeScript を導入する際、学習コストを抑えるため Express を継続しました。ただし、declare global の管理が煩雑になり、途中で型定義ファイルを一元化する対応が必要でした。

NestJS を採用したケース: 新規プロジェクトで、複雑な認証・認可ロジックが求められたため、NestJS を採用しました。デコレーターと DI により、テストが容易で保守性が向上しました。ただし、チームの学習期間として 1〜2 週間を要しました。

つまずきポイント

  • Express は型安全性の担保に工夫が必要で、設計ミスが後から響く
  • NestJS は学習コストが高く、小規模プロジェクトではオーバースペック

まとめ

本記事では、TypeScript におけるミドルウェア設計を、Express と NestJS の比較を通じて解説しました。

  • Express は柔軟性が高く、小規模・既存プロジェクトに向く
  • NestJS は型安全性・保守性に優れ、大規模・長期運用に向く
  • 型安全性を担保するには、Express では declare global、NestJS ではデコレーターを活用
  • エラーハンドリング・ログ・認証といった横断的関心事は、ミドルウェアで統一的に処理する

どちらが優れているかではなく、プロジェクトの規模・チームの習熟度・運用期間に応じて適切に選択することが重要です。実務では、型安全性と開発速度のバランスを取りながら、段階的に改善していく姿勢が求められます。

この記事が、あなたのミドルウェア設計の判断材料となれば幸いです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;