Overview

This is the Nexus schema component guide. Here you will find concepts explained and a survey of how to use the API. If you are not familiar with Nexus this is a good document to read. If you are familiar, then API Docs may be of more use to you.

GraphQL type builders

We will now begin exploring the GraphQL schema building parts of the schema component. Having prior knowledge of GraphQL language itself will greatly help. If you are new to GraphQL you may want to read some of the resources listed below.

Object Type

graphql.org Object Types

Basic anatomy

1 objectType({
2// The singleton instance of ---------^ |
3// the Nexus schema component |
4// |
5// A type Builder method --------------------^
6 name: 'Foo',
7// The name of this type ----------------------^
8 definition(t) {
9// The type definition block -----------^ |
10// Where fields are defined |
11// |
12// Object of Object Type Field --------------------^
13// Builder methods
14 t.field('bar', {
15// A field builder method ------------------^ |
16// The name of this field -------------------------^
17 type: 'Bar',
18// The type of this field -------------------------^
19 resolve(parent, args, ctx, info) {
20// The method called to return a -----------^ | | | |
21// value for this field when queried | | | |
22// | | | |
23// The backing data model for Foo ------------------^ | | |
24// | | |
25// The client arguments to this field ----------------------^ | |
26// | |
27// Contextual data for this request ------------------------------^ |
28// Shared across all resolvers |
29// |
30// Technical detail about this request --------------------------------^
31// E.g. client's query AST
32
33// Your logic to return a value ------------> ...
34// for this field
35 },
36 })
37
38 t.string('qux')
39// A scalar-type convenience builder -------^ |
40// |
41// No resolver means Nexus returns the -------------^
42// `qux` property from the backing data model
43
44 },
45 })

Scalar fields

1objectType({
2 name: 'Alpha',
3 definition(t) {
4 t.id('a')
5 t.string('b')
6 t.int('c')
7 t.float('d')
8 t.boolean('e')
9 },
10})
1type Alpha {
2 a: ID!
3 b: String!
4 c: Int!
5 d: Float!
6 e: Boolean!
7}

Relational fields

1objectType({
2 name: 'Alpha',
3 definition(t) {
4 t.field('beta', {
5 type: 'Beta',
6 resolve() {
7 return { foo: 'bar' }
8 },
9 })
10 },
11})
12objectType({
13 name: 'Beta',
14 definition(t) {
15 t.string('foo')
16 },
17})
1type Alpha {
2 beta: Beta!
3}
4
5type Beta {
6 foo: String!
7}
Lists and nullability
1objectType({
2 name: 'Alpha',
3 definition(t) {
4 t.id('a', { nullable: true })
5 t.list.id('b')
6 t.list.id('c', { nullable: true })
7 t.list.id('c', { list: [false] })
8 t.list.id('c', { list: [false], nullable: true })
9 },
10})
1type Alpha {
2 a: ID
3 b: [ID!]!
4 c: [ID!]
5 c: [ID]!
6 c: [ID]
7}

Entrypoint types

Enum type

Enum types are a scalar with a finite set of allowed values. They can be used as argument types and as field types.

graphql.org Enumeration Types docs

1enumType({
2 name: 'Alpha',
3 members: ['Zeta', 'Yolo'],
4})
1enum Alpha {
2 Zeta
3 Yolo
4}

Example: As argument type and field type

1queryType({
2 definition(t) {
3 t.field('anyAlpha', {
4 type: 'Alpha',
5 resolve(t) {
6 return Math.random() > 0.1 : 'Zeta' : 'Yolo'
7 }
8 })
9 t.list.field('alphas', {
10 type: 'Alpha',
11 args: {
12 except: schema.arg({
13 list: true,
14 type: "Alpha",
15 required: true,
16 })
17 },
18 resolve(_root, args) {
19 return ['Zeta', 'Yolo'].filter(alpha => {
20 return !args.except.includes(alpha)
21 })
22 }
23 })
24 }
25})
1type Query {
2 anyAlpha: Alpha!
3 alphas(except: [Alpha!]!): [Alpha!]!
4}
1query {
2 anyAlpha
3 alphas(except: ["Zeta"])
4}
1{
2 "data": {
3 "anyAlpha": "Zeta",
4 "alphas": ["Yolo"]
5 }
6}

Union Type

🚧 Work in progress.

Interface Type

🚧 Work in progress.

Field Arguments

🚧 Work in progress.

Input Object Type

🚧 Work in progress.

Lists

🚧 Work in progress.

Descriptions

🚧 Work in progress.

Deprecations

🚧 Work in progress.

Data Modelling

