TypeDrop

2026-05-28 Challenge

2026-05-28 Medium

Typed Middleware Pipeline Builder

You're building the request-processing layer for an internal API gateway. Incoming requests pass through a chain of typed middleware handlers — each one can enrich the context, short-circuit with a typed error, or pass control to the next handler — all with zero `any`.

Goals

  • Define the `Middleware<C, Extra>` type alias with a default type parameter and implement the `Pipeline<C>` builder so that `.use()` correctly widens the context type to `C & Extra` on each call.
  • Implement `Pipeline.run()` to execute all registered middleware sequentially, short-circuiting on the first `{ ok: false }` result and catching thrown exceptions as `{ kind: 'internal' }` errors.
  • Implement the three middleware factories (`authMiddleware`, `roleMiddleware`, `rateLimitMiddleware`) with their exact return types — no `any`, no type assertions.
  • Implement `describeError` with an exhaustive `switch` over all five `MiddlewareError` variants, including every variant-specific field in the output string.
challenge.ts
// Key types and main builder signature

type Middleware<C, Extra = Record<string, never>> =
  (ctx: C) => Promise<StepResult<C & Extra>>;

type StepResult<C> =
  | { ok: true; ctx: C }
  | { ok: false; error: MiddlewareError };

type PipelineResult<T> =
  | { ok: true; value: T }
  | { ok: false; error: MiddlewareError };

// Fluent builder — each .use() widens the context type
class Pipeline<C> {
  constructor(initialCtx: C) { /* ... */ }

  use<Extra>(step: Middleware<C, Extra>): Pipeline<C & Extra> { /* ... */ }

  run<T>(handler: (ctx: C) => Promise<T>): Promise<PipelineResult<T>> { /* ... */ }
}

// Usage:
const result = await new Pipeline(requestCtx)
  .use(authMiddleware())           // ctx gains: { auth: { token: string } }
  .use(roleMiddleware("admin"))    // ctx gains: { user: { role: string } }
  .run(async (ctx) => ctx.user.role);
Hints (click to reveal)

Hints

  • For the internal step array in `Pipeline`, consider storing steps as `((ctx: unknown) => Promise<StepResult<unknown>>)[]` and casting only at the boundary inside `use()` — this is the one place where you may need to think carefully about structural compatibility.
  • The `use<Extra>()` method should construct and return a brand-new `Pipeline` instance (passing the accumulated steps), rather than mutating `this` — this keeps the type widening sound.
  • For `describeError`, TypeScript will flag a missing case if you omit any `kind` variant from the `switch`; use a helper `function assertNever(x: never): never { throw new Error(...) }` in the `default` branch to get compile-time exhaustiveness proof.

Or clone locally

git clone -b challenge/2026-05-28 https://github.com/niltonheck/typedrop.git