feat(http): typed context extension via next(extra) in middleware #14

Merged
codinget merged 4 commits from worktree-bridge-cse_01XUhonMp8zrZxUpQHFuASV2 into main 2026-06-06 01:58:23 +02:00
Owner

What

Three additions to the http package router:

1. Typed context extension via next(extra)

Middlewares declare what they add to the context by annotating next with Next<TAdd>. TypeScript propagates the type to all downstream middlewares and handlers. No casts needed.

```typescript
app.use(async (ctx, next: Next<{ user: User }>) => {
await next({ user: await authenticate(ctx.req) })
})
.get("/profile", async (ctx) => {
ctx.user // typed as User
})
```

2. route(path) sub-path proxy

router.route(path) returns a RouteProxy<T, TGlobal> that prepends path to every registration method. Routes land directly in the underlying router's layer list. Nesting is supported.

```typescript
const api = app.route("/api")
api.get("/users", listUsers) // GET /api/users
api.route("/users/:id").get(getUser) // GET /api/users/:id
```

`route(path).use(mw)` is also the correct pattern for path-scoped typed extension — registering a middleware whose context additions are only visible to handlers also registered on that proxy.

3. Soundness fixes

Several type-level soundness issues are resolved:

  • Path-scoped extending overload removed. use(path, mw) on both Router and RouteProxy only has non-extending overloads. If the middleware's next is annotated with Next<TAdd>, the call still compiles (callback contravariance), but T is not updated and handlers at other paths cannot access the added properties. route(path).use(mw) is the safe pattern.

  • Router<T, TGlobal> second type parameter. TGlobal tracks what global middleware has contributed. Global middleware receives Context<TGlobal & Partial<T> & PartialMatch>: its own prior global additions are required, but non-global additions are Partial because non-global mw does not run on 404/405 paths.

  • RouteProxy exported as type-only. The class is accessible via _internals for advanced use; public consumers only see the TypeScript type (returned by router.route()).

4. Compile-time type tests

tsconfig.typetest.json + src/server/router.typetest.ts add positive and negative @ts-expect-error assertions, checked via npm run typetest. If a regression removes an expected error, the "unused @ts-expect-error" directive becomes a build failure.

How

Next<TAdd> — conditional generic: () => Promise<void> when TAdd = {}, (extra: TAdd) => Promise<void> otherwise. Annotating next: Next<TAdd> gives TypeScript enough information to infer TAdd and select the extending overload.

Router<T, TGlobal>T = full accumulated context (route handlers see Context<T & Match>); TGlobal ⊆ T = globally-guaranteed subset. Non-global use(mw) grows T only; global use({global:true}, mw) grows both. The formula TGlobal & Partial<T> correctly makes non-global additions optional for global middleware while keeping global additions required.

Overload ordering — extending overloads come before non-extending ones. TypeScript rejects an extending overload when next() is called without args and falls through to the non-extending form.

RouteProxy — delegates all registration to Router.layer() with paths joined. The #router field is typed as Router<T, TGlobal>, so both type parameters flow through the proxy chain.

