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: Int3 height: Int4 url: String5}67enum MovieRating {8 g9 pg10 pg1311 r12}1314type Movie {15 rating: MovieRating16 url: String17}1819type Song {20 album: String21 url: String22}2324union SearchResult = Photo | Movie | Song2526type Query {27 search(pattern: String): SearchResult28}
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 width5 height6 url7 }8 ... on Movie {9 rating10 url11 }12 ... on Song {13 album14 url15 }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})1011const MovieRating = enumType({12 name: 'MovieRating',13 members: ['g', 'pg', 'pg13', 'r'],14})1516const Photo = objectType({17 name: 'Photo',18 definition(t) {19 t.string('url')20 t.int('width')21 t.int('height')22 },23})2425const Song = objectType({26 name: 'Song',27 definition(t) {28 t.string('url')29 t.string('album')30 },31})3233const SearchResult = unionType({34 name: 'SearchResult',35 definition(t) {36 t.members('Photo', 'Movie', 'Song')37 },38})3940const 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).
- Centralized Strategy: Implement
resolveType
on the union type itself - Modular Strategy: Implement
isTypeOf
on each member of the union - 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' : null67 if (!__typename) {8 throw new Error(`Could not resolve the type of data passed to union type "SearchResult"`)9 }1011 return __typename12 },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:
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
- Each member type has had
resolveType
data
param will be typed as a union of all the member types' model types (backing types).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' : null1213 if (!__typename) {14 throw new Error(15 `Could not resolve the type of data passed to union type "SearchResult"`16 )17 }1819 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.
The resolver return type for fields whose type is a union will ensure all returned data includes a
__typename
field.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 typeresolveType
will be optional.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 thePhoto
model type is not required to include__typename
under thephotos
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 here7 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 here17 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})1314const 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})2526const 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:
- 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
- The union type has defined
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'23makeSchema({4 features: {5 abstractTypeStrategies: {6 resolveType: true, // default7 isTypeOf: false, // default8 __typename: false, // default9 },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'23makeSchema({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 here4 isTypeOf: true,5 __typename: false,6 }7}
Not:
1{2 abstractTypeStrategies: {3 resolveType: true, // <-- NOT what actually happens4 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:
Questions | If 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:
- Nexus can provide clear and concise feedback at runtime whereas with TypeScript the error messages and reasons can be cryptic.
- 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:
Combination | Effect |
---|---|
Modular & Centralized | isTypeOf 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:
1makeSchema({2 features: {3 abstractTypeStrategies: {4 resolveType: true,5 __typename: true,6 },7 },8 //...9})1011// ... your types elsewhere ...1213queryType({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})3132unionType({33 name: 'SearchResult',34 resolveType(data) {35 // <-- OPTIONAL; If given, __typename becomes OPTIONAL36 if (data.__typename /* <-- OPTIONAL b/c resolveType used */) {37 return data.__typename38 }3940 // ... fallback to whatever other logic you need41 },42 definition(t) {43 t.members('Photo' /* ... */)44 },45})4647objectType({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.
- Centralized (
resolveType
) - DMF (
__typename
) - 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: String3}45type Photo implements Media {6 width: Int7 height: Int8}910enum MovieRating {11 g12 pg13 pg1314 r15}1617type Movie implements Media {18 rating: MovieRating19}2021type Song implements Media {22 album: String23}2425type Query {26 search(pattern: String): Media27}
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 url4 ... on Photo {5 width6 height7 }8 ... on Movie {9 rating10 }11 ... on Song {12 album13 }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})78const Movie = objectType({9 name: 'Movie',10 definition(t) {11 t.implements('Media')12 t.field('rating', {13 type: 'MovieRating',14 })15 },16})1718const MovieRating = enumType({19 name: 'MovieRating',20 members: ['g', 'pg', 'pg13', 'r'],21})2223const Photo = objectType({24 name: 'Photo',25 definition(t) {26 t.implements('Media')27 t.int('width')28 t.int('height')29 },30})3132const Song = objectType({33 name: 'Song',34 definition(t) {35 t.implements('Media')36 t.string('album')37 },38})3940const 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.