Writing plugins for Pothos may seem a little intimidating at first, because the types used by Pothos are fairly complex. Fortunately, for many types of plugins, the process is actually pretty easy, once you understand the core concepts of how Pothos's type system works. Don't worry if the descriptions don't make complete sense at first. Going through the examples in this guide will hopefully make things seem a lot easier. This guide aims to cover a lot of the most common use cases for creating plugins, but does not contain full API documentation. Exploring the types or source code to see what all is available is highly encouraged, but should not be required for most use cases.
Pothos has 2 main pieces to it's type system:
PothosSchemaTypes
: A global namespace for shared typesSchemaTypes
: A collection of types passed around through Generics specific to each instance of
SchemaBuilder
PothosSchemaTypes
The PothosSchemaTypes
contains interfaces for all the various options objects used throughout the
API, along with some other types that plugins may want to extend. Each of the interfaces can be
extended by a plugin to add new options. Each interface takes a number of relevant generic
parameters that can be used to make the options more useful. For example, the interface for field
options will be passed the shape of the parent, the expected return type, and any arguments.
SchemaTypes
The SchemaTypes
type is based on the Generic argument passed to the SchemaBuilder
, and extended
with reasonable defaults. Almost every interface in the PothosSchemaTypes
will have access to it
(look for Types extends SchemaTypes
in the generics of almost any interface). This Type contains
the types for Scalars, backing models for some object and interface types, and many custom
properties from various plugins. If your plugin needs the user to provide some types that will be
shared across the whole schema, this is how you will be able to access them when adding fields to
the options objects defined in PothosSchemaTypes
.
The best place to start is by looking through the example plugin.
The general structure of a plugin has 3 main parts:
index.ts
which contains a plugins actual implementationglobal-types.ts
which contains any additions to Pothos
s built in types.types.ts
which should contain any types that do NOT belong to the global PothosSchemaTypes
namespace.To get set up quickly, you can copy these files from the example plugin to suit your needs. The first few things to change are:
index.ts
index.ts
Plugins
interface in global-types.ts
After setting up the basic layout of your plugin, I recommend starting by defining the types for
your plugin first (in global-types.ts
) and setting up a test schema that uses your plugin. This
allows you to get the user facing API for your plugin working first, so you can see that any new
options you add to the API are working as expected, and that any type constraints are enforced
correctly. Once you are happy with your API, you can start building out the functionality in
index.ts. Building the types first also make the implementation easier because the properties you
will need to access in your extension may not exist on the config objects until you have defined
your types.
global-types.ts
global-types.ts
must contain the following:
A declaration of the PothosSchemaTypes
namespace
declare global {
export namespace PothosSchemaTypes {}
}
An addition to the Plugins
interface that maps the plugin name, to the plugin type (this needs
to be inside the PothosSchemaTypes
namespace)
export interface Plugins<Types extends SchemaTypes> {
example: PothosExamplePlugin<Types>;
}
global-types.ts
should NOT include definitions that do not belong to the PothosSchemaTypes
namespace. Types for your plugin should be added to a separate types.ts
file, and imported as
needed into global-types.ts
.
To add properties to the various config objects used by the SchemaBuilder
, you should start by
finding the interface that defines that config object in @pothos/core
. Currently there are 4 main
file that define the types that make up PothosSchemaTypes
namespace.
Contains the interfaces that define the options objects for the various types (Object, Interface, Enum, etc).
Contains the interfaces that define the options objects for creating fields
Contains the interfaces for SchemaBuilder options, SchemaTypes, options for toSchema
, and other
utility interfaces that may be useful for plugins to extend that do not fall into one of the
other categories.
Contains interfaces that describe the classes used by Pothos, include SchemaBuilder
and the
various field builder classes.
Once you have identified a type you wish to extend, copy it into the PothosSchemaTypes
namespace
in your global-types.ts
, but remove all the existing properties. You will need to keep all the
Generics used by the interface, and should import the types used in generics from @pothos/core
.
You can now add any new properties to the interface that your plugin needs. Making new properties
optional (newProp?: TypeOfProp
) is recommended for most use cases.
index.ts
index.ts
must contain the following:
A bare import of the global types (import './global-types';
)
The plugins name, which should be typed as a string literal rather than as a generic string:
const pluginName = 'example' as const;
A default export of the plugin name export default pluginName
A class that extends BasePlugin:
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {}
BasePlugin
and SchemaTypes
can both be imported from @pothos/core
A call to register the plugin: SchemaBuilder.registerPlugin(pluginName, PothosExamplePlugin);
SchemaBuilder
can also be imported from @pothos/core
The SchemaBuilder
will instantiate plugins each time the toSchema
method is called on the
builder. As the schema is built, it will invoke the various life cycle methods on each plugin if
they have been defined.
To hook into each lifecycle event, simply define the corresponding function in your plugin class.
For the exact function signature, see the index.ts
of the example plugin.
onTypeConfig
: Invoked for each type, with the config object that will be used to construct the
underlying GraphQL type.
onOutputFieldConfig
: Invoked for each Object, or Interface field, with the config object
describing the field.
onInputFieldConfig
: Invoked for each InputObject field, or field argument, with the config
object describing the field.
onEnumValueConfig
: Invoked for each value in an enum
beforeBuild
: Invoked before building schemas, last chance to add new types or fields.
afterBuild
: Invoked with the fully built Schema.
wrapResolve
: Invoked when creating the resolver for each field
wrapSubscribe
: Invoked for each field in the Subscriptions
object.
wrapResolveType
: Invoked for each Union and Interface.
Each of the lifecycle methods above (except beforeBuild
) expect a return value that matches
their first argument (either a config object, or the resolve/subscribe/resolveType function). If
your plugin does not need to modify these values, it can simple return the value that was passed in.
When your plugin does need to change one of the config values, you should return a copy of the
config object with your modifications, rather than modifying the config object that was passed in.
This can be done by either using Object.assign
, or spreading the original config into a new object
{...originalConfig, newProp: newValue }
.
Each config object will have the properties expected by the GraphQL for creating the types or fields
(although some properties like resolve
will be added later), but will also include a number of
Pothos specific properties. These properties include graphqlKind
to indicate what kind of GraphQL
type the config object is for, pothosOptions
, which contains all the options passed in to the
schema builder when creating the type or field.
If your plugin needs to add additional types or fields to the schema it should do this in the
beforeBuild
hook. Any types added to the schema after this, may not be included correctly. Plugins
should also account for the fact that a new instance of the plugin will be created each time the
schema is called, so any types or fields added the the schema should only be applied once (per
schema), even if multiple instances of the plugin are created. The help with this, there is a
runUnique
helper on the base plugin class, which accepts a key, and a callback, and will only run
a callback once per schema for the given key.
Below are a few of the most common use cases for how a plugin might extend the Pothos with very simplified examples. Most plugins will likely need a combination of these strategies, and some uses cases may not be well documented. If you are unsure about how to solve a specific problem, feel free to open a GitHub Issue for more help.
In the examples below, when "extending an interface", the interface should be added to the
PothosSchemaTypes
namespace in global-types.ts
.
You may have noticed that plugins are not instantiated by the user, and therefore users can't pass
options directly into your plugin when creating it. Instead, the recommended way to configure your
plugin is by contributing new properties to the options object passed the the SchemaBuilder
constructor. This can be done by extending the SchemaBuilderOptions
interface.
export interface SchemaBuilderOptions<Types extends SchemaTypes> {
optionInRootOfConfig?: boolean;
nestedOptionsObject?: ExamplePluginOptions; // imported from types.ts
}
Extending this interface will allow the user to pass in these new options when creating an instance
of SchemaBuilder
.
You can then access the options through this.builder.options
in your plugin, with everything
correctly typed:
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
onTypeConfig(typeConfig: PothosTypeConfig) {
console.log(this.builder.options.optionInRootOfConfig)
return typeConfig;
}
toSchema
)In some cases, your plugin may be designed for schemas that be built in different modes. For example
the mocks plugin allows the schema to be built repeatedly with different sets of mocks, or the
subGraph allows building a schema multiple times to generate separate subgraphs. For these cases,
you can extend the options passed to toSchema
instead:
export interface BuildSchemaOptions<Types extends SchemaTypes> {
customBuildTimeOptions?: boolean;
}
These options can be accessed through this.options
in your plugin:
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
onTypeConfig(typeConfig: PothosTypeConfig) {
console.log(this.options.customBuildTimeOptions)
return typeConfig;
}
Each GraphQL type has it's own options interface which can be extended. For example, to extend the options for creating an Object type:
export interface ObjectTypeOptions<Types extends SchemaTypes, Shape> {
optionOnObject?: boolean;
}
These options can then be accessed in your plugin when you receive the config for the type:
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
onTypeConfig(typeConfig: PothosTypeConfig) {
if (typeConfig.kind === 'Object') {
console.log(typeConfig.pothosOptions.optionOnObject);
}
return typeConfig;
}
In the example above, we need to check typeConfig.kind
to ensure that the type config is for an
object. Without this check, typescript will not know that the config object is for an object, and
will not let us access the property. typeConfig.kind
corresponds to how Pothos splits up Types for
its config objects, meaning that it has separate kind
s for Query
, Mutation
, and Subscription
even though these are all Objects
in GraphQL terminology. The typeConfig.graphqlKind
can be used
to get the actual GraphQL type instead.
Similar to Types, fields also have a number of interfaces that can be extended to add options to various types of fields:
export interface MutationFieldOptions<
Types extends SchemaTypes,
Type extends TypeParam<Types>,
Nullable extends FieldNullability<Type>,
Args extends InputFieldMap,
ResolveReturnShape,
> {
customMutationFieldOption?: boolean;
}
Field interfaces have a few more generics than other interfaces we have looked at. These generics
can be used to make the options you add more specific to the field currently being defined. It is
important to copy all the generics of the interfaces as they are defined in @pothos/core
even if
you do not use the generics in your own properties. If the generics do not match, typescript won't
be able to merge the definitions. You do NOT need to include the extends
clause of the interface,
if the interface extends another interface (like FieldOptions
).
Similar to Type options, Field options will be available in the fieldConfigs in your plugin, once
you check that the fieldConfig is for the correct kind
of field.
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
onOutputFieldConfig(fieldConfig: PothosOutputFieldConfig<Types>) {
if (fieldConfig.kind === 'Mutation') {
console.log(fieldConfig.pothosOptions.customMutationFieldOption);
}
return fieldConfig;
}
}
Adding new method to SchemaBuilder
or one of the FieldBuilder
classes is also done through
extending interfaces. Extending these interfaces is how typescript is able to know these methods
exist, even though they are not defined on the original classes.
export interface SchemaBuilder<Types extends SchemaTypes> {
buildCustomObject: () => ObjectRef<{ custom: 'shape' }>;
}
The above is a simple example of defining a new buildCustomObject
method that takes no arguments,
and returns a reference to a new custom object type. Defining this type will not work on it's own,
and we still need to define the actual implementation of this method. This might look like:
const schemaBuilderProto = SchemaBuilder.prototype as PothosSchemaTypes.SchemaBuilder<SchemaTypes>;
schemaBuilderProto.buildCustomObject = function buildCustomObject() {
return this.objectRef<{ custom: 'shape' }>('CustomObject').implement({
fields: () => ({}),
});
};
Note that the above function does NOT use an arrow function, so that the function can access this
as a reference the the SchemaBuilder instance.
Some plugins will need to add runtime behavior. There are a few lifecycle hooks for wrapping
resolve
, subscribe
, and resolveType
. These hooks will receive the function they are wrapping,
along with a config object for the field or type they are associated with, and should return either
the original function, or a wrapper function with the same API.
It is important to remember that resolvers can resolve values in a number of ways (normal values,
promises, or even something as complicated Promise<(Promise<T> | T)[]>
. So be careful when using a
wrapper that introspected the return value of a resolve function. Plugins should only wrap resolvers
when absolutely necessary.
export class PothosExamplePlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
wrapResolve(
resolver: GraphQLFieldResolver<unknown, Types['Context'], object>,
fieldConfig: PothosOutputFieldConfig<Types>,
): GraphQLFieldResolver<unknown, Types['Context'], object> {
return (parent, args, context, info) => {
console.log(`Resolving ${info.parentType}.${info.fieldName}`);
return resolver(parent, args, context, info);
};
}
}
For some plugins the other provided lifecycle may not be sufficiently powerful to modify the schema
in all the ways a plugin may want. For example removing types from the schema (eg. the SubGraph
plugin). In these cases, the afterBuild
hook can be used. It receives the built schema, and is
expected to return either the schema it was passed, or a completely new schema. This allows plugins
to use 3rd party libraries like graphql-tools
to arbitrarily transform schemas if desired.
You may have noticed that almost every interface and type in @pothos/core
take a generic that
looks like: Types extends SchemaTypes
. This type is what allows Pothos and its plugins to share
type information across the entire schema, and to incorporate user defined types into that system.
These SchemaTypes are a combination of default types merged with the Types provided in the Generic
parameter of the SchemaBuilder constructor, and includes a wide variety of useful types:
There are many ways these types can be used, but one of the most common is to access the type for the context object, so that you can correctly type a callback function for your plugin that accepts the context object.
export interface SchemaBuilderOptions<Types extends SchemaTypes> {
exampleSetupFn?: (context: Types['Context']) => ExamplePluginSetupConfig;
}
As mentioned above, your plugin can also contribute its own user definable types to the SchemaTypes
interface. You can see examples of this in the several of the plugins including the directives and
scope-auth
plugins. Adding your own types to SchemaTypes requires extending 2 interfaces: The
UserSchemaTypes
which describes the user provided type will need to extend, and the
ExtendDefaultTypes
interface, which is used to set default values if the User does not provide
their own types.
export interface UserSchemaTypes {
NewExampleTypes: Record<string, ExampleShape>;
}
export interface ExtendDefaultTypes<PartialTypes extends Partial<UserSchemaTypes>> {
NewExampleTypes: PartialTypes['NewExampleTypes'] & {};
}
The User provided type can then be accessed using Types['NewExampleTypes']
in any interface or
type that receive SchemaTypes
as a generic argument.
Plugins that wrap resolvers may need to store some data that us unique the current request. In these
cases your plugin can define a createRequestData
method, and use the requestData
method to get
the data for the current request.
export class PothosExamplePlugin<Types extends SchemaTypes, { resolveCount: number }> extends BasePlugin<Types> {
createRequestData(context: Types['Context']): T {
return { resolveCount: 0 };
}
wrapResolve(
resolver: GraphQLFieldResolver<unknown, Types['Context'], object>,
fieldConfig: PothosOutputFieldConfig<Types>,
): GraphQLFieldResolver<unknown, Types['Context'], object> {
return (parent, args, context, info) => {
const requestData = this.requestData(context);
requestData.resolveCount += 1;
console.log(`request has resolved ${requestData.resolveCount} fields`);
return resolver(parent, args, context, info);
};
}
}
The shape of requestData can be defined via the second generic parameter of the BasePlugin
class.
The requestData
method expects the context object as its only argument, which is used to uniquely
identify the current request.
The plugin API does not directly have a method for wrapping input fields, instead, the wrapResolve
and wrapSubscribe
methods can be used to modify the args
object before passing it down to the
original resolver.
Figuring out how to wrap inputs can be a little complex, especially when dealing with recursive inputs, and optimizing to wrap as little as possible. To help with this, Pothos has a couple of utility functions that can make this easier:
mapInputFields
: Used to select affected input fields and extract some configurationcreateInputValueMapper
: Creates a mapping function that uses the result of mapInputFields
to
map inputs in an args object to new values.The relay plugin uses these methods to decode globalID
inputs:
export class PothosRelayPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
wrapResolve(
resolver: GraphQLFieldResolver<unknown, Types['Context'], object>,
fieldConfig: PothosOutputFieldConfig<Types>,
): GraphQLFieldResolver<unknown, Types['Context'], object> {
// Given the args for the this field, select the fields that are globalIds
const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => {
if (inputField.extensions?.isRelayGlobalID) {
return true;
}
// returning null means no mapping will be created for this input field
return null;
});
// If all fields reachable through args return null for their mapping, we don't need to wrap the resolver
if (!argMappings) {
return resolver;
}
// Calls the mapping function for each value with a mapping if the value is not null or undefined
const argMapper = createInputValueMapper(argMappings, (globalID, mapping) =>
internalDecodeGlobalID(this.builder, String(globalID)),
);
return (parent, args, context, info) => resolver(parent, argMapper(args), context, info);
}
}
Using these utilities allows moving more logic to build time (figuring out which fields need mapping) so that the runtime overhead is as small as possible.
createInputValueMapper
may be useful for some use cases, for some plugins it may be better to
create a custom mapping function, but still use the result of mapInputFields
.
mapInputFields
returns a map who's keys are field/argument names, and who's values are objects
with the following shape:
interface InputFieldMapping<Types extends SchemaTypes, T> {
kind: 'Enum' | 'Scalar' | 'InputObject';
isList: boolean;
config: PothosInputFieldConfig<Types>;
value: T; // the value returned by the mapping function (if it was not null).
// The value may still be for `InputObject` mappings if there are nested fields with non-null mappings
}
if the kind
is InputObject
then the mapping object will also have a fields property with an
object of the following shape:
interface InputTypeFieldsMapping<Types extends SchemaTypes, T> {
configs: Record<string, PothosInputFieldConfig<Types>>;
map: Map<string, InputFieldMapping<Types, T>> | null;
}
Both the root level map, and the fields.map
maps will only contain entries for fields where the
mapping function did not return null. If the mapping function returned null for all fields, the
mapInputFields
will return null instead of returning a map to indicate no wrapping should occur
Plugins can remove fields from objects, interfaces, and input objects, and remove specific values from enums. To do this, simply return null from the corresponding on*Config plugin hook:
onOutputFieldConfig(fieldConfig: PothosOutputFieldConfig<Types>) {
if (fieldConfig.name === 'removeMe') {
return null;
}
return fieldConfig;
}
onInputFieldConfig(fieldConfig: PothosInputFieldConfig<Types>) {
if (fieldConfig.name === 'removeMe') {
return null;
}
return fieldConfig;
}
onEnumValueConfig(valueConfig: PothosEnumValueConfig<Types>) {
if (valueConfig.value === 'removeMe') {
return null;
}
return valueConfig;
}
Removing whole types from the schema needs to be done by transforming the schema during the
afterBuild
hook. See the sub-graph
plugin for a more complete example of removing types.
builder.configStore.onTypeConfig
: Takes a type ref and a callback, and will invoke the callback
with the config for the referenced type once available.
builder.configStore.onFieldUse
Takes a field ref (returned by a field builder) and a callback
to invoke once the config for the field is available.
buildCache.getTypeConfig
Gets the config for a given type after it has been passed through any
modifications applied by plugins.