tags
POPULAR

Your Guide to GraphQL with TypeScript

12 August, 2021 | 11 min read

Type safety is an enormous benefit for developers when embracing the GraphQL ecosystem. This tutorial guides you through a practice known as generative type tooling which allows you to unlock the full potential of GraphQL types in the front-end.

Why Type Safety

The GraphQL specification guarantees that GraphQL APIs are strongly typed. On its own, this means that a query can be validated for syntactical correctness and validity before executing, which eliminates runtime errors. Strongly typed schemas also allow us to fully know the shape of a thing, which has led to the movement of rich API tooling in the GraphQL ecosystem – a fact often referenced in explaining GraphQL’s rapid rise to popularity.

Type safety, the name given to the benefit of using strongly typed languages and schemas, helps developers write code faster and with fewer errors. But type safety on the API alone doesn't capture all of the benefits that can be provided. Other benefits include:

  • Callsite safety
  • Improved developer efficiency
  • Integrated documentation
  • Enhanced workflow

Paired with a “type-safe” programming language for back-end and front-end development, a type-checking toolchain can remove virtually all syntactical errors from occuring, both in development and production.

However, when paired with tooling generated from a type-safe API, not only can that type-checker validate our data access patterns, it can also help us “peek inside” our API to know what data is available, make guarantees around those access patterns that support features like lazy loading, and help us ship code with considerably improved speed and accuracy.

Why isn’t everyone embracing type-safe languages?

So if type safety is such an improvement, why isn’t everyone using it? One could look at the historical coupling of GraphQL and JavaScript, a language without types, as being one of the reasons (note, you don’t have to use JavaScript to use GraphQL, it’s language agnostic!). Another reason could be that enabling type-safe applications often requires a lot of boilerplate. The typical type-safe project needs a typing layer over the API (typically written by hand), setting up all the type tooling to actually validate type-safe code in development, and lastly, but still not the least important, teaching developers to embrace the new syntax of a typesafe language.

Well, with GraphQL and generative tooling we’ll get the manual typings for our API. With Next.js we’ll get a typesafe runtime for TypeScript out of the box. And in this tutorial, we’ll show you how. Let’s begin.

Tutorial

Stack

We’re going to assume that if you’re reading this, here on this blog, you’re already familiar with GraphQL. The other two parts of our stack may be less familiar to you.

  • Typescript is a JavaScript flavored language that compiles to JavaScript. It has the benefit of funded development from Microsoft, and for better or worse, has become the path to type-safe development for most JavaScript developers. Thanks to the historical linking of React (JavaScript) and GraphQL, many GraphQL users are also familiar with JS.

  • Next.js is an application framework with built-in support for TypeScript. It reduces a large amount of the tooling needed to write, validate, and compile TypeScript. It also provides a very simple path to deploying your application on either the Vercel platform (creators of Next.js) or other serverless runtimes.

Defining “Generative”

Generative tooling simply refers to having our code written for us by the machine. Given a provided input (I), we get predictable output, written to the filesystem (O). In the case of our GraphQL tooling, we’ll provide an API with a couple of parameters, and get typings and an SDK in return. For our project, we’ll use a CLI called GraphQL Zeus for this part of the chain.

Prerequisites:

Docker installed and running on a non-ARM machine or a Hasura Cloud account.
Node 12+ installed.

Step One: Clone and configure the code repository

You can find all the code for this tutorial on this Github repository:

Clone the repository to your local filesystem and either:

  • A) Use docker compose to run a local image of Hasura GraphQL Engine
  • B) Swap the URL value at typescript-gql-workshop-demo/utils/constants.ts from localhost:8080 to your Hasura Cloud app's URL

For A) the process is:

$ docker-compose up -d # Start Hasura + Postgres in Docker
$ cd hasura
$ hasura migrate apply # Apply DB table migrations
$ hasura metadata apply # Apply metadata for tables
$ cd ../
$ yarn install
$ yarn dev
# Now visit http://localhost:3000

For B) open typescript-gql-workshop-demo/utils/constants.ts and modify the file contents:

export const CONSTANTS = {
  HASURA_URL: "https://my.hasura.app"
}

And then run a similar set of commands as you see above, instead passing your Cloud app endpoint and credentials:

$ cd hasura
$ hasura migrate apply --endpoint https://my.hasura.app --admin-secret secret # Apply DB table migrations
$ hasura metadata apply  --endpoint https://my.hasura.app --admin-secret secret # Apply metadata for tables
$ cd ../
$ yarn install
$ yarn dev
# Now visit http://localhost:3000

Step Two: Run Hasura Console

As part of this tutorial we'll want to make changes to our data model in a realistic manner.

In the real world, when we do this, generally we want those changes to be tracked somewhere.

While we COULD directly alter our tables or columns from the "normal" Hasura web console, instead we will launch the specific console instance which is capable of automatically creating migration and metadata files based on our changes.

To do this, all we have to do is run:

$ cd hasura
# With Hasura OSS:
$ hasura console
# Or, if you're using Hasura Cloud:
$ hasura console --endpoint https://my.hasura.app --admin-secret secret

This should launch a new web console available at http://localhost:9695

Now, any changes we make there will be recorded and tracked.

Step Three: Generate the typings

Lets start by generating the TypeScript SDK for our GraphQL API operations.

In the project, there's an NPM package.json script which is an alias that does this for us. For conveniences' sake, that command is:

  • yarn generate-gql-client

If we look in our package.json, we can see what that command is actually doing, under the hood:

{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start",
    "post-update": "yarn upgrade --latest",
    "generate-gql-client": "zeus http://localhost:8080/v1/graphql ./utils/generated --ts"
  }
}

It's calling zeus http://localhost:8080/v1/graphql ./utils/generated --ts. This command says:

  • Run graphql-zeus on the API that exists at localhost:8080
  • Place the generated file(s) in the directory ./utils/generated
  • Use TypeScript (--ts) for the SDK. (Alternatives: JS, ESM)

There is one more, fairly important piece of info that this command doesn't use.

That is: how to attach headers. If our API has an admin-secret set, then we need to append:

  • -H 'X-Hasura-Admin-Secret: mysecret

Step Four: Consume the SDK

Assuming all has gone well, you should have an SDK generated.
To test it out, we can start by creating some file, say utils/generated/sandbox.ts and import utils/generated/graphql-zeus.

import { Gql } from "./graphql-zeus"

If you hover over this object, you'll notice it has three properties -- query, mutation, and subscription. If we ctrl + click it, we can see that the definition is:

export const Gql = Chain('http://localhost:8080/v1/graphql')

export const Chain = (...options: fetchOptions) => ({
  query: ((o: any, variables) =>
    fullChainConstruct(apiFetch(options))('query', 'query_root')(o, variables).then(
      (response: any) => response
    )) as OperationToGraphQL<ValueTypes["query_root"],query_root>,
mutation: ((o: any, variables) =>
    fullChainConstruct(apiFetch(options))('mutation', 'mutation_root')(o, variables).then(
      (response: any) => response
    )) as OperationToGraphQL<ValueTypes["mutation_root"],mutation_root>,
subscription: ((o: any, variables) =>
    fullChainConstruct(apiFetch(options))('subscription', 'subscription_root')(o, variables).then(
      (response: any) => response
    )) as OperationToGraphQL<ValueTypes["subscription_root"],subscription_root>
});

So what this Gql object is, is just a typed wrapper around fetch() requests.
Some TypeScript magic is used to map objects of input arguments and objects of selected fields, onto a schema and schema types.

If we do Gql.query({}) and press Ctrl + Space to autocomplete with our cursor inside of the curly braces, we'll see we get a list of every operation in our GraphQL API, along with the corresponding TypeScript types:

1_basic_sdk_usage

With the API of GraphQL Zeus, the function args are a tuple of [queryArgs, selectionSet]

For example, the GraphQL query:

query {
  user(where: {
     username: { _eq: "person " }
  }) {
    id
    username
    user_todos {
      todo {
        id
        description
        is_completed
      }
    }
  }
}

Would be written as:

const result = Gql.query({
  user: [
    {
      where: {
        username: { _eq: "person " },
      },
    },
    {
      id: true,
      username: true,
      user_todos: [
        {},
        {
          todo: {
            id: true,
            description: true,
            is_completed: true,
          },
        },
      ],
    },
  ],
})