As the API author, there are three design tasks you will invariable perform over and over again:

  1. Create data types that model logical entities and concepts in your business domain.
  2. Define connections between these data types that model how logical entities and concepts relate in your business domain.
  3. Define entrypoints which allow traversal into this graph of data.

This is an iterative process that can generally be seen as an finite loop wherein your team gradually refines and expands (or contracts!) the data graph as you respond to changing client app needs, business needs, and so on. Data modelling is hard work. For one thing it is a subtle art, occasionally underappreciated. There are typically multiple ways to model any one thing and competing tradeoffs that leave no obvious winner abound. If the process of data modelling itself or data modelling in GraphQL is new to you, you may find this book by Marc-Andre Giroux helpful: Production Ready GraphQL.

Nullability in principle

When creating an API, especially before going to production or lifting features out of beta, thinking about if arguments and input object fields (inputs) should be required and if object type fields (outputs) should be nullable is an important design consideration. How easy your API is to consume trades for how easy it is to change and some reliability characteristics.

If inputs are optional or outputs are guaranteed then client developers will have a simpler API to deal with since making requests demands no up front configuration and handling responses presents no null cases. On the other hand, for the API developer, changing the API becomes harder since turning inputs from optional to required or making outputs go from guaranteed to nullable are breaking changes from the client's point of view.

Also, as more outputs are guaranteed, the greater the potential of the "null blast radius" can be. This is the effect where, within a schema runtime, a null or error received from some data source where the schema states there shall be no null requires propagating the null up the data tree until a nullable type is found (or, at root, finally error).

If you'd like to see these design considerations discussed further here are a few articles/resources you may find helpful:

Nullability in Nexus

Nexus defaults to both inputs and outputs being nullable. This means by default your API is conservative in what it sends but flexible in what it accepts. With this approach, by default:

  • You're free to defer some hard thinking about output nullability, knowing you can always change your mind later without breaking clients.
  • Client developers work more to processing API responses, having to handle null conditions.
  • You're forced to frontload some hard thinking about inputs, since realizing something should have been required later will require breaking clients.
  • Client developers work less to satisfy minimum query requirements.
  • The "null blast radius" (refer to Nullability in Principal) is reduced to zero.

There is no right or wrong answer to nullability. These are just defaults, not judgments. Understand the tradeoffs, and react to your use-case, above all.

You can override the global defaults at the per-type level or per-field level. If you find yourself writing local overrides in a majority of cases then it might mean the global defaults are a bad fit for your API. In that case you can change the global defaults.

When you make an input nullable then Nexus will alter its TypeScript type inside your resolver to have null | undefined. null is for the case that the client passed in an explicit null while undefined is for the case where the client simply did not specify the input at all.

If an arg has been given a default value, then it will be used when the client passes nothing, but since clients can still pass explicit null, resolvers must still handle nullability. If this surprises you then you may be interested in #485.

Example: Default nullability settings

1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: 'String',
6 },
7 resolve(_root, args) {
8 return args.message ?? 'nil'
9 },
10 })
11 },
12})
1type Query {
2 echo(message: String): String
3}

Example: Nullability flipped at global level

1makeSchema({
2 nonNullDefaults: {
3 input: false,
4 output: false
5 },
6 types:[...]
7})
1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: 'String',
6 },
7 resolve(_root, args) {
8 return args.message
9 },
10 })
11 },
12})
1type Query {
2 echo(message: String): String!
3}

Example: Nullability flipped at type level

1queryType({
2 nonNullDefaults: {
3 input: true,
4 output: true,
5 },
6 definition(t) {
7 t.string('echo', {
8 args: {
9 message: 'String',
10 },
11 resolve(_root, args) {
12 return args.message
13 },
14 })
15 },
16})
1type Query {
2 echo(message: String): String!
3}

Example: Nullability flipped at input and field level

1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: schema.arg({
6 type: 'String',
7 nullable: false,
8 }),
9 },
10 nullable: false,
11 resolve(_root, args) {
12 return args.message
13 },
14 })
15 },
16})
1type Query {
2 echo(message: String): String!
3}

Example: Mixing levels

It is possible to use type and input/field layers together. This provides flexibility to optimize for local sections of your API that have different characteristics. For example here, a type deviates from the global default for all but but one field and its input.

1schema.queryType({
2 // flip the global defaults
3 nonNullDefaults: {
4 input: true,
5 output: true,
6 },
7 definition(t) {
8 // ... Everything in this type uses the type-level
9 // nullability config ... Except the following,
10 // which effectively reverts back to what the global
11 // defaults are:
12 t.string('echo', {
13 nullable: false,
14 args: {
15 message: schema.arg({
16 type: 'String',
17 nullable: false,
18 }),
19 },
20 resolve(_root, args) {
21 return args.message
22 },
23 })
24 },
25})
1type Query {
2 echo(message: String!): String!
3}

