Game of Types: A Song of GraphQL and TypeScript

May 23, 2019

Over the last few years, the popularity of both GraphQL and TypeScript has exploded in the web ecosystem—and for good reason: They help developers solve real problems encountered when building modern web applications. One of the primary benefits of both technologies is their strongly typed nature.

Strong typing is a communication tool that uses explicit statements of intent. Method signatures tell you exactly what kind of input they expect and what kind of output they return. The compiler doesn't allow you to break those rules. It's about failing fast at compile time instead of by users at runtime. TypeScript is a strongly typed language that brings this to the JavaScript ecosystem.

GraphQL also brings this benefit to an area of web applications that is notoriously error-prone–interacting with backend APIs. By providing a schema both the server and client can depend on (because it is enforced by the specification), GraphQL provides a strongly typed “bridge” between both sides of the application.

Going forward, this post will assume the reader has a working knowledge of both GraphQL and TypeScript. If you don’t, that’s totally fine, and you should still be able to understand the concepts.

An app of the seven kingdoms

Let’s build an app about one of my favorite shows, Game of Thrones, so we have an example to work from. We’ll build a GraphQL server for the backend and a React app for the frontend, both written in TypeScript.

GraphQL server

We’ll start with the server. To keep this example focused on GraphQL, we aren’t going to use a real database to store our data. Instead, we’ll hard-code the data to serve as an in-memory “database”. I’ve taken the liberty of writing the data functionality ahead of time, but if you’re curious, you can inspect all of the code in this repository.

First, we’ll get the boring boilerplate out of the way. We’ll spin up a server and provide the GraphQL context, which will be provided as an argument to all of our resolvers. It’s a good place to store user information, data models, etc. Pretty standard stuff. The details here aren’t pertinent to this post.

import { ApolloServer } from "apollo-server"; import * as bookModel from "./models/book"; import * as characterModel from "./models/character"; import * as houseModel from "./models/house"; import * as tvSeriesModel from "./models/tv-series"; import { resolvers } from "./resolvers"; import { schema } from "./schema"; export interface Context { models: { character: typeof characterModel; house: typeof houseModel; tvSeries: typeof tvSeriesModel; book: typeof bookModel; }; } const context: Context = { models: { character: characterModel, house: houseModel, tvSeries: tvSeriesModel, book: bookModel } }; const server = new ApolloServer({ typeDefs: schema, resolvers, context }); server.listen().then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });

Schema

When I’m working on a GraphQL server, I like to start by modeling the “domain” in the schema, and then later implement the resolvers and data fetching. Here’s what our Game of Thrones schema looks like:

import gql from "graphql-tag"; export const schema = gql` type Query { getCharacters(sortDirection: SortDirection): [Character!]! getCharacter(characterId: ID!): Character getHouses(sortDirection: SortDirection): [House!]! getHouse(houseId: ID!): House } type Character { id: ID! name: String! culture: String titles: [String!] aliases: [String!] born: String died: String father: Character mother: Character spouse: Character children: [Character!] allegiances: [House!] appearedIn: [TvSeason!]! isAlive: Boolean! playedBy: String books: [Book!] } type TvSeason { id: ID! startDate: String! endDate: String! name: String! characters: [Character!]! } type House { id: ID! name: String! titles: [String!] members: [Character!]! slogan: String overlord: Character currentLord: Character founder: Character ancestralWeapons: [String!] coatOfArms: String seats: [String!] } type Book { id: ID! name: String! releaseDate: String! } enum SortDirection { ASC DESC } `;

Resolvers

Next, we’ll implement the resolvers:

import { Context } from "./"; import { Character } from "./data/characters"; import { TvSeries } from "./data/tv-series"; import { House } from "./data/houses"; type ResolverFn = (parent: any, args: any, ctx: Context) => any; interface ResolverMap { [field: string]: ResolverFn; } interface Resolvers { Query: ResolverMap; Character: ResolverMap; TvSeason: ResolverMap; House: ResolverMap; } export const resolvers: Resolvers = { Query: { getCharacters: (root, args: { sortDirection: "ASC" | "DESC" }, ctx) => { return ctx.models.character.getAll(args.sortDirection); }, getCharacter: (root, args: { characterId: string }, ctx) => { return ctx.models.character.getById(parseInt(args.characterId)); }, getHouses: (root, args: { sortDirection: "ASC" | "DESC" }, ctx) => { return ctx.models.house.getAll(args.sortDirection); }, getHouse: (root, args: { houseId: string }, ctx) => { return ctx.models.house.getById(parseInt(args.houseId)); } }, Character: { allegiances: (character: Character, args, ctx) => { if (!character.allegiances) return null; return character.allegiances.map(allegianceId => ctx.models.house.getById(allegianceId) ); }, appearedIn: (character: Character, args, ctx) => { if (!character.tvSeries) return []; return character.tvSeries.map(seriesId => ctx.models.tvSeries.getById(seriesId) ); }, isAlive: (character: Character, args, ctx) => { return !character.died; }, father: (character: Character, args, ctx) => { if (!character.fatherId) return null; return ctx.models.character.getById(character.fatherId); }, mother: (character: Character, args, ctx) => { if (!character.motherId) return null; return ctx.models.character.getById(character.motherId); }, spouse: (character: Character, args, ctx) => { if (!character.spouseId) return null; return ctx.models.character.getById(character.spouseId); }, children: (character: Character, args, ctx) => { if (!character.childrenIds) return null; return character.childrenIds.map(childId => ctx.models.character.getById(childId) ); }, playedBy: (character: Character, args, ctx) => { if (!character.playedBy || character.playedBy.length === 0) return null; return character.playedBy[0]; }, books: (character: Character, args, ctx) => { if (!character.bookIds) return null; return character.bookIds.map(bookId => ctx.models.book.getById(bookId)); } }, TvSeason: { name: (tvSeries: TvSeries, args, ctx) => { return tvSeries.id; } }, House: { members: (house: House, args, ctx) => { return ctx.models.character.getByHouseId(house.id); }, overlord: (house: House, args, ctx) => { if (!house.overlordId) return null; return ctx.models.character.getById(house.overlordId); }, currentLord: (house: House, args, ctx) => { if (!house.currentLordId) return null; return ctx.models.character.getById(house.currentLordId); }, founder: (house: House, args, ctx) => { if (!house.founderId) return null; return ctx.models.character.getById(house.founderId); } } };

A few TypeScript-related things should jump out at you. First, we have to define the properties of our resolver object so TypeScript knows what to expect (Query, Character, etc.). For each of those properties (resolver functions), we have to manually define the type definitions for all of the parameters.

The first argument, usually referred to as the “root” or “parent” parameter, has a different type depending on which resolver you are working on. In this example, we see any, Character, TvSeries, and House, depending on which parent type we are working with. The second argument contains the GraphQL query arguments, which is different for each resolver. In this example, some are any, some take are sortDirection, and some accept an ID. The third argument, context, is thankfully the same for every resolver.

If we want type-safe resolvers, we have to do this for every resolver. Exhausting.

And there’s a catch. If we decide to change our GraphQL schema by, for example, changing or adding a new argument, we have to remember to manually update the type definitions for the respective resolver. I don’t know about you, but the chances I’ll remember to do that are lower than 100%. Our code will compile, TypeScript will be happy, but we’ll get a runtime error.

Bummer.

(spoiler alert: we will solve this problem later in the post.)

Now that our server is done, let’s run the app and make sure it works.

A GraphQL playground with a GraphQL request and the result of that GraphQL request

It worked! Awesome.

Front-end application

Now that we have a server, let’s write a front-end application. We’ll use create-react-app to simplify the setup and Apollo Client for the GraphQL functionality. Our app will show a list of Game of Thrones characters with some basic information. If you click on a character, you will see more information about that character.

When I’m working on a component, I like to start with the data. The following GraphQL query will give us the data we need to render the character list.

import gql from "graphql-tag"; export const CharacterListQuery = gql` { getCharacters(sortDirection: ASC) { id name playedBy culture allegiances { name } isAlive } } `;

Let’s use that query along with react-apollo’s Query component to fetch our data and display it in the UI.

import React, { SyntheticEvent } from "react"; import { Query } from "react-apollo"; import { CharacterListQuery } from "./queries/CharacterListQuery"; import CharacterListItem from "./CharacterListItem"; import "./CharacterList.css"; interface Props { setSelectedCharacter: (characterId: number) => void; } export interface Character { id: string; name: string; playedBy: string; culture?: string; allegiances?: Array<{ name: string }>; isAlive: boolean; } interface Data { getCharacters: Character[]; } const CharacterList: React.FC<Props> = ({ setSelectedCharacter }) => { return ( <div className="CharacterList"> <h2>All Characters</h2> <Query<Data> query={CharacterListQuery}> {({ loading, error, data }) => { if (loading) return "Loading..."; if (error || !data) return `Error!`; return ( <ul> {data.getCharacters.map(character => ( <CharacterListItem key={character.id} character={character} select={(e: SyntheticEvent) => { e.preventDefault(); setSelectedCharacter(parseInt(character.id)); window.scrollTo(0, 0); }} /> ))} </ul> ); }} </Query> </div> ); }; export default CharacterList;