If we do this, we see that the result type is dynamically calculated from the fields we select (it contains ONLY those fields, and their correct types):

2_example_query

Because the core of the SDK is essentially just a way to map objects to GraphQL queries and their types, we can use it without fetch() and write custom wrappers for other tools.

One such example is as a wrapper for Apollo Client.

An implementation of that could look something like this:

import {
  ApolloClient,
  gql,
  LazyQueryHookOptions,
  MutationHookOptions,
  MutationOptions,
  NormalizedCacheObject,
  QueryHookOptions,
  QueryOptions,
  SubscriptionHookOptions,
  SubscriptionOptions,
  useLazyQuery,
  useMutation,
  useQuery,
  useSubscription,
} from "@apollo/client"

import {
  MapType,
  mutation_root,
  query_root,
  Selectors,
  subscription_root,
  ValueTypes,
  Zeus,
} from "./generated/graphql-zeus"

export function useTypedQuery<Q extends ValueTypes["query_root"]>(
  query: Q,
  options?: QueryHookOptions<MapType<query_root, Q>, Record<string, any>>,
) {
  return useQuery<MapType<query_root, Q>>(gql(Zeus.query(query)), options)
}

export function useTypedLazyQuery<Q extends ValueTypes["query_root"]>(
  query: Q,
  options?: LazyQueryHookOptions<MapType<query_root, Q>, Record<string, any>>,
) {
  return useLazyQuery<MapType<query_root, Q>>(gql(Zeus.query(query)), options)
}

export function useTypedMutation<Q extends ValueTypes["mutation_root"]>(
  mutation: Q,
  options?: MutationHookOptions<MapType<mutation_root, Q>, Record<string, any>>,
) {
  return useMutation<MapType<mutation_root, Q>>(
    gql(Zeus.mutation(mutation)),
    options,
  )
}

export function useTypedSubscription<Q extends ValueTypes["subscription_root"]>(
  subscription: Q,
  options?: SubscriptionHookOptions<
    MapType<subscription_root, Q>,
    Record<string, any>
  >,
) {
  return useSubscription<MapType<subscription_root, Q>>(
    gql(Zeus.subscription(subscription)),
    options,
  )
}

export function useTypedClientQuery<Q extends ValueTypes["query_root"]>(
  apollo: ApolloClient<NormalizedCacheObject>,
  query: Q,
  options?: QueryOptions<MapType<query_root, Q>, Record<string, any>>,
) {
  return apollo.query<MapType<query_root, Q>>({
    query: gql(Zeus.query(query)),
    ...options,
  })
}

export function useTypedClientMutation<Q extends ValueTypes["mutation_root"]>(
  apollo: ApolloClient<NormalizedCacheObject>,
  mutation: Q,
  options?: MutationOptions<MapType<mutation_root, Q>, Record<string, any>>,
) {
  return apollo.mutate<MapType<mutation_root, Q>>({
    mutation: gql(Zeus.mutation(mutation)),
    ...options,
  })
}

function useTypedClientSubscription<Q extends ValueTypes["subscription_root"]>(
  apollo: ApolloClient<NormalizedCacheObject>,
  subscription: Q,
  options?: SubscriptionOptions<
    MapType<subscription_root, Q>,
    Record<string, any>
  >,
) {
  return apollo.subscribe<MapType<subscription_root, Q>>({
    query: gql(Zeus.subscription(subscription)),
    ...options,
  })
}

Now these wrappers such as useTypedQuery() can be used wherever useQuery() would have been -- and same with useTypedClientQuery() and apollo.query(), etc.

3_used_typed_query_example

Step Five: Iterate on the pattern

Now that you have a typed SDK for your GraphQL API, and you understand:

  • How to generate it
  • How it works under the hood
  • How to adapt it to your needs by using the underlying type definitions

The final piece is to discuss how to continue to iterate when using these sorts of SDK's.

In order to keep your codebase type-safe and in sync with your schema, whenever you make schema changes you should:

  • Re-run the SDK generator (in our case, yarn generate-gql-client)
  • Restart all consuming clients of the SDK (in our case, only Next.js)
  • Confirm that:
      1. The TypeScript compiler passes typechecks (IE that you have not made an incompatible signature change)
      1. That all services still function normally (Typically done via test suites)

