A Type-Safe API Client Without Codegen
Oleksandr Zhuravlov
Reach for this when you want typed API calls but don't want a codegen step in your build — no OpenAPI document to obtain, no generated client to commit, and no regenerate chore when the API changes. You declare the endpoints you actually call and the types come from a schema you write, checked against the live response on every call.
"Type-safe API client" almost always means one of two setups. Either a generator like Orval or openapi-typescript-codegen reads a spec and emits a whole typed client ahead of time, or a thin runtime wrapper like openapi-fetch imports types generated by openapi-typescript and applies them to fetch. Both give you compile-time safety. Both depend on a spec you trust enough to treat as the source of truth. This post is about the case where you don't have that spec — or don't want the generated artifact even when you do.
The usual path: types generated from a spec
With openapi-fetch the flow is two steps. First you generate a types file from the OpenAPI document:
npx openapi-typescript ./openapi.yaml -o ./api-types.tsThen you create a client bound to those types and call paths against them:
import type { paths } from './api-types';
import createClient from 'openapi-fetch';
const client = createClient<paths>({ baseUrl: 'https://api.example.com' });
const { data, error } = await client.GET('/users/{id}', {
params: { path: { id: 1 } },
});data is typed as whatever openapi-typescript derived from the spec. The editor autocompletes the path, the params, and the response shape. For an API with a maintained, accurate OpenAPI document and a surface you'll touch broadly, this is hard to beat — one generate command types the whole thing.
The cost is the part the types can't show you. They describe what the spec said the response would be, frozen at generation time, then erased at runtime. If the provider renames total to amount next Tuesday, the generated type still says total, the build still passes, and data.total is undefined three layers downstream — until you notice something broke and regenerate. The types are honest about the spec; they were never told about the live API.
The stitch way: the schema is the type and the runtime check
A stitch inverts where the types come from. Instead of generating them from a document, you hand the endpoint a validator and pair it with a generic. The validator runs against real bytes on every call, so the type and the runtime check come from one source:
import { stitch, toValidator } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.number(), name: z.string() });
const getUser = stitch<{ id: number; name: string }>({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
output: toValidator(User),
});
const user = await getUser({ params: { id: 1 } }); // typed · validatedoutput takes a Validator, and toValidator() adapts a Zod schema — or any Standard Schema validator like Valibot or ArkType, or a plain predicate — into one. The stitch<{ id: number; name: string }> generic types the resolved value, so user is { id: number; name: string } in the editor exactly as a generated type would be. There is no api-types.ts to generate, no client to commit, and nothing to regenerate when you add an endpoint — you write the next stitch.
Input is typed and checked the same way, before the request leaves the process:
const createUser = stitch<{ id: number; name: string }>({
method: 'POST',
baseUrl: 'https://api.example.com',
path: '/users',
input: { body: toValidator(z.object({ name: z.string() })) },
output: toValidator(z.object({ id: z.number(), name: z.string() })),
});A bad body rejects before the POST is sent; a response that doesn't match output rejects after. Either way the stitch throws STITCH_VALIDATION at the boundary instead of letting a malformed shape slip downstream as undefined. The full set of input keys — params, query, body, headers — is in the validation guide.
Catching the rename the generated type can't see
A strict output validator throws when the response stops matching, which is right for a hard break but wrong for an additive change — a new field the provider added shouldn't fail your call. Wrap the schema in drift() instead, and each response is validated and then diffed against the validated value, with every difference classified by severity:
const listOrders = stitch({
baseUrl: 'https://api.example.com',
path: '/orders',
output: drift(OrdersSchema, {
ignore: ['[].meta'], // acknowledged, unconsumed — don't report it
severity: { coerced: 'info' }, // re-level a kind without failing the call
}),
});A required field that vanishes or turns incompatible fails loudly; a coercion the schema quietly absorbed or an undeclared key the API added surfaces as a non-fatal signal on the event stream. That total → amount rename arrives as a typed drift event the moment it happens — a class of change a spec-derived type can't report, because the spec was never told. The reasoning behind treating it that way is in schema drift is a production bug.
When the generated client is enough
The honest line: if you have a maintained, accurate spec and accurate types are the whole requirement, a generator is less ceremony for that goal.
- The spec is real, current, and owned. When someone keeps the OpenAPI document honest and you'll touch a large fraction of the surface, generation types everything for the cost of one command. Per-endpoint stitch declarations would be more typing for no gain.
- You don't want runtime validation. A stitch's guarantees come from the
outputvalidator running on every response. If you don't want that work happening at runtime — or won't write a schema — a generated type is the leaner choice, and a stitch without anoutputis a fetch wrapper with extra steps. - The contract and its spec move together. When the API and document change on a cadence you control, regenerate-on-change is a feature, not a chore, and you may not need drift signals at all.
A stitch earns its place on the opposite shape of problem: spec-less, partial-spec, internal, or long-tail APIs; surfaces that change without warning; and anywhere you'd rather the type and the runtime check come from one schema than from a document and a build step. For the broader axis-by-axis version of this trade — generators versus runtime stitching across the whole surface — see stitching vs. codegen API clients.
Try it
npm i stitchapiDeclare one endpoint, give its output a toValidator() schema, pair it with the stitch<T> generic, and you have a typed, runtime-validated call with no generated file in your tree. The mechanics live in Validation, the validator adapter in Standard Schema, and the primitive itself in The stitch.