8 min read

Building type safe GraphQL APIs with Prisma and Pothos

My good people of the TypeScript community. I have reached a state of type safety nirvana that I never thought possible when building GraphQL APIs with TypeScript, and I want to share with you how.

Before I achieved this state, I frequently struggled with getting my GraphQL resolvers to be fully type safe. A lot of existing documentation on the topic either defaults to an approach that isn't type safe, like writing your schema with GraphQL's SDL, or they reference libraries that misleadingly claim to offer type safety by default, but fall short of actually doing so.

This left a massive type hole in my resolvers, where the only way I would be notified of type mismatches between my schema definition and its implementation was via tests, or via runtime errors from the GraphQL server letting me and all of my users know that I had goofed. This was truly a bummer- I had an expectation that the type strictness of GraphQL could be leveraged in TypeScript code, but this was proving to be a more difficult task than I had anticipated.

I was able to plug this type hole once and for all with a library that actually fulfills its promise of type safety, but weirdly doesn't get as much attention as its alternatives, Pothos. Coupled with using Prisma as my ORM, it finally became possible to ensure that the implementation of my APIs were completely type safe from back to front, from the data layer to the presentation layer. This meant that I could catch type mismatching issues in my build step instead of letting them manifest as runtime errors.

And, because GraphQL is programming language agnostic, the type safety of my TypeScript APIs could be extended to all of its consumers, regardless of if they were written with TypeScript or not. Let me show you how I made my type safety dreams come true so that you too may achieve this level of bliss.

Start with strict mode

Before we begin, let's make sure we starting ourselves off on our best foot by ensuring that strict: true is enabled in our tsconfig.json file. Without this, you will be missing out on a lot of common type errors, such as not realizing that a property on an object can be null:

// Without strict mode enabled 

type Post = {
	publishedAt: Date | null
}

// This doesn't generate a type error, 
// even though publishedAt can be null :( 
const formatPublishedAtDate = (post: Post) => 
	post.publishedAt.toDateString()

There's a whole host of other type checking errors that you'd be missing out on as well besides null checks.

The only reason you should want to disable strict mode is when you are migrating a JavaScript code base to TypeScript. Turning it on for a code base that's existed for a while with it off will not be fun, so it's better if we start with it enabled.

The data layer

One issue I've seen that frequently undermines type safety through out a code base is incorrect typing at the data level. Odds are your app is going to need to store and query data, and a lot of its core logic is going to be based on working with that data. Therefore if the types for your data and queries aren't as accurate as they can be, then neither will anything that's built on top of it.

Some ORMs and data mapping libraries are better at this than others. I highly recommend you pick one that handles typing for you as much as possible. If you are manually mapping database types and queries to TypeScript types, then you and your customers are in for a world of hurt, in the form of runtime errors for silly little mistakes like mistyping a nullable column.

A library I've used that does this very well, and that you might have already heard of, is Prisma. If you're not familiar, it works by generating types and query builders for you based on a schema that you write in a language that sort of looks like (but isn't) GraphQL. It maps that schema to tables and columns for your database of choice in migrations, and spits out the code you need for querying those tables. All of the major players like Postgres, MySQL, and SQLite are supported, as well as inferior ones like MongoDB.

I've found the quality of the typing in the generated code to be very good, if not great. Prisma has a powerful query API that lets you build queries with an object, and it dynamically adjusts the return type of the query based on what fields you've selected and relationships you've opted in to loading.  It even lets you filter on properties that belong to related models. Let's take a look at this example, for a schema that belongs to a blog:

datasource db {
	provider = "postgresql" // My goto. 
	url = env("DATABASE_URL")
}

generator client {
	provider = "prisma-client-js"
}

model Author {
	email String @unique
	fullName String? // Make fullName nullable in Postgres, and in our app

	posts Post[] // One-to-many relationship with Post

	id String @id @default(uuid()) // I like uuids.
	createdAt DateTime @default(now())
	updatedAt DateTime @updatedAt
}

model Post {
	// Specify a foreign key for Author
	author Author @relation(fields: [authorId], references: [id])
	authorId String
    
	// Track if and when posts are published with a nullable timestamp column
	publishedAt DateTime? 
    
	content String // We'd store this in S3 or Elasticsearch IRL.
    
	id String @id @default(uuid())
	createdAt DateTime @default(now())
	updatedAt DateTime @updatedAt
}

After running prisma migrate dev to generate our migrations and client code, we can write queries that look like this:

import { PrismaClient, Author, Post } from '@prisma/client'

// type Author = { email: string; fullName: string | null; id: string; createdAt: Date; updatedAt: Date; }

// type Post = { authorId: string; publishedAt: Date | null; id: string; /* ... */ }

const connection = new PrismaClient() // Auto connects to process.env.DATBASE_URL

const author = await connection.author.create({
	data: {
		// TypeScript will complain if we don't set email,
		// because it wasn't marked as nullable in our schema.
		email: 'mario@coquito.io',
        
		// We don't have to set fullName because it is nullable
		// fullName: null, 
    }
})


// Get me all blog posts currently published by Mario
const posts = await connection.post.findMany({
	where: {
		publishedAt: {
			gte: new Date()
		},
		author: {
			email: author.email,
		}
	},
	include: {
		// Example of how we'd include related Author records in our query result.
		// Passing true means include all fields, but we can be more selective if we want.
		// If author had other relation fields we could include those as well, 
		// like: { author: { otherRelation: true } }.
		author: true
	}
})

// typeof posts === { author: Author; authorId: string; publishedAt: Date | null; /* ...id, etc... */ }[]

The type of our query result matches what we queried, and we didn't have to write any types ourselves. The extra build step needed to achieve this is admittedly a little inconvenient; I'd personally prefer to have this schema defined in code. But the accuracy of the types that you get makes that inconvenience very minor IMO.

You would think that something so magical would be limiting, but I haven't found this to be the case. You can write most queries using the built in API, and if you can't, there's always the option to write your own raw SQL. They also have support for many database specific features, like Postgres extensions. That being said, I'd still highly recommend you consult their documentation before going all in, in case you have more esoteric data fetching needs.

The presentation layer

With our API, we want to make sure that we are giving our clients what we said we were going to give them. Ideally, we can be notified at build time if we try to implement an API endpoint whose return type doesn't match its specified contract. We can accomplish this with GraphQL by using a code-first approach that allows us to define queries, mutations, types that can be type checked against.

I've been using Pothos to achieve this, a library that doesn't get nearly enough attention as it should. Pothos lets you define type safe resolvers in a declarative fashion. Like Prisma, it doesn't force you to build and maintain your own types in order to get that type safety.

Let's take a look at it works by extending our blog example from before, starting with defining GraphQL types for Authors and Posts:

import { createYoga } from 'graphql-yoga'
import SchemaBuilder from '@pothos/core'
import { createServer } from 'node:http'

import { Author, Post } from '@prisma/client'

const builder = new SchemaBuilder({});

// <Author> tells Pothos we want to use our generated Author type as the basis for this GraphQL type.
export const authorType = builder.objectRef<Author>('Author').implement({
  fields: (t) => {
    return {
	  // "expose" = we don't need to specify a resolver for this field,
	  // we'll supply a value when returning it in a query or mutation;
      // we're "exposing" it from the supplied <Author> interface
      id: t.exposeString('id'),
      email: t.exposeString('email'),
      
      // Pothos rightfully defaults to fields being non-nullable.
      // So we have to tell it that fullName is nullable.
      fullName: t.exposeString('fullName', { nullable: true }),
    }
  },
})

export const postType = builder.objectRef<Post>('Post').implement({
    fields: (t) => {
        return {
            id: t.exposeString('id'),
            content: t.exposeString('content'),      
            author: t.field({
                type: authorType,
                resolve: (post) => {
                    // TODO: Use the Dataloader plugin to remove this N+1 issue
                    return connection.author.findFirstOrThrow({ where: { id: post.authorId } })
                }
			}),
    }
})

We've specified GraphQL types for Authors and Posts, along with the fields that we want to expose on those types to our clients. These types will be represented as types in the GraphQL schema, as well as types in the implementations of our queries and mutations.

Note that we've specified an author field on the Post type, which queries an author where post.authorId === author.id. This isn't ideal if we're fetching a list of posts, because we'd be initiating a new database query for each post in that list if the client has opted to fetch the author field for posts in their query. We could and should be using Pothos's dataloader plugin to make this more efficient, but I am going to leave this inefficient query inline to keep this example simple.

Now, let's take a look at how we'd use these types to implement a query for a list of posts:

builder.queryField('posts', t => {
	return t.field({
        // The resolver of this query must return an array of objects that each match the Post schema
        type: [postType],
        args: {
            // Make authorId a required String! in the schema, and in our code
            authorId: t.arg.string({ required: true }),
        }
        // authorId: string
        resolve: (_parent, { authorId }, _context) {
			// We have to return data that matches [postType]
			// { authorId: string; content: string; /*...*/ }[]
			// The author field will be resolved for us with the resolver on postType.
			return connection.post.findMany({ where: { authorId, publishedAt: { not: null } } })
        	}
	})
})

In this query, we've specified the return type via the type argument of queryField() to be an array of postType.

To ensure that we're matching the schema of that type, we are resolving this query by querying a list of post records. If we were to say, query a different model that had different fields, or only select a subset of fields on Post, we'd get a type error from tsc. We'd also get a type error if Post.content was nullable in our Prisma schema but not with Pothos (if you've remembered to enable strict mode, that is).

This type safety extends to the arguments of the query. We've specified an authorId arg with a type of String!: this ensures that when this resolver runs, we have received a string from the client for realsies, and not null or undefined.

Because Pothos has strict typing for the input and return types of its resolvers, we can be assured that we will get notified at build time if we accept or return a value that doesn't match what Pothos is expecting. And with Prisma type checking our database query inputs and results, we have even more assurance that misalignments between our schema and database operations will be caught as well. This, my friends, is true end-to-end type safety.

Extending type safety to the front-end

One of my favorite aspects of GraphQL is that its spec is both type safe and language agnostic. Because of this, it's possible to auto generate type safe query and mutation code from your schema, further extending the type safety of the API to its consumers.

The most popular (and my fav) option for front-end JavaScript is GraphQL Codegen. If you're a Swift programmer, it seems like Apollo offers a similar tool for Swift as well (disclaimer: I am not a Swift programmer).

Summing up

Prisma and Pothos have become my personal gotos for building type safe APIs. If you choose to go with alternatives for your own project, I recommend you start out by implementing some sample CRUD operations that involve your database, and testing that you get expected type-errors for things like accidentally returning null for a non-nullable field to make sure you're fully covered end-to-end.

At any rate, I hope this example has inspired you to integrate type safety into your API implementation. Having this level of type safety has been a huge boon to my productivity, and has enabled to me to build much more reliable APIs.