Tutorial 2 (using GraphQL Code Generator)

Overview

Not covered in the text above, yet incredibly popular is another tool called GraphQL Code Generator. This tool serves the same purpose, but does so in a different way.

One of the benefits of GraphQL Code Generator is that it isn't limited to generating TypeScript types -- it has generators for both frontend and backend, in several languages including C#, Java, Kotlin, and many others.

The primary difference between GraphQL Code Generator and tools like graphql-zeus, is that GraphQL Code Generator generates operation-specific types, rather than a single SDK containing all possible operations.

Setup

The process for configuring and running GraphQL Code Generator is similar to the process above, albeit wih some minor differences:

  • We will need a .yml file containing configuration
  • The generation process will need to be run either continually, or each time we change the queries we use in our codebase

For this demo, we'll look at how to use one of the many plugins available:

The installation and configuration process, from zero, goes something like this:

# Create a new package.json
yarn init -y
# Add GraphQL Codegen dependencies, and GraphQL "TypedDocumentNode" typings package
yarn add -D @graphql-codegen/cli @graphql-codegen/gql-tag-operations-preset @graphql-typed-document-node/core
# Add GraphQL itself, and URQL (used as a demonstration)
yarn add graphql urql
# Initialize Typescript
tsc --init

Next, we need to config our codegen.yml file. This is the file GraphQL Code Generator uses to produce the generated code from our schema and types.
Let's create a file with contents like these:

# Put the URL to your Hasura app here
schema: http://localhost:8080/v1/graphql
# schema: ./schema.graphql  <-- Alternatively, you can use a GraphQL schema file
documents:
  - 'pages/**/*.tsx'
  - '!utils/gql/**/*'
generates:
  ./utils/gql/:
    preset: gql-tag-operations-preset

What we've said here is to:

  • Read the schema using an introspection query from the API hosted at http://localhost:8080/v1/graphql
  • Use every file matching ./pages/**/*.tsx as a document to check for GraphQL fragments/queries
    • Omit every file under ./utils/gql/**/* -- don't check these (this is where we are going to put our generated files, we don't want to double-generate)
  • For generated files:
    • in the directory ./utils/gql/
      • use the plugin gql-tag-operations-preset on the documents that were found

Finally, the last step is to run it!

Development Process

You need to ensure that you have GraphQL documents/operations in the documents directory listed.
For the sake of this demo, there's a sample application you can view along with hosted here:

Here, we have an index.tsx page which has a GQL operation:

import { gql } from "../utils/gql";
import { useQuery } from "urql";

const UsersQuery = gql(/* GraphQL */ `
  query UsersQuery {
    user {
      id
      email
      name
    }
  }
`);

The code generator will pick this up, and generate the corresponding type for it

To run the code generator, the command is:

  • yarn graphql-codegen

The output should be something like this:
4_graphql_codegen_generate_cmd_output

Now, we hover over the type of UsersQuery, we can see that it contains a type specific to that particular GQL type and selection of fields:

6_example_type_1

And the same is true for the type of useQuery({ query: UsersQuery })

7_example_type_2

The way you'd more commonly use this during development would likely be in "watch" mode, where it picks up file changes and re-generates automatically.
To do that, instead run:

  • yarn graphql-codegen --watch

Then, as you below, it should stay synced with your operation definitions as you alter them:


We hope you found this guide useful. If you have questions, you can reach out to us on Discord or Github Discussions.

If you're new to GraphQL & Hasura, we have a 2 hour tutorial for Typescript developers to get hands-on with GraphQL and a Hasura Basics course to get you up and running with Hasura.

Close

Get Started with GraphQL Now

Hasura Cloud gives you a fully managed, production ready GraphQL API as a service to help you build modern apps faster.

Gavin Ray

Gavin Ray

Technical Evangelist, Hasura.

Read More

Ready to get started?
Start for free on Hasura Cloud or you could contact our sales team for a detailed walk-through on how Hasura may benefit your business.
Get monthly product updates
Sign up for full access to our community highlights, new features, and occasional baby animal gifs! Oh, and we have a strict no-spam rule. ✌️