Release candidate — 1.0.0-rc.4
← 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 unwrapped data — 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 User = { id: string; name: string };

async function getUser(id: string): Promise<User> {
    const res = await fetch('https://api.example.com/graphql', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
            query: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
            variables: { id },
        }),
    });

    const json = await res.json();
    // res.ok is true even when the operation failed.
    return json.data.user as User;
}

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 unwrap: 'data':

import { graphql } from 'stitchapi';

const getUser = graphql<{ user: { id: string; name: string } }>({
    baseUrl: 'https://api.example.com',
    path: '/graphql',
    query: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
});

const data = await getUser({ variables: { id: '1' } });
// data is already unwrapped from `data`: { 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 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 query string and mint a stitch per call. Because unwrap defaults to 'data', the resolved value is the contents of the response's data field; override unwrap 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 unwrapping 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 { StitchError, graphql } from 'stitchapi';

const getUser = graphql<{ user: { name: string } }>({
    baseUrl: 'https://api.example.com',
    path: '/graphql',
    query: `query GetUser($id: ID!) { user(id: $id) { name } }`,
});

try {
    await getUser({ variables: { id: '42' } });
} catch (err) {
    if (err instanceof StitchError) {
        // 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).
        console.error(err.message);
    }
}

(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 unwrapped data is validated before you touch it — the as User cast is gone, replaced by a runtime check the compiler can trust:

import { bearer, env, graphql } from 'stitchapi';
import { z } from 'zod';

const getUser = graphql({
    baseUrl: 'https://api.example.com',
    path: '/graphql',
    query: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
    output: z.object({
        user: z.object({ id: z.string(), name: z.string() }),
    }),
    auth: bearer(env('API_TOKEN')),
    retry: 3,
    timeout: '10s',
});

const { user } = await getUser({ variables: { id: '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 + query
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 unwrapped data at runtime
Unwrapping datajson.data.user, by handunwrap: '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 { bearer, env, seam } from 'stitchapi';

const api = seam({
    baseUrl: 'https://api.example.com',
    auth: bearer(env('API_TOKEN')),
});

const getUser = api.graphql({
    path: '/graphql',
    query: `query GetUser($id: ID!) { user(id: $id) { id name } }`,
});

const listOrders = api.graphql({
    path: '/graphql',
    query: `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.