Skip to content

Middleware: declare required context without hard-wiring the provider #7821

Description

@jacobgad

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions