Skip to main content
Version: 4.x

Interceptors

Aspect-Oriented Programming (AOP) for cross-cutting concerns.

Overview​

FeatureDescription
Pre/Post ProcessingExecute code before and after handlers
Priority-BasedControl execution order
Dependency InjectionFull DI support
One-Liner SetupsetupInterceptorsForExpress()
Built-InPerformance tracking included
Type-SafeFull TypeScript generics
ExpressoTS v4 interceptor chain: priority-ordered execution from AuthInterceptor through LoggingInterceptor and CacheInterceptor to the controller handler, then unwinding in reverse order

Quick Start​

1. Setup Interceptors​

Use the one-liner setup function:

import { setupInterceptorsForExpress } from "@expressots/adapter-express";
import { CustomLoggingInterceptor, CacheInterceptor } from "./interceptors";

export class App extends AppExpress {
async configureServices(): Promise<void> {
const { interceptorsRegistered } = setupInterceptorsForExpress(
this.container.Container,
{
builtIn: {
performance: true // Enable built-in performance interceptor
},
customInterceptors: [
CustomLoggingInterceptor,
CacheInterceptor
]
}
);

console.log(`Registered ${interceptorsRegistered} interceptors`);
}
}

2. Create an Interceptor​

import { provide, inject, Logger, Interceptor } from "@expressots/core";
import type { IInterceptor, ExecutionContext, CallHandler } from "@expressots/core";

@Interceptor({ priority: 10 })
@provide(LoggingInterceptor)
export class LoggingInterceptor implements IInterceptor {
readonly priority = 10;

constructor(@inject(Logger) private logger: Logger) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();

this.logger.info(`→ ${request.method} ${request.path}`);

const result = await next.handle();

this.logger.info(`← ${request.method} ${request.path}`);

return result;
}
}

3. Apply to Routes​

Registration via setupInterceptorsForExpress() makes interceptors available in the DI container, but does not apply them to routes. Use @UseInterceptors() to apply them at the controller or method level:

import { controller, Get } from "@expressots/adapter-express";
import { UseInterceptors } from "@expressots/core";

@controller("/users")
@UseInterceptors(CacheInterceptor) // Apply to all routes in controller
export class UserController {

@Get("/:id")
@UseInterceptors(LoggingInterceptor) // Apply to specific route
getUser() {
return { id: 1, name: "John" };
}
}

Creating Interceptors​

Basic Structure​

Every interceptor must:

  1. Implement IInterceptor interface
  2. Have @Interceptor() decorator (for auto-discovery and priority)
  3. Have @injectable() decorator (for DI)
  4. Implement intercept() method
import { injectable, Interceptor } from "@expressots/core";
import type { IInterceptor, ExecutionContext, CallHandler } from "@expressots/core";

@Interceptor({ priority: 10 })
@injectable()
export class MyInterceptor implements IInterceptor {
async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
// Before handler execution
console.log("Before");

// Execute handler
const result = await next.handle();

// After handler execution
console.log("After");

return result;
}
}

Execution Context​

The ExecutionContext provides access to request and response objects:

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();
const response = context.getResponse();

// Access request properties
const method = request.method;
const path = request.path;
const headers = request.headers;
const query = request.query;
const params = request.params;
const body = request.body;

// Access response properties
const statusCode = response.statusCode;

return next.handle();
}

Call Handler​

The CallHandler controls execution flow:

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
// Option 1: Execute handler
const result = await next.handle();

// Option 2: Short-circuit (don't call handler)
if (someCondition) {
return cachedResult as T;
}

// Option 3: Modify result
const result = await next.handle();
return { ...result, modified: true } as T;
}

Priority-Based Execution​

Interceptors execute in priority order (lower numbers run first):

@Interceptor({ priority: 5 }) // Runs first
@provide(AuthInterceptor)
export class AuthInterceptor implements IInterceptor {
readonly priority = 5;
}

@Interceptor({ priority: 10 }) // Runs second
@provide(LoggingInterceptor)
export class LoggingInterceptor implements IInterceptor {
readonly priority = 10;
}

