Game of Types: A Song of GraphQL and TypeScript
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.
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!
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?
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.
Compile errors, not runtime 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:
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.
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!
Recommended workflow
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.