Example: Args that have default values

When an arg has a default you might think that then it should be nullable to the client but non-nullable within your resolver logic. However it turns out that if the client passes an explicit null then that is considered an actual value, and hence is not subject to being assigned the default value. Thus, and then, the resolver still can observe null from the client. If you are curious about seeing this change and/or become configurable then please refer to #485.

1queryType({
2 definition(t) {
3 t.string('echo', {
4 args: {
5 message: schema.arg({
6 type: 'String',
7 default: 'nil via default',
8 }),
9 },
10 nullable: false,
11 resolve(_root, args) {
12 const fallback = 'nil via client null'
13 return args.message ?? fallback
14 },
15 })
16 },
17})
1type Query {
2 echo(message: String = "nothing via default"): String!
3}}
1query {
2 echo1: echo
3 echo2: echo(message: null)
4}
1{
2 "data": {
3 "echo1": "nil via default",
4 "echo2": "nil via client null"
5 }
6}

Backing types in principle

As you begin to implement a schema for the first time you will notice something that may not have been obvious at first. The data that the client sees in the data graph is not the same data flowing through the internal resolvers used to fulfill that graph. The client sees the API types but the API author deals with something else, backing types.

Here is an example of resolution for a query as it would be seen roughly from a GraphQL type only point of view.

When a field's type is an object, then the field's resolver returns a backing type. Concretely this might for example be a plain JavaScript object containing node/row/document data from a database call. This backing type data is in turn passed down to all the object type's own field resolvers.

Here is the above diagram updated to include backing types now.

