Release candidate — 1.0.0-rc.4
StitchAPI

JSON Schema

JsonSchema.adapt turns a runtime-discovered JSON Schema into a StitchSchema (a Standard Schema) a stitch accepts — validated with an engine you supply, so it ships none of its own.

A stitch's input/output accepts any Standard Schema — Zod, Valibot, ArkType — but those describe schemas you wrote. Sometimes the schema 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. You can't generate a type for a schema that doesn't exist yet — so you don't type across that boundary, you validate at it.

@stitchapi/json-schema adapts that JSON Schema into a StitchSchema (a Standard Schema) a stitch accepts directly. It ships no validation engine — you bring your own, so the schema is checked with exactly the keywords, formats, $refs, and draft your app already uses.

Example

npm install @stitchapi/json-schema@rc stitchapi@rc ajv

stitchapi is a peer; ajv is an optional peer, needed only for the { ajv } engine.

Adapt a schema

Pass your configured engine as the second argument. With { ajv }, the adapter calls .compile() on the instance you hand it — reusing all of its configuration:

import { JsonSchema } from '@stitchapi/json-schema';
import Ajv from 'ajv';
import { stitch } from 'stitchapi';

const ajv = new Ajv(); // your app's engine — formats, keywords, $refs, draft

// `discovered` is a JSON Schema you obtained at runtime.
const search = stitch({
    baseUrl: 'https://api.example.com',
    path: '/search',
    output: JsonSchema.adapt(discovered, { ajv }),
});

Used standalone, the returned validator exposes the Standard Schema ~standard.validate, which checks a value and returns a result — never throwing:

const validator = JsonSchema.adapt(discovered, { ajv });

const result = await validator['~standard'].validate(payload);
if (result.issues) {
    // Structured, per-path — hand it back to the sender to correct.
    // [{ message: 'must be <= 50', path: ['limit'] }]
    return respondWithErrors(result.issues);
}
handle(result.value); // `unknown` — a runtime schema carries no static shape

JsonSchema.adapt<T>() takes an optional type argument. Leave it unknown for a runtime-obtained schema; pass T only when you already know the shape at authoring time.

Why you pass the engine

Ajv configuration is stateful and app-specific — custom keywords, formats, $ref resolvers, the draft you target. An engine this package instantiated itself would throw or silently mis-validate a schema that relies on your setup: "works everywhere except through the adapter." So the engine isn't hidden — you pass the instance you already use, and the schema is checked with those exact semantics. It also keeps ajv out of this package's bundle (the adapter is ~800 bytes) and off your dependency tree unless you use it.

Bring your own engine

Not on Ajv? Pass a compiled check instead — a Workers-safe validator, a draft-2020-12 engine, a shared instance. Return valid plus a JSON Pointer + message per failure; the issue-path mapping into { message, path } stays identical.

JsonSchema.adapt(discovered, {
    check: (value) => {
        const { valid, errors } = myEngine.validate(value);
        return {
            valid,
            issues: errors.map((e) => ({
                pointer: e.instanceLocation, // JSON Pointer, e.g. '/limit'
                message: e.message,
            })),
        };
    },
});

The output type is unknown by design: a schema you only learn at runtime carries no compile-time shape, so the honest result is unknown — narrow it where you read it. Anchoring a stitch's output to the same schema also lets inspect() report where the live service drifts from the contract it handed you.

See also

On this page