@Interceptor({ priority: 15 }) // Runs third
@provide(CacheInterceptor)
export class CacheInterceptor implements IInterceptor {
readonly priority = 15;
}

Execution Flow:

Request → Auth (5) → Logging (10) → Cache (15) → Handler → Cache (15) → Logging (10) → Auth (5) → Response

Built-In Interceptors​

Performance Interceptor​

Tracks execution time automatically:

setupInterceptorsForExpress(container, {
builtIn: {
performance: true // Enable performance tracking
}
});

Output:

[Performance] GET /api/users completed in 45ms

Access Performance Data:

@Get("/users")
async getUsers(@req() request: Request) {
const perfData = request.performanceData;
// { startTime, endTime, duration }
}

Common Interceptor Examples​

1. Logging Interceptor​

Log all requests and responses:

@Interceptor({ priority: 5 })
@provide(CustomLoggingInterceptor)
export class CustomLoggingInterceptor implements IInterceptor {
readonly priority = 5;

constructor(@inject(Logger) private logger: Logger) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();
const method = request.method;
const path = request.path;

this.logger.info(`→ ${method} ${path}`);

const startTime = Date.now();

try {
const result = await next.handle();
const duration = Date.now() - startTime;

this.logger.info(`← ${method} ${path} (${duration}ms)`);

return result;
} catch (error) {
const duration = Date.now() - startTime;

this.logger.error(`✗ ${method} ${path} (${duration}ms)`, error);

throw error;
}
}
}

2. Cache Interceptor​

Cache GET requests:

@Interceptor({ priority: 10 })
@provide(CacheInterceptor)
export class CacheInterceptor implements IInterceptor {
readonly priority = 10;
private cache: Map<string, { data: unknown; expiry: number }> = new Map();
private ttl = 5000; // 5 seconds

constructor(@inject(Logger) private logger: Logger) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();

// Only cache GET requests
if (request.method !== "GET") {
return next.handle();
}

const cacheKey = request.url;
const cached = this.cache.get(cacheKey);

// Check if cached and not expired
if (cached && cached.expiry > Date.now()) {
this.logger.info(`Cache HIT for ${cacheKey}`);
return cached.data as T;
}

// Cache miss - execute handler
this.logger.info(`Cache MISS for ${cacheKey}`);
const result = await next.handle();

// Store in cache
this.cache.set(cacheKey, {
data: result,
expiry: Date.now() + this.ttl
});

return result;
}
}

3. Transform Interceptor​

Transform responses:

@Interceptor({ priority: 15 })
@provide(TransformInterceptor)
export class TransformInterceptor implements IInterceptor {
readonly priority = 15;

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const result = await next.handle();

// Wrap response in standard format
return {
success: true,
data: result,
timestamp: new Date().toISOString()
} as T;
}
}

Before Transform:

{ "id": 1, "name": "John" }

After Transform:

{
"success": true,
"data": { "id": 1, "name": "John" },
"timestamp": "2026-01-10T10:30:45.123Z"
}

4. Timing Interceptor​

Add execution time to response headers:

@Interceptor({ priority: 5 })
@provide(TimingInterceptor)
export class TimingInterceptor implements IInterceptor {
readonly priority = 5;

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const response = context.getResponse();
const startTime = Date.now();

const result = await next.handle();

const duration = Date.now() - startTime;
response.setHeader("X-Response-Time", `${duration}ms`);

return result;
}
}

5. Authentication Interceptor​

Check authentication before handler:

@Interceptor({ priority: 1 }) // Run first
@provide(AuthInterceptor)
export class AuthInterceptor implements IInterceptor {
readonly priority = 1;

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();
const response = context.getResponse();

const token = request.headers.authorization?.replace("Bearer ", "");

if (!token) {
response.status(401).json({ error: "Unauthorized" });
throw new Error("Unauthorized");
}

// Verify token
const user = await this.verifyToken(token);

// Attach user to request
(request as any).user = user;

return next.handle();
}

