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:

middleware-example.tsTypeScript
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:

post-route.tsTypeScript
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:

profile-route.tsTypeScript
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:

dashboard-route.tsTypeScript
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:

async-middleware.tsTypeScript
// 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:

composing-middleware.tsTypeScript
// 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:

accessing-context.tsTypeScript
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:

builtin-middleware.tsTypeScript
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:

error-handling.tsTypeScript
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:

type-safety.tsTypeScript
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