GraphQL in TypeScript over the years

How things have changed and still can change

Renato Ribeiro
Renato Ribeiro

First of all, I would like to make it clear that all the examples that I will cite here are in the context of React, as it is the context in which I am currently inserted. But the general idea of a query and its respective types can be fit in any context if you consume a graphql endpoint, whether using other frameworks on the frontend, an sdk on the backend, a microservice, etc.

One of the main reasons that I really like the combination of GraphQL with TypeScript is the reliable type safety it brings. Due to its graph nature, GraphQL has a strong feature which is the possibility to introspect your schema. In a single request, you can know not only what are all possible queries, but also all types of objects and fields from all your operations. You can know basically everything about your schema. And that's a big deal for tools to be able to build full-featured and never-outdated documentation, CI checks for schema-breaking changes, and of course type generation - which is what interests us here.

But since I started using GraphQL and TypeScript in 2017 (I used to use flowtype before, but let's pretend it was always TS), the way of dealing with types changed a few times.

# By hand

I remember that we had to type, "by hand", all the expected results of the operations, as well as their variables. And I remember that it was always something that bothered me. It felt like I was (and I really was) typing the query twice since the resulting types were practically a mirror.

I honestly don't know if there were type generation tools and I didn't know about them at this time, but if they already existed it was definitely not widespread.

The code looked a bit like this:

import React from "react";
import { useQuery } from "my-favorite-graphql-client-library";
const FILE_QUERY = `
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`;
type FileQuery = {
document: {
id: string;
name: string;
meta: {
size: number;
}
}
};
type FileQueryVariables = {
id: string;
};
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useQuery<FileQuery, FileQueryVariables>({
query: FILE_QUERY,
variables: { id }
});
return <>...</>;
};

And that had some problems, as you can imagine. Keeping typings "by hand" is almost never a good idea, unless you are on the side that is defining the contract, in which case you still need type-checking at runtime as well. Manual types of external data is not reliable source. Some fields that you type as a string but it's actually a number will not be noticed in the type-check. It will probably be noticed if there is a runtime bug, what is too late. Type-checking is to prevent that we discover bugs in runtime. Also, it's not resilient to change. Everything that changes in the schema, you will also need to change in all the types of the affected operations, and the chance of you forgetting one is very very high.

It didn't take long for the community to realize that not only is a bad practice, but the solution seemed to be quite simple. Or at least, obvious. As I said before, GraphQL has the strong feature of introspection, so deducing that we could have a tool that statically analyzes the code and generates its respective types was something natural.

# Typegen

We have entered the era of gen tools. I don't know precisely who exactly was the first of them all, or at least the first one that was successful adopted, but I know that Relay has been doing this for as long as I've known it. However, for those who do not use Relay, there is graphql-code-generator, which I guess is the most adopted tool nowadays.

I well remember that the first few times I used it, I had an experience that more or less referred to this code:

import React from "react";
import { gql, useQuery } from "my-favorite-graphql-client-library";
import { FileQuery, FileQueryVariables } from "./__generated__";
const FILE_QUERY = gql`
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`;
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useQuery<FileQuery, FileQueryVariables>({
query: FILE_QUERY,
variables: { id }
});
return <>...</>;
};

Now I no longer need to write the typings with my own hands. By introspection, the tool is able to understand what the return of this query will be, and also what variables I will need to pass. So for each operation, a type is generated for the result and a type is generated for the variables.

It's important to note that the GetDocumentQuery and GetDocumentVariables types do not exist until you run the tool. As well as any changes you made to the query, the script needs to be run again so that the types corresponding to the changes are also changed. Many leave the tool running in watch mode, but it is also common to have scripts that run in the editor's save or even manually.

# Codegen

It can be seen that the code has already improved a lot: the typings are now reliable. It reflect the schema types using the schema as a source of truth. The amount of code has also been reduced as you no longer need to write all the typings. But as not everything is rosy, over time it was realized that it was possible to improve even more the DX here. As much as the types are already reliable, and we don't need to type by ourselves, the final code is still quite verbose, especially here:

const { data, fetching } = useQuery<FileQuery, FileQueryVariables>({
query: FILE_QUERY,
variables: { id }
});

If we can generate types, what prevents us from generating code aswell?

As an evolution, people wanted to reduce verbosity generating typed code instead of only types. And this is one of the main approaches that is used nowadays (with some variations). It is very common today for me to write components like this:

import React from "react";
import { gql, useFileQuery } from "my-favorite-graphql-library";
import { useFileQuery } from "./__generated__";
gql`
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`;
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useFileQuery({ variables: { id } });
return <>...</>;
};

Instead of generating the types that we will need to use in useQuery hook, the codegen already generates a typed useQuery called useFileQuery. It reduces verbosity as we don't need to import and use the types. The code generated by codegen looks something like this:

