Relay plugin
Build a Relay-compliant schema with the Node interface, global IDs, connections, and Relay mutations.
Relay is a set of GraphQL conventions: a Node interface every object can be refetched through, opaque global IDs, and cursor-paginated connections. The relay plugin adds the builder methods that generate these: builder.node turns a type into a node addressable by a global ID, and t.connection defines a cursor-paginated field along with its Connection, Edge, and PageInfo types.
The examples below model an Ultimate League: Team and Player nodes, with each team's roster exposed as a connection.
Setup
npm install --save @pothos/plugin-relayRegister the plugin and pass a relay options object; empty is fine to start.
import SchemaBuilder from '@pothos/core';
import RelayPlugin, { resolveArrayConnection } from '@pothos/plugin-relay';
const builder = new SchemaBuilder({
plugins: [RelayPlugin],
relay: {},
});The builder key is relay. Older versions used relayOptions; that name is only accepted in v3-compatibility mode, and new code should use relay.
Nodes
A node is any object reachable by a single opaque global ID. Call builder.node to turn a type into one: you point at an objectRef (or a class), define how to read its id, and supply a loader that hydrates a node from that id.
const Team = builder.objectRef<ITeam>('Team');
builder.node(Team, {
id: {
resolve: (team) => team.id,
},
loadOne: (id) => Teams.get(id) ?? null,
loadMany: (ids) => ids.map((id) => Teams.get(id) ?? null),
fields: (t) => ({
name: t.exposeString('name'),
city: t.exposeString('city'),
}),
});builder.node creates an object type that implements the Node interface, and creates the Node interface itself the first time it's used. The id.resolve function returns a string or number, which the plugin encodes into a global ID. It also wires up two query fields (node(id:) and nodes(ids:)) that refetch any node directly from its global ID by calling your loader.
Define exactly one of the loader methods:
loadOne/loadManyload a node (or list of nodes) by id, with a per-request cache so the same id is only loaded once.loadWithoutCache/loadManyWithoutCacheskip the cache. Use these if caching is undesirable or you already load through a dataloader.
When you back a node with a class rather than an objectRef, pass a name so the type has a GraphQL name:
class Player {
id: string;
name: string;
}
builder.node(Player, {
name: 'Player', // required when the type parameter is a class
id: { resolve: (player) => player.id },
loadOne: (id) => loadPlayer(id),
fields: (t) => ({ name: t.exposeString('name') }),
});Resolving the node type
The node field returns the Node interface, so Pothos needs to map a loaded object back to its concrete type. By default (brandLoadedObjects: true) any object returned from a load* method is tagged with a hidden symbol that the default resolveType reads, so most schemas never write an isTypeOf check.
You still need isTypeOf for union and interface fields that return manually-loaded node objects where no custom resolveType knows the type. A node may also define its own isTypeOf:
builder.node(Player, {
isTypeOf: (value) => value instanceof Player,
// ...
});When the type parameter is a class, isTypeOf defaults to an instanceof check (falling back to the prototype's constructor), so class-backed nodes often need nothing here, though declaring it explicitly is clearer.
Turning off brandLoadedObjects means the default resolveType can no longer identify loaded nodes. Only disable it if every node defines its own isTypeOf.
Parsing node ids
Node ids arrive as strings. Supply a parse function on the id field to convert them (say, to a number) before they reach your loader:
builder.node(Player, {
id: {
resolve: (player) => player.id,
parse: (id) => Number.parseInt(id, 10),
},
// `id` is now a number in loadOne
loadOne: (id) => loadPlayerByNumber(id),
fields: (t) => ({ name: t.exposeString('name') }),
});Global IDs
Global IDs let a client refetch anything by a single opaque string. The plugin adds field and argument builders for them.
t.globalID and t.globalIDList produce fields whose resolver returns either a global ID string or an object with id and type (a type name, or any ref usable as a type parameter):
import { encodeGlobalID } from '@pothos/plugin-relay';
builder.queryFields((t) => ({
featuredTeamId: t.globalID({
resolve: () => ({ id: 1, type: 'Team' }),
}),
rosterIds: t.globalIDList({
resolve: () => [{ id: 1, type: 'Player' }],
}),
}));On the input side, t.arg.globalID and t.arg.globalIDList accept a global ID string from the client and hand your resolver a decoded { id, typename }:
builder.queryFields((t) => ({
roster: t.field({
type: [Player],
args: {
teamId: t.arg.globalID({ required: true }),
extra: t.arg.globalIDList(),
},
resolve: (_parent, args) => {
console.log(`type ${args.teamId.typename}, id ${args.teamId.id}`);
return loadRoster(args.teamId.id);
},
}),
}));Restrict which node types an argument accepts with for, either a single ref or an array:
teamId: t.arg.globalID({
for: Team, // or [Team, Player]
required: true,
}),For working with global IDs directly, the plugin exports encodeGlobalID(typename, id) and decodeGlobalID(globalID):
import { decodeGlobalID } from '@pothos/plugin-relay';
builder.mutationFields((t) => ({
renamePlayer: t.field({
type: Player,
args: {
id: t.arg.id({ required: true }),
name: t.arg.string({ required: true }),
},
resolve: (_parent, args) => {
const { typename, id } = decodeGlobalID(args.id);
return renamePlayer(id, args.name);
},
}),
}));Custom id encoding
To encode ids differently from the built-in base64 scheme, pass encodeGlobalID and decodeGlobalID into the relay options:
const builder = new SchemaBuilder({
plugins: [RelayPlugin],
relay: {
encodeGlobalID: (typename, id) => `${typename}:${id}`,
decodeGlobalID: (globalID) => {
const [typename, id] = globalID.split(':');
return { typename, id };
},
},
});Exposing extra node fields
t.node and t.nodeList add standalone node fields anywhere. Their id/ids return values match t.globalID: a global ID string or an { id, type } object. Loading goes through the same per-request cache, so a node used in several places loads once.
builder.queryFields((t) => ({
featuredPlayer: t.node({
id: () => ({ id: 1, type: 'Player' }),
}),
rivalTeams: t.nodeList({
ids: () => [{ id: 1, type: 'Team' }, { id: 2, type: 'Team' }],
}),
}));Connections
t.connection defines a cursor-paginated field. It creates the Connection and Edge object types, adds the before, after, first, and last arguments, and creates PageInfo the first time it's used. Here a team exposes its roster:
players: t.connection({
type: Player,
resolve: (team, args) =>
resolveArrayConnection(
{ args },
[...Players.values()].filter((player) => player.teamId === team.id),
),
}),The full form takes two extra option objects (one for the Connection type, one for the Edge type) for naming and adding fields:
t.connection(
{ type: Player, resolve: /* ... */ },
{
name: 'TeamRosterConnection', // default: Parent + capitalize(field) + 'Connection'
fields: (tc) => ({ /* extra Connection fields — use the tc builder */ }),
edgesField: {}, // customize the edges field
},
{
name: 'TeamRosterEdge', // default: Connection name + 'Edge'
fields: (te) => ({ /* extra Edge fields — use the te builder */ }),
nodeField: {}, // customize the node field
},
);Connection helpers
Three helpers build the edges/pageInfo shape from common data shapes, so you don't assemble it by hand.
resolveArrayConnection slices a fully-materialized array:
import { resolveArrayConnection } from '@pothos/plugin-relay';
t.connection({
type: Player,
resolve: (_parent, args) => resolveArrayConnection({ args }, loadAllPlayers()),
});resolveOffsetConnection drives a limit/offset API and caps how much a single query can pull:
import { resolveOffsetConnection } from '@pothos/plugin-relay';
t.connection({
type: Player,
resolve: (_parent, args) =>
resolveOffsetConnection({ args }, ({ limit, offset }) => loadPlayers(offset, limit)),
});It accepts a few sizing options alongside args:
{
args: ConnectionArguments;
defaultSize?: number; // defaults to 20
maxSize?: number; // defaults to 100
totalCount?: number; // required to support `last` without `before`
}resolveCursorConnection drives true cursor pagination against any store that supports limits, ordering, and filtering. Annotate the callback argument with ResolveCursorConnectionArgs so the return type infers correctly:
import { resolveCursorConnection, ResolveCursorConnectionArgs } from '@pothos/plugin-relay';
t.connection({
type: Player,
resolve: (_parent, args) =>
resolveCursorConnection(
{ args, toCursor: (player) => player.joinedAt.toISOString() },
({ before, after, limit, inverted }: ResolveCursorConnectionArgs) =>
db.players.findMany({
take: limit,
where: { joinedAt: { lt: before, gt: after } },
orderBy: { joinedAt: inverted ? 'desc' : 'asc' },
}),
),
});Reusing connection and edge objects
To share one Connection type across several fields, build it once with builder.connectionObject and pass it to a plain field with t.arg.connectionArgs() for the standard args:
const PlayersConnection = builder.connectionObject(
{ type: Player, name: 'PlayersConnection' },
{ name: 'PlayersEdge' }, // Edge options (optional); defaults to name + 'Edge'
);
builder.queryFields((t) => ({
players: t.field({
type: PlayersConnection,
args: { ...t.arg.connectionArgs() },
resolve: (_parent, args) => resolveArrayConnection({ args }, loadAllPlayers()),
}),
}));builder.edgeObject creates a reusable Edge type on its own, which you can then pass into connectionObject:
const PlayersEdge = builder.edgeObject({ name: 'PlayersEdge', type: Player });
const PlayersConnection = builder.connectionObject(
{ type: Player, name: 'PlayersConnection' },
PlayersEdge,
);Fields on every connection
builder.globalConnectionField and builder.globalConnectionFields add a field to every Connection type, such as a totalCount:
builder.globalConnectionField('totalCount', (t) =>
t.int({ nullable: false, resolve: (parent) => parent.totalCount }),
);For that parent.totalCount to type-check, declare the extra property on the Connection generic so every connection resolver is required to return it:
const builder = new SchemaBuilder<{
Connection: { totalCount: number };
}>({
plugins: [RelayPlugin],
relay: {},
});The connection helpers don't know about your custom properties, so they won't return them. Merge the extra fields in after calling a helper: return result && { totalCount: players.length, ...result };
Nullability of edges and nodes
Set the nullability of the edges field and the node field globally through the relay options; the DefaultEdgesNullability and DefaultNodeNullability generics must match the option values:
const builder = new SchemaBuilder<{
DefaultEdgesNullability: false;
DefaultNodeNullability: true;
}>({
plugins: [RelayPlugin],
relay: {
edgesFieldOptions: { nullable: false },
nodeFieldOptions: { nullable: true },
},
});edges defaults to { list: defaultFieldNullability, items: true } and node to defaultFieldNullability (itself true by default). Override per connection with edgesNullable and nodeNullable:
t.connection({
type: Player,
edgesNullable: { items: true, list: false },
nodeNullable: false,
resolve: (_parent, args) => resolveArrayConnection({ args }, loadAllPlayers()),
});The same two keys work on builder.connectionObject. Set nodesOnConnection: true in the relay options to also add a flattened nodes field to every Connection.
Relay mutations
builder.relayMutationField generates a Relay-compliant mutation: an input object carrying a clientMutationId, a payload object carrying the matching clientMutationId, and the mutation field wiring them together.
builder.relayMutationField(
'signPlayer',
{
inputFields: (t) => ({
playerId: t.id({ required: true }),
teamId: t.id({ required: true }),
}),
},
{
nullable: false, // adjust the mutation field's nullability here
resolve: async (_root, args, ctx) => {
const player = await signPlayer(args.input.playerId, args.input.teamId);
return { success: Boolean(player) };
},
},
{
outputFields: (t) => ({
success: t.boolean({ resolve: (result) => result.success }),
}),
},
);Which produces:
input SignPlayerInput {
clientMutationId: ID!
playerId: ID!
teamId: ID!
}
type SignPlayerPayload {
clientMutationId: ID!
success: Boolean
}
type Mutation {
signPlayer(input: SignPlayerInput!): SignPlayerPayload!
}The method takes four arguments: the field name, then inputOptions, fieldOptions, and payloadOptions. inputOptions accepts a ref to an existing input object or two extra keys: name to name the generated input, and argName to rename the default input argument. payloadOptions accepts a name for the payload object.
Whether a clientMutationId field is generated (and whether it's required) is controlled by the clientMutationId option: omit (default), required, or optional.
Capture the generated refs to reuse the input and payload elsewhere:
const { inputType: SignPlayerInput, payloadType: SignPlayerPayload } =
builder.relayMutationField('signPlayer', /* ... */);Customizing generated types
Renaming Node and PageInfo
If Node or PageInfo collide with existing types, rename them with nodeTypeOptions and pageInfoTypeOptions; both take the standard type options (name, description, extensions):
const builder = new SchemaBuilder({
plugins: [RelayPlugin],
relay: {
nodeTypeOptions: { name: 'RelayNode', description: 'A node in the graph' },
pageInfoTypeOptions: { name: 'RelayPageInfo' },
},
});Custom node loading
To change how the node/nodes query fields load, pass a resolve in nodeQueryOptions / nodesQueryOptions. Each receives a resolveNode/resolveNodes callback for the default behavior:
const builder = new SchemaBuilder({
plugins: [RelayPlugin],
relay: {
nodeQueryOptions: {
resolve: (_root, { id }, ctx, info, resolveNode) =>
id.typename === 'Player' ? loadPlayerNode(id) : resolveNode(id),
},
nodesQueryOptions: {
// return nodes in the same order the ids were requested
resolve: (_root, { ids }, ctx, info, resolveNodes) => resolveNodes(ids),
},
},
});Set nodeQueryOptions or nodesQueryOptions to false to omit that query field entirely.
Extending the Node interface
Add a derived field to the Node interface itself via builder.nodeInterfaceRef:
builder.interfaceField(builder.nodeInterfaceRef(), 'extra', (t) =>
t.string({ resolve: () => 'it works' }),
);Builder options reference
Every option on the relay object, grouped by what it configures:
| Option | Purpose |
|---|---|
idFieldName | Name of the global id field on nodes. Defaults to id. |
idFieldOptions | Options passed to the generated id field. |
cursorType | String (default) or ID; the type used for cursor fields. |
clientMutationId | omit (default), required, or optional; controls clientMutationId on Relay mutations. |
relayMutationFieldOptions | Default options for relayMutationField. |
nodeQueryOptions / nodesQueryOptions | Options (or false to omit) for the node / nodes query fields. |
nodeTypeOptions / pageInfoTypeOptions | Options for the Node interface / PageInfo type, including name. |
clientMutationIdFieldOptions / clientMutationIdInputOptions | Options for the clientMutationId payload field / input field. |
mutationInputArgOptions | Options for the input arg created for each Relay mutation. |
cursorFieldOptions | Options for the cursor field on an edge. |
nodeFieldOptions / edgesFieldOptions | Options for the node field on an edge / the edges field on a connection. |
pageInfoFieldOptions | Options for the pageInfo field on a connection. |
hasNextPageFieldOptions / hasPreviousPageFieldOptions | Options for the PageInfo boolean fields. |
startCursorFieldOptions / endCursorFieldOptions | Options for the PageInfo cursor fields. |
beforeArgOptions / afterArgOptions / firstArgOptions / lastArgOptions | Options for each connection argument. |
defaultConnectionTypeOptions / defaultEdgeTypeOptions | Default options for generated Connection / Edge types. |
defaultPayloadTypeOptions / defaultMutationInputTypeOptions | Default options for generated Relay Payload / Input types. |
defaultConnectionFieldOptions | Default options for fields defined with t.connection. |
nodesOnConnection | Add a flattened nodes field to every Connection. |
brandLoadedObjects | Tag loaded nodes so the default resolveType can identify them. Defaults to true. |
encodeGlobalID / decodeGlobalID | Override the global ID encoding scheme. |