Typing middleware is a topic that comes up often when working with TypeScript in backend or frontend frameworks like Express, Koa, or even custom middleware pipelines. Middleware functions are a core part of many web servers and applications, acting as the glue between requests and responses. Properly typing middleware not only improves developer experience with better autocompletion and error checking but also helps avoid subtle bugs that can arise from incorrect assumptions about the data flowing through the middleware chain.
Let me walk you through how to type middleware effectively, covering the core concepts, practical examples, common pitfalls, and some tips for interviews or real-world production code.
Middleware is essentially a function that sits between the incoming request and the final response handler. It can modify the request, response, or even terminate the request-response cycle early. In frameworks like Express, middleware typically looks like this:
function middleware(req, res, next) {
// do something
next();
}
Here, req and res are objects representing the HTTP request and response, and next is a callback to pass control to the next middleware. Typing these parameters correctly means you get better safety and tooling support.
At its core, typing middleware means defining the types for the parameters and return value of the middleware function. The exact signature depends on the framework you use, but the general pattern is:
Request or a custom interface extending itResponse or a custom extensionNextFunctionFor example, in Express, the official types from @types/express define middleware as:
import { Request, Response, NextFunction } from 'express';
function myMiddleware(req: Request, res: Response, next: NextFunction): void {
// middleware logic
next();
}
This is straightforward, but real-world scenarios often require extending these types.
One common use case is when you attach custom properties to the req or res objects. For instance, authentication middleware might add a user property to req. If you don’t type this properly, TypeScript will complain or you’ll lose type safety.
Here’s how you can extend the Request interface:
import { Request, Response, NextFunction } from 'express';
interface AuthenticatedRequest extends Request {
user?: {
id: string;
roles: string[];
};
}
function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
// Simulate authentication
const token = req.headers['authorization'];
if (token === 'valid-token') {
req.user = { id: '123', roles: ['admin'] };
next();
} else {
res.status(401).send('Unauthorized');
}
}
By creating an interface that extends the base Request, you can safely access req.user> in downstream middleware or route handlers without TypeScript errors.
any?Sometimes developers default to typing middleware parameters as any to quickly bypass TypeScript errors. While this might speed up initial development, it defeats the purpose of using TypeScript and can lead to runtime bugs. For example, if you assume req.user exists but it doesn’t, your app might crash.
@types/express or the framework’s own types. They cover most cases and keep your code consistent.req.user before authentication), mark it as optional to avoid false assumptions.next function correctly: The next function can accept an error argument (next(err)), so its type reflects that. Using the official NextFunction type covers this.next function type: Typing next as () => void instead of (err?: any) => void can cause issues when handling errors.Request interface without declaration merging can lead to confusing errors or conflicts.Promise or call next appropriately. Forgetting to type async functions or mixing callbacks and promises can cause unexpected behavior.any: This reduces type safety and defeats the purpose of TypeScript.Typing middleware itself doesn’t impact runtime performance since TypeScript types are erased during compilation. However, well-typed middleware improves maintainability and scalability by preventing bugs early and making it easier to refactor or onboard new developers.
In large applications, you might have dozens of middleware functions. Using consistent and clear types helps avoid confusion about what data is available on req or res at each stage. This can prevent subtle bugs that only show up under certain conditions.
Middleware often handles sensitive data, like authentication tokens or user information. Proper typing can help enforce security best practices by making it clear when certain properties are available or required.
For example, if your middleware adds a user object to the request, typing it explicitly helps ensure downstream code checks for its presence before accessing sensitive fields. This reduces the risk of null reference errors or unauthorized access.
next typing or overusing any.Express and Koa are two popular Node.js frameworks, but their middleware typing differs due to their design:
| Aspect | Express | Koa |
|---|---|---|
| Middleware signature | (req, res, next) => void |
async (ctx, next) => Promise<void> |
| Request/Response objects | Separate Request and Response types |
Single Context object encapsulating request and response |
| Typing approach | Extend Request and Response interfaces |
Extend Context interface |
| Async support | Callback-based, but supports async middleware | Designed for async/await, middleware returns promises |
For Koa, typing middleware looks like this:
import Koa, { Context, Next } from 'koa';
async function myMiddleware(ctx: Context, next: Next) {
// do something with ctx.request or ctx.response
await next();
}
Here, you typically extend the Context interface if you want to add custom properties, similar to Express.
Imagine you’re building an API with Express and need middleware to authenticate users via JWT tokens. You want to add the decoded user info to the request object so downstream handlers can access it.
A typed middleware might look like this:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface UserPayload {
id: string;
email: string;
roles: string[];
}
interface AuthenticatedRequest extends Request {
user?: UserPayload;
}
function authenticateJWT(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).send('Missing authorization header');
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as UserPayload;
req.user = decoded;
next();
} catch (err) {
res.status(403).send('Invalid token');
}
}
This approach ensures that any handler after authenticateJWT can safely access req.user> with proper type information, reducing runtime errors and improving developer confidence.
Typing middleware is about more than just adding types to function parameters. It’s about understanding the data flow through your application and making sure TypeScript can help you catch mistakes early. Use official types, extend interfaces when needed, avoid any, and be mindful of async and error handling.
In interviews, showing that you know how to type middleware properly demonstrates your grasp of TypeScript’s strengths and your ability to write maintainable, scalable backend code. Plus, it’s a practical skill you’ll use every day in real projects.