export type FileQueryVariables = Exact<{ id: Scalars['String']; }>;
export type FileQuery = { __typename?: 'Query', file?: { __typename?: 'File', ...etc; } };
export const FileDocument = gql`query File($id: String!) { file(id: $id) { id name meta { size } } }`;
export function useFileQuery(options: Omit<MyLibrary.UseQueryArgs<FileQueryVariables>, 'query'> = {}) {
return MyLibrary.useQuery<GetDocumentQuery, GetDocumentQueryVariables>({ query: GetDocumentDocument, ...options });
};

# TypedDocumentNode

There is also another method that is used a lot these days, which is using TypedDocumentNode. It consists of you generating a combination of a precompiled document node along with their respective types.

export type FileQueryVariables = Exact<{ id: Scalars['String']; }>;
export type FileQuery = { __typename?: 'Query', file?: { __typename?: 'File', ...etc; } };
export const FileQueryDocument: TypedDocumentNode<FileQuery, FileQueryVariable> = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { ... }
}
]
};

The trick is made by TypedDocumentNode<FileQuery, FileQueryVariable>, where libraries can use TypeScript to infer from. That way, if the library you use is compatible with TypedDocumentNode (urql, apollo, etc), you don't need to generate the hook/typed function. By passing the document node, the library function will know what variables you need to pass and what it will return thanks to type inference.

import React from "react";
import { gql, useQuery } from "my-favorite-graphql-client-library";
import { FileQueryDocument } from "./__generated__";
gql`
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`;
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useQuery({ query: FileQueryDocument, variables: { id } });
// ^ types inferred from FileQueryDocument ^ variables inferred aswell
return <>...</>;
};

# Gql Tag Operations

This method has, in my opinion, the best API. It feels natural because the gql operation declaration itself returns the code you will need to use. It also uses the TypedDocumentNode type so you can use this way:

import React from "react";
import { gql } from "@app/gql"; // It need to be an alias
import { useQuery } from "my-favorite-graphql-client-library";
const FileQuery = gql(/* GraphQL */`
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`);
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useQuery({ query: FileQuery, variables: { id } });
return <>...</>;
};

In case you're wondering how this works, there's still a codegen process in the middle. You can't use any gql tag, you will need the special one, aliased from the codegen plugin. It will need to be a function because under the hood it will generate a lot of overloads:

const FooDocument: TypedDocumentNode<FooQuery, FooQueryVariables> = { ... }
const BarDocument: TypedDocumentNode<BarQuery, BarQueryVariables> = { ... }
const FileDocument: TypedDocumentNode<FileQuery, FileQueryVariables> = { ... }
const documents = {
'query Foo { ... }': FooDocument,
'query Bar { ... }': BarDocument,
'query File { ... }': FileDocument,
}
export function gql(source: 'query Foo { ... }'): typeof documents['query Foo { ... }']
export function gql(source: 'query Bar { ... }'): typeof documents['query Bar { ... }']
export function gql(source: 'query File { ... }'): typeof documents['query File { ... }']
export function gql(source: string): unknown
export function gql(source: string) {
return (documents as any)[source] ?? {}
}

But there's a problem with this. If you notice, it access entire documents object to return its corresponding document so the bundler cannot make assumption over what is actually used at runtime.

export function gql(source: string) {
return (documents as any)[source] ?? {}
}

You can still get around this issue using a babel plugin to improve your bundle size, but you are still too limited. Also if you don't use babel it's a problem.

# Future?

Sometimes I wonder what the next evolution will be from here. While I'm comfortable with the current DX, there are things that bother me, like the fact that a script have to be run every time you create or change a operation. It also bothers me that you have to import things from a file other than the one where the query is (from the file that was generated).

In order to avoid the need for a build step, it would be necessary a) the typescript understand graphql; which will never happen because it is completely out of scope; or b) typescript supports macros so that the macro is able to generate the types of all queries at the same time the typescript does the type-check; or c) typescript supports type providers, which is similar to macros but more specific

In any of these worlds, I think the ideal DX/api would be something like:

import React from "react";
import { gql, useQuery } from "my-favorite-graphql-library";
// some weirdo macro syntax
const FileQuery = gql!`
query File($id: String!) {
file(id: $id) {
id
name
meta {
size
}
}
}
`;
export const File: React.FC<{ id: string }> = ({ id }) => {
const { data, fetching } = useQuery({ query: FileQuery, variables: { id } });
// ^ typed without any codegen, just some magic behind the macro
return <>...</>;
};

I really don't know if we will achieve those any day. Perhaps if that day comes, maybe we won't even use graphql anymore, or we have solved it in some other way. But you can follow the macro feature request here. Also there are a type provider feature request here. You can find comments about graphql in both of issues.

By the way, there are already a ts-macros plugin/transformer but as it says in their readme, it doesn't work to generate types as it runs after the type-checking.

Follow me on twitter, and github.