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. JsonSchema.adapt turns it into a Standard Schema using an engine you supply. compile turns that schema into a reusable checker — a function that validates a value and returns a result, never throwing, whatever the schema's source.
import { } from '@stitchapi/json-schema';
import from 'ajv';
import { } from 'stitchapi';
const = new (); // your app's configured engine — formats, keywords, $refs, draft
// `schema` is JSON Schema you obtained at runtime — no static shape until now.
const = (.adapt(schema, { }));
const = await (payload);
if (.) {
handle(.); // `unknown` — narrow it where you read it
}That check is nothing special — the schema behind it is the same SchemaLike a stitch accepts in input or output, and the .inspect() example below hands this very adapter to output. One schema, whichever door you send it through: compile for a standalone check, a stitch for a live call. A contract you wrote by hand in Zod and one you received as JSON Schema compile the same way — Standard Schema, not a Zod lock-in, with Valibot, ArkType, and a raw JSON Schema all arriving 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. On ok: false, the result carries 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 check(payload);
if (!.ok) {
// 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 'ajv';
import { } from 'stitchapi';
const = new ();
const = ({
: endpoint,
: 'POST',
: .adapt(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; whether you validate in one shot or compile a checker you reuse, the result is the same { ok, value }-or-{ ok, issues } shape; 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.