Again, there are a few TypeScript-related things that will jump out at you. The loading and error properties are already auto-typed, which is great. Unfortunately, the data property is not. In order to have type-safe data, we need to manually define a TypeScript interface (based on what’s requested in the query—see Data and Character). Just like before, if we change the query, we have to remember to update the TypeScript interface.

Here’s the character detail query and component:

import gql from "graphql-tag"; export const CharacterDetailQuery = gql` query CharacterDetail($id: ID!) { getCharacter(characterId: $id) { name playedBy culture titles aliases born died allegiances { name } isAlive father { id name } mother { id name } spouse { id name } children { id name } appearedIn { name } books { id name } } } `;
import React from "react"; import { Query } from "react-apollo"; import { CharacterDetailQuery } from "./queries/CharacterDetailQuery"; import "./CharacterDetail.css"; interface Props { selectedCharacter?: number; setSelectedCharacter: (characterId: number) => void; } interface CharacterDetail { id: string; name: string; playedBy: string; culture?: string; born?: string; died?: string; titles?: string[]; aliases?: string[]; father: { id: string; name: string }; mother: { id: string; name: string }; spouse: { id: string; name: string }; children: Array<{ id: string; name: string }>; allegiances?: Array<{ name: string }>; appearedIn: Array<{ name: string }>; isAlive: boolean; books: Array<{ id: string; name: string }>; } interface Data { getCharacter: CharacterDetail; } interface Variables { id: string; } const CharacterDetail: React.FC<Props> = ({ selectedCharacter, setSelectedCharacter }) => { return ( <div className="CharacterDetail"> {selectedCharacter ? ( <Query<Data, Variables> query={CharacterDetailQuery} variables={{ id: String(selectedCharacter) }} > {({ loading, error, data }) => { if (loading) return "Loading..."; if (error || !data) return `Error!`; return ( <Detail character={data.getCharacter} select={(id: number) => { setSelectedCharacter(id); window.scrollTo(0, 0); }} /> ); }} </Query> ) : ( <> <h2>Character Detail</h2> <div>Please select a character</div> </> )} </div> ); }; const Detail: React.FC<{ character: CharacterDetail; select: (characterId: number) => void; }> = ({ character, select }) => { return ( <> <h2>{character.name}</h2> {character.allegiances && character.allegiances.length > 0 && ( <div> <strong>Loyal to</strong>:{" "} {character.allegiances.map(allegiance => allegiance.name).join(", ")} </div> )} {renderItem("Culture", character.culture)} {renderItem("Played by", character.playedBy)} {renderListItem("Titles", character.titles)} {renderListItem("Aliases", character.aliases)} {renderItem("Born", character.born)} {renderItem("Died", character.died)} {renderItem("Culture", character.culture)} {renderCharacter(select, "Father", character.father)} {renderCharacter(select, "Mother", character.mother)} {renderCharacter(select, "Spouse", character.spouse)} {character.children && character.children.length > 0 && ( <div> <strong>Children</strong>:{" "} {character.children.map(child => ( <> <a href="#" onClick={() => select(parseInt(child.id))}> {child.name} </a>{" "} </> ))} </div> )} {renderListItem( "TV Seasons", character.appearedIn ? character.appearedIn.map(x => x.name) : [] )} {renderListItem( "Books", character.books ? character.books.map(x => x.name) : [] )} </> ); }; export default CharacterDetail; const renderItem = (label: string, item?: string) => { return ( item && ( <div> <strong>{label}</strong>: {item} </div> ) ); }; const renderListItem = (label: string, items?: string[]) => { return ( items && items.length > 0 && ( <div> <strong>{label}</strong>: {items.join(", ")} </div> ) ); }; const renderCharacter = ( select: any, label: string, item: { name: string; id: string } ) => { return ( item && ( <div> <strong>{label}</strong>:{" "} <a href="#" onClick={() => select(parseInt(item.id))}> {item.name} </a> </div> ) ); };

We see the same issues here. A lot of manual work. But at least our app works!

List of the characters in the app of the Seven Kingdoms

Stepping back

What we have is pretty cool—GraphQL data-fetching and type-safe client and server applications. But we also have a big problem. There is a ton of duplication between the GraphQL schema and the TypeScript interfaces, requiring manual synchronization when changing our schema.

What we really want is for our GraphQL schema to be the single source of truth for our types.

