Skip to content

Server function middleware: first-class typed context dependencies (requires) for DI and testability #7821

Description

@jacobgad

Summary

Server function middleware can provide context to downstream middleware/handlers with full type inference, but it cannot declare a typed requirement on upstream context. The only way for a middleware to consume typed context today is to compose its provider directly via .middleware([provider]) — which hard-couples a concrete provider into every consumer.

Proposal: a requires capability — a middleware declares the context shape it needs, and TypeScript enforces at the createServerFn().middleware([...]) call site that an earlier middleware in the chain provides it.

The problem today

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

// What I want to write: an atomic middleware that CONSUMES db without
// baking in WHICH provider supplies it
const userServiceMiddleware = createMiddleware({ type: 'function' })
	.server(({ next, context }) => {
		context.db // ❌ TS error — and rightly so, nothing guarantees it exists
		return next({ context: { userService: new UserService(context.db) } })
	})

The supported pattern is composition:

const userServiceMiddleware = createMiddleware({ type: 'function' })
	.middleware([dbMiddleware]) // provider is now hard-wired into the consumer
	.server(({ next, context }) =>
		next({ context: { userService: new UserService(context.db) } }))

This works and is type-safe, but:

  1. No inversion of control. The consumer picks its concrete provider forever. You can't supply a different db (per-tenant, transactional, or test) — the original provider always runs.
  2. Implicit dependencies. A server function listing .middleware([userServiceMiddleware]) silently drags in dbMiddleware. The endpoint's full dependency graph isn't visible where the endpoint is defined.
  3. No test seam. Because providers are baked in, there's no supported way to execute a server function or middleware with substituted context.

Proposed API

const userServiceMiddleware = createMiddleware({ type: 'function' })
	.requires<{ db: DB }>() // 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(...))

// ❌ compile error: userServiceMiddleware requires { db: DB },
// not provided by any earlier middleware in this chain
const broken = createServerFn()
	.middleware([userServiceMiddleware])
	.handler(...)

Semantics:

  • .requires<T>() adds T to the middleware's server context type without contributing runtime context.
  • Validation happens wherever chains are assembled (createServerFn().middleware([...]), createMiddleware().middleware([...]), createStart global middleware): walking the flattened chain in order, every middleware's requirements must be satisfied by the accumulated context of earlier middleware.

Benefits

  1. Atomic, single-purpose middleware. dbMiddleware, userServiceMiddleware, isAuthed each do one thing. Auth middleware requires { sessionService } without knowing how sessions are stored.
  2. Explicit per-endpoint dependency manifests. The .middleware([...]) array becomes a readable, compiler-verified list of everything an endpoint depends on.
  3. Inversion of control without a container. Providers are chosen at the edge, not baked into consumers — swapping in a tenant-scoped or transaction-scoped db is a one-line, type-checked chain change.
  4. Incremental adoption. Purely additive; existing composition keeps working. requires is just "composition minus the hard-wired provider".

How this unlocks a testing story

Server functions are where an app's business logic lives, but they're awkward to test today. Because middleware providers are hard-wired, the only way to substitute a dependency is module mocking:

// today: intercept the modules the implementation happens to import
vi.mock('~/server/user/service', () => ({
	userService: { getById: vi.fn().mockResolvedValue(fakeUser) },
}))
vi.mock('~/server/email/service', () => ({
	emailService: { send: vi.fn() },
}))

This works, but it's messy: string paths coupled to file layout, hoisting rules, and mock state leaking between tests. Worse, nothing checks that a fake matches the real service — rename a method and the mocks keep passing while production breaks.

With requires, a dependency is a typed context slot, and a fake is just another value that satisfies it. No module interception, and TypeScript keeps every fake in sync with the real interface:

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

const fakeUserService: UserService = {
	getById: async () => fakeUser,
	// ...
}

const result = await testServerFn(getUser, {
	data: { id: '123' },
	// checked against the chain's declared requirements — a missing slot won't compile
	context: { db: pgliteDb, userService: fakeUserService },
})

Two testing modes fall out naturally:

  1. Provider substitution — run the real middleware chain, but pre-seed some requires slots with fakes (in-memory db, fake email client, frozen clock) while the rest of the chain runs for real.
  2. Handler isolation — skip middleware entirely and invoke the handler with a fully-specified, type-checked context.

The runtime pieces mostly exist (executeMiddleware, flattenMiddlewares are exported from @tanstack/start-client-core), but there's no typed contract that would make a public testing API sound. requires provides that contract.

Happy to help iterate on the API design if there's interest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions