Guides

Abstract Types

Overview

This guide covers the topic of GraphQL Union types and Interface types. You will learn how Nexus makes these features of the GraphQL specification easy and safe to work with.

Union Types

Union Types in Theory

Introduction

A GraphQL Union type is a polymorphic type. That is, it permits multiple disparate types to be gathered under a single field. Take for example this schema:

1type Photo {
2 width: Int
3 height: Int
4 url: String
5}
6
7enum MovieRating {
8 g
9 pg
10 pg13
11 r
12}
13
14type Movie {
15 rating: MovieRating
16 url: String
17}
18
19type Song {
20 album: String
21 url: String
22}
23
24union SearchResult = Photo | Movie | Song
25
26type Query {
27 search(pattern: String): SearchResult
28}

SearchResult is a union type and with it we can model search results which may be of various types.

References:

Over the Wire

When it comes time to implementing union types in your GraphQL schema it is necessary to annotate the data so that clients can send operations to the GraphQL API in a type safe way. Take for example this query against the schema we've seen above:

1query {
2 search(pattern: "Strawberry") {
3 ... on Photo {
4 width
5 height
6 url
7 }
8 ... on Movie {
9 rating
10 url
11 }
12 ... on Song {
13 album
14 url
15 }
16 }
17}

In order for this builtin polymorphic system to work, your GraphQL API must ultimately annotate the outgoing data with a property to discriminate results by on the client side. The spec states that this discriminant property is __typename.

Union Types in Practice

We can represent the above schema in Nexus as follows:

1const Movie = objectType({
2 name: 'Movie',
3 definition(t) {
4 t.string('url')
5 t.field('rating', {
6 type: 'MovieRating',
7 })
8 },
9})
10
11const MovieRating = enumType({
12 name: 'MovieRating',
13 members: ['g', 'pg', 'pg13', 'r'],
14})
15
16const Photo = objectType({
17 name: 'Photo',
18 definition(t) {
19 t.string('url')
20 t.int('width')
21 t.int('height')
22 },
23})
24
25const Song = objectType({
26 name: 'Song',
27 definition(t) {
28 t.string('url')
29 t.string('album')
30 },
31})
32
33const SearchResult = unionType({
34 name: 'SearchResult',
35 definition(t) {
36 t.members('Photo', 'Movie', 'Song')
37 },
38})
39
40const Query = queryType({
41 definition(t) {
42 t.field('search', {
43 type: 'SearchResult',
44 args: {
45 pattern: stringArg(),
46 },
47 })
48 },
49})

But what is missing here is the implementation of the discriminant property. In Nexus there are three possible implementations to choose from, depending on your preferences or requirements (e.g. team standards).

  1. Centralized Strategy: Implement resolveType on the union type itself
  2. Modular Strategy: Implement isTypeOf on each member of the union
  3. Discriminant Model Field (DMF) Strategy: Return a __typename discriminant property in the model data.

Let's explore each of these strategies.

Centralized Strategy (resolveType)

The Centralized strategy allows you to discriminate your union member types in a centralized (to the union type) way. For example:

1const SearchResult = unionType({
2 name: 'SearchResult',
3 resolveType(data) {
4 const __typename =
5 'album' in data ? 'Song' : 'rating' in data ? 'Movie' : 'width' in data ? 'Photo' : null
6
7 if (!__typename) {
8 throw new Error(`Could not resolve the type of data passed to union type "SearchResult"`)
9 }
10
11 return __typename
12 },
13 definition(t) {
14 t.members('Photo', 'Movie', 'Song')
15 },
16})

Each time you add a new member to the union type you will need to update your implementation of resolveType.

Nexus leverages TypeScript to statically ensure that your implementation is correct. Specifically:

  1. resolveType field will be required, unless one of:
    • Each member type has had isTypeOf implemented
    • Each member type has had its model type (backing type) specified to include __typename
    • The ResolveType strategy is disabled globally
  2. resolveType data param will be typed as a union of all the member types' model types (backing types).
  3. resolveType return type will be typed as a union of string literals matching the GraphQL object type names of all members in the GraphQL union type.

Discriminant Model Field (DMF) Strategy (__typename)

The DMF strategy allows you to discriminate your union member types in a potentialy modular way. It is based on supplying at __typename field in the model data returned by resolvers of fields typed as an abstract type. Here is an example:

1const Query = queryType({
2 definition(t) {
3 t.field('search', {
4 type: 'SearchResult',
5 args: {
6 pattern: stringArg(),
7 },
8 resolve(root, args, ctx) {
9 return ctx.db.search(args.pattern).map((result) => {
10 const __typename =
11 'album' in data ? 'Song' : 'rating' in data ? 'Movie' : 'width' in data ? 'Photo' : null
12
13 if (!__typename) {
14 throw new Error(
15 `Could not resolve the type of data passed to union type "SearchResult"`
16 )
17 }
18
19 return {
20 ...result,
21 __typename,
22 }
23 })
24 },
25 })
26 },
27})

As you can see the technique looks quite similar at face value to the Centralized strategy shown before. However your implementation might not look like this at all. For example maybe your data models already contain a discriminant property typeName. For example:

1const Query = queryType({
2 definition(t) {
3 t.field('search', {
4 type: 'SearchResult',
5 args: {
6 pattern: stringArg(),
7 },
8 resolve(root, args, ctx) {
9 return ctx.db.search(args.pattern).map((result) => {
10 return {
11 ...result,
12 __typename: result.typeName,
13 }
14 })
15 },
16 })
17 },
18})

In a serious/large application with a model layer in the codebase it's likely this kind of logic would not live in your resolvers at all.

Like with the Centralized strategy Nexus leverages TypeScript to ensure your implementation is correct.

  1. The resolver return type for fields whose type is a union will ensure all returned data includes a __typename field.

  2. For a given union type, if all fields that are typed as it have their resolvers returning data with __typename then back on the union type resolveType will be optional.

  3. Nexus is smart about when it needs to be satisfied by __typename presence. Rather than being a requirement of the model type, it is a requirement of the model type in resolver cases under the union type. For example note below how the Photo model type is not required to include __typename under the photos Query type field:

    1const Query = queryType({
    2 definition(t) {
    3 t.list.field('photos', {
    4 type: 'Photo',
    5 resolve(root, args, ctx) {
    6 // Nexus does not require __typename here
    7 return ctx.db.get.photos()
    8 },
    9 })
    10 t.field('search', {
    11 type: 'SearchResult',
    12 args: {
    13 pattern: stringArg(),
    14 },
    15 resolve(root, args, ctx) {
    16 // Nexus requires __typename here
    17 return ctx.db.search(args.pattern).map((result) => {
    18 return {
    19 ...result,
    20 __typename: result.typeName,
    21 }
    22 })
    23 },
    24 })
    25 },
    26})

Beware that when this strategy is enabled the abstractTypeRuntimeChecks feature will automatically be disabled. This is because it is not practical at runtime to find out if resolvers will return objects that include the __typename field. This trade-off can be acceptable since the runtime checks are a redundant safety measure over the static type checks. So as long as you are not ignoring static errors related to Nexus' abstract type type checks then you should still have a safe implementation.

Modular Strategy (isTypeOf)

The Modular strategy allows you to discriminate your union member types in a modular way (surprise). It uses a predicate function that you implement that allows Nexus (actually GraphQL.js under the hood) to know at runtime if data being sent to the client is of a respective type or not. Here is an example:

1const Movie = objectType({
2 name: 'Movie',
3 isTypeOf(data) {
4 return Boolean(data.rating)
5 },
6 definition(t) {
7 t.string('url')
8 t.field('rating', {
9 type: 'MovieRating',
10 })
11 },
12})
13
14const Photo = objectType({
15 name: 'Photo',
16 isTypeOf(data) {
17 return Boolean(data.width)
18 },
19 definition(t) {
20 t.string('url')
21 t.int('width')
22 t.int('height')
23 },
24})
25
26const Song = objectType({
27 name: 'Song',
28 isTypeOf(data) {
29 return Boolean(data.album)
30 },
31 definition(t) {
32 t.string('url')
33 t.string('album')
34 },
35})

Like with the Centralized and DMF strategies Nexus leverages TypeScript to ensure your implementation is correct in the following ways:

  1. If an object is a member of a union type then that object's isTypeOf field will be required, unless:
    • The union type has defined resolveType
    • The model type includes __typename property whose type is a string literal matching the GraphQL object name (case sensitive).
    • The fields where the union type is used include __typename in the returned model data
    • The IsTypeOf strategy is disabled globally

Picking Your Strategy (Or Strategies)

Configuration

