genqlient: A truly type-safe Go GraphQL client

genqlient: A truly type-safe Go GraphQL client

by Ben Kraft

Kevin has written about our multi-year project to replace our Python 2.7 monolith with services written in Go. As part of the transition to a service-oriented architecture, we decided to migrate all our REST APIs to GraphQL—including new APIs for service-to-service communication. Even with Apollo Federation handling much of our fan-out, we have over 300 distinct GraphQL requests from one service to another. While queries from our web and mobile frontends still together comprise about half of requests, our other services have become first-class clients of each service’s GraphQL API.

To support those services, we’ve written our own GraphQL client for Go, genqlient, which we’ve just open-sourced. While we started out using an existing GraphQL client, we realized it wasn’t quite right for us: we couldn’t be sure until runtime that our queries—and the boilerplate we needed to execute them—were well-formed. Over the last 5 months we’ve transitioned over to genqlient, which uses code generation to solve both problems, providing stronger type safety with less boilerplate. The rest of this post will talk more about why we wrote a new client, what genqlient does, and how it solves our problem. But if you just want to check out genqlient and use it in your projects, you can find genqlient on GitHub and start using it today!

Why another GraphQL client?

To illustrate the problems with genqlient, we’ll use an example schema similar to Khan Academy’s. Existing Go GraphQL clients have you write code that looks something like this (the details vary from client to client):

query := `query GetVideoTitle($id: ID!) { video(id: $id) { title } }`
variables := map[string]interface{}{"id": "123"}
var resp struct {
	Video struct {
		Title string
	}
}
err := client.Query(ctx, query, &resp, variables)
fmt.Println(resp.Video.Title, err)
// Output: The carbon cycle 

This syntax works well to get started, but it has a few problems:

  • Type-safety for responses: while the response struct is type safe at the Go level—you can’t accidentally write resp.Exercise—there’s no type-checking for the query, nor that the query matches the response-type. Maybe the field is called name, not title; or maybe you capitalized something wrong (since Go and GraphQL have different conventions for initialisms); you won’t know until runtime.
  • Type-safety for requests: the GraphQL variables aren’t type-safe at all; you could have passed {"id": true} and again you won’t know until runtime!
  • Boilerplate: you have to write everything twice, once in the query and once in the response, or hide parts of the query in complicated struct tags, or give up what type safety you do have and resort to interface{}.

These problems aren’t a big deal in a small application, but for serious production-grade tools they’re not ideal. Since GraphQL and Go are both statically-typed languages, we wanted to be able to write a query and automatically validate the query against our schema, then generate a Go struct which we can use in our code. And we knew it was possible: we already do similar things in our GraphQL servers and in JavaScript!

A quick tour of genqlient

Our library, genqlient, fills that gap, providing complete type safety in a more concise syntax. You tell genqlient where to find your GraphQL schema, and what queries you want to make. For each query, genqlient validates the queries against the schema—checking that they are syntactically valid, that the requested fields exist, and so on. Next, it generates Go source code for a correctly-typed function to execute the query, complete with the type it will return.

With genqlient, you write:

// video.go
_ = `# @genqlient
  query GetVideoTitle($id: ID!) { video(id: $id) { title } }
`
resp, err := generated.GetVideoTitle(ctx, client, "123")
fmt.Println(resp.Video.Title, err)
// Output: The carbon cycle 

Then, you run genqlient (perhaps via go generate), and genqlient does the hard work: it generates types and functions corresponding to your query, which look something like:

// generated.go
type GetVideoTitleResponse struct {
  Video GetVideoTitleResponseVideo
}
type GetVideoTitleResponseVideo struct {
  Title string
}
func GetVideoTitle(
  ctx context.Context,
  client graphql.Client,
  id string,
) (*GetVideoTitleResponse, error) {
  // (implementation elided)
}

In the end, the runtime code works basically the same way as other clients, but genqlient does the work — and most importantly the type-checking — for you.

Our query above uses only simple object types, but genqlient supports more complicated queries as well. For example, we might make a more complex query using fragments and interfaces:

_ = `# @genqlient
  query GetContent($id: ID!) {
    content(id: $id) {
      title
      ... on Exercise {
        problems {
          type: problemType
          correctAnswer
        }
      }
      ... on Video {
        duration
      }
    }
  }
`

resp, err := generated.GetContent(ctx, client, "123")
// (error handling elided)

// Content can take on several types, use a type switch to differentiate:
switch content := resp.Content.(type) {
case *generated.GetContentContentExercise:
  fmt.Println("Exercise %s:", content.Title)
  for i, problem := range content.Problems {
    // ProblemType is an enum, switch on the value:
    switch problem.Type {
    case generated.ProblemTypeMultipleChoice: // generated enum-value
      fmt.Println("Problem %d (multiple choice): %s", i, problem.CorrectAnswer)
    case generated.ProblemTypeNumericAnswer:
      ...
    }
  }
case *generated.GetContentContentArticle:
  ...
}

