Skip to Content
Interceptors

This page documents the 0.4 release candidates of @nexusdi/core. The newest on npm is 0.4.0-rc.1. The documentation for 0.3.2, the current release, is at nexus.js.org. Try the release candidate.

Method Interceptors

In any complex application, you’ll find logic that needs to run across many different services, such as logging every call, measuring performance, or validating inputs. If you write this logic inside every method, your codebase becomes cluttered with boilerplate.

Interceptors solve this by allowing you to “wrap” your service methods with cross-cutting logic. An interceptor can observe a call, change the arguments, cache the result, or even skip the method entirely.

How Interceptors Work

An interceptor is a provider that implements the Interceptor interface. It declares its deps like any class provider. Its core logic lives in the intercept(call, next) method:

  • call: A context object containing the service instance, the method name, the arguments, and the current scope. The object is a CallContext, which also holds the provider and the async flag.
  • next: A function that triggers the next interceptor in the chain, eventually calling the actual service method.

To use them, you first install the interceptors package:

npm install @nexusdi/interceptors@next

Registering the Plugin

interceptors({ register }) from @nexusdi/interceptors is a plugin that you pass to Nexus.create. register lists each interceptor under a token with interceptor(token, { useClass }), and imports names the modules the interceptors’ own deps come from. The region below registers an AUDIT interceptor that writes to a FlightLog from the Logs module.

Attaching Interceptors

You can attach interceptors at different levels of granularity:

1. Method-Level

Target a specific method within a class using a static interceptors map. The map is checked by satisfies InterceptorMap<T>. methods maps a method name to its interceptors.

import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor } from '@nexusdi/interceptors'; import type { InterceptorMap, Next } from '@nexusdi/interceptors'; interface IFlightLog { readonly entries: string[]; } interface IShipComputer { course(target: string): string; } const FLIGHT_LOG = new Token<IFlightLog>('FlightLog'); const COMPUTER = new Token<IShipComputer>('ShipComputer'); const AUDIT = new Token<Interceptor>('Audit'); class ShuttleFlightLog implements IFlightLog { readonly entries: string[] = []; } class AuditInterceptor implements Interceptor { static deps = [FLIGHT_LOG] as const; constructor(private readonly log: IFlightLog) {} intercept(call: CallContext, next: Next) { this.log.entries.push( `${call.provider.name}.${String(call.method)}(${call.args.join(', ')})`, ); return next(); } } class QuantumComputer implements IShipComputer { static interceptors = { methods: { course: [AUDIT] }, } satisfies InterceptorMap<QuantumComputer>; course(target: string) { return `course to ${target}`; } } const Logs = defineModule({ name: 'Logs', providers: [provide(FLIGHT_LOG, { useClass: ShuttleFlightLog })], exports: [FLIGHT_LOG], }); const Engineering = defineModule({ name: 'Engineering', imports: [Logs], providers: [provide(COMPUTER, { useClass: QuantumComputer })], exports: [COMPUTER, FLIGHT_LOG], }); await using ship = await Nexus.create(Engineering, { plugins: [ interceptors({ imports: [Logs], register: [interceptor(AUDIT, { useClass: AuditInterceptor })], }), ], }); const course = ship.get(COMPUTER).course('Kepler-442b'); // -> 'course to Kepler-442b' console.log(course); const entries = ship.get(FLIGHT_LOG).entries; // -> ['ShipComputer.course(Kepler-442b)'] console.log(entries);

Try it:

  1. Save the region as audit.ts and run npx tsx audit.ts. The flight log holds one entry for the course call.
  2. Call course a second time and run again. The log holds two entries.
  3. Remove imports: [Logs] from the interceptors() options and run again. The plugin’s module cannot see FlightLog, so Nexus.create rejects with NEXUS_BLUEPRINT_INVALID, whose errors list holds NEXUS_MISSING_PROVIDER for FlightLog, requested by Audit. Put imports: [Logs] back.
  4. Change course: [AUDIT] to cource: [AUDIT]. npx tsc reports TS2561 on the cource key, because satisfies InterceptorMap<QuantumComputer> knows the class has no such method. Run it anyway and Nexus.create rejects with NEXUS_BLUEPRINT_INVALID, whose errors list holds NEXUS_INTERCEPTOR_INVALID with reason=unknown-method. Change the key back.

2. Class-Level

Apply an interceptor to every method in a class by using the class key in the map. The tap region below attaches TIMING with class: [TIMING].

3. Global-Level

