Typed GraphQL Without Apollo
Oleksandr Zhuravlov
You point a fetch at a GraphQL endpoint, POST { query, variables }, get back a 200, read json.data.user — and three weeks later that call is silently returning undefined because the server moved the failure into a 200-with-errors body instead of a status code. The transport said success; the operation did not. Most GraphQL clients you'd reach for to close that gap — Apollo Client, urql — also bring a normalized cache, a React layer, and a build step you didn't ask for when all you wanted was to call one query and trust the result.
A graphql() stitch is that one query as a typed function. It POSTs the operation, treats a 200 carrying an errors array as the failure it is, validates the response, and hands you the data payload — without a client object, a cache, or codegen.
The honest "before"
Here is the call most TypeScript codebases write against a GraphQL API they don't own:
type = { : string; : string };
async function (: string): <> {
const = await ('https://api.example.com/graphql', {
: 'POST',
: { 'content-type': 'application/json' },
: .({
: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
: { },
}),
});
const = await .();
// res.ok is true even when the operation failed.
return .data.user as ;
}The sharp edges are real. res.ok is true for a 200 that carries { "errors": [...] }, so the failure path never runs and json.data is undefined — you read .user off undefined and crash somewhere far from the cause. The as User is a lie the compiler believes: nothing checked that the body matches the shape. And every variant of this query is a fresh copy of the POST boilerplate, each free to drift.
The same job as a stitch
Declare the operation once. graphql() is a thin wrapper over stitch() that fixes kind: 'graphql', method: 'POST', and pick: 'data':
import { } from 'stitchapi';
const = <{ : { : string; : string } }>({
: 'https://api.example.com',
: '/graphql',
: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
});
const = await ({ : { : '1' } });
// data is already the response's `data` payload: { user: { id, name } }Each field maps to glue you no longer write. graphql() sends the POST and the JSON body shape for you, so the method, the content-type header, and the { query, variables } envelope vanish from your code — the document you declare travels as the envelope's query field. The arguments travel in the call input's variables field at call time, so one declared stitch serves every set of arguments — you do not interpolate values into the document and mint a stitch per call. Because pick defaults to 'data', the resolved value is the contents of the response's data field; override pick to read a different field or the raw envelope.
The footgun closes here. A GraphQL endpoint can return HTTP 200 while carrying a top-level errors array; the stitch treats that as a failure and surfaces it, rather than handing you a data that isn't there. A failed query rejects the awaitable with a StitchError whose message carries the GraphQL error text, prefixed GraphQL: (joined with ; when there is more than one):
import { , } from 'stitchapi';
const = <{ : { : string } }>({
: 'https://api.example.com',
: '/graphql',
: `query GetUser($id: ID!) { user(id: $id) { name } }`,
});
try {
await ({ : { : '42' } });
} catch () {
if ( instanceof ) {
// err is a StitchError. The GraphQL messages are folded into err.message,
// prefixed `GraphQL:` — e.g. 'GraphQL: Not authorized to view user 42.'
// err.status is the HTTP status (200 for an errors-in-body failure).
.(.);
}
}(STITCH_GRAPHQL is the provisional registry label for this failure in the error reference; the runtime throws a plain StitchError today — it carries no .code.)
It is still a stitch
graphql() returns the same object stitch() does, so every resilience and validation field applies. Add an output schema and the picked data is validated before you touch it — the as User cast is gone, replaced by a runtime check the compiler can trust:
import { } from 'stitchapi';
import { , } from 'stitchapi/auth';
import { } from 'zod';
const = <{ : { : string; : string } }>({
: 'https://api.example.com',
: '/graphql',
: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
: .({
: .({ : .(), : .() }),
}),
: (('API_TOKEN')),
: 3,
: '10s',
});
const { } = await ({ : { : '1' } });
// user is typed AND validated; auth, retry, and timeout all applied.The secret resolves at call time from the environment, so the caller never holds the token. retry retries the request on transient transport failures; timeout bounds the call. operationName rides along with the request when you set it.
What moved where
| Axis | Hand-rolled fetch | A graphql() stitch |
|---|---|---|
| The POST + JSON envelope | You write method, content-type, JSON.stringify({ query, variables }) every call | graphql() fixes them; you supply path + document |
| Variables | Re-stringified per call site | Travel in variables at call time; one stitch, every argument set |
200 with errors | res.ok is true — failure slips through as undefined | A StitchError whose message is prefixed GraphQL: |
| Response shape | as User — unchecked | output schema validates the picked data at runtime |
Picking data | json.data.user, by hand | pick: 'data' by default; resolved value is data |
| Auth, retry, timeout | More hand-written loops and headers | Config fields on the same declaration |
The layer split is clean: the GraphQL document describes what you're asking for, and the stitch config describes how the call behaves — validation, auth, resilience — without a client object owning either. You keep writing GraphQL; you stop writing the POST around it.
From one query to a typed surface
The bare graphql() form is one query, one function — and it scales the way the rest of StitchAPI does. When a second query against the same endpoint shows up, bind the shared baseUrl and auth once with a seam and let each operation inherit them:
import { } from 'stitchapi';
import { , } from 'stitchapi/auth';
const = ({
: 'https://api.example.com',
: (('API_TOKEN')),
});
const = .({
: '/graphql',
: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
});
const = .({
: '/graphql',
: `query Orders($userId: ID!) { orders(userId: $userId) { id total } }`,
});The first query is one line you can ship today; the tenth is the same declaration ten times, each one a typed, validated, resilient function. No client to instantiate, no schema to generate, no second code path between the query you tried in a playground and the one running in production.
Try it
npm install stitchapi@rc zodgraphql() wraps stitch(), so install the core package, bring your own validator, and point a query at an endpoint.
- Guide: GraphQL — how
graphql()posts the operation and picksdata - Error: STITCH_GRAPHQL — what a
200-with-errorsfailure looks like - Validate an API response with Zod — wiring an
outputschema - A type-safe API client without codegen — the same idea across REST and GraphQL