private async verifyToken(token: string) {
// Verify JWT token
return { id: 1, email: "[email protected]" };
}
}

6. Rate Limiting Interceptor​

Limit requests per user:

@Interceptor({ priority: 2 })
@provide(RateLimitInterceptor)
export class RateLimitInterceptor implements IInterceptor {
readonly priority = 2;
private requests: Map<string, number[]> = new Map();
private limit = 100; // 100 requests
private window = 60000; // per minute

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();
const response = context.getResponse();
const ip = request.ip;

const now = Date.now();
const userRequests = this.requests.get(ip) || [];

// Filter recent requests
const recentRequests = userRequests.filter(time => now - time < this.window);

if (recentRequests.length >= this.limit) {
response.status(429).json({
error: "Too many requests",
retryAfter: Math.ceil((recentRequests[0] + this.window - now) / 1000)
});
throw new Error("Rate limit exceeded");
}

// Add current request
recentRequests.push(now);
this.requests.set(ip, recentRequests);

return next.handle();
}
}

7. Validation Interceptor​

Validate request data:

@Interceptor({ priority: 3 })
@provide(ValidationInterceptor)
export class ValidationInterceptor implements IInterceptor {
readonly priority = 3;

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();
const response = context.getResponse();

// Validate request body
if (request.method === "POST" || request.method === "PUT") {
const errors = await this.validate(request.body);

if (errors.length > 0) {
response.status(400).json({
error: "Validation failed",
details: errors
});
throw new Error("Validation failed");
}
}

return next.handle();
}

private async validate(body: any): Promise<string[]> {
const errors: string[] = [];
// Add validation logic
return errors;
}
}

8. Error Handling Interceptor​

Catch and format errors:

@Interceptor({ priority: 100 }) // Run last
@provide(ErrorHandlerInterceptor)
export class ErrorHandlerInterceptor implements IInterceptor {
readonly priority = 100;

constructor(@inject(Logger) private logger: Logger) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
try {
return await next.handle();
} catch (error) {
const request = context.getRequest();
const response = context.getResponse();

this.logger.error(
`Error in ${request.method} ${request.path}`,
error
);

// Format error response
response.status(500).json({
error: "Internal server error",
message: error.message,
timestamp: new Date().toISOString()
});

throw error;
}
}
}

Applying Interceptors​

Two steps are required to activate an interceptor:

  1. Register: setupInterceptorsForExpress({ customInterceptors: [...] }) in app.ts binds the class in the DI container and the InterceptorRegistry.
  2. Apply: @UseInterceptors(...) on a controller (all routes) or on a single method (one route).

Register (app.ts)​

setupInterceptorsForExpress(container, {
customInterceptors: [
LoggingInterceptor,
CacheInterceptor
]
});

This makes the interceptors resolvable but does not apply them to any route.

Controller-Level​

Apply to all routes in a controller:

import { UseInterceptors } from "@expressots/core";

@UseInterceptors(CacheInterceptor)
@controller("/users")
export class UserController {
// All routes use CacheInterceptor
}

Method-Level​

Apply to a specific route:

import { UseInterceptors } from "@expressots/core";

@controller("/users")
export class UserController {
@Get("/:id")
@UseInterceptors(CacheInterceptor, LoggingInterceptor)
getUser() {
// Only this route uses both interceptors
}
}

Multiple Interceptors​

Apply multiple interceptors (execute in priority order):

@Get("/data")
@UseInterceptors(AuthInterceptor, LoggingInterceptor, CacheInterceptor)
getData() {
return { data: "example" };
}

Dependency Injection​

Interceptors support full dependency injection:

@Interceptor({ priority: 10 })
@provide(MyInterceptor)
export class MyInterceptor implements IInterceptor {
readonly priority = 10;

constructor(
@inject(Logger) private logger: Logger,
@inject(CacheService) private cache: CacheService,
@inject(AuthService) private auth: AuthService
) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
// Use injected services
this.logger.info("Processing request");
const user = await this.auth.getCurrentUser();

return next.handle();
}
}

