Plug-and-play GraphQL type safety using gql.tada

Plug-and-play GraphQL type safety using gql.tada

Christian De Frène
Christian De Frène
22 November 2024

Setup

Although the actual API of GraphQL is typed, unfortunately there is no automaticity in that data retrieved on the client is also typed.

There have long been tools such as e.g. GraphQL Codegen which automatically generates types based on an existing GraphQL API. My experience is that such tools require a lot of setup with packages that need to be installed and scripts that need to be added, and are difficult to get to work exactly as you want.

The framework gql.tada works in a completely different way, and is written as a TypeScript plugin (new in version 5) that automatically fetches the types from the GraphQL API when TypeScript is compiled. It means that you as a developer get a "set it and forget it" experience, and personally I have almost forgotten that gql.tada is installed because it just works.

In addition, you choose which queries and mutations are to be typed automatically or not, which makes it easier to use on an existing project without having to fix thousands of errors first.

We adopted the tool on the frontend of Mist, a web application written in React that uses @apollo/client to communicate with a GraphQL API exposed by a backend. Here is a simplified example of the code for an existing query that retrieves a paginated list of users and displays them in a table:

import { gql, useQuery } from '@apollo/client'
import { AllUsersQuery, AllUsersQueryInput } from 'modules/users/types'

const ALL_USERS_QUERY = gql`
  query AllUsers(
    $q: String
    $orderBy: String
  ) {
    allUsers(
      q: $q
      orderBy: $orderBy
    ) {
      edges {
        node {
          id
		  username
		  email
		  firstName
          lastName
          phone
        }
      }
    }
  }
`

const { data } = useQuery<AllUsersQuery, AllUsersQueryInput>(
    ALL_USERS_QUERY,
    {
      variables: {
        q: debouncedSearch,
        orderBy: sort
      },
    }
)
  
const tableData = data.allUsers.edges.map(({ node }) => node).map(user => {
  return {
    id: user.id,
    data: {
      username: user.username,
      email: user.email,
      firstName: user.firstName,
      lastName: user.lastName,
      phone: parseInt(user.phone)
    },
  }
})

return (
	<TableComponent data={tableData} />
)

This is code that probably seems familiar to most people who have worked with GraphQL in React before. In contrast, changes in User - the resource from the API is not automatically reflected in the code. It creates challenges if you forget to maintain the types AllUsersQuery  and AllUsersQueryInput when a change occurs.

If you e.g. removes firstName  and lastName to introduce a new field name instead, this code will not throw any error messages on it, but all the GraphQL queries will start to fail because the requested fields no longer exist in the schema. And if phone suddenly is returned as one Int , will parseInt -function will fail, because it expects an argument of type String .

Results

Let's install gql.tada and use it to automatically follow the types from the API. We followed the starter guide on the official website, which at the time of writing involves:

  1. Install the gql.tada package.
  2. Add gql.tada/ts-plugin under compilerOptions.plugins in your existing tsconfig.json, then configure its schema and generated-types output.
  3. Make sure your editor uses the workspace version of TypeScript so the plugin can provide diagnostics and completions.

Then we can rewrite the code above to get rid of the static types entirely:

import { useQuery } from '@apollo/client'
import { graphql } from 'gql.tada';

const ALL_USERS_QUERY = graphql(`
	query AllUsers {
		allUsers {
			edges {
				node {
					id
					username
					email
					firstName
					lastName
					phone
				}
			}
		}
	}
`)

const { data } = useQuery(
    ALL_USERS_QUERY,
    { ... }
)
  
const tableData = data.allUsers.edges.map(({ node }) => node).map(user => {
  return {
    id: user.id,
    data: {
      username: user.username,
      email: user.email,
      firstName: user.firstName,   // TS2339: Property firstName does not exist on type
      lastName: user.lastName,     // TS2339: Property lastName does not exist on type
      phone: parseInt(user.phone)  // TS2345: Argument of type `number` is not assignable to parameter of type `string`
    },
  }
})

return (
		<TableComponent data={tableData} />
)

Now both of the issues mentioned above are picked up by the TypeScript compiler before the code is even built. If firstName  and lastName has been removed, we will get an error stating that user.firstName and user.lastName no longer exists. And correspondingly with phone : Now we will get an error message that the function expects one String , but that the type is Number .

In other files that used the static types, we can instead replace them with the built-in utility types ResultOf<typeof ALL_USERS_QUERY> and VariablesOf<typeof ALL_USERS_QUERY> . To encourage reuse even more, one can extract the requested fields into a fragment, and then use this to define how User -type looks like.

After turning on typing on more and more queries in the existing Mist code, errors started to appear that had unfortunately been overlooked before. This was mostly wrong about data potentially being returned as null or undefined , but in one case we discovered a different implementation of an enum on the frontend and backend. This probably wouldn't have been discovered organically by us developers, so there gql.tada saved us from a potential (angry) customer email!

Pitfalls

Finally, I would like to mention some less positive experiences I have had after using gql.tada for a while.

My biggest challenge is that type safety only works on the fetched data, and not while writing the actual queries or mutations. That is, all code inside graphql() -the wrapper must be written as plain text, and all the potential errors that entails. With the caveat that I've configured something wrong, one could argue that this goes a bit outside the tool's original mission. In any case, there are separate GraphQL plugins for the biggest IDEs that take care of this task, not to mention GraphiQL or other GraphQL clients. But it would have been nice to have everything in one place!

If you use pipelines to build and roll out code, it must be mentioned that you have to check in and include the auto-generated env.d.ts the type file for the code to be built on the server. If you use an external API, or do not develop the backend yourself, it is often the case that changes appear in this file that have nothing to do with the code you are writing right now, which can confuse pull requests. In addition, the file size is relatively large, in our case it is over 1 MB.

It is also worth reading through the documentation, especially if you use fragments. These are not automatically typed, and you have to call the function readFragment() for the types from the fragment to be used. And if you have defined your own scalars in the API, e.g. one ID  or Date , you will have to override the default configuration and import graphql() -wrapper from there so that these are correctly typed.

Conclusion

No tool is perfect, but all in all, I think the utility gql.tada delivers makes it worth trying out, especially when the effort is so low. It is an exciting tool that has already helped us in development, and which we will continue to use on Mist and other projects in the future.

Build the future of your product with zero headaches

From MVP prototypes to scalable platforms, our full-stack dev team turns your roadmap into rock-solid code. Get to market faster without sacrificing quality.

Get started

Related articles