DEV Community

Robert Ozimek
Robert Ozimek

Posted on

Type-Safe GraphQL Without Writing a Single Response Type

Every GraphQL query is a subset, so the response type belongs to the operation, not the schema. Something has to derive it per query.

Usually that is one of two things: an interface you write by hand, which drifts the first time someone renames a field, or a codegen step over your query strings — a watch process, a file per operation, and queries living as strings your editor cannot check until the generator has run. Either way you also declare variables twice, $id: ID! in the document and { id: string } in TypeScript, both restating the schema.

buildgql makes the selection a TypeScript value. The result type and the variable types are inferred from what you selected.

The Selection Is the Type

Generation runs once against the schema, not against your queries:

npx buildgql generate
Enter fullscreen mode Exit fullscreen mode

That gives you one module that knows every type your API has. Operations are plain functions — the callback gets a typed proxy of the root type, and the array you return is the selection:

import { query, createClient } from './src/gql';

const client = createClient({ url: 'https://api.example.com/graphql' });

const Posts = query('Posts', ($, q) => [
  q.posts((post) => [
    post.id,
    post.title,
    post.author((author) => [author.id, author.firstName, author.lastName]),
  ]),
]);

const result = await client.execute(Posts);
//    ^? { posts: { id: string; title: string;
//                  author: { id: string; firstName: string;
//                            lastName: string | null } }[] }
Enter fullscreen mode Exit fullscreen mode

No generic at the call site, no response interface anywhere. Drop post.title from the array and result.posts[0].title stops compiling on the same keystroke.

Nullability comes from the schema rather than from optimism. lastName is string | null because the field is nullable, and a list of non-null posts is Post[], not (Post | null)[] | null. Hand-written interfaces get this wrong constantly, because writing string is easier than checking.

Variables Come From Where You Used Them

$ marks an argument as a variable. It takes its name from the argument and its type from the schema:

import { mutation } from './src/gql';

const CreateNewUser = mutation('CreateNewUser', ($, m) => [
  m.createUser({ name: $.name, email: $.email }, (user) => [user.id, user.firstName]),
]);

await client.execute(CreateNewUser, { name: 'John Smith', email: 'john@smith.com' });
//                                   ^ typed as { name: string; email: string }
Enter fullscreen mode Exit fullscreen mode

Which prints the obvious document:

mutation CreateNewUser($name: String!, $email: String!) {
  createUser(name: $name, email: $email) { id firstName }
}
Enter fullscreen mode Exit fullscreen mode

Naming variables after arguments is wrong exactly once: when two fields in one operation need different variables for the same argument name. Hence the explicit form:

import { v } from './src/gql';

q.post({ id: v('postId') }, (post) => [post.title]);
Enter fullscreen mode Exit fullscreen mode

Optional schema arguments produce optional variables, which matters more than it sounds — an operation with a required variable makes the second argument to execute mandatory, so forgetting it is a type error rather than a runtime one.

Scalars

Config is one file. The .mjs form works on every supported Node version, CI included:

import { defineConfig } from 'buildgql/config';

export default defineConfig({
  // a URL, './schema.graphql', or './introspection.json'
  schema: 'https://api.example.com/graphql',
  headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  output: './src/gql',
  scalars: { DateTime: 'string', JSON: 'unknown' },
  client: 'buildgql', // 'apollo' | 'urql' | 'none'
});
Enter fullscreen mode Exit fullscreen mode

Outside GraphQL's built-in five, the server gives you a scalar's name and nothing else, so every custom scalar needs a mapping or it generates as unknown — with a warning naming it.

The case worth showing is a scalar that differs by direction. A DateTime you pass as a Date but always read back as an ISO string, because that is what JSON transport gives you:

scalars: {
  DateTime: { input: 'string | Date', output: 'string' },
  Money: { name: 'Money', from: './src/types/money' },
  JSON: {
    name: 'JSONValue',
    declare: 'string | number | boolean | null | JSONValue[] | { [k: string]: JSONValue }',
  },
}
Enter fullscreen mode Exit fullscreen mode

from imports a type you already own as an import type, so nothing new appears at runtime. declare inlines it instead — the safer choice if you generate in your backend repo and publish the module to your frontends, where a relative path would point at files the tarball does not contain.

The config is validated before anything is written, so a misspelled key or a name that collides with something the module already binds fails at generate time rather than as a syntax error in a file you did not write.

Fragments, Unions, Directives

The parts that make response types hard infer too:

import { on, spread, include, v, userFragment } from './src/gql';

const NameBits = userFragment('NameBits', (user) => [user.firstName, user.lastName]);

query('Feed', ($, q) => [
  q.users((user) => [user.id, spread(NameBits)]),
  q.pet((pet) => [
    on('Dog', Dog, (dog) => [dog.breed]),
    on('Cat', Cat, (cat) => [cat.lives]),
  ]),
  q.me((me) => [include(me.email, v('withEmail'))]),
]);
Enter fullscreen mode Exit fullscreen mode

on() yields a discriminated union that narrows on __typename, so reading pet.breed without checking does not compile. include() and skip() make the field optional, which is correct — the server may not have sent it.

Subscriptions work the same way over SSE or graphql-ws, and the generated module only exports subscription if your schema declares one.

It Isn't a Client

buildgql ships a small client, but operations are just documents plus inferred types. Every adapter is built on a TypedDocumentNode<Result, Variables> — the node Apollo and urql already understand. Set client in the config and the generated module re-exports that adapter:

import { query, apolloDocument, toApolloQuery } from './src/gql';
import { useQuery } from '@apollo/client';

const UserById = query('UserById', ($, q) => [
  q.user({ id: $.id }, (user) => [user.id, user.firstName]),
]);

const { data } = await apolloClient.query(toApolloQuery(UserById, { id: '7' }));
//      ^? { user: { id: string; firstName: string } }

// Hooks take the document positionally.
const { data: hookData } = useQuery(apolloDocument(UserById), { variables: { id: '7' } });
Enter fullscreen mode Exit fullscreen mode

Your caching, devtools, and framework bindings keep working. Only the source of the types changes.

What It Won't Do

Relay is out, and no adapter is planned — a structural mismatch, not an oversight. Relay's store does not consume documents at runtime; it needs ConcreteRequest artifacts emitted ahead of time by relay-compiler from source files it has scanned. buildgql builds documents at runtime from TypeScript values, so there is nothing to scan and nothing to normalize against.

Two smaller edges. $.argName does not type-check under noUncheckedIndexedAccess, since the proxy is an index signature and that flag widens every read to include undefined — use v('argName'). And an enum literal nested inside an input-object literal prints quoted, because the printer cannot see the input type graph that deep; pass it as a variable instead.

The real trade is giving up query documents as strings. If yours are shared with a non-TypeScript consumer, or extracted for persisted-query allowlists by scanning for gql tags, this is not your tool.

What you get back is one place where a type is written down. Regenerate when the schema changes, and every operation that no longer matches fails at tsc.


npm: buildgql · GitHub: robertozimek/buildgql

Top comments (0)