Best Practices​

1. Use Priority Wisely​

Order interceptors logically:

// ✅ Good: Auth → Logging → Cache → Handler
AuthInterceptor: priority 1
LoggingInterceptor: priority 5
CacheInterceptor: priority 10

// ❌ Bad: Cache → Auth (cache would bypass auth)
CacheInterceptor: priority 1
AuthInterceptor: priority 10

2. Handle Errors Properly​

Always handle errors in interceptors:

// ✅ Good
async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
try {
return await next.handle();
} catch (error) {
this.logger.error("Handler failed", error);
throw error; // Re-throw after logging
}
}

// ❌ Bad
async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
return await next.handle(); // Errors not handled
}

3. Short-Circuit When Appropriate​

Return early for cached responses:

// ✅ Good
if (cached) {
return cached as T; // Don't call handler
}

return next.handle();

4. Don't Block Event Loop​

Avoid synchronous operations:

// ✅ Good
const result = await asyncOperation();

// ❌ Bad
const result = syncOperation(); // Blocks event loop

5. Use Descriptive Names​

Name interceptors clearly:

// ✅ Good
CustomLoggingInterceptor
CacheInterceptor
AuthInterceptor

// ❌ Bad
MyInterceptor
Interceptor1
Helper

Testing Interceptors​

Unit Testing​

Test interceptors in isolation with mocked dependencies:

import { CustomLoggingInterceptor } from "./custom-logging.interceptor";
import { Logger } from "@expressots/core";

describe("CustomLoggingInterceptor", () => {
let interceptor: CustomLoggingInterceptor;
let mockLogger: jest.Mocked<Logger>;

beforeEach(() => {
mockLogger = {
info: jest.fn(),
error: jest.fn()
} as any;

interceptor = new CustomLoggingInterceptor(mockLogger);
});

it("should log request and response", async () => {
const context = {
getRequest: () => ({
method: "GET",
path: "/users"
}),
getResponse: () => ({})
} as any;

const next = {
handle: jest.fn().mockResolvedValue({ data: "test" })
};

await interceptor.intercept(context, next);

expect(mockLogger.info).toHaveBeenCalledTimes(2);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining("→ GET /users")
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining("← GET /users")
);
});

it("should log errors", async () => {
const context = {
getRequest: () => ({
method: "GET",
path: "/users"
}),
getResponse: () => ({})
} as any;

const error = new Error("Test error");
const next = {
handle: jest.fn().mockRejectedValue(error)
};

await expect(
interceptor.intercept(context, next)
).rejects.toThrow(error);

expect(mockLogger.error).toHaveBeenCalled();
});

it("should measure execution time", async () => {
const context = {
getRequest: () => ({
method: "GET",
path: "/users"
}),
getResponse: () => ({})
} as any;

const next = {
handle: jest.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ data: "test" }), 50))
)
};

await interceptor.intercept(context, next);

expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(/\(\d+ms\)/)
);
});
});

Testing Priority Order​

Verify interceptors execute in the correct order:

import { setupInterceptorsForExpress } from "@expressots/adapter-express";
import { Container } from "@expressots/core";

describe("Interceptor Priority", () => {
let executionLog: string[] = [];

class TestInterceptor1 implements IInterceptor {
readonly priority = 5;
async intercept(context: ExecutionContext, next: CallHandler) {
executionLog.push("before-5");
const result = await next.handle();
executionLog.push("after-5");
return result;
}
}

class TestInterceptor2 implements IInterceptor {
readonly priority = 10;
async intercept(context: ExecutionContext, next: CallHandler) {
executionLog.push("before-10");
const result = await next.handle();
executionLog.push("after-10");
return result;
}
}

it("should execute in priority order", () => {
executionLog = [];

const container = new Container();
setupInterceptorsForExpress(container, {
customInterceptors: [TestInterceptor1, TestInterceptor2]
});

// Execute request
// ...

expect(executionLog).toEqual([
"before-5", // Lower priority runs first (before)
"before-10",
"after-10", // Higher priority runs first (after)
"after-5"
]);
});
});

