🧯

NestJS のフィルタは LIFO で評価される

に公開

NestJS の Filter を使って例外処理のハンドリングを行っている方は多いのではないでしょうか。

先日、複数の Filter を併用した際に登録順を誤り、意図しない挙動に遭遇しました。
この記事では、正しい実行順(LIFO)とその理由、そして安全な並べ方をまとめます。

複数 Filter を使うときに起きたこと

個別の例外と全体の例外を分けて管理したいなど、複数の Filter を運用したくなる場面は珍しくありません。

  • 未ハンドリングの例外について @sentry/nestjsSentryGlobalFilter を使って Sentry に送信したい
  • 一方で既知の独自エラー(例えばドメインオブジェクトのバリデーションで投げられる DomainError)を BadRequestException として返すだけにし、Sentry に投げたくない

これらを同一のスコープ(今回はグローバルフィルタ)に下記の順で登録していました。

  1. 独自のエラーフィルタ
  2. SentryGlobalFilter

意図としては「先に独自エラーを処理し、漏れを Sentry が拾う」でした。
しかし Filter の評価は逆順になるため、実際には常に SentryGlobalFilter が先に適用されてしまいます。

つまり、狙いと逆の挙動になってしまいました。

Filter のライフサイクル

公式ドキュメント では、スコープ間の評価順は次の通りであると明記されています。

  1. ルート(GETfindAll のようなメソッド単位)
  2. コントローラ
  3. グローバル

各メソッドで細かいエラーをキャッチし、最後にグローバルで漏れた分を拾うという考え方は自然に感じます。

では、同一スコープ内に複数の Filter を登録した場合はどうでしょうか?
この場合も以下のように触れられていました。

すべてをキャッチする例外フィルターと特定の型にバインドされたフィルターを組み合わせる場合、「何でもキャッチする」フィルターは、特定のフィルターがバインドされた型を正しく処理できるように、最初に宣言する必要があります。

NestJS Core の実装を確認すると、登録されたフィルタ配列に対して reverse をかけているため、書いた順番とは逆順で評価されることがわかります。
https://github.com/nestjs/nest/blob/7180ec1fedece5aac99ee1fb60483ba8fe8cdcff/packages/core/router/router-exception-filters.ts#L43

なお、Guard や Pipe など他の機能のライフサイクルは

  1. グローバル
  2. コントローラ
  3. ルート

の順に評価されます。
同一スコープに関しても書いた順になるため、Filter だけ扱いが異なることに注意しましょう。

Filter の評価順をまとめると以下のようになります。

  1. グローバル フィルタで AB の順に登録
  2. コントローラ フィルタで C を登録
  3. ルート フィルタで D を登録

フィルタ配列は [A, B, C, D]

これを逆順にするため、実際の評価順序は D → C → B → A

書いた順に処理されると思っていると、想定外の挙動に出会うことになります。

サンプルで確認する

準備

以下の 2 つの Filter を用意します。

  • ハンドリングしたい例外を扱う HandleErrorFilter
  • 上記から漏れた例外を扱う UnhandleErrorFilter
handle-error.ts
import { type ArgumentsHost, Catch, type ExceptionFilter } from '@nestjs/common'
import type { FastifyReply } from 'fastify'
import { DomainError } from './domain-error'

@Catch(DomainError)
export class HandleErrorFilter implements ExceptionFilter {
	catch(exception: DomainError, host: ArgumentsHost) {
		const ctx = host.switchToHttp()
		const res = ctx.getResponse<FastifyReply>()

		console.log('handled:', exception.message)
		res.status(422).send({ code: 'DOMAIN_ERROR', message: exception.message })
	}
}
unhandle-error.ts
import { type ArgumentsHost, Catch, type ExceptionFilter } from '@nestjs/common'
import type { FastifyReply } from 'fastify'

@Catch()
export class UnhandleErrorFilter implements ExceptionFilter {
  catch(exception: Error, host: ArgumentsHost) {
    const ctx = host.switchToHttp()
    const res = ctx.getResponse<FastifyReply>()

    console.log('unhandled:', exception.message)
    res.status(500).send({ code: 'SYSTEM_ERROR', message: exception.message })
  }
}

これらを AppModule に登録します。
LIFO となるため「先に実行したいものを後ろ(下)に書く」 のがコツです。

app.module.ts
@Module({
  imports: [CatsModule],
  providers: [
    // 先に処理したいフィルタを後に書くこと
    {
      provide: APP_FILTER,
      useClass: UnhandleErrorFilter,
    },
    {
      provide: APP_FILTER,
      useClass: HandleErrorFilter, // これが先
    },
  ],
})
export class AppModule {}

useGlobalFilter を利用する場合も同じです。
先に処理したいフィルタを後に書きます。

main.ts
app.useGlobalFilters(
  new UnhandleErrorFilter(),
  new HandleErrorFilter(), // これが先
)

動作確認

任意のコントローラで

  • throw new DomainError('domain error')
    → HandleErrorFilter が先にマッチし 422 DOMAIN_ERROR を返す

  • throw new Error('unhandle error')
    → UnhandledErrorFilter が 500 を返す(必要なら Sentry 通知)

providers の順を逆にすると、DomainError が SYSTEM_ERROR/500 として返る=意図と逆になります。

おまけ

APP_FILTER を複数モジュールで提供した場合、登録の順はモジュール解決順に依存します。

AppModule で フィルタ A を、CatsModule でフィルタ B を登録したとします。
main.ts → AppModule → CatsModule という依存関係となり、A → B の順で積み上がります。

その結果

B → A

の順で評価されます。

実行順を厳密にコントロールしたい場合は、app.useGlobalFilters や AppModule に一本化して順序を明示するのが安全といえます。

まとめ

Filter は LIFO

ドキュメントちゃんと読みましょう案件でしたが、直感的でない挙動をするときは注意が必要ですね。

ちなみに codex/gpt5 に聞いたら登録順で評価されますと返ってきました。

AI は速く教えてくれるけど、正しさは自分で取りに行け⚡️

スペースマーケット Engineer Blog

Discussion