Reusable fields
Share field, argument, and input shapes across multiple Pothos types without using an interface.
When several types share a common field or argument shape but you don't want to introduce a GraphQL interface to model it, write a helper that returns the shared definition. Pothos exposes the underlying builders so the helper stays type-safe.
Sharing fields across object types
builder.objectFields(ref, builderFn) adds fields to an existing object type. Wrap a few of them in a function that takes a list of refs:
function addAuditFields<
Refs extends readonly ObjectRef<TypesWithDefaults, { id: string; createdAt: Date }>[],
>(refs: Refs) {
for (const ref of refs) {
builder.objectFields(ref, (t) => ({
id: t.field({ type: 'ID', resolve: (parent) => parent.id }),
createdAt: t.field({ type: 'DateTime', resolve: (parent) => parent.createdAt }),
}));
}
}
const Team = builder.objectRef<ITeam>('Team').implement({
fields: (t) => ({
name: t.exposeString('name'),
}),
});
const Player = builder.objectRef<IPlayer>('Player').implement({
fields: (t) => ({
name: t.exposeString('name'),
}),
});
addAuditFields([Team, Player]);Both types now expose id and createdAt without duplicating the field definitions. The generic constraint on the helper guarantees every passed ref has the backing-model fields the closures rely on. TypesWithDefaults is PothosSchemaTypes.ExtendDefaultTypes<SchemaTypes>, the fully-extended types object every builder method operates on.
The same approach works for interfaces: builder.interfaceFields(ref, builderFn) adds fields to an interface type, so a helper that needs to cover both can call whichever builder matches the ref it's given.
Sharing arguments across fields
Arguments are inferred from the literal args: object Pothos sees, so you can't add them after the fact the way you can with objectFields. Instead, write a helper that returns the args map and spread it into each field:
function pagination(t: { arg: ArgBuilder<TypesWithDefaults> }) {
return {
limit: t.arg.int({ required: true, defaultValue: 25 }),
offset: t.arg.int({ required: true, defaultValue: 0 }),
};
}
builder.queryFields((t) => ({
teams: t.field({
type: [Team],
args: { ...pagination(t) },
resolve: (_root, args) => listTeams(args),
}),
players: t.field({
type: [Player],
args: { ...pagination(t), teamId: t.arg.id() },
resolve: (_root, args) => listPlayers(args),
}),
}));The helper takes an object shaped like the field builder's arg property (t.arg), so the returned args carry their full type. Spreading the result preserves the inference Pothos relies on.
Sharing input fields
Input fields work the same way:
function timestampInputs(t: InputFieldBuilder<TypesWithDefaults, 'InputObject'>) {
return {
createdAfter: t.field({ type: 'DateTime', required: false }),
createdBefore: t.field({ type: 'DateTime', required: false }),
};
}
builder.inputType('GameFilter', {
fields: (t) => ({
...timestampInputs(t),
teamId: t.id(),
}),
});
builder.inputType('PlayerFilter', {
fields: (t) => ({
...timestampInputs(t),
position: t.string(),
}),
});Each input type ends up with its own version of the timestamp fields plus whatever it adds on top.
When an interface fits better
If the shared fields represent a real abstraction your clients can query against (every entity has an id, every audit-logged record has createdAt/updatedAt), the cleaner answer is a GraphQL interface. Clients can fragment over the interface, codegen tools see the relationship, and the schema documents the polymorphism.
The helper pattern fits when the shared fields are an implementation detail rather than an abstraction worth exposing.