Nexus enables you to pick the strategy you want to use. By default only the Centralized strategy is enabled. You can pick your strategies in the makeSchema configuration.

1import { makeSchema } from 'nexus'
2
3makeSchema({
4 features: {
5 abstractTypeStrategies: {
6 resolveType: true, // default
7 isTypeOf: false, // default
8 __typename: false, // default
9 },
10 },
11 //...
12})

Nexus enables enabling/disabling strategies because having them all enabled at can lead to a confusing excess of type errors when there is an invalid implementation of an abstract type. Nexus doesn't force you to pick only one strategy, however it does consider using multiple strategies slightly more advanced. Refer to the Multiple Strategies section for details.

When you customize the strategy settings all strategies become disabled except for those that you opt into. For example in the following:

1import { makeSchema } from 'nexus'
2
3makeSchema({
4 features: {
5 abstractTypeStrategies: {
6 isTypeOf: true,
7 },
8 },
9 //...
10})

The resolved settings would be:

1{
2 abstractTypeStrategies: {
3 resolveType: false, // The `true` default when no config is given is NOT inherited here
4 isTypeOf: true,
5 __typename: false,
6 }
7}

Not:

1{
2 abstractTypeStrategies: {
3 resolveType: true, // <-- NOT what actually happens
4 isTypeOf: true,
5 __typename: false,
6 }
7}

One Strategy

There is no right or wrong strategy to use. Use the one that you and/or your team agree upon. These are some questions you can ask yourself:

QuestionsIf affirmative, then maybe
Is my schema simple? Do I develop alone?Centralized (ResolveType)
Is my schema large? Do I have many collaborators?Modular (DMF, IsTypeOf)
Do I keep a discriminant property in the databse already?DMF: implement __typename in your model layer if you have one
Do I have objects that are part of multiple unions types and/or implement interfaces?DMF or IsTypeOf to avoid repeated logic across multiple resolveType implementations required by the ResolveType strategy

Runtime Checks

In addition to giving typings for static type checks Nexus also performs runtime checks. This redundancy exists for a few reasons:

  1. Nexus can provide clear and concise feedback at runtime whereas with TypeScript the error messages and reasons can be cryptic.
  2. Can help you in the case where you miss the type errors for some reason.

Runtime checks are automatically enabled. In development they will be a warning but in production they will be a thrown error. This way your development flow will not be unduly interrupted but your e.g. deployment pipeline in CI will be halted helping ensure you ship correct code to your users.

If ever you need to disable this runtime check you can disable the feature like so:

1makeSchema({
2 features: {
3 abstractTypeRuntimeChecks: false,
4 },
5})

Note that if you enable the DMF strategy (features.abstractTypeStrategies.__typename) then this runtime checks feature will be automatically disabled. For details about why refer to the DMF strategy docs above.

Multiple Strategies

It is possible to enable multiple strategies at once. However doing so creates harder to understand feedback. The following discusses the build and runtime ramifications in detail. Enabling multiple strategies is not recommended for people new to GraphQL.

At Buildtime

Nexus leverages TypeScript to statically encode all the rules discussed in this guide. When you have enabled multiple strategies it will degrade Nexus' ability to provide precise feedback. This is why Nexus considers having multiple strategies an advanced pattern. The static errors produced are likely to confuse newcomers, who cannot tell which ones stem from which strategy or why __typename is required while not isTypeOf/resolveType.

Here is a summary of the effects:

CombinationEffect
Modular & CentralizedisTypeOf on all member types & resolveType on abstract type required until either all members have isTypeOf implemented or resolveType is implemented.
DMF & Centralized* __typename required unless resolveType implemented on the abstract type.
DMF & Modular* __typename required unless isTypeOf implemented on all member types of the abstract type.
DMF & Modular & centralized* __typename required unless isTypeOf implemented on all member types of the abstract type or resolveType on the abstract type.

* Nexus cannot require resolveType/isTypeOf even though it should because it cannot statically detect when you have implemented __typename.

Here is a code example:

