DEV Community

loading...
IT Minds

GraphQL as a typesafe contract for your domain!

janitminds profile image Jonas Antvorskov Updated on ・6 min read

Building type-safe GraphQL schemas with @nexus/schema and TypeScript

In this post, I will discuss how @nexus/schema can be used to construct a typesafe GraphQL schema. I will assume that the reader has a passing familiarity with GraphQL. If interested in learning the basics of GraphQL look up GraphQL's homepage on https://graphql.org/learn/.

Motivation

When writing type-safe GraphQL implementations based on graphql-js in the past, the developer always had to take great care to supply the correct types to all resolvers themselves. Providing the correct types was often error prone; you either had to reference a common type export from the consuming and producing resolver, or take great care to track down all consuming and producing resolvers related to a type if it ever changed. @nexus/schema seeks to make this largely a thing of the past; by combining new type constructors wrapping vanilla graphql-js type constructors and allowing the developer to supply a few type imports, this allows @nexus/schema to generate typescript artifacts when you run your schema. This makes it possible to see most type errors you've made in relation to your schema before even having to test it.

makeSchema

The makeSchema function is what ties everything together and makes the magic of @nexus/schema happen. In order to generate the appropriate type artifacts this function needs to be given the correct configuration, this includes references to all types used by your schema.

export const schema = makeSchema({
  types: [...schemaTypes, ...scalars],
  outputs: {
    schema: path.resolve('./src/generated/schema.graphql'),
    // where to save the schema declaration artifact
    typegen: path.resolve('./src/generated/typings.ts'),
    // where to save the typescript schema definitions artifact
  },
});
Enter fullscreen mode Exit fullscreen mode

Here we provide the config with all types used in the schema and instruct @nexus/schema in where to output the SDL schema artifacts and the typescript typing artifacts, these generated files should ideally not be included in your git repository and should instead be generated ad-hoc in your CI pipeline.

In the schema configuration you should also provide some configuration for where the Context type can be found for the schema.

export const schema = makeSchema({
  ...,
  typegenAutoConfig: {
    sources: [
      {
        source: path.resolve(__dirname, './context.ts'),
        // this points to where the RequestContext type can be imported from
        alias: 'ctx',
        // the alias the module containing the RequestContext type is given in the schema typings artifact
      },
    ],
    contextType: 'ctx.RequestContext',
    // the path to the RequestContext in the typings artifact
  },
});
Enter fullscreen mode Exit fullscreen mode

This instructs @nexus/schema to import the file './context.ts' which is collocated to the file performing the makeSchema function call and using the type exported from that module called RequestContext as the type of the Context parameter for all resolvers in your schema.

Creating schema types

Schema types for a @nexus/schema schema are constructed through the use of a set of constructor methods, each of which takes a config object; containing among other things the name of the type, and an optional description of it.

  • objectType: This function is used for constructing the underlying GraphQLObjectType. The config must be provided with a definition function which takes an ObjectDefinitionBlock parameter. This parameter is what is used to add fields to the type by calling methods named after the type the field should return or by calling field and providing it with the correct return type of the field. Each of these functions must be provided with the name of the field that they are adding and a config for the field containing a resolve function, this function becomes typesafe after the type artifacts have been generated. The ObjectDefinitionBlock is also used to instructing GraphQL that the object type should implement an interface through the use of the implements method.
  • interfaceType: The interfaceType function works much the same as the objectType function, it is used to construct the underlying GraphQLInterfaceType.
  • unionType: This function is used to constructing the underlying GraphQLUnionType. The config for this type must be provided with a definition function which takes a UnionDefinitionBlock. This is used to add members to the type through the members method, and instructing graphql-js in how to determine which member type a given object, returned to a field that should resolve to the union type, should resolve to.
  • extendType: This function is used to append an existing object type. It should be given a config containing the type that is being extended and a definition function like objectType which adds any new fields.
  • queryField: This is a decorated version of the extendType function which only acts on the Query type and thus is only given the definition function. It should be used to declare any queries possible in the schema.
  • mutationField: This is a decorated version of the extendType function which only acts on the Mutation type and thus is only given the definition function. It should be used to declare any mutations possible in the schema.
  • enumType: The enumType function is used to construct the GraphQLEnumType. This function must be given the set of members of the enum through the members property.
  • scalarType: The scalarType function is used to construct scalar types. These types have special handling, if asNexusMethod is set to true in their config they will become available on the ObjectDefinitionBlock type. The config should also specify 3 functions:
    • parseLiteral: This function is used to parse the value of the field if written in the SDL.
    • parseValue: This function is used to parse the value of the field if given as a parameter.
    • serialize: This function is used to transform the value given to the field to a scalar value to be transferred to the client.

rootTyping

You should only specify the rootTyping property when declaring an object, interface, or scalar type, when specifying other types @nexus/schema is smart enough to infer the correct type graphql-js will be expecting. Specifying another type for these cases is more likely to trip you up than to give you any benefit.

When specifying the rootTyping I always use __filename for the path property. This variable contains the absolute path to the current module. This means that if I ever move the file, I don't have to worry about changing the file import paths manually - I simply have to generate new artifacts. If the declaration of a schema type is not collocated with its root type; I suggest placing the RootTypingImport with the type declaration and importing that constant to the schema type declaration in order to maintain this behavior.

Runtime

Setting up a runtime configuration for running a @nexus/schema server is made a lot easier by using ts-node, it removes the need for adding .js and .js.map files to your .gitignore and having to filter them out in your editor of choice; or outputting your typescript compilation to a separate dist folder, and thus doesn't change the value of the __filename variables in the runtime.

Generating artifacts and making changes

When working on your schema you will from time to time need to verify that the changes you've made to the schema are typed correctly before finalizing all the schema changes you're making to the server. To do this, you need to generate new artifacts for the schema. This can be simplified by adding a check to the makeSchema constructor:

export const schema = makeSchema({
  ...,
  shouldExitAfterGenerateArtifacts: process.argv.includes('--nexus-exit'),
  // if the --nexus-exit param is given to the process, exit after the schema artifacts have been generated
});
Enter fullscreen mode Exit fullscreen mode

And using the following script to generate the type artifacts:

"scripts": {
  "generate": "yarn ts-node ./src/server --nexus-exit",
},
Enter fullscreen mode Exit fullscreen mode

This script will run the schema up to the point where the artifacts are generated, and then exit. This is useful behavior when working on the schema since the correctness of the types can only truly be ascertained after the new artifacts are generated. This script would also be useful to run as a step in your CI process, as it allows you to remove the generated artifacts from your git repository improving clarity of pull requests.

Sometimes you will need to make changes to your schema, or how some field should be resolved. This can be a hassle if you've already generated the type artifacts for the schema earlier, and you're running on ts-node. To resolve this issue, use the following script:

"scripts": {
  "generate:force": "yarn ts-node --log-error ./src/server --nexus-exit",
},
Enter fullscreen mode Exit fullscreen mode

With the --log-error flag set, ts-node will find just any type errors and still execute the script. This means, you can generate your new type artifacts even when in the middle of making a large set of changes where your server won't compile correctly until all changes are finalized, giving you invaluable type checking for the changes you've already made. The errors reported by the script should generally be ignored though, since they will be based on the old type artifacts.

Resources

A demo project utilizing all the techniques described can be found here.

Discussion

pic
Editor guide