Apply logic to every single service in the container. This is ideal for global logging or security audits. An entry { use, when } skips a method where when({ provider, method }) returns false, and when runs once per provider and method.

import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor, Next } from '@nexusdi/interceptors'; interface IFlightLog { readonly entries: string[]; } interface IReactorCore { output(): number; } interface IShipComputer { status(): string; } const FLIGHT_LOG = new Token<IFlightLog>('FlightLog'); const REACTOR = new Token<IReactorCore>('ReactorCore'); const COMPUTER = new Token<IShipComputer>('ShipComputer'); const AUDIT = new Token<Interceptor>('Audit'); class ShuttleFlightLog implements IFlightLog { readonly entries: string[] = []; } class FusionReactor implements IReactorCore { output() { return 1.21; } } class QuantumComputer implements IShipComputer { static deps = [REACTOR] as const; constructor(private readonly reactor: IReactorCore) {} status() { return `online at ${this.reactor.output()} GW`; } } class AuditInterceptor implements Interceptor { static deps = [FLIGHT_LOG] as const; constructor(private readonly log: IFlightLog) {} intercept(call: CallContext, next: Next) { this.log.entries.push(`${call.provider.token}.${String(call.method)}`); return next(); } } const Logs = defineModule({ name: 'Logs', providers: [provide(FLIGHT_LOG, { useClass: ShuttleFlightLog })], exports: [FLIGHT_LOG], }); await using ship = await Nexus.create( defineModule({ name: 'Engineering', imports: [Logs], providers: [ provide(REACTOR, { useClass: FusionReactor }), provide(COMPUTER, { useClass: QuantumComputer }), ], exports: [FLIGHT_LOG, COMPUTER], }), { plugins: [ interceptors({ imports: [Logs], register: [interceptor(AUDIT, { useClass: AuditInterceptor })], global: [ { use: AUDIT, when: ({ provider }) => provider.token !== REACTOR }, ], exempt: [FLIGHT_LOG], }), ], }, ); const status = ship.get(COMPUTER).status(); // -> 'online at 1.21 GW' console.log(status); const entries = ship.get(FLIGHT_LOG).entries; // -> ['ShipComputer.status'] console.log(entries);

Note: Global interceptors must be “exempt” from the services they depend on to avoid infinite recursion (e.g., a logging interceptor should not intercept the FlightLog service it uses to write logs). exempt lists every service the interceptors directly depend on outside the plugin’s own providers, and global entries skip an exempt service and every provider it reaches. Without the exempt entry, Nexus.create rejects with NEXUS_BLUEPRINT_INVALID, whose errors list holds NEXUS_INTERCEPTOR_INVALID with reason=unexempted-dep. The message names the service to list:

import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core'; import { provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor, Next } from '@nexusdi/interceptors'; interface IFlightLog { readonly entries: string[]; } const FLIGHT_LOG = new Token<IFlightLog>('FlightLog'); const AUDIT = new Token<Interceptor>('Audit'); class ShuttleFlightLog implements IFlightLog { readonly entries: string[] = []; } class AuditInterceptor implements Interceptor { static deps = [FLIGHT_LOG] as const; constructor(private readonly log: IFlightLog) {} intercept(call: CallContext, next: Next) { this.log.entries.push(String(call.method)); return next(); } } const Logs = defineModule({ name: 'Logs', providers: [provide(FLIGHT_LOG, { useClass: ShuttleFlightLog })], exports: [FLIGHT_LOG], }); const error = await Nexus.create( defineModule({ name: 'Engineering', imports: [Logs], exports: [FLIGHT_LOG] }), { plugins: [ interceptors({ imports: [Logs], register: [interceptor(AUDIT, { useClass: AuditInterceptor })], global: [AUDIT], }), ], }, ).catch((caught: unknown) => caught); const inner = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID') ? error.errors[0] : null; const message = inner?.message; // -> '[NEXUS_INTERCEPTOR_INVALID] reason=unexempted-dep token=Audit target=FlightLog detail=FlightLog. https://nexus.js.org/errors/NEXUS_INTERCEPTOR_INVALID' console.log(message);

Try it: Save the global region as global.ts and run npx tsx global.ts. It prints online at 1.21 GW and [ 'ShipComputer.status' ]. Delete exempt: [FLIGHT_LOG] and run it again: Nexus.create rejects, and the uncaught error lists NEXUS_INTERCEPTOR_INVALID with reason=unexempted-dep token=Audit target=FlightLog. Put the entry back and the two lines return.