Testing Cache Interceptor​

Test caching behavior:

import { CacheInterceptor } from "./cache.interceptor";

describe("CacheInterceptor", () => {
let interceptor: CacheInterceptor;
let mockLogger: jest.Mocked<Logger>;

beforeEach(() => {
mockLogger = { info: jest.fn() } as any;
interceptor = new CacheInterceptor(mockLogger);
});

it("should cache GET requests", async () => {
const context = {
getRequest: () => ({ method: "GET", url: "/users" }),
getResponse: () => ({})
} as any;

const next = {
handle: jest.fn().mockResolvedValue({ data: "cached" })
};

// First call - cache miss
const result1 = await interceptor.intercept(context, next);
expect(next.handle).toHaveBeenCalledTimes(1);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining("Cache MISS")
);

// Second call - cache hit
const result2 = await interceptor.intercept(context, next);
expect(next.handle).toHaveBeenCalledTimes(1); // Still 1, not called again
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining("Cache HIT")
);

expect(result1).toEqual(result2);
});

it("should not cache non-GET requests", async () => {
const context = {
getRequest: () => ({ method: "POST", url: "/users" }),
getResponse: () => ({})
} as any;

const next = {
handle: jest.fn().mockResolvedValue({ data: "not cached" })
};

await interceptor.intercept(context, next);
await interceptor.intercept(context, next);

expect(next.handle).toHaveBeenCalledTimes(2); // Called both times
});

it("should expire cached entries after TTL", async () => {
jest.useFakeTimers();

const context = {
getRequest: () => ({ method: "GET", url: "/users" }),
getResponse: () => ({})
} as any;

const next = {
handle: jest.fn().mockResolvedValue({ data: "cached" })
};

// First call
await interceptor.intercept(context, next);
expect(next.handle).toHaveBeenCalledTimes(1);

// Advance time beyond TTL
jest.advanceTimersByTime(6000); // 6 seconds

// Second call - cache expired
await interceptor.intercept(context, next);
expect(next.handle).toHaveBeenCalledTimes(2); // Called again

jest.useRealTimers();
});
});

E2E Testing​

Test interceptors in a real application:

import { bootstrap } from "@expressots/core";
import { App } from "./app";

describe("Interceptors E2E", () => {
let app: Awaited<ReturnType<typeof bootstrap>>;
let port: number;

beforeAll(async () => {
app = await bootstrap(App, { port: 0 });
port = await app.getPort();
});

afterAll(async () => {
const httpServer = await app.getHttpServer();
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
});

it("should add timing header to response", async () => {
const response = await fetch(`http://localhost:${port}/users`);

expect(response.headers.get("X-Response-Time")).toMatch(/\d+ms/);
});

it("should transform response format", async () => {
const response = await fetch(`http://localhost:${port}/users`);
const data = await response.json();

expect(data).toHaveProperty("success", true);
expect(data).toHaveProperty("data");
expect(data).toHaveProperty("timestamp");
});

it("should cache GET requests", async () => {
const url = `http://localhost:${port}/users`;

const start1 = Date.now();
const response1 = await fetch(url);
const duration1 = Date.now() - start1;

const start2 = Date.now();
const response2 = await fetch(url);
const duration2 = Date.now() - start2;

// Second request should be significantly faster (cached)
expect(duration2).toBeLessThan(duration1 / 2);

const data1 = await response1.json();
const data2 = await response2.json();

expect(data1).toEqual(data2);
});

it("should handle errors gracefully", async () => {
const response = await fetch(
`http://localhost:${port}/users/error`
);

expect(response.status).toBe(500);

const error = await response.json();
expect(error).toHaveProperty("error");
expect(error).toHaveProperty("timestamp");
});
});

Advanced Patterns​

Composable Interceptors​

Create interceptors that can be composed:

@Interceptor({ priority: 10 })
@provide(ComposableInterceptor)
export class ComposableInterceptor implements IInterceptor {
readonly priority = 10;

constructor(
@inject(Logger) private logger: Logger,
@inject("interceptorChain") private chain?: IInterceptor[]
) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
// Execute pre-chain
for (const interceptor of this.chain || []) {
await interceptor.intercept(context, next);
}

const result = await next.handle();

// Execute post-chain
return result;
}
}

Conditional Interceptor Execution​

Execute interceptors based on runtime conditions:

@Interceptor({ priority: 5 })
@provide(ConditionalInterceptor)
export class ConditionalInterceptor implements IInterceptor {
readonly priority = 5;

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();

// Only log in development
if (process.env.NODE_ENV === "development") {
console.log(`${request.method} ${request.path}`);
}

// Only cache if header is present
if (request.headers["x-enable-cache"]) {
// Check cache...
}

return next.handle();
}
}

Performance Optimization​

Async Interceptors​

For non-critical operations, use async patterns:

@Interceptor({ priority: 100 })
@provide(AsyncLoggingInterceptor)
export class AsyncLoggingInterceptor implements IInterceptor {
readonly priority = 100;

constructor(@inject(Logger) private logger: Logger) {}

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const request = context.getRequest();

// Don't await - log asynchronously
this.logger.info(`${request.method} ${request.path}`).catch(() => {
// Handle error without blocking request
});

return next.handle();
}
}

Interceptor Pooling​

Reuse interceptor instances for better performance:

const interceptorPool = new Map<string, IInterceptor>();

function getInterceptor(key: string, factory: () => IInterceptor): IInterceptor {
if (!interceptorPool.has(key)) {
interceptorPool.set(key, factory());
}
return interceptorPool.get(key)!;
}

Performance Considerations​

Overhead​

  • Each interceptor adds ~1-5ms per request
  • Multiple interceptors stack (5 interceptors ≈ 5-25ms)
  • Async operations increase overhead

Recommendations​

ScenarioRecommended Interceptors
Production APIAuth (1), Logging (5), Performance (10)
DevelopmentAuth (1), Logging (5), Error Handler (100)
High TrafficAuth (1), Cache (5) only
MicroservicePerformance (10) only

Optimization Tips​

  1. Keep interceptors fast: Avoid heavy operations
  2. Use caching: Cache expensive checks
  3. Minimize interceptors: Only add what you need
  4. Order matters: Put fast interceptors first
  5. Short-circuit early: Return cached results ASAP
  6. Profile in production: Monitor interceptor execution time
  7. Use async patterns: Don't block on non-critical operations

Troubleshooting​

Interceptor Not Running​

Check registration in app.ts:

setupInterceptorsForExpress(container, {
customInterceptors: [MyInterceptor] // Must be in array
});

Check application on the controller or method:

@UseInterceptors(MyInterceptor) // Required to apply to routes
@controller("/users")
export class UserController { }

Check class decorators:

@Interceptor({ priority: 10 }) // Required
@injectable() // Required

Wrong Execution Order​

Check priorities:

// Lower numbers run first
@Interceptor({ priority: 1 }) // Runs first
@Interceptor({ priority: 10 }) // Runs second

Dependency Injection Not Working​

Check @provide() decorator:

@Interceptor({ priority: 10 })
@provide(MyInterceptor) // Must have @provide()
export class MyInterceptor { }

Performance Issues​

Profile interceptors:

async intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T> {
const start = Date.now();
const result = await next.handle();
console.log(`Interceptor took ${Date.now() - start}ms`);
return result;
}

API Reference​

IInterceptor Interface​

interface IInterceptor {
readonly priority: number;
intercept<T>(context: ExecutionContext, next: CallHandler<T>): Promise<T>;
}

ExecutionContext​

interface ExecutionContext {
getRequest<T = Request>(): T;
getResponse<T = Response>(): T;
}

CallHandler​

interface CallHandler<T = any> {
handle(): Promise<T>;
}

@Interceptor Decorator​

@Interceptor(options?: { priority?: number })

setupInterceptorsForExpress​

