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
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 } }[] }
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 }
Which prints the obvious document:
mutation CreateNewUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) { id firstName }
}
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]);
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'
});
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 }',
},
}
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'))]),
]);
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' } });
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)