## What Three additions to the `http` package router: ### 1. Typed context extension via `next(extra)` Middlewares declare what they add to the context by annotating `next` with `Next<TAdd>`. TypeScript propagates the type to all downstream middlewares and handlers. No casts needed. \`\`\`typescript app.use(async (ctx, next: Next<{ user: User }>) => { await next({ user: await authenticate(ctx.req) }) }) .get("/profile", async (ctx) => { ctx.user // typed as User }) \`\`\` ### 2. `route(path)` sub-path proxy `router.route(path)` returns a `RouteProxy<T, TGlobal>` that prepends `path` to every registration method. Routes land directly in the underlying router's layer list. Nesting is supported. \`\`\`typescript const api = app.route("/api") api.get("/users", listUsers) // GET /api/users api.route("/users/:id").get(getUser) // GET /api/users/:id \`\`\` \`route(path).use(mw)\` is also the correct pattern for **path-scoped typed extension** — registering a middleware whose context additions are only visible to handlers also registered on that proxy. ### 3. Soundness fixes Several type-level soundness issues are resolved: - **Path-scoped extending overload removed.** `use(path, mw)` on both `Router` and `RouteProxy` only has non-extending overloads. If the middleware's `next` is annotated with `Next<TAdd>`, the call still compiles (callback contravariance), but `T` is not updated and handlers at other paths cannot access the added properties. `route(path).use(mw)` is the safe pattern. - **`Router<T, TGlobal>` second type parameter.** `TGlobal` tracks what global middleware has contributed. Global middleware receives `Context<TGlobal & Partial<T> & PartialMatch>`: its own prior global additions are required, but non-global additions are `Partial` because non-global mw does not run on 404/405 paths. - **`RouteProxy` exported as type-only.** The class is accessible via `_internals` for advanced use; public consumers only see the TypeScript type (returned by `router.route()`). ### 4. Compile-time type tests `tsconfig.typetest.json` + `src/server/router.typetest.ts` add positive and negative `@ts-expect-error` assertions, checked via `npm run typetest`. If a regression removes an expected error, the "unused @ts-expect-error" directive becomes a build failure. ## How **`Next<TAdd>`** — conditional generic: `() => Promise<void>` when `TAdd = {}`, `(extra: TAdd) => Promise<void>` otherwise. Annotating `next: Next<TAdd>` gives TypeScript enough information to infer `TAdd` and select the extending overload. **`Router<T, TGlobal>`** — `T` = full accumulated context (route handlers see `Context<T & Match>`); `TGlobal ⊆ T` = globally-guaranteed subset. Non-global `use(mw)` grows `T` only; global `use({global:true}, mw)` grows both. The formula `TGlobal & Partial<T>` correctly makes non-global additions optional for global middleware while keeping global additions required. **Overload ordering** — extending overloads come before non-extending ones. TypeScript rejects an extending overload when `next()` is called without args and falls through to the non-extending form. **`RouteProxy`** — delegates all registration to `Router.layer()` with paths joined. The `#router` field is typed as `Router<T, TGlobal>`, so both type parameters flow through the proxy chain.
codinget added 4 commits 2026-06-06 01:53:24 +02:00
Router<T> is now generic. Middlewares can call next({ key: value }) to
extend the context for all downstream middlewares and handlers. TypeScript
infers the added type from the argument — no casts or explicit annotations
required by callers.

The overload design places extending overloads (required next arg) before
non-extending ones so TypeScript naturally falls through based on whether
next() is called with or without arguments. The chain builder was reworked
to thread context as an argument so extensions accumulate per-request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
RouteProxy<T> wraps a Router and prepends a fixed prefix to every
path-taking method (get, post, put, patch, delete, options, use, layer,
route). Routes and middleware registered through the proxy land directly
in the underlying router's layer list, so the original server's handler
picks them up without any extra wiring.

Nesting is supported: route().route() compounds the prefix, and the
context-extension API (use with next(extra)) works identically on proxies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three related fixes:

- Remove path-scoped extending overloads (use(path, mw) on Router and
  RouteProxy). Path-scoped middleware cannot safely extend T because
  handlers at other paths won't run it. route(path).use(mw) is the
  correct pattern for path-scoped typed extension.

- Add TGlobal second type parameter to Router<T, TGlobal> and
  RouteProxy<T, TGlobal>. Global middleware receives
  Context<TGlobal & Partial<T> & PartialMatch>: its own prior global
  additions are required (TGlobal), but non-global additions are Partial
  because non-global mw does not run on 404/405 paths.

- Export RouteProxy as a type-only from the public API; the class value
  is accessible via _internals for advanced consumers. Users construct
  proxies exclusively via router.route(path).

Add tsconfig.typetest.json (noEmit) + src/server/router.typetest.ts with
positive and negative compile-time assertions, verified via npm run typetest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TypeScript does not reduce impossible intersections (e.g. string & number)
to never — it keeps them as intersection types assignable to all constituent
types, which is unsound at runtime. TAdd must introduce new keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
codinget force-pushed worktree-bridge-cse_01XUhonMp8zrZxUpQHFuASV2 from 913f964031 to 00f7f5600e 2026-06-06 01:53:24 +02:00 Compare
codinget marked the pull request as ready for review 2026-06-06 01:53:27 +02:00
codinget merged commit 00f7f5600e into main 2026-06-06 01:58:23 +02:00
codinget deleted branch worktree-bridge-cse_01XUhonMp8zrZxUpQHFuASV2 2026-06-06 01:58:23 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: webnet/webnet#14