setupInterceptorsForExpress(
container: Container,
options: {
builtIn?: { performance?: boolean };
customInterceptors?: Array<new (...args: any[]) => IInterceptor>;
}
): { interceptorsRegistered: number }

Composition helpers​

The framework ships four helpers that let you compose interceptors at the point of attachment without writing a wrapper class:

HelperPurpose
whenInterceptor(cond, i)Apply i only when cond(ctx) returns true.
unlessInterceptor(cond, i)Apply i only when cond(ctx) returns false, the inverse of when.
pipeInterceptors(...i)Run interceptors in order; each next.handle() chains to the following.
combineInterceptors(...i)Run interceptors in parallel for side-effect-only work (logging, metrics).

whenInterceptor / unlessInterceptor​

import {
UseInterceptors,
whenInterceptor,
unlessInterceptor,
PerformanceInterceptor,
LoggingInterceptor,
} from "@expressots/core";
import { controller, Get } from "@expressots/adapter-express";
import { CacheInterceptor } from "./interceptors";

@controller("/users")
export class UserController {
@Get("/")
@UseInterceptors(
PerformanceInterceptor,
whenInterceptor(
(ctx) => ctx.getRequest().headers["x-cache"] === "true",
CacheInterceptor,
),
unlessInterceptor(
(ctx) => ctx.getRequest().method === "POST",
LoggingInterceptor,
),
)
list() {}
}

whenInterceptor / unlessInterceptor evaluate their condition lazily at request time. The condition receives the ExecutionContext and can be sync or async.

pipeInterceptors: sequential composition​

import { pipeInterceptors, UseInterceptors } from "@expressots/core";

@Get("/heavy")
@UseInterceptors(pipeInterceptors(AuthCheckInterceptor, RateLimitInterceptor, CacheInterceptor))
heavy() {}

Each interceptor's next.handle() resolves the next item in the pipe. Short-circuit by returning early, and the rest of the pipe is skipped.

combineInterceptors: parallel composition​

import { combineInterceptors, UseInterceptors } from "@expressots/core";

@Get("/")
@UseInterceptors(combineInterceptors(MetricsInterceptor, AuditInterceptor))
list() {}

combineInterceptors fires every member side-by-side and waits for all of them to finish before resolving. Use it for observation-only interceptors that don't mutate the response.

Type guards​

When inspecting interceptors at runtime (rare, but useful in testing helpers):

import {
isComposedInterceptor,
isConditionalInterceptor,
} from "@expressots/core";

if (isComposedInterceptor(x)) { /* it's a pipe or combine */ }
if (isConditionalInterceptor(x)) { /* it's a when or unless */ }

Built-in interceptors​

InterceptorPurpose
LoggingInterceptorStructured request/response logging.
PerformanceInterceptorPer-request timing that feeds the Studio "slow routes" view.
PerformanceInterceptorServiceAggregated metrics service (P50/P95/P99 per route).
TimeoutInterceptorAborts handlers that exceed TimeoutOptions.timeout (default 30000 ms).
import {
UseInterceptors,
PerformanceInterceptor,
TimeoutInterceptor,
} from "@expressots/core";
import { controller, Get } from "@expressots/adapter-express";

@controller("/api")
export class ApiController {
@Get("/slow")
@UseInterceptors(new TimeoutInterceptor({ timeout: 5000 }), PerformanceInterceptor)
slow() {}
}

Comparison with Other Frameworks​

FeatureExpressoTSNestJSFastify
Interceptors✅ Built-in✅ Built-in⚠️ Via hooks
Priority System✅ Numeric priority❌ Order-based❌ Order-based
One-Liner Setup✅ Yes❌ Manual registration❌ Manual registration
Dependency Injection✅ Full DI✅ Full DI⚠️ Limited
Built-In Performance✅ Yes❌ Custom only⚠️ Via plugins
Type Safety✅ Full TypeScript✅ Full TypeScript✅ Full TypeScript

Support the Project​

ExpressoTS is MIT-licensed open source. See the support guide to contribute.