Release candidate — 1.0.0-rc.7
← Back to blog

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

AxisHand-rolled fetchA graphql() stitch
The POST + JSON envelopeYou write method, content-type, JSON.stringify({ query, variables }) every callgraphql() fixes them; you supply path + document
VariablesRe-stringified per call siteTravel in variables at call time; one stitch, every argument set
200 with errorsres.ok is true — failure slips through as undefinedA StitchError whose message is prefixed GraphQL:
Response shapeas User — uncheckedoutput schema validates the picked data at runtime
Picking datajson.data.user, by handpick: 'data' by default; resolved value is data
Auth, retry, timeoutMore hand-written loops and headersConfig 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 zod

graphql() wraps stitch(), so install the core package, bring your own validator, and point a query at an endpoint.