TanStack / TanStack/router

Middleware: declare required context without hard-wiring the provider

Open
#7,821 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

Problem

Middleware can provide context to downstream middleware/handlers with full type inference, but there's no way for a middleware to declare a required context. The only option today is to compose the concrete provider directly via .middleware([provider]).

interface DatabaseClient {
	query<T>(sql: string, params?: unknown[]): Promise<Array<T>>
}
class RealDatabaseClient implements DatabaseClient {} // talks to Postgres
class FakeDatabaseClient implements DatabaseClient {} // in-memory, for tests

const dbMiddleware = createMiddleware({ type: 'function' })
	.server(({ next }) => next({ context: { db: new RealDatabaseClient() } }))

// what I want to write: a middleware that consumes a DatabaseClient
// without baking in which implementation supplies it
const userServiceMiddleware = createMiddleware({ type: 'function' })
	.server(({ next, context }) => {
		context.db // ❌ TS error, fair enough, nothing guarantees it exists
		return next({ context: { userService: new UserService(context.db) } })
	})

The supported pattern is composition:

const userServiceMiddleware = createMiddleware({ type: 'function' })
	.middleware([dbMiddleware])
	.server(({ next, context }) =>
		next({ context: { userService: new UserService(context.db) } }))

This works and is type safe, but the provider is now hard-wired into the consumer. UserService was written against the DatabaseClient interface, yet the chain pins it to RealDatabaseClient:

  • I can't swap db for a different implementation (FakeDatabaseClient in tests, transactional or per-tenant clients in requests). dbMiddleware always runs and always constructs the real one, so the interface may as well not exist.
  • A server function listing .middleware([userServiceMiddleware]) silently drags in dbMiddleware too, so you can't see an endpoint's full dependency graph where the endpoint is defined.
  • There's no seam for testing, since there's no way to substitute what a middleware provides.

Proposal

Let a middleware declare a typed requirement — depend on the interface, let the chain supply the implementation:

const userServiceMiddleware = createMiddleware({ type: 'function' })
	.requires<{ db: DatabaseClient }>() // typed hole, provides nothing at runtime
	.server(({ next, context }) => {
		context.db // ✅ typed via the requirement
		return next({ context: { userService: new UserService(context.db) } })
	})

// ✅ requirement satisfied by an earlier middleware in the chain
const getUser = createServerFn()
	.middleware([dbMiddleware, userServiceMiddleware])
	.handler(({ context }) => context.userService.getById(...))

// ✅ any provider satisfying the interface works — this is the DI seam
const fakeDbMiddleware = createMiddleware({ type: 'function' })
	.server(({ next }) => next({ context: { db: new FakeDatabaseClient() } }))

const getUserForTest = createServerFn()
	.middleware([fakeDbMiddleware, userServiceMiddleware])
	.handler(({ context }) => context.userService.getById(...))

// ❌ compile error: userServiceMiddleware requires { db: DatabaseClient },
// nothing earlier in the chain provides it
const broken = createServerFn()
	.middleware([userServiceMiddleware])
	.handler(...)

.requires<T>() adds T to the middleware's server context type but contributes nothing at runtime. The check happens wherever chains are assembled (createServerFn().middleware([...]), createMiddleware().middleware([...]), global middleware in createStart): walk the flattened chain in order and make sure each middleware's requirements are covered by the accumulated context of earlier middleware.

It's purely additive. Existing composition keeps working, and requires is basically composition minus the hard-wired provider. The nice side effects: middleware stays small and single-purpose (an auth middleware can require { sessionService } without knowing how sessions are stored), and the .middleware([...]) array becomes a compiler-checked list of everything an endpoint actually depends on.

The testing angle

Server functions are where the business logic lives, but right now the only way to substitute a dependency in a test is module mocking:

vi.mock('~/server/user/service', () => ({
	userService: { getById: vi.fn().mockResolvedValue(fakeUser) },
}))
vi.mock('~/server/email/service', () => ({
	emailService: { send: vi.fn() },
}))

That works, but it couples tests to file layout, comes with hoisting gotchas, and nothing checks that the fake matches the real service. Rename a method and the mocks keep passing while production breaks.

With requires, a dependency is just a typed context slot, so a fake is just another value that satisfies it — and the compiler enforces that FakeDatabaseClient actually implements DatabaseClient, so fakes can't silently drift from the real thing:

import { testServerFn } from '@tanstack/react-start/testing' // hypothetical

const result = await testServerFn(getUser, {
	data: { id: '123' },
	// checked against the chain's declared requirements
	context: { db: new FakeDatabaseClient(), userService: fakeUserService },
})

You could either run the real chain with some slots pre-seeded (in-memory db, fake email client), or skip middleware entirely and call the handler with a fully specified context. The runtime pieces mostly exist already (executeMiddleware and flattenMiddlewares are exported from @tanstack/start-client-core), but without a typed contract for what a chain needs, a public testing API can't really be sound. requires would provide that contract.

Happy to help with the API design if there's interest in this.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the exported executeMiddleware and flattenMiddlewares entry points, then trace how createMiddleware and createServerFn().middleware assemble and type-check chains. Define how a typed requires() contract is checked against earlier context without adding runtime behavior, while preserving existing composition. Done means valid providers satisfy requirements and missing context produces compile-time errors.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend-api-design, testing
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.