Validate Calls Against Schemas You Obtain at Runtime
Oleksandr Zhuravlov
Sometimes the schema you validate against shows up at runtime — you fetch it, a tenant registers it, a config push delivers it — as JSON Schema you didn't have when you compiled.
The type you're chasing can't exist
Code generation assumes the schema is known when you build. A schema you fetch, or one a tenant defines after you ship, isn't — so a static type for it is off the table. The autocomplete you're mourning was never on offer either: an editor can't complete against a schema that isn't present while you write the code. The type assertions pile up because you keep asserting a shape you don't actually know.
So stop typing across the boundary and validate at it. The data arrives as JSON; you hold the schema; you check one against the other at call time. Past that check the data is unknown until you narrow it where you use it — and unknown you narrow on purpose beats any you cast on reflex.
Validate at the boundary, not across it.
Turn the discovered schema into a validator
The schema reaches you as JSON Schema, whatever delivered it. jsonSchemaValidator turns it into a Standard Schema, and you validate through its ~standard interface — validate checks a value and returns a result, never throwing.
import { } from '@stitchapi/json-schema';
// `schema` is JSON Schema you obtained at runtime — no static shape until now.
const = (schema);
const = await ['~standard'].validate(payload);
if (!.issues) {
handle(.value); // `unknown` — narrow it where you read it
}One interface spans every source. A contract you wrote by hand in Zod and one you received as JSON Schema expose the same ~standard.validate. Standard Schema, not a Zod lock-in — Valibot, ArkType, and a raw JSON Schema all arrive through the same door.
The compiler settings this pattern needs
A runtime-obtained schema hands you unknown. That only helps if the compiler forces you to narrow it instead of letting it decay to any. Turn these on in your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}strictmakesunknownunusable until you narrow it — the whole reason the boundary is worth drawing. It also hands youunknownincatch, so a throwing validator can't smuggleanyback in.noUncheckedIndexedAccesstypesvalue.items[0]as possiblyundefined. You're reading data you never compiled against; the index you assume is there might not be.exactOptionalPropertyTypeskeeps a missing field and a field set toundefinedfrom collapsing into one type — the distinction the validator just measured.
Then keep any out with lint, so nobody casts around the boundary you built: @typescript-eslint/no-explicit-any and the no-unsafe-* family.
Return the errors to the sender
The value isn't the boolean; it's the shape of the failure. ~standard.validate returns issues as { message, path } — a list you hand back to whoever sent the data, not a ZodError you catch, stringify, and hope reads well.
const = await validator['~standard'].validate(payload);
if (.issues) {
// result.issues → [{ message: 'must be <= 50', path: ['limit'] }]
return respondWithErrors(
.issues
.map(() => `- ${(.path ?? []).join('.') || '(root)'}: ${.message}`)
.join('\n'),
);
}A per-path rejection is a correction the sender can apply: it reads that limit must not exceed 50, drops the offending field, and retries. A thrown exception is a dead end. Structured feedback the caller can act on, not a stack trace it can't.
The call fronts a service you didn't write
The data you validate often came back from a request you also make. When you hold the response schema too, the same schema takes a second job. Run the call as a stitch, anchor its response to that schema, and inspect() reports where the live service returned something its own contract never promised — a field that appeared, a number that came back a string, a null where the schema said otherwise.
import { } from '@stitchapi/json-schema';
import { } from 'stitchapi';
const = ({
: endpoint,
: 'POST',
: (responseSchema),
});
const = await .({ : payload });
.; // validated response, or null on a hard failure
for (const of .) {
// e.g. { path: 'items[].sku', change: 'undeclared' }
log.warn(`response drift at ${.}: ${.}`);
}The schema told you what the service claims to accept and return. Drift tells you when the running service stops honouring that claim — the failure mode a schema you discovered, and never compiled against, has and a hand-written one rarely does.
Validate where you know, type where you don't
A runtime-obtained schema has no compile-time shape, so the compiler can't guard it and pretending it can costs you casts. A validator can guard it: it turns the incoming JSON into either data you narrow or errors you return. One Standard Schema interface covers the schemas you write and the schemas you receive; one validate() gives you the same result shape for both; the same schema that checks the incoming data also catches the service drifting underneath it.
Try it
npm install @stitchapi/json-schema@rc stitchapi@rc- Type-safe API clients without codegen — the same "no build-time shape" bind, solved at the call.
- Schema drift is a production bug — what
inspect()surfaces and how to act on findings. - Typed errors without try/catch — return failures as values instead of throwing.