typed-express-pipeline
v1.0.2
Published
A type-safe Express middleware pipeline composer with advanced error handling and handler composition patterns
Maintainers
Readme
Express Pipeline
A type-safe Express middleware pipeline composer with advanced error handling and handler composition patterns.
Features
- Type-Safe Composition: Full TypeScript support with request/response type transformations across the pipeline
- Error Handling: Built-in error handler support with type validation (
CatchErrorandThrowErrors) - Handler Types: Support for both transforming handlers (
Function) and consuming handlers (Consumer) - Terminal Validation: Compile-time prevention of adding handlers after terminal handlers (Consumers)
- Flexible API: Builder pattern for chainable pipeline construction
- Zero Dependencies: Minimal footprint (build for Express)
Installation
npm install typed-express-pipelineQuick Start
import { Request, Response } from 'express';
import { pipeline } from 'typed-express-pipeline';
// Create handlers with explicit types
const middleware = pipeline<Request, Response>()
.next((req, res) => {
// TypeScript infers the type automatically
console.log('Request received');
return [req, res];
})
.next((req, res) => {
// Send response - automatically detected as terminal
res.json({ success: true, data: req.body });
})
.build();
// Use with Express
app.post('/user', middleware);Core Concepts
Handlers vs Consumers
Functions (transforming handlers):
- Return
[req, res]tuple - Internally: pipeline automatically calls
next()to pass control to the next handler - Used for middleware transformations that need to transform request/response
const handler: Function<ReqIn, ResIn, ReqOut, ResOut> =
async (req, res) => [transformedReq, transformedRes];Consumers (terminal handlers):
- Return
void(no value) - The chain ends here
- Used for final response handlers that send the response
const consumer: Consumer<Req, Res> =
async (req, res) => {
res.json({ data: 'response' });
};Error Handlers
Handle errors thrown by previous handlers:
class ValidationError extends Error {}
const validate: Function<Request, Response, Request, Response, ValidationError> =
(req, res) => {
if (!req.body.username) throw new ValidationError('Username required');
return [req, res];
};
pipeline<Request, Response>()
.next(validate) // Declares it throws ValidationError via type parameters
.next((err: ValidationError, req, res) => {
res.status(400).json({ error: err.message });
})
.build();ErrorFunction (transforming):
const handler: ErrorFunction<ValidationError, Req, Res, Req, Res> =
async (err, req, res) => {
// Internally: pipeline catches the error, passes it to this handler,
// and automatically calls next() with the returned [req, res] tuple
req.body.error = err.message;
return [req, res];
};ErrorConsumer (terminal):
const handler: ErrorConsumer<ValidationError, Req, Res> =
async (err, req, res) => {
res.status(400).json({ error: err.message });
};Type Transformations
Track how request/response types evolve:
type Req0 = Request<any, any, {}>;
type Res0 = Response<{}>;
type Req1 = Request<any, any, { validated: true }>;
type Res1 = Response<{ user: any }>;
const step1: Function<Req0, Res0, Req1, Res1> =
async (req, res) => [req as Req1, res as Res1];
const step2: Function<Req1, Res1> =
async (req, res) => {
// req.body.validated is guaranteed true
return [req, res];
};
pipeline()
.next(step1) // ✅ Req0 → Req1
.next(step2) // ✅ Req1 accepted
.build();Type Inference
You don't need to explicitly type every handler - TypeScript infers handler types based on what you return:
const middleware = pipeline<Request, Response>()
.next((req, res) => {
// TypeScript infers: Function<Request, Response, Request, Response>
// (because return type is [Request, Response] and input types are Request and Response)
return [req, res];
})
.next((req, res) => {
// TypeScript infers: Consumer<Request, Response>
// (because return type is void and input types are Request and Response)
res.json({ success: true });
})
.build();Type Inference Rules:
- If handler returns
[req, res]tuple → inferred asFunction(chainable) - If handler returns
void→ inferred asConsumer(terminal) - If handler has error parameter → inferred as
ErrorFunctionorErrorConsumer - Request/Response types flow from the pipeline's generic parameters
- Output request type defaults to input request type (unless you cast/return different type)
- Output response type defaults to input response type (unless you cast/return different type)
Important: Type inference is based on actual return values, not runtime modifications:
// ❌ This does NOT change the inferred type
const handler = (req: Request, res: Response) => {
req.body.newField = 'value'; // Type doesn't change
return [req, res]; // Still Request, Response
};
// ✅ This explicitly changes the type
type ModifiedReq = Request<any, any, { newField: string }>;
const handler = (req: Request, res: Response) => {
return [req as ModifiedReq, res]; // Now explicitly ModifiedReq
};vs Express Middleware
Express Middleware (Traditional)
app.use((req, res, next) => {
req.body.timestamp = Date.now(); // How do you know the body has timestamp?
next(); // Must call next() manually
});
app.use((req, res, next) => {
res.json({ data: req.body }); // May forget to call next()
});
app.use((err, req, res, next) => {
// Error handlers take any as err type
if(err instanceof Error)
res.status(500).json({ error: err.message });
else
// What do you do here?
});Challenges:
- ❌ No type safety for request/response transformations
- ❌ Must manually call
next()(easy to forget) - ❌ No compile-time check for error types
- ❌ Hard to track how request/response shape evolves
Express Pipeline
const middleware = pipeline<Request, Response>()
.next((req, res) => {
// Transform - return tuple
return [req as ModifiedReq, res as ModifiedRes];
})
.next(((req, res) => {
// Terminal - send response, no return
if (some wrong flow)
throw new CustomError();
res.json({ data: req.body });
}) satisfies Consumer(ModifiedReq, ModifiedRes, CustomError)) // Here the type is needed to declare it throws Custom Error as it cannot be infered from the throw statement
.next((err: CustomError, req, res) => {
// Error handler - catches CustomError, not any
res.status(500).json({ error: err.message });
})
.build();Benefits:
- ✅ Full type safety for transformations
- ✅ Automatic
next()handling based on return value - ✅ Compile-time error type tracking
- ✅ Type-safe request/response evolution
Error Tracking
The pipeline tracks error types through ThrownErrors:
class ValidationError extends Error {}
class AuthError extends Error {}
const handler1: Function<Request, Response, Request, Response, ValidationError> =
(req, res) => {
if (!valid) throw new ValidationError('Invalid');
return [req, res];
};
const handler2: Function<Request, Response, Request, Response, AuthError> =
(req, res) => {
if (!auth) throw new AuthError('Unauthorized');
return [req, res];
};
pipeline<Request, Response>()
.next(handler1) // ThrownErrors = ValidationError
.next(handler2) // ThrownErrors = ValidationError | AuthError
.next((err: ValidationError, req, res) => {
// Handles ValidationError
res.status(400).json({ error: err.message });
// After handling: ThrownErrors = AuthError
})
.next((err: AuthError, req, res) => {
// Handles AuthError
res.status(401).json({ error: err.message });
// After handling: ThrownErrors = never
})
.next((req, res) => {
// ✅ No errors possible here
res.json({ success: true });
})
.build();Terminal Handlers
A TerminalPipeline is created when you add a Consumer or ErrorConsumer to the pipeline. It prevents adding handlers after the terminal handler, maintaining type safety:
pipeline()
.next(handler1)
.next(consumer) // Returns TerminalPipeline
.next(handler2) // ❌ TypeScript Error: TerminalPipeline has no next()
.build();However, you can still add error handlers to a TerminalPipeline, since the consumer may throw an error:
const consumerThatMayThrow: Consumer<Request, Response, ValidationError> = (req: Request, res: Response) => {
if (!req.body) throw new ValidationError('No body');
res.json({ success: true });
};
const errorHandler = (err: ValidationError, req: Request, res: Response) => {
res.status(400).json({ error: err.message });
};
pipeline()
.next(consumerThatMayThrow) // Returns TerminalPipeline
.next(errorHandler) // ✅ Allowed - handles potential error from consumer
.build();This allows robust error handling patterns where even terminal consumers can recover from errors if needed.
API Reference
pipeline<Req, Res>()
Factory function to create a new pipeline.
const pb = pipeline<Request, Response>();.next(handler)
Add a handler to the pipeline. Returns either:
Pipelineif handler is a Function/ErrorFunction (chainable)TerminalPipelineif handler is a Consumer/ErrorConsumer (terminal)
Type Signature:
// Simplified - actual implementation has 7 overloads
next(handler: Function | ErrorFunction | Consumer | ErrorConsumer): Pipeline | TerminalPipelineOverloads (for type safety):
// Function handler → returns Pipeline (chainable)
next<NextReq extends LastReq, NextRes extends LastRes, ThrowErrors extends Error = never>(
handler: Function<LastReq, LastRes, NextReq, NextRes, ThrowErrors>
): Pipeline<NextReq, NextRes, ThrownErrors | ThrowErrors>;
// Consumer handler → returns TerminalPipeline (terminal)
next<ThrowErrors extends Error = never>(
handler: Consumer<LastReq, LastRes, ThrowErrors>
): TerminalPipeline<LastReq, LastRes, ThrownErrors | ThrowErrors>;
// ErrorFunction handler → returns Pipeline (chainable)
next<CatchError extends ThrownErrors | Error = Error, ThrowErrors extends Error = never>(
handler: ErrorFunction<CatchError, LastReq, LastRes, LastReq, LastRes, ThrowErrors>
): Pipeline<LastReq, LastRes, Exclude<ThrownErrors, CatchError> | ThrowErrors>;
// ErrorConsumer handler → returns TerminalPipeline (terminal)
next<CatchError extends ThrownErrors | Error = Error, ThrowErrors extends Error = never>(
handler: ErrorConsumer<CatchError, LastReq, LastRes, ThrowErrors>
): TerminalPipeline<LastReq, LastRes, Exclude<ThrownErrors, CatchError> | ThrowErrors>;
// Pipeline merging → returns Pipeline or TerminalPipeline depending on input
next(handler: Pipeline | TerminalPipeline): Pipeline | TerminalPipeline;.build()
Compile the pipeline into Express middleware array.
const middleware = pipeline<Request, Response>()
.next(handler1)
.next(handler2)
.build();
router.use(...middleware);TerminalPipeline.next(errorHandler)
A TerminalPipeline can still accept error handlers, allowing the terminal consumer to recover from errors:
// Returns Pipeline if errorHandler is ErrorFunction (chainable)
next<ErrorFunction>(handler: ErrorFunction): Pipeline;
// Returns TerminalPipeline if errorHandler is ErrorConsumer (terminal)
next<ErrorConsumer>(handler: ErrorConsumer): TerminalPipeline;Example:
const terminal = pipeline<Request, Response>()
.next((req, res) => [req, res])
.next(((req, res) => {
// Terminal consumer
if (some worng flow)
throw new CustomError()
res.json({ data: 'response' });
}) satisfies Consumer<Request, Response, CustomError>);
// Add error handler to the terminal consumer
const withErrorHandling = terminal
.next((err: CustomError, req, res) => {
res.status(500).json({ error: err.message });
});How Runtime Type Detection Works
TypeScript's .next() method is overloaded to provide compile-time type safety, but at runtime, the pipeline uses the actual return value to determine handler type:
TypeScript Level (Compile-time):
// Simplified illustration - the actual implementation has 7+ overloads
// See src/index.ts for the complete type definitions with all combinations
class Pipeline {
// When you pass a Function, TypeScript knows it returns Pipeline
next(handler: Function): Pipeline;
// When you pass a Consumer, TypeScript knows it returns TerminalPipeline
next(handler: Consumer): TerminalPipeline;
// Similar overloads for ErrorFunction, ErrorConsumer, Pipeline merging, etc.
// The real implementation covers all possible handler type combinations
}Runtime Detection:
// At runtime, the pipeline detects handler type by checking return value
// This is a simplified illustration of the concept, not the actual implementation
function next(
handler: Function | Consumer | ErrorFunction | ErrorConsumer | Pipeline | TerminalPipeline
) {
// Detect error handler by parameter count (3 params vs 2)
const isErrorHandler = handler.length === 3;
// Execute handler to see what it returns
const result = await handler(req, res, err?);
// Determine type based on return value
if (Array.isArray(result) && result.length === 2) {
// Returns [req, res] tuple → Function (continue pipeline)
return new Pipeline([...steps, handler]);
} else if (result === undefined) {
// Returns void → Consumer (end pipeline)
return new TerminalPipeline([...steps, handler]);
}
}Key Points:
- ✅ TypeScript level: 7+ overloads cover all handler type combinations
- ✅ Runtime level: Actual return value determines if handler continues or ends
- ✅ See src/index.ts for the real, complete implementation
- ✅ Both work together: TypeScript prevents type errors at compile-time, runtime ensures behavior is correct
Type Parameters
Function<ReqIn, ResIn, ReqOut, ResOut, ThrowErrors>
- Transforming handler
ThrowErrors: Errors this handler may throw
Consumer<ReqIn, ResIn, ThrowErrors>
- Terminal handler that doesn't chain further
ThrowErrors: Errors this handler may throw
ErrorFunction<CatchError, ReqIn, ResIn, ReqOut, ResOut, ThrowErrors>
- Error handler that can transform
CatchError: Type of error it catches (must extend ThrownErrors)ThrowErrors: Errors it may throw
ErrorConsumer<CatchError, ReqIn, ResIn, ThrowErrors>
- Terminal error handler
CatchError: Type of error it catches (must extend ThrownErrors)
Examples
Basic Usage
import { Request, Response, Router } from 'express';
import { pipeline } from 'typed-express-pipeline';
const router = Router();
const middleware = pipeline<Request, Response>()
.next((req, res) => {
// TypeScript infers type - no explicit typing needed
console.log('Request received');
return [req, res];
})
.next((req, res) => {
// Send response
res.json({ success: true, data: req.body });
})
.build();
router.post('/api/endpoint', middleware);Type-Safe Request Transformation
import { Request, Response } from 'express';
import { pipeline, Function } from 'typed-express-pipeline';
type AuthenticatedRes = Response<any, { userId: string }>;
// Explicitly declare the transformation
const authenticate: Function<Request, Response, Request, AuthenticatedRes> =
async (req, res) => {
const userId = await verifyToken(req);
(res as AuthenticatedRes).locals.userId = userId; // Casting is not needed here but recommended to ensure res.locals.userId needs a string
return [req, res as AuthenticatedRes];
};
const middleware = pipeline()
.next(authenticate) // Response → AuthenticatedRes
.next((req, res) => { // res is AuthenticatedRes
// res.locals.userId is guaranteed to exist
res.json({ user: res.locals.userId });
})
.build();Error Handling
class AuthError extends Error {}
class ValidationError extends Error {}
const validate: Function<Request, Response, Request, Response, ValidationError> =
(req, res) => {
if (!req.body.username) throw new ValidationError('Username required');
return [req, res];
};
const authenticate: Function<Request, Response, Request, Response, AuthError> =
(req, res) => {
if (!req.headers.authorization) throw new AuthError('No token');
return [req, res];
};
const middleware = pipeline<Request, Response>()
.next(validate) // ThrownErrors = ValidationError
.next(authenticate) // ThrownErrors = ValidationError | AuthError
.next((err: ValidationError, req, res) => {
res.status(400).json({ error: 'Invalid input' });
// After handling: ThrownErrors = AuthError
})
.next((err: AuthError, req, res) => {
res.status(401).json({ error: 'Unauthorized' });
// After handling: ThrownErrors = never
})
.next((req, res) => {
res.json({ success: true });
})
.build();Complex Type Transformations
type AuthenticatedRes = Response<any, { userId: string }>;
type DataRes = Response<{ data: string[] }>;
const authenticate: Function<Request, Response, Request, AuthenticatedRes> =
async (req, res) => {
const userId = await verifyToken(req);
res.locals.userId = userId;
return [req, res as AuthenticatedRes];
};
const fetchData: Function<Request, AuthenticatedRes, Request, AuthenticatedRes> =
async (req, res) => {
const data = await db.getData(res.locals.userId);
res.locals.data = data;
return [req, res as DataRes];
};
const sendData = async (req: Request, res: DataRes) => {
res.json({ res.locals.data });
};
pipeline()
.next(authenticate) // Response → AuthenticatedRes
.next(fetchData) // Fetch with auth context
.next(sendData) // Send response
.build();License
MIT
Contributing
Contributions welcome! Please follow the existing code style and ensure tests pass.
npm test
npm run build