Skip to content

Typed Documents

Although GraphQL defines conventions and guarantees for the client-side GraphQL query language and the server-side GraphQL type system, it’s still ultimately an API specifaction for client-server applications.

In GraphQL, we create schemas that describe the type system that queries are executed against, and said schema describes a shape of GraphQL types and scalars. As such, even if we use strong types on the client-side and strong types on the server-side, we still have to bridge the gap between both ends.

Schemas and Queries

Given a GraphQL schema, expressed here in the Schema Definition Language (“SDL”), in its simplest form, we define fields on objects that, when queried, may resolve to scalar values.

type Query {
helloWorld: String
numberOfRequests: Int!
}

When querying this schema we may write a query that requests our defined fields:

query {
helloWorld
numberOfRequests
}

Simplifying this, a GraphQL query, which has been validated against a GraphQL schema, matches a subset of the structure our GraphQL schema defines. However, while it “selects” fields and defines how to execute their resolvers, type information is only present on the schema.

As such, as per the specification, a GraphQL API with the above SDL may only return data matching our query, such as:

{
"helloWorld": "Hello!",
"numberOfRequests: 1
}

It’s the server-side type system’s responsibility to ensure that when this schema is executed against a valid query, that the execution result matches the types defined.

As such, while the server-side, written in any library for any language implementing the GraphQL specification, has complete knowledge of the schemas types and structure, queries are subject to these types implicitly.

Type Generation

If we’re using TypeScript on the client-side and have a set of GraphQL documents that we may execute against our schema, we can only know the documents’ result types by looking comparing them to the schema.

In GraphQL, this is often done ahead of time in with “Type Generation”. This means that we input our schema and our queries into a process at compile-time and convert the shape of the query to TypeScript’s type system.

As such, the shape of data in the queries above would match a TypeScript type looking like the following:

interface Result {
helloWorld: string | null;
numberOfRequests: number;
}

In many tools this is done using code generation, a process that, like many concepts in GraphQL, has already been established with JSON Schemas, or other API-shape specifications. In code generation, we would have to ensure that a compile-time tool generates or connects our TypeScript type to what we use at runtime — a query string or AST.

With gql.tada, this all happens in the TypeScript type system and is more invisible since no files are automatically generated per GraphQL document.

Integration with Clients

If we don’t integrate GraphQL on the client-side with Type Generation, then a minimal example using GraphQL is quite simple.

In this example, we’ll have a GraphQL query sent to an API using a simple fetch call:

query.ts
import { DocumentNode, parse, print } from 'graphql';
const query = parse(/* GraphQL */ `
{ helloWorld, numberOfRequests }
`);
async function execute(query: DocumentNode, variables?: any): Promise<any> {
const response = await fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
query: print(query),
variables,
}),
});
return (await response.json()).data;
}
const data = await execute(query);

In TypeScript however, we’re lacking two vital integration points here. Without Type Generations, neither data nor variables are typed according to our GraphQL schema, although with GraphQL’s guarantees these types should be unambiguous.

Manual Type Generation

With manual code generation tools, a separate tool would output a file containing our query’s types. For instance, it may output a separate file that contains the Result and Variables types we need:

query.generated.ts
export interface Result {
helloWorld: string | null;
numberOfRequests: number;
}
export interface Variables {}

However, this now requires us to make an effort to include these types manually in our execute function:

query.ts
import { DocumentNode, parse, print } from 'graphql';
import type { Result, Variables } from './query.generated.ts';
const query = parse(/* GraphQL */ `
{ helloWorld, numberOfRequests }
`);
async function execute<
Result = any,
Variables = any
>(query: DocumentNode, variables: Variables): Promise<Result> {
const response = await fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
query: print(query),
variables,
}),
});
return (await response.json()).data;
}
const data = await execute<Result, Variables>(query);

This is a very manual process though that doesn’t match our expectation that GraphQL is strongly typed and types should hence be inferred implicitly.

TypedDocumentNode types

What we really wish to do with client-side GraphQL is to attach our generated types to the DocumentNode type directly. This would mean that our query above can only lead to the correct types being used. Furthermore, by using said query, its types could be inferred automatically.

Instead of having a DocumentNode type, ideally, we’d want the query to be typed as TypedDocumentNode<Result, Variables>.

As a result, GraphQL in TypeScript has two ways of attaching types to GraphQL documents:

Our type generation tools can now output a TypedDocumentNode that has types attached to it directly:

query.generated.ts
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
interface Result {
helloWorld: string | null;
numberOfRequests: number;
}
interface Variables {}
export const query: TypedDocumentNode<Result, Variables> = parse(
'{ helloWorld, numberOfRequests }'
);

Which for clients executing a GraphQL query means, that they can infer the types of a given GraphQL query by matching it against TypedDocumentNode instead. In our example, we can now infer the generic types from the query instead:

query.ts
import { DocumentNode, parse, print } from 'graphql';
import { query } from './query.generated.ts';
async function execute<
Result = any,
Variables = any
>(
query: TypedDocumentNode<Result, Variables>,
variables: Variables
): Promise<Result> {
const response = await fetch('/graphql', {
method: 'POST',
body: JSON.stringify({
query: print(query),
variables,
}),
});
return (await response.json()).data;
}
const data = await execute(query);

gql.tada type inference

Having types output by a separate code generation tool again doesn’t quite match our expectation that GraphQL is strongly typed and types should hence be inferred implicitly.

In gql.tada however, the idea is that we get from writing a query to having a TypedDocumentNode type just via TypeScript inference, without running a separate tool or having files be generated for each query.

import { graphql } from 'gql.tada';
const query = graphql(`
{ helloWorld, numberOfRequests }
`);

In essence, what gql.tada does is give you a fully typed GraphQL document that tells TypeScript what the Result and Variables types are just via inference.

Client Support

Today, supporting typed documents in GraphQL is an accepted and de-facto standard, and below you can find a non-exhaustive list of GraphQL clients that support typed documents and will hence also work well with gql.tada.

import { graphql } from 'gql.tada';
import { useQuery } from '@apollo/client';
const getBooksQuery = graphql(`
query GetBooks {
books {
id
title
}
}
`);
function Books() {
const { loading, error, data } = useQuery(getBooksQuery);
return null; // ...
}