4. Decorators and Bindings

@UseInterceptors(...tokens) from @nexusdi/interceptors attaches interceptors to a class or one method with a standard decorator, for a project that already compiles decorators (Writing Providers with Decorators):

import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { UseInterceptors, interceptor } from '@nexusdi/interceptors'; import { interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor, Next } from '@nexusdi/interceptors'; interface IShipComputer { course(target: string): string; } const COMPUTER = new Token<IShipComputer>('ShipComputer'); const AUDIT = new Token<Interceptor>('Audit'); const audited: string[] = []; class AuditInterceptor implements Interceptor { intercept(call: CallContext, next: Next) { audited.push(String(call.method)); return next(); } } class QuantumComputer implements IShipComputer { @UseInterceptors(AUDIT) course(target: string) { return `course to ${target}`; } } await using ship = await Nexus.create( defineModule({ name: 'Engineering', providers: [provide(COMPUTER, { useClass: QuantumComputer })], exports: [COMPUTER], }), { plugins: [ interceptors({ register: [interceptor(AUDIT, { useClass: AuditInterceptor })], }), ], }, ); const course = ship.get(COMPUTER).course('Kepler-442b'); // -> 'course to Kepler-442b' console.log(course); const methods = audited; // -> ['course'] console.log(methods);

bindings attaches interceptors to a token whose provider you cannot edit, such as a factory or a class from a package:

import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor, Next } from '@nexusdi/interceptors'; interface INavCharts { plot(target: string): string; } const NAV_CHARTS = new Token<INavCharts>('NavCharts'); const AUDIT = new Token<Interceptor>('Audit'); const audited: string[] = []; class AuditInterceptor implements Interceptor { intercept(call: CallContext, next: Next) { audited.push(`${call.provider.token}.${String(call.method)}`); return next(); } } await using ship = await Nexus.create( defineModule({ name: 'Tactical', providers: [ provide(NAV_CHARTS, { useFactory: (): INavCharts => ({ plot: (target) => `course to ${target}`, }), }), ], exports: [NAV_CHARTS], }), { plugins: [ interceptors({ register: [interceptor(AUDIT, { useClass: AuditInterceptor })], bindings: [{ token: NAV_CHARTS, methods: { plot: [AUDIT] } }], }), ], }, ); const course = ship.get(NAV_CHARTS).plot('Kepler-442b'); // -> 'course to Kepler-442b' console.log(course); const methods = audited; // -> ['NavCharts.plot'] console.log(methods);

Chain Order

The chain of a method runs the global entries first, then the bindings, then the class list, then the method list. A token that appears twice runs once, at its outermost position. In a list, the first interceptor is the outermost.

Controlling the Execution Flow

Interceptors give you full control over the method call:

  • Modify Arguments: Pass different arguments to next(args) to alter the input. The rest of the chain runs with the new input.
  • Short-circuit: Return a value without calling next() to skip the method entirely (perfect for caching). An interceptor that calls next() twice runs the method twice.
  • Observe Results: Use the tap() helper to run logic after the method completes without altering the result.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor } from '@nexusdi/interceptors'; import type { InterceptorMap, Next } from '@nexusdi/interceptors'; interface INavCharts { plot(target: string): string; } const NAV_CHARTS = new Token<INavCharts>('NavCharts'); const UPPERCASE = new Token<Interceptor>('Uppercase'); const CACHE = new Token<Interceptor>('Cache'); let plotted = 0; class StarCharts implements INavCharts { static interceptors = { methods: { plot: [CACHE, UPPERCASE] }, } satisfies InterceptorMap<StarCharts>; plot(target: string) { plotted++; return `course to ${target}`; } } class UppercaseInterceptor implements Interceptor { intercept(call: CallContext, next: Next) { return next(call.args.map((arg) => String(arg).toUpperCase())); } } class CacheInterceptor implements Interceptor { private readonly saved = new Map<string, unknown>(); intercept(call: CallContext, next: Next) { const key = String(call.args[0]); if (!this.saved.has(key)) this.saved.set(key, next()); return this.saved.get(key); } } await using ship = await Nexus.create( defineModule({ name: 'Tactical', providers: [provide(NAV_CHARTS, { useClass: StarCharts })], exports: [NAV_CHARTS], }), { plugins: [ interceptors({ register: [ interceptor(UPPERCASE, { useClass: UppercaseInterceptor }), interceptor(CACHE, { useClass: CacheInterceptor }), ], }), ], }, ); const first = ship.get(NAV_CHARTS).plot('Kepler-442b'); // -> 'course to KEPLER-442B' console.log(first); const second = ship.get(NAV_CHARTS).plot('Kepler-442b'); // -> 'course to KEPLER-442B' console.log(second); const runs = plotted; // -> 1 console.log(runs);

