Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 5 min read

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 f

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: '[email protected]' });
//                                   ^ 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

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.