Is there any way to accomplish this? As you can probably guess from the title of this post, the answer is yes.

GraphQL Code Generator

GraphQL Code Generator is a tool built to solve this problem. By parsing and analyzing our GraphQL schema, it outputs a wide variety of TypeScript definitions we can use in our GraphQL resolvers and front-end components. It supports many output formats, but we will focus on the resolver definitions and the react-apollo component generation. That’s right, it can even generate fully typed React components for us.

Server

Let’s start with generating type definitions for the resolvers. After reading the documentation, which is quite thorough, this is what I came up with:

schema: ./server/schema.ts generates: server/gen-types.ts: config: defaultMapper: any contextType: ./#Context plugins: - typescript - typescript-resolvers

There are a few important pieces that I’ll explain. The schema field, as the name implies, tells GraphQL Code Generator where to find our schema. The generates field tells it where to place the generated type definitions, and the plugins array tells it which plugins to use when generating that file.

After running the tool with the above configuration, we now have this file. It’s pretty complex and uses a ton of TypeScript generics, but I recommend you dig around to see what it’s doing.

It’s pretty magical.

Using only our GraphQL schema, the tool automatically generated type definitions for all of our resolvers.

We can now replace all of the manual type definitions we wrote earlier with the generated types. Our modified resolver file is here, and you can view a before and after comparison below. Notice how many of the manual type definitions we were able to delete?

Screenshot of the code difference when the GraphQL Code Generator creates definitons based on a GraphQL schema

The benefits are already enormous, but it gets better. This workflow really shines when we need to make a change to our GraphQL schema. All we have to do is make the change, generate new types, and we’ll get type errors in all of the places that need to change. In this example, we removed the sortDirection parameter from the getHouses query.

Screenshot of the error that results from removing a field from the GraphQl schema

Compile errors, not runtime errors!

A meme of shunning runtime errors and acknowledging compiler errors

Client

Now we’ll move on to the client. Here’s the modified configuration file to generate client-side stuff:

schema: ./server/schema.ts generates: server/gen-types.ts: config: defaultMapper: any contextType: ./#Context plugins: - typescript - typescript-resolvers ./client/src/gen-types.tsx: documents: ./client/src/queries/*.tsx plugins: - add: /* eslint-disable */ - typescript - typescript-operations - typescript-react-apollo

GraphQL Code Generator will use the previously -configured schema from our server, as well as the client-side queries it finds in the queries directory, to generate type definitions and React components. Check out the generated file. Again, it’s pretty complex, but try to understand what it’s doing.

Using only our GraphQL queries, the tool automatically generated fully-typed react-apollo components that we can use in our application.

We can now replace all of our previous usage of react-apollo’s Query component (which required manual typedefs) with the auto generated components, which come “batteries included.” Here’s a before and after comparison:

Screenshot demonstrating logic being abstracted into a component

Again, this is huge. And as before, it’s worth its weight in gold when there is a schema or query change. They will be reflected in the generated types and you’ll immediately see what needs to be fixed.

Screenshot of the error that results by trying to access a field that does not exist on the generated type from the GraphQl schema

Conclusion

GraphQL and TypeScript’s popularity explosion in the web ecosystem is, in my opinion, a Good Thing. They help developers solve real problems encountered when building and maintaining modern web applications. One of the primary benefits of both technologies is their strongly typed nature.

Since they are both independent type systems, however, there is a risk of duplication and divergence between the two. By treating GraphQL as the single source of truth for our types and generating TypeScript definitions from it, we diminish this risk. Luckily for us, amazing packages like GraphQL Code Generator exist!

While you can manually run the code generator after a schema or query change, you might forget. Instead, I recommend adding an npm script which monitors the appropriate files and runs the generator tool when it detects changes. You can run this command concurrently with your normal development workflow using the concurrently package.

Code

The code for the completed application can be found in the following repositories.

Related Posts

Green with Envy: Tracing Network Calls Across Your Application Stack

November 20, 2023
Envy is a zero config network tracing and telemetry viewer for development. It allows engineers to have a live view into the network requests your application stack is making during local development.

Mastering UI Testing: Crushing False Failures with GraphQL Mocking

September 20, 2023
what happens when you have UI tests running which hit your GraphQL server and subsequently fail because of issues in upstream systems?

The Evolution of urql

December 6, 2022
As Formidable and urql evolve, urql has grown to be a project that is driven more by the urql community, including Phil and Jovi, than by Formidable itself. Because of this, and our commitment to the ethos of OSS, we are using this opportunity to kick off what we’re calling Formidable OSS Partnerships.