Your Guide to GraphQL with TypeScript
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
- Why isn’t everyone embracing type-safe languages?
- Tutorial
- Tutorial 2 (using GraphQL Code Generator)
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
fromlocalhost: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 atlocalhost: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:
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):
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.
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:
-
- The TypeScript compiler passes typechecks (IE that you have not made an incompatible signature change)
-
- 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)
- Omit every file under
- For generated files:
- in the directory
./utils/gql/
- use the plugin
gql-tag-operations-preset
on the documents that were found
- use the plugin
- in the directory
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:
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:
And the same is true for the type of useQuery({ query: UsersQuery })
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.