Here is a step-by-step breakdown of what is going on (follow the diagram annotation numbers):

  1. Client sends a query

  2. The field resolver for Query.user runs. Remember Query fields (along with Subscription, Mutation) are entrypoints.

  3. Within this resolver, the database client fetches a user from the database. The resolver returns this data. This data will now become backing type data...

  4. Resolution continues since the type of Query.user field is an object, not a scalar. As such its own fields need resolving. The fields that get resolved are limited to those selected by the client, in this case: fullName, age, comments. Those three field resolvers run. Their parent argument is the user model data fetched in step 3. This is the backing type data for the GraphQL User object.

    1t.field('...', {
    2 resolve(parent, args, ctx, info) {
    3 // ^------------------------------- Here
    4 },
    5}
  5. The comments field is is an object type so just like with Query.users before, its own fields must be resolved. The comments resolver fetches comments data from the database. Like in step 3 this data becomes backing type data.

  6. Much like the GraphQL Comment object field were resolved, so is Comment. Resolution runs once for every comment retrieved from the database in the previous step. The text field is scalar so resolution of that path can terminate there. But the likers field is typed to an object and so once again goes through the object-field resolution pattern.

  7. A request to the database for users who liked this comment is made.

  8. A repeat of step 4. But this time from a different edge in the graph. Before it was the entrypoint field Query.user. Now we're resolving from relation with Comment. Note how the backing type requirements of User, regardless of which part of the graph is pointing at it, remain the same. One other difference from step 4 is that, like in step 6, we are dealing with a list of data. That is, this resolution is run every user returned in step 7.

Hopefully you can see how the GraphQL types seen by the client are distinct from the backing types flowing through the resolvers. Below, you can find a code sample of how the implementation of this schema might look like.

1query({
2 definition(t) {
3 t.user({
4 args: {
5 id: schema.arg({ type: 'ID', required: true }),
6 },
7 resolve(_, { id }, { db }) {
8 return db.fetchUser({ where: { id } })
9 },
10 })
11 },
12})
13
14object({
15 name: 'User',
16 rootTyping: 'Prisma.User',
17 definition(t) {
18 t.string('fullName', {
19 resolve(user) {
20 return [user.firstName, user.middleName, user.lastName].join(', ')
21 },
22 })
23 t.int('age', {
24 resolve(user) {
25 return yearsSinceUnixTimestamp(user.birthDate)
26 },
27 })
28 t.list.field('comments', {
29 type: 'Comment',
30 resolve(user, _args, { db }) {
31 return db.comment.fetchMany({ where: { author: user.id } })
32 },
33 })
34 },
35})
36
37object({
38 name: 'Comment',
39 rootTyping: 'Prisma.Comment',
40 definition(t) {
41 t.string('title', {
42 resolve(comment) {
43 return comment.title
44 },
45 })
46 t.field('body', {
47 resolve(comment) {
48 return comment.body
49 },
50 })
51 t.field('post', {
52 type: 'Post',
53 resolve(comment, _args, { db }) {
54 return db.post.fetchOne({ where: { id: comment.postId } })
55 },
56 })
57 t.field('author', {
58 type: 'User',
59 resolve(comment, _args, { db }) {
60 return db.user.fetchOne({ where: { id: comment.authorId } })
61 },
62 })
63 },
64})
65
66object({
67 name: 'Post',
68 rootTyping: 'Prisma.Post',
69 definition(t) {
70 t.string('title', {
71 resolve(post) {
72 return post.title
73 },
74 })
75 t.field('body', {
76 resolve(post) {
77 return post.body
78 },
79 })
80 t.list.field('comments', {
81 type: 'Comment',
82 resolve(post, _args, { db }) {
83 return db.comment.fetchMany({ where: { id: post.commentId } })
84 },
85 })
86 },
87})

Backing types in Nexus

Inferred types

When you first begin creating your schema, you may have objects without backing types setup. In these cases Nexus infers that the backing type is an exact match of the GraphQL type. Take this schema for example:

1// Nexus infers the backing type of:
2//
3// { fullName: string, age: number } ---> |
4// |
5object({ // |
6 name: 'User', // |
7 definition(t) { // |
8 t.string('fullName', { // |
9 resolve(user) { // |
10// ^-------------------------- |
11 return user.fullName // |
12 }, // |
13 }) // |
14 t.int('age', { // |
15 resolve(user) { // |
16// ^-------------------------- |
17 return user.age // |
18 }, // |
19 }) // |
20 }, // |
21}) // |
22 // |
23queryType({ // |
24 definition(t) { // |
25 t.list.field('users', { // |
26 type: 'User', // |
27 resolve() { // |
28 return [/**/] // |
29// ^----------------------- |
30 },
31 })
32 },
33})

This may suffice well enough for some time, but most apps will eventually see their GraphQL and backing types diverge. Once this happens, you can tell Nexus about it using the rootTyping object type config property.

rootTyping property

1export interface MyDBUser {
2 // | ^-------------------- Create your backing type
3 // ^-------------------------------- Export your backing type (required)
4 firstName: string
5 lastName: string
6 birthDate: number
7}
8
9object({
10 name: 'User',
11 rootTyping: 'MyDBUser',
12 // ^---------------------- Tell Nexus what the backing type is.
13 // Now, Nexus types...
14 definition(t) {
15 t.string('fullName', {
16 resolve(user) {
17 // ^----------------------- as: MyDBUser
18 return [user.firstName, user.lastName].join(', ')
19 },
20 })
21 t.int('age', {
22 resolve(user) {
23 // ^------------------------ as: MyDBUser
24 return yearsSinceUnixTimestamp(user.birthDate)
25 },
26 })
27 },
28})
29
30queryType({
31 definition(t) {
32 t.list.field('users', {
33 type: 'User',
34 resolve(_root, args, ctx) {
35 // ^------- return as: MyDBUser[]
36 return ctx.db.user.getMany()
37 },
38 })
39 },
40})

Nexus does not care about where MyDBUser is defined. So long as it is defined and exported from a module within your app, it will be available for use in any rootTyping property.

The rootTyping property is statically typed as a union of all the valid possible types your app makes available. Thus, your IDE will/should give you autocompletion here.

Third-party types

If you would like to use types from a third party package, you can just re-export them in your own app. Here's the above example re-visited using some third party typings:

1export type * as Spotify from 'spotify-api'
2// ^------ Export your third-party type(s)
3// Can be anywhere within your app
4
5object({
6 name: 'User',
7 rootTyping: 'Spotify.Foo',
8 // ^---------------------- Tell Nexus what the backing type is.
9 // Now, Nexus types...
10 definition(t) {
11 t.string('fullName', {
12 resolve(user) {
13 // ^----------------------- as: Spotify.Foo
14 return user.fullName
15 },
16 })
17 t.int('age', {
18 resolve(user) {
19 // ^------------------------ as: Spotify.Foo
20 return user.age
21 },
22 })
23 },
24})
25
26queryType({
27 definition(t) {
28 t.list.field('users', {
29 type: 'User',
30 resolve(_root, args, ctx) {
31 // ^------- return as: Spotify.Foo[]
32 return ctx.db.user.getMany()
33 },
34 })
35 },
36})

Note: The backing type configuration is co-located with the GraphQL object because Nexus takes the view that a GraphQL object owns its backing type requirements and all nodes in the graph pointing to it must then satisfy those requirements in their own resolvers. We saw a bit of this in the Backing Types Conepts section before, where User object was related to by multiple nodes in the graph, and those various nodes passed the same kinds of backing types during resolution.

Edit this page on Github