Skip to main content
Version: Next (2.x)

Middlewares

Middlewares wrap a resolver so you can run logic before, after, or instead of it. Chain $use(fn) calls onto a field, follow with .resolve(), and pass the result into $fields.

A middleware receives two arguments:

  • next: () => Promise<Result> — calls the wrapped resolver (or the next middleware in the chain). Call it once and await its return.
  • params: { source, args, ctx, info } — same parameters the resolver gets. Destructure what you need.

Whatever the middleware returns becomes the value the caller sees. That means you can short-circuit (skip next()), transform the result, or rewrap errors.

Basic usage

import { UserModule } from "./typedef.ts";

const { Query } = UserModule;

const userQuery = Query.user
.$use(async (next, { args }) => {
console.log("Before resolver:", args);
const result = await next();
console.log("After resolver:", result);
return result;
})
.resolve(({ args }) => {
return findUser(args.where);
});

export default Query.$fields({
user: userQuery,
});

Common patterns

Logging

const userQuery = Query.user
.$use(async (next) => {
const startTime = Date.now();
const result = await next();
console.log(`Query.user took ${Date.now() - startTime}ms`);
return result;
})
.resolve(({ args }) => {
return findUser(args.where);
});

Authentication

const userQuery = Query.user
.$use(async (next, { ctx }) => {
if (!ctx.user) {
throw new UnauthenticatedError();
}
return next();
})
.resolve(({ args }) => {
return findUser(args.where);
});

Stripping internal fields from results

const userQuery = Query.user
.$use(async (next) => {
const user = await next();
if (user) {
delete user.internalNotes;
}
return user;
})
.resolve(({ args }) => {
return findUser(args.where);
});

Error handling

const userQuery = Query.user
.$use(async (next) => {
try {
return await next();
} catch (error) {
console.error("Error in user resolver:", error);
throw new GraphQLError("Failed to fetch user");
}
})
.resolve(({ args }) => {
return findUser(args.where);
});

Chaining multiple middlewares

Middlewares execute outside-in — the first $use wraps the second, which wraps the resolver:

const { Query } = UserModule;

const userQuery = Query.user
.$use(async (next) => {
console.log("First middleware - before");
const result = await next();
console.log("First middleware - after");
return result;
})
.$use(async (next) => {
console.log("Second middleware - before");
const result = await next();
console.log("Second middleware - after");
return result;
})
.resolve(({ args }) => {
return findUser(args.where);
});

// Output order:
// First middleware - before
// Second middleware - before
// Second middleware - after
// First middleware - after

Type-level middlewares

Chain $use on the type itself (before $fields) to apply a middleware to every field — useful for logging or authorization that covers the whole type:

const { User } = UserModule;

const userResolver = User
.$use(async (next, { source }) => {
console.log("Resolving User field for:", source.id);
return next();
})
.$fields({
id: User.id.key("id"),
email: User.email.key("email"),
name: User.name.resolve(({ source }) => `${source.firstName} ${source.lastName}`),
});
caution

Type-level $use adds an async hop to every field of the type. The cost is usually invisible next to I/O, but can show up in synthetic benchmarks.

Module-level middlewares

Chain $use on the module itself (before $schema) to apply a middleware to every field across every type in the module:

import { UserModule } from "./typedef.ts";

export default UserModule
.$use(async (next, { ctx }) => {
console.log("Request from:", ctx.userId);
return next();
})
.$schema({
User: userResolver,
Query: queryResolver,
});
caution

Same applies at module scope, with a broader blast radius: every field of every type in the module pays the cost.

tip

Execution order is module → type → field. Broad policies live at the module level; refine per type or per field as needed.