npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

typed-express-pipeline

v1.0.2

Published

A type-safe Express middleware pipeline composer with advanced error handling and handler composition patterns

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 (CatchError and ThrowErrors)
  • 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-pipeline

Quick 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 as Function (chainable)
  • If handler returns void → inferred as Consumer (terminal)
  • If handler has error parameter → inferred as ErrorFunction or ErrorConsumer
  • 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:

  • Pipeline if handler is a Function/ErrorFunction (chainable)
  • TerminalPipeline if handler is a Consumer/ErrorConsumer (terminal)

Type Signature:

// Simplified - actual implementation has 7 overloads
next(handler: Function | ErrorFunction | Consumer | ErrorConsumer): Pipeline | TerminalPipeline

Overloads (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