In this case, genqlient generates not only the usual response struct but also enum-values for ProblemType, an interface for Content and a struct for each of its GraphQL implementations, and even a custom UnmarshalJSON method to handle those interfaces correctly.

Benefits of genqlient

Static error checking

The biggest benefit of using genqlient, for us, has been its compile-time validation of our GraphQL queries. Here’s a snippet of the GraphQL schema from above:

type Query {
  videoByURL(videoUrl: String!): Video
}

type Video {
  url: String
}

Before genqlient, we might have written the following query:

var query struct {
  VideoByURL struct {
    Name string
  } `graphql:"videoByURL(videoUrl: $videoUrl)"`
}
variables := map[string]interface{}{"videoURL": videoURL}
err := client.Query(ctx, &query, "VideoByURL", variables)
return query.Video.Name, err

Can you spot the errors? It’s hard; you need to know exactly how the client translates this struct into a query (which it does to avoid having to write everything in triplicate), and then match that up to the schema in your head. With genqlient, we’d write:

_ = `# @genqlient
  query GetVideoForURL($videoURL: ID!) {
    videoByURL(videoUrl: $videoUrl) {
      name
    }
  }
`
resp, err := generated.GetVideoForURL(ctx, client, videoURL)
return query.Video.Name, err

Here, perhaps, the issues are more obvious, but more importantly, genqlient just tells you what they are:

example.go:9: query-spec does not match schema: Variable "$videoUrl" is not defined by operation "GetVideoForURL".
example.go:10: query-spec does not match schema: Variable "$videoURL" of type "ID!" used in position expecting type "String!".
example.go:11: query-spec does not match schema: Cannot query field "name" on type "Video".

That’s right, the field-name (it’s title, not name), variable-name ($videoURL vs. $videoUrl), and variable-type (ID vs. String) were all incorrect. Typos like these were quite common in our code, to the point where we actually wrote custom validation logic for our GraphQL queries in tests to catch them; genqlient does it all automatically at compile-time.

Developer ergonomics

Beyond simple type checking, having the queries explicitly in our source, and extracted at compile time, makes them easier to manually test. For example, our developers can use our web GraphiQL interface to write their queries with interactive documentation and field autocomplete, and just copy-paste them into our source. Conversely, when writing a mock for tests, one can simply copy the query into GraphiQL and run it in production, to see what it actually returns, and adapt that result to the test.

This also makes it easier to ensure that changes to our schema don’t break any queries actually in use. We require all frontend queries to be registered before they are used in production, and we’ve built tooling that allows developers to query it to see which registered queries make use of a particular field. With genqlient, we can easily add our backend queries to this system as well. But in some ways we don’t even need to: if a developer removes a field that’s in use, re-running genqlient will immediately point to the error.

300 queries later

We’ve now converted almost all of the GraphQL queries in our Go services to use genqlient, including over 300 distinct queries which are collectively executed hundreds of millions of times each day. Over 30 different engineers have added new genqlient-based queries, some of them executed millions of times a day. Many of genqlient’s more recent features have been driven by needs we’ve come across in the course of converting our queries. In a future post, we’ll talk more about some of the technical challenges we encountered along the way.

And now, you too can have the power and safety of genqlient in your projects! We hope the investment we’ve put into genqlient will pay dividends not only for us but for other users of GraphQL in Go. Check out our getting started guide to use genqlient in your project, and if you see more you think genqlient could do for you, send us a pull request on GitHub. And of course, if working on projects like genqlient to benefit millions of learners around the world sounds like fun to you, check out our careers page.

Thanks to Benjamin Tidor, Craig Silverstein, Gaurav Singh, Kevin Dangoor, and Slim Lim for comments on drafts of this post, and to Craig Silverstein, Mark Sandstrom, and many more of our colleagues for design feedback and in-depth code reviews on genqlient.

Appendix: Example schema

Here is the GraphQL schema used in the examples in the post:

type Query {
  video(id: ID!): Video
  videoByURL(videoUrl: String!): Video
  content(id: ID!): Content
}

type Article implements Content {
  id: ID!
  title: String
  url: String
  text: String
}

type Exercise implements Content {
  id: ID!
  title: String
  url: String
  problems: [Problem!]
}

type Problem {
  id: ID!
  problemType: ProblemType
  correctAnswer: Int
}

enum ProblemType {
  MULTIPLE_CHOICE
  NUMERIC_ANSWER
}

type Video implements Content {
  id: ID!
  title: String
  url: String
  duration: Int
  prerequisites: [Video!]
}

interface Content {
  id: ID!
  title: String
  url: String
}