The cache runs first, so the second call returns the saved value and plot runs once.

Try it: Save the region as change.ts and run npx tsx change.ts. It prints course to KEPLER-442B twice and 1. Change plot: [CACHE, UPPERCASE] to plot: [UPPERCASE] and run it again: the two course lines stay the same and the last line is 2, because nothing saves the first result. Change it back.

Observing with tap()

tap(next, { value, error }) runs next(), calls value with the result or error with the failure, and returns or rethrows the original. It handles a plain return, a returned promise, a rejected promise and a synchronous throw alike, so a logging interceptor needs no branch for async methods.

import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors, tap } from '@nexusdi/interceptors'; import type { CallContext, Interceptor } from '@nexusdi/interceptors'; import type { InterceptorMap, Next } from '@nexusdi/interceptors'; interface ISubspaceLink { send(message: string): Promise<string>; } const SUBSPACE_LINK = new Token<ISubspaceLink>('SubspaceLink'); const TIMING = new Token<Interceptor>('Timing'); const outcomes: string[] = []; class TimingInterceptor implements Interceptor { intercept(call: CallContext, next: Next) { return tap(next, { value: () => outcomes.push(`${String(call.method)} sent`), error: () => outcomes.push(`${String(call.method)} failed`), }); } } class SubspaceRelay implements ISubspaceLink { static interceptors = { class: [TIMING], } satisfies InterceptorMap<SubspaceRelay>; async send(message: string) { if (message === '') throw new Error('empty message'); return `relayed ${message}`; } } await using ship = await Nexus.create( defineModule({ name: 'Comms', providers: [provide(SUBSPACE_LINK, { useClass: SubspaceRelay })], exports: [SUBSPACE_LINK], }), { plugins: [ interceptors({ register: [interceptor(TIMING, { useClass: TimingInterceptor })], }), ], }, ); const sent = await ship.get(SUBSPACE_LINK).send('hail'); // -> 'relayed hail' console.log(sent); const failure = await ship .get(SUBSPACE_LINK) .send('') .catch((caught: unknown) => (caught as Error).message); const reason = failure; // -> 'empty message' console.log(reason); const log = outcomes; // -> ['send sent', 'send failed'] console.log(log);

A method declared async turns a synchronous throw from any interceptor into a rejected promise, so the caller’s .catch sees it.

The Proxy Pattern

When you get() a service that has interceptors, NexusDI returns a Proxy. The container stores the proxy, so a call through get() or through an injected dependency runs the chain.

It is important to understand that:

  1. Calls made through the proxy (e.g., computer.status()) are intercepted.
  2. Calls made via this inside the class (e.g., this.status()) bypass the proxy and are not intercepted.
  3. A method runs with this set to the service itself, so private fields such as #serial work.
  4. instanceof holds for the proxy, and get(T) === this inside the class is false, because this is the service itself and get() returns its proxy.