With resolveType implemented
Without resolveType implemented
1makeSchema({
2 features: {
3 abstractTypeStrategies: {
4 resolveType: true,
5 __typename: true,
6 },
7 },
8 //...
9})
10
11// ... your types elsewhere ...
12
13queryType({
14 definition(t) {
15 t.field('search', {
16 type: 'SearchResult',
17 args: {
18 pattern: stringArg(),
19 },
20 resolve(_, args, ctx) {
21 return ctx.db.search(args.pattern).map((result) => {
22 return {
23 ...result,
24 __typename: 'Photo', // <-- OPTIONAL b/c resolveType present (below)
25 }
26 })
27 },
28 })
29 },
30})
31
32unionType({
33 name: 'SearchResult',
34 resolveType(data) {
35 // <-- OPTIONAL; If given, __typename becomes OPTIONAL
36 if (data.__typename /* <-- OPTIONAL b/c resolveType used */) {
37 return data.__typename
38 }
39
40 // ... fallback to whatever other logic you need
41 },
42 definition(t) {
43 t.members('Photo' /* ... */)
44 },
45})
46
47objectType({
48 name: 'Photo',
49 definition(t) {
50 t.string('url')
51 t.int('width')
52 t.int('height')
53 },
54})

Local Abstract Type Settings in the Future?

Currently Nexus only supports global abstract type strategy settings. In the future however (#623) there may be ways to enable a particular strategy for only a subset of your schema. This way you could for example express that __typename is required in the returned model data of resolvers for one abstract type but require all other objects whom are members of other abstract types to implement isTypeOf.

At Runtime

When multiple strategies are enabled the following runtime precedence rules apply. Using a strategy here means the implementations of others of lower priority are discarded.

  1. Centralized (resolveType)
  2. DMF (__typename)
  3. Modular (isTypeOf)

The default resolveType implementation is actually to apply the other strategies. If you're curious how that looks internally you can see the code here.

Interface Types

Interface Types in Theory

Interfaces allow you to define a set of fields that you can then use across objects in your schema to enforce that they all have the fields defined in the interface. Interfaces also act as a form of polymorphism at runtime. You can make a field's type be an interface and may thus then return any type that implements the interface. To illustrate the point we'll appropriate the schema that we used to show off union types.

1interface Media {
2 url: String
3}
4
5type Photo implements Media {
6 width: Int
7 height: Int
8}
9
10enum MovieRating {
11 g
12 pg
13 pg13
14 r
15}
16
17type Movie implements Media {
18 rating: MovieRating
19}
20
21type Song implements Media {
22 album: String
23}
24
25type Query {
26 search(pattern: String): Media
27}

When a client sends a search query they will be able to select common fields as specified by the interface. Note this is unlike unions in which GraphQL assumes zero overlap between members. In contrast in GraphQL interfaces signify an intersection of fields between implementors and thus can be selected unqualified.

1query {
2 search(pattern: "Strawberry") {
3 url
4 ... on Photo {
5 width
6 height
7 }
8 ... on Movie {
9 rating
10 }
11 ... on Song {
12 album
13 }
14 }
15}

The mechanisms by which type discrimination is communicated over the wire for interface types is identical to how it works for union types, the __typename field. Refer to the union type guide for details.

References:

Interface Types in Practice

We can represent the above schema in Nexus as follows:

1const Media = interfaceType({
2 name: 'Media',
3 definition(t) {
4 t.string('url')
5 },
6})
7
8const Movie = objectType({
9 name: 'Movie',
10 definition(t) {
11 t.implements('Media')
12 t.field('rating', {
13 type: 'MovieRating',
14 })
15 },
16})
17
18const MovieRating = enumType({
19 name: 'MovieRating',
20 members: ['g', 'pg', 'pg13', 'r'],
21})
22
23const Photo = objectType({
24 name: 'Photo',
25 definition(t) {
26 t.implements('Media')
27 t.int('width')
28 t.int('height')
29 },
30})
31
32const Song = objectType({
33 name: 'Song',
34 definition(t) {
35 t.implements('Media')
36 t.string('album')
37 },
38})
39
40const Query = queryType({
41 definition(t) {
42 t.field('search', {
43 type: 'Media',
44 args: {
45 pattern: stringArg(),
46 },
47 })
48 },
49})

What's missing here is the discriminant property implementation. Nexus supports the same system for interface types as it does for union types. So for details about the various strategies to do this please refer to the corresponding section for union types.

If you are already familiar with how the system works for union types, here are a few notes to reaffirm the sameness:

  • resolveType on interface type configuration receives a param whose type is the union of all model types of implementing GraphQL objects in your schema. Akin to how for union types it is data of one of the GraphQL union's member types.
  • isTypeOf and __typename work the same and in fact their presence can serve the needs of both interface types and union type use-cases at the same time.
Edit this page on Github