Middleware
Type-safe context accumulation for routes
Middleware functions transform and enrich request context. Each middleware can add properties that become available to subsequent middleware, guards, and handlers. Everything is fully typed.
What is Middleware?
A middleware is a function that takes a context object and returns an object with additional properties. The returned object is merged into the context for downstream processing:
import { Get } from '@justscale/http';
// Middleware type signature
type Middleware<TIn, TOut> = (ctx: TIn) => TOut | Promise<TOut>;
// Example: Add timestamp to context
const addTimestamp = (ctx: any) => ({
timestamp: Date.now(),
});
// Usage in route
Get('/events')
.use(addTimestamp) // Adds { timestamp: number }
.handle(({ timestamp, res }) => {
res.json({ timestamp });
});Context Accumulation
The key feature of middleware is context accumulation. Each .use()call adds to the context, building it up step by step:
import { Post } from '@justscale/http';
import { parseAuth, body } from '@justscale/http/builder';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string(),
content: z.string(),
});
Post('/posts')
.use(parseAuth) // Adds: { user: User }
.apply(body(CreatePostSchema)) // Adds: { body: CreatePostDto }
.handle(({ user, body, res }) => {
// Both 'user' and 'body' available
const post = createPost({
title: body.title,
content: body.content,
authorId: user.id,
});
res.json({ post });
});Creating Inline Middleware
The simplest middleware is an inline function that returns an object:
import { Get } from '@justscale/http';
import { parseAuth } from '@justscale/http';
// Add request ID
const addRequestId = (ctx: any) => ({
requestId: crypto.randomUUID(),
});
// Add current timestamp
const addTimestamp = (ctx: any) => ({
timestamp: new Date().toISOString(),
});
// Async middleware
const loadUserSettings = async (ctx: { user: User }) => {
const settings = await db.settings.findOne({ userId: ctx.user.id });
return { settings };
};
// Usage
Get('/profile')
.use(parseAuth) // { user }
.use(addRequestId) // { user, requestId }
.use(addTimestamp) // { user, requestId, timestamp }
.use(loadUserSettings) // { user, requestId, timestamp, settings }
.handle(({ user, settings, res }) => {
res.json({ user, settings });
});Middleware with Dependencies
For middleware that needs services, use createMiddleware to inject dependencies:
import { createMiddleware } from '@justscale/core';
import { Get } from '@justscale/http';
import { parseAuth } from '@justscale/http';
import { UserService, ProfileService } from './services';
// Middleware that needs services
const LoadUserProfile = createMiddleware({
inject: {
users: UserService,
profiles: ProfileService,
},
handler: ({ users, profiles }) => async (ctx: { user: User }) => {
const profile = await profiles.findByUserId(ctx.user.id);
return { profile };
},
});
// Use it in routes
Get('/dashboard')
.use(parseAuth) // Adds { user }
.use(LoadUserProfile) // Adds { profile }
.handle(({ user, profile, res }) => {
res.json({ user, profile });
});Async Middleware
Middleware can be async. They're awaited automatically:
// Fetch data from external API
const enrichWithGeoData = async (ctx: any) => {
const ip = ctx.req.ip;
const geo = await fetch(`https://api.ipapi.com/${ip}`).then(r => r.json());
return { geo };
};
// Load related data
const loadRelatedPosts = async (ctx: { params: { id: string } }) => {
const related = await db.posts.findRelated(ctx.params.id);
return { relatedPosts: related };
};
Get('/posts/:id')
.use(enrichWithGeoData) // Async - automatically awaited
.use(loadRelatedPosts) // Async - automatically awaited
.handle(({ geo, relatedPosts, res }) => {
res.json({ geo, relatedPosts });
});Composing Middleware
Create reusable middleware pipelines by composing smaller middleware functions:
// Composable middleware pieces
const parseAuth = (ctx: any) => ({ user: parseToken(ctx.headers.authorization) });
const requireAdmin = (ctx: { user: User }) => {
if (!ctx.user.isAdmin) throw new Error('Admin required');
return {};
};
const loadPermissions = async (ctx: { user: User }) => {
const permissions = await db.permissions.find({ userId: ctx.user.id });
return { permissions };
};
// Compose into reusable pipelines
const adminAuth = [parseAuth, requireAdmin, loadPermissions];
// Use the pipeline
Delete('/users/:id')
.use(adminAuth[0])
.use(adminAuth[1])
.use(adminAuth[2])
.handle(({ user, permissions, res }) => {
// Guaranteed admin with permissions loaded
});Accessing Previous Context
Middleware receives all previously accumulated context:
const addGreeting = (ctx: { user: User }) => ({
greeting: `Hello, ${ctx.user.name}!`,
});
const addPersonalizedMessage = (ctx: { user: User; greeting: string }) => ({
message: `${ctx.greeting} You have ${ctx.user.notifications} notifications.`,
});
Get('/welcome')
.use(parseAuth) // { user }
.use(addGreeting) // { user, greeting }
.use(addPersonalizedMessage) // { user, greeting, message }
.handle(({ message, res }) => {
res.json({ message });
});Built-in HTTP Middleware
The @justscale/http package provides common middleware:
import { Get, Post } from '@justscale/http';
import { body, populate, cors } from '@justscale/http';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
export const UsersController = createController('/users', {
inject: { users: UserService },
routes: (services) => ({
// parseBody - Validates and parses request body
create: Post('/')
.apply(body(CreateUserSchema))
.handle(({ body, res }) => {
// body is typed and validated
}),
// parseAuth - Extracts and validates auth token
profile: Get('/me')
.use(parseAuth)
.handle(({ user, res }) => {
res.json({ user });
}),
// populate - Auto-loads entity by ID, returns 404 if not found
// Pass the repository directly from services
getOne: Get('/:userId')
.use(populate(services.users, 'user', 'userId'))
.handle(({ user, res }) => {
res.json({ user });
}),
}),
});Error Handling in Middleware
If middleware throws an error, request processing stops and the error is handled by your error handler:
const requireValidToken = (ctx: any) => {
const token = ctx.headers.authorization;
if (!token) {
throw new Error('Authorization header required');
}
const user = validateToken(token);
if (!user) {
throw new Error('Invalid token');
}
return { user };
};
Get('/protected')
.use(requireValidToken) // Throws if invalid
.handle(({ user, res }) => {
// Only reached if token is valid
res.json({ user });
});Type Safety
Middleware benefits from full TypeScript type inference. The context type flows through each .use() call:
const mw1 = (ctx: any) => ({ foo: 'hello' });
const mw2 = (ctx: { foo: string }) => ({ bar: ctx.foo.toUpperCase() });
const mw3 = (ctx: { bar: string }) => ({ baz: ctx.bar.length });
Get('/typed')
.use(mw1) // ctx: {} -> { foo: string }
.use(mw2) // ctx: { foo: string } -> { bar: string }
.use(mw3) // ctx: { bar: string } -> { baz: number }
.handle(({ foo, bar, baz, res }) => {
// All three properties available and typed
foo; // Type: string
bar; // Type: string
baz; // Type: number
});Best Practices
- Keep middleware focused - Each middleware should do one thing well
- Return only what's needed - Don't pollute context with unnecessary properties
- Type your inputs - Use TypeScript to document what context properties you expect
- Fail fast - Throw errors early if requirements aren't met
- Compose for reusability - Build small, composable middleware pieces
- Use createMiddleware for DI - When you need services, use the DI-aware version