import { Nexus, Token, defineModule, provide } from '@nexusdi/core'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor } from '@nexusdi/interceptors'; import type { InterceptorMap, Next } from '@nexusdi/interceptors'; interface IShipComputer { status(): string; selfTest(): string; isSelf(other: unknown): boolean; } const COMPUTER = new Token<IShipComputer>('ShipComputer'); const AUDIT = new Token<Interceptor>('Audit'); const calls: string[] = []; class AuditInterceptor implements Interceptor { intercept(call: CallContext, next: Next) { calls.push(String(call.method)); return next(); } } class QuantumComputer implements IShipComputer { static interceptors = { class: [AUDIT], } satisfies InterceptorMap<QuantumComputer>; #serial = 'QC-7'; status() { return `online ${this.#serial}`; } selfTest() { return this.status(); } isSelf(other: unknown) { return other === this; } } await using ship = await Nexus.create( defineModule({ name: 'Engineering', providers: [provide(COMPUTER, { useClass: QuantumComputer })], exports: [COMPUTER], }), { plugins: [ interceptors({ register: [interceptor(AUDIT, { useClass: AuditInterceptor })], }), ], }, ); const computer = ship.get(COMPUTER); const isInstance = computer instanceof QuantumComputer; // -> true console.log(isInstance); const sameObject = computer.isSelf(computer); // -> false console.log(sameObject); const status = computer.selfTest(); // -> 'online QC-7' console.log(status); const intercepted = calls; // -> ['isSelf', 'selfTest'] console.log(intercepted);

selfTest calls status through this, so the audit log holds isSelf and selfTest and no status.

Try it: Save the region as identity.ts and run npx tsx identity.ts. It prints true, false, online QC-7 and [ 'isSelf', 'selfTest' ]. Add computer.status(); before the selfTest line and run it again: the log is [ 'isSelf', 'status', 'selfTest' ], because that call goes through the proxy. Remove the line again.

If you need an internal call to be intercepted, call the method through the token from outside the class, or move the method to another service and call it through that service’s proxy. Do not inject the class’s own token into the class: a class that depends on itself is a cycle, and Nexus.create rejects it.

Interceptor Lifetimes

Interceptors are always Singletons. Each interceptor is built once per container. A scoped or transient interceptor makes create reject with NEXUS_BLUEPRINT_INVALID, which holds NEXUS_INTERCEPTOR_LIFETIME. A scoped interceptor also holds core’s NEXUS_LIFETIME_VIOLATION for the singleton registry that would capture it. If an interceptor needs request-specific data, it reads it from call.scope or call.instance.

import { Nexus, Token, defineModule, isNexusError } from '@nexusdi/core'; import { provide } from '@nexusdi/core'; import { errors } from '@nexusdi/errors'; import { interceptor, interceptors } from '@nexusdi/interceptors'; import type { CallContext, Interceptor, Next } from '@nexusdi/interceptors'; import { interceptorsText } from '@nexusdi/interceptors/text'; interface IShipComputer { status(): string; } const COMPUTER = new Token<IShipComputer>('ShipComputer'); const AUDIT = new Token<Interceptor>('Audit'); class QuantumComputer implements IShipComputer { status() { return 'online'; } } class AuditInterceptor implements Interceptor { intercept(_call: CallContext, next: Next) { return next(); } } const error = await Nexus.create( defineModule({ name: 'Engineering', providers: [provide(COMPUTER, { useClass: QuantumComputer })], exports: [COMPUTER], }), { plugins: [ errors({ text: [interceptorsText] }), interceptors({ register: [ interceptor(AUDIT, { useClass: AuditInterceptor, lifetime: 'scoped', }), ], global: [AUDIT], }), ], }, ).catch((caught: unknown) => caught); const found = isNexusError(error, 'NEXUS_BLUEPRINT_INVALID') ? error.errors : []; const codes = found.map((inner) => inner.code); // -> ['NEXUS_LIFETIME_VIOLATION', 'NEXUS_INTERCEPTOR_LIFETIME'] console.log(codes); const lifetime = found.find( (inner) => inner.code === 'NEXUS_INTERCEPTOR_LIFETIME', ); const lines = lifetime?.message.split('\n'); // -> ['[NEXUS_INTERCEPTOR_LIFETIME] the interceptor Audit is scoped, and interceptors are singletons.', ' Fix: remove its lifetime, and read request data from call.instance.'] console.log(lifetime?.message);

Try it: Save the region as lifetime.ts and run npx tsx lifetime.ts. It prints the two codes and the full text of the second. Delete lifetime: 'scoped' and run it again: create succeeds, the first line is [] and the last is undefined. Put the line back.

Error Text

The errors create reports through the plugin’s checks, and the bad-argument fault a wrapped method raises after setup, carry a one-line message with a link to their page. The full text and fix lines live in interceptorsText at @nexusdi/interceptors/text. Pass it to errors(), as the lifetime region does:

Not run

import { errors } from '@nexusdi/errors'; import { interceptors } from '@nexusdi/interceptors'; import { interceptorsText } from '@nexusdi/interceptors/text'; const plugins = [errors({ text: [interceptorsText] }), interceptors(options)];

Error Text Packs covers the packs of every package.

Rules

  • NEXUS_INTERCEPTOR_NOT_READY carries its own full text, and errors() packs do not change it. A method called after its container is disposed throws it. So does one called from a constructor before its interceptors are built, which create reports as NEXUS_PROVIDER_FAILED with that code as its cause.
  • Call interceptors() once per container. A plugin object in use by a running container makes a second create reject with NEXUS_BLUEPRINT_INVALID, whose errors list holds NEXUS_INTERCEPTORS_SHARED.