Building a GraphQL API with union and interface types with Nexus and Photon.js

Overview

Are you looking to learn how the GraphQL union and interface types work together with Nexus.js and Photon.js? This article is for you.

We start by explaining the differences between the union and interface types and providing a recommendation on which type should be used when. We then walk you through an example of implementing both union and interface types in a GraphQL API project.

Scroll to the end of the article for the list of references and resources where you can learn more about GraphQL union and interface types, Nexus.js, and Photon.js.

What are union and interface types in GraphQL?

When creating a GraphQL schema, the principles of not repeating yourself and providing maximum clarity for the API users can sometimes be at odds with each other. You can often see this when creating a GraphQL endpoint that aggregates objects of different types under one query, for example, when writing search functionality or creating the endpoints that power dashboards.

GraphQL addresses the developer needs in cases like this one by providing two types: unions and interfaces.

The interfaces allow you to easily represent entities that share part of their schema. You define most of the schema at the interface level, then have the individual object types inherit all the schema from the interface plus add the fields of their own. You may already have heard about interfaces if you took an object-oriented programming (OOP) course. A common example given in OOP courses is a set of books that are available for sale at a bookstore: everything you’re selling has an ISBN number, a title, an author and a year of printing, while, say, for each foreign-language science book you also record the language of the edition and the scientific discipline this book relates to.

The union types are similar but different: these allow you to group independent object types that don’t necessarily have much in common and still be able to perform operations on the group as a whole. A simple example of this case is a search engine (at least in the recent years): you might be storing the information about webpages, videos, news articles, and items available for sale in different databases, but you want to display the search results for all of these categories together.

Union types vs. interfaces: which one should I use?

In general in the GraphQL world, the interfaces are a better fit for closely related entities that have many fields in common. The union types are best used when you are looking to aggregate multiple independent types that don’t have that many common properties.

How union types and interfaces are implemented in GraphQL

In a GraphQL schema, the interface definition could look like this:

interface Book {
  id: ID!
  name: String!
  authors: [Author]
  isbn: String!
}

While the types that implement the interface could have the following shape:

type ScienceBook implements Book {
  id: ID!
  name: String!
  authors: [Author]
  isbn: String!
  scienceFields: [ScienceField]
}

The unions are specified more simply:

union BookstoreFurniture = Desk | Bookshelf | Chair

Where each type could have its own definition.

Implementing the API with GraphQL Nexus and Photon

While building your social network for authors and photographers, you realise that you’d like to generate a feed of all activity in the system that includes both photos and articles. As the activity feed is going to be a highly used feature, it would be ideal to not have to query the photos and the articles individually. Having a single GraphQL query for both things would also simplify your frontend implementation.

After evaluating the GraphQL union types, you decide to create a new type, Post, that underneath can be either a photo or an article, and expose Posts in your feed.

TODO; START with project directory, dependencie

Building a schema You start by building the schema of your data. You use Prisma to define a schema on top of a simple sqlite development database:

// schema.prisma
datasource db {
  provider = "sqlite"
  url      = "file:dev.db"
  default  = true
}

generator photon {
  provider = "photonjs"
}
...

You define the users:

// schema.prisma
...
model User {
  id       String  @default(cuid()) @id
  email    String  @unique
  name     String?
  articles Article[]
  photos   Photo[]
}
...

And then define the models for both Articles and Photos that we want to store.

// schema.prisma
...
model Article {
  id        String   @default(cuid()) @id
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  published Boolean  @default(false)
  title     String
  content   String?
  author    User?
}

model Photo {
  id           String   @default(cuid()) @id
  createdAt    DateTime @default(now())
  description  String
  photographer User?
}

You start Prisma with npx prisma2 dev, tell Prisma to go ahead and create the sqlite database, and run the migrations for you. Nice work.

Thinking through the API structure The structure of t he API that you want to achieve in this section is:

  • Users type

    • Sub-type: Author
    • Sub-type: Photographer
  • Post type

    • Sub-type: Article
    • Sub-type: Photo

You are looking to achieve the sub-types using different means, however. With the User type, the difference between Authors and Photographers is only the associated photos or articles. With the Post type, in contrast, you would like the articles and the photos to potentially contain very different data.

You start by implementing the User type.

Housekeeping In order to use the syntax in the rest of this example, you’ll need to import the Nexus Prisma plugin. It will allow you to easily map the object types to the database. You’ll also need to import the right types from Nexus as well as the Photon library.

// schema.ts
import { nexusPrismaPlugin } from 'nexus-prisma'
import { idArg, makeSchema, objectType, interfaceType, unionType, stringArg } from 'nexus'
import { Photo, PhotoClient } from '@prisma/photon'
...

Creating the User type with Authors and Photographers Based on the fact that the authors and the photographers only differ slightly, you make a choice to implement these types as the implementations of the User interface.

You start by defining the interface:

// schema.ts
...
const User = interfaceType({
  name: 'User',
  definition(t) {
    t.resolveType((user) => {
      if (user.hasOwnProperty('articles')) {
        return 'Author'
      }
      return 'Photographer'
    })
    t.model.id()
    t.model.name()
    t.model.email()
  },
})
...

Note that you supply a name for the type — this is for clarity only and isn’t used by other parts of our implementation.

In the definition block you include a resolveType function: this helps Nexus identify whether a User object is actually an author or a photographer.

After that, you include three t.model calls that define the presence of the id, name and email fields on all objects that can be part of the User type. Therefore both Author and Photographer objects will have these fields available. By using the model stance you also bind the field to the Prisma model definition and therefore map the three fields — id, name and email — to the respective columns in the Users table in your sqlite database.

The Author and Photographer types themselves include just one additional detail: either the articles field or the photos field, respectively:

// schema.ts
...
const Author = objectType({
  name: 'Author',
  definition(t) {
    t.implements(User)
    t.model('User').articles({
      pagination: false,
    })
  }
})

const Photographer = objectType({
  name: 'Photographer',
  definition(t) {
    t.implements(User)
    t.model('User').photos()
  }
})
...

Both of these are declared as objectType’s. By using the t.implements function you point their implementations to the User interface, and thus Author and Photographer types inherit all the fields from the User type.

When using t.model, by default the model that the nexus-prisma looks at is the one that matches the class name. So if you use, say, t.model.photos() on the Photographer type, the plugin will look for the Photographer. You didn’t create a separate Photographer model but rather want to point the photos field to the existing User model. In oder to do this you call t.model with the 'User' parameter — t.model('User').photos() — to define the Photos field. You do the same for the articles field for the Author type.

As both the Author and Photographer types point to the same User model in the database, you use the User CRUD functions to write the mutations for creating authors and photographers separately:

// schema.ts
...
const Mutation = objectType({
  name: 'Mutation',
  definition(t) {
    t.crud.createOneUser({
      alias: 'signupUser',
      type: 'Author',
    })
    t.crud.createOneUser({
      alias: 'signupUser',
      type: 'Photographer',
    })
    ...
  }
})
...

The t.crud.* functions, including t.crud.createOneUser, are made available by the nexus-prisma plugin. You can find more details about how this function works in the CRUD section of the nexus-prisma docs.

In this case, creating both an author and a photographer uses the same function but supplies a different type parameter to distinguish between the types of users.

Creating the Post type that contains Articles and Photos As the Author and Photographer types only have a small difference — the articles field vs. the photos field — it makes sense to specify them as different implementations of a single interface. With Article and Photo types, however, you want to include potentially very different information in each type and still aggregate the objects of both types on the activity feed you produce.

Because Article and Photo types have more differences between them, you decide to implement them as a union type. You start with the individual Article and Photo types:

// schema.ts
...
const Article = objectType({
  name: 'Article',
  definition(t) {
    t.model.id()
    t.model.createdAt()
    t.model.updatedAt()
    t.model.title()
    t.model.content()
    t.model.published()
    t.model.author({
      type: 'Author'
    })
  },
})
...

The Article type uses the t.model calls to map all the fields of the type to the database fields using the Prisma model. There is a gotcha here: the Prisma model isn’t aware of the differences between Authors and Photographers, for the model they are both a User type. However, on the validation layer you’ll need to be specific about the type you expect in the author field in the Article type. You specify type: 'Author' to make sure that only Author objects are associated with the articles.

The Photo type is quite similar to Article in structure but defines different fields:

// schema.ts
...
const Photo = objectType({
  name: 'Photo',
  definition(t) {
    t.model.id()
    t.model.createdAt()
    t.model.description()
    t.model.photographer({
      type: 'Photographer'
    })
  },
})
...

Both Article and Photo types use t.model without additional parameters which means both these types have their own models on the backend. Notice how only Photographer objects are allowed in the photographer field for the Photo type.

We can now make a union type that consists of both Photos and Articles by using the unionType function:

// schema.ts
...
const Post = unionType({
  name: 'Post',
  definition(t) {
    t.members('Article', 'Photo')
    t.resolveType((item) => {
      // Only Articles have content,
      // Photos only have descriptions.
      return item.hasOwnProperty('content') ? 'Article' : 'Photo';
    })
  },
})
...

The resolveType function here allows Nexus to distinguish between the articles and the photos in the Post union. You simply look at the content property to determine if a post is an article, as Photo objects don’t have a content field. Therefore all objects with a content field are Articles. With this, your Post type is done and you can proceed to creating queries that use it.

Creating an activity feed query With the Post type defined, now is the time to create your queries. You use the default Photon CRUD queries to add the CRUD queries for articles and photos:

// schema.ts
...
const Query = objectType({
  name: 'Query',
  definition(t) {
    t.crud.article({
      alias: 'article',
    })

    t.crud.photo({
      alias: 'photo',
    })
...
})

In order to build a feed you’ll need a query that aggregates both articles and photos as Posts, even though those are represented by different database models. To achieve this you go the following route:

// schema.ts
...
const Query = objectType({
    ...
    t.list.field('feed', {
      type: 'Post',
      resolve: async (_parent, _args, ctx) => {
        const [articles, photos] = await Promise.all([
          ctx.photon.articles.findMany({
            where: { published: true },
          }),
          ctx.photon.photos.findMany({})
        ])
        return [...articles, ...photos].sort((a, b) => {
          return a.createdAt.getTime() - b.createdAt.getTime();
        })
      },
    })
    ...
)}

Here you use the t.list.field abstraction from nexus-prisma plugin to specify that you want to generate a list response of Posts. Now that the response consists of two different database entities (articles and photos), you need to fetch the content of both types and then combine it into a single result set. You then sort the results so that the API response has both photos and articles ordered chronologically.

You then try out the query:

query {
  feed {
    ... on Article {
      createdAt
      title
      content
      author {
        name
        email
      }
    }
    ... on Photo {
      createdAt
      description
      photographer {
        name
        email
      }
    }
  }
}

Now that a Post is a union type, you indicate explicitly in the query which fields you’d like to get back for each of the entities that can be returned.

You get a response back:

{
  "data": {
    "feed": [
      {
        "createdAt": "2019-12-13T09:46:14.977Z",
        "title": "Watch the talks from Prisma Day 2019",
        "content": "https://www.prisma.io/blog/z11sg6ipb3i1/",
        "author": {
          "name": "Alice",
          "email": "alice@prisma.io"
        }
      },
      ...
    ]
  }
}

Great, the feed is working.

Post filtering with both articles and photos You then decide that you’d like the make the feed searchable, and that the results should include both the articles and the photos from your network. To achieve this you add a new query:

// schema.ts
const Query = objectType({
  name: 'Query',
  definition(t) {
    ...
    t.list.field('filterPosts', {
      type: 'Post',
      args: {
        searchString: stringArg({ nullable: true }),
      },
      resolve: async (_, { searchString }, ctx) => {
        const [articles, photos] = await Promise.all([
          ctx.photon.articles.findMany({
            where: {
              OR: [
                { title: { contains: searchString } },
                { content: { contains: searchString } },
              ],
            },
          }),
          ctx.photon.photos.findMany({
            where: { description: { contains: searchString } },
          }),
        ])
        return [...articles, ...photos].sort((a, b) => {
          return a.createdAt.getTime() - b.createdAt.getTime();
        })
      },
    })
  },
})

The structure of the resolver for this query is very similar to that of the feed resolver. As you are fetching the results that are both articles and photos, you search these two entities independently and return the combined set of results.

The query with the search element now looks like this:

query {
  filterPosts(searchString: "Prisma Day") {
    ... on Article {
      createdAt
      title
      content
      author {
        name
        email
      }
    }
    ... on Photo {
      createdAt
      description
      photographer {
        name
        email
      }
    }
  }
}

And the result, as expected, contains only the elements from both articles and photos that match the search term:

{
  "data": {
    "filterPosts": [
      {
        "createdAt": "2019-12-13T09:46:14.977Z",
        "title": "Watch the talks from Prisma Day 2019",
        "content": "https://www.prisma.io/blog/z11sg6ipb3i1/",
        "author": {
          "name": "Alice",
          "email": "alice@prisma.io"
        }
      }
    ]
  }
}

Great! You can now easily implement the filter function on your feed.

  • Other changes to support schema.ts
  • Trying out the example

    • Screenshots
    • TK
    • TK

A note on the lookup implementation for multiple models In the Article and Photo part of this example, you might have noticed that the fetching of data is done from multiple database tables at the same time. The example then sorts the data from both entities according to the creation date.

With very large lists of returned objects this operation can be very inefficient. It is a best practice to use your database’s functionality to return an already sorted list instead of sorting it in your application, and it can be worth implementing a more efficient result combination algorithm if working with large datasets.

Summary

In this article you walked through creating a GraphQL API that uses both union types and interfaces with Nexus.js. You used Photon.js to generate the data layer code and save time on writing the mappings between the database tables and the fields on your objects.