@statedelta/schema-checker
v0.1.0
Published
JSON Schema compatibility matching — decide whether schemaA ⊆ schemaB (subschema / containment). Sound, fast, zero-dependency. For type-safe port connections, breaking-change detection and data-contract validation.
Downloads
37
Maintainers
Readme
@statedelta/schema-checker
JSON Schema compatibility matching — decide whether
schemaA ⊆ schemaB(subschema / containment), soundly and fast.
match(A, B) answers one question:
Is every JSON document valid against
Aalso valid againstB? (A ⊆ B)
That makes it the type-checker for connections between typed data ports —
the foundation of a graph-based nocode backend: an output port (schema A)
may feed an input port (schema B) only when A ⊆ B. It also detects
breaking changes in contract/entity evolution, validates DTO pipelines,
and gates service/saga wiring — anywhere you need to know, before data
flows, that two shapes are compatible.
Unlike mainstream nocode tools (n8n, Zapier, Make, Node-RED), which are
dynamically typed and defer compatibility to runtime, schema-checker brings
real schema containment (à la IBM jsonsubschema / Confluent Schema
Registry) to JavaScript, over JSON Schema Draft-07.
- ✅ Sound — a
compatibleverdict is a proof you can trust (validated at 1,000,000+ random pairs against Ajv: zero unsound). - ✅ Tri-state & fail-closed —
compatible | incompatible | unknown; never throws, never hangs. - ✅ Fast — polynomial in schema size; entity/aggregate checks are sub-millisecond.
- ✅ Zero runtime dependencies.
Install
pnpm add @statedelta/schema-checkerQuick start
import { match, isSubschema } from "@statedelta/schema-checker";
// Producer output → consumer input. Safe to connect iff A ⊆ B.
const out = { type: "integer", minimum: 18, maximum: 65 };
const in_ = { type: "number", minimum: 0 };
match(out, in_).verdict; // "compatible" (every int in [18,65] is a number ≥ 0)
isSubschema(out, in_); // true
// Breaking-change direction:
match({ type: "number" }, { type: "integer" }).verdict; // "incompatible"The result model — tri-state, sound, fail-closed
type MatchVerdict = "compatible" | "incompatible" | "unknown";
interface MatchResult {
verdict: MatchVerdict;
reasons: Reason[]; // empty when compatible
}| Verdict | Meaning | Your gate |
| --- | --- | --- |
| compatible | Proven A ⊆ B. Safe to connect. | allow |
| incompatible | Proven A ⊄ B — there is a value of A that B rejects. | block (real type error) |
| unknown | Could not be decided (a construct outside the decidable profile). | block (fail-closed) — show a warning |
Why tri-state? The engine is sound, not complete: it prefers unknown
over a wrong compatible. For a security gate, treat anything that is not
compatible as a block — that is exactly isSubschema (below). incompatible
vs unknown is a UX distinction: "this is wrong" vs "I can't prove it".
match never throws and never hangs: any internal error is caught and
returned as unknown; a degenerate schema is bounded by a step budget and
returns unknown. The gate fails closed.
API
match(source, target, opts?) → MatchResult
Does source ⊆ target? Returns the rich tri-state result with reasons.
source is the producer/output (A); target is the consumer/input (B).
const r = match(producerSchema, consumerSchema, { registry });
if (r.verdict === "compatible") connect();
else showReasons(r.reasons);isSubschema(source, target, opts?) → boolean
Boolean convenience — true only when provably compatible (unknown →
false, fail-closed). This is the recommended gate:
if (isSubschema(out, in_, opts)) proceed();
else block();isCompatible(out, in, opts?) → MatchResult
Port-semantics alias of match — reads naturally for out → in wiring.
validateProfile(schema) → { ok, violations }
Lints a schema against the supported decidable subset. Run this at authoring
time (e.g. in your editor) so users get a clear error instead of a silent
unknown later.
const { ok, violations } = validateProfile(schema);
if (!ok) reportToAuthor(violations); // e.g. used `oneOf`, `not`, fractional multipleOf, remote $refcreateRegistry(entities) → Registry
Builds an in-memory registry of named entities so schemas can reference each
other by $ref. Required whenever your schemas use references.
import { match, createRegistry } from "@statedelta/schema-checker";(See Entities & references below.)
Options — MatchOptions
interface MatchOptions {
registry?: Registry; // resolves $ref (entities)
onUnsupported?: "unknown" | "throw"; // default "unknown"; "throw" surfaces internal errors (tests/CI)
}Port semantics (variance) — the one rule not to get wrong
Connecting output(A) → input(B) is safe iff A ⊆ B: the producer must be
narrower than or equal to what the consumer accepts.
- property values & array items are covariant:
A.prop ⊆ B.prop. - a consumer that requires a field rejects producers that don't guarantee it.
- a closed consumer (
additionalProperties: false) rejects a producer that can emit extra keys.
// adding a required field to the consumer is a breaking change:
const oldProducer = { type: "object", properties: { id: { type: "integer" } }, required: ["id"] };
const newConsumer = { type: "object", properties: { id: { type: "integer" }, tenantId: { type: "integer" } }, required: ["id", "tenantId"] };
isSubschema(oldProducer, newConsumer); // false — producer lacks tenantIdEntities & references
Declare entities once and reference them by $ref — the natural DDD shape
(entities, value objects, aggregates). Recursive and mutually-recursive entities
are fully supported (termination is guaranteed).
import { match, createRegistry } from "@statedelta/schema-checker";
const defs = {
Email: { type: "string", minLength: 3, maxLength: 254 },
User: {
type: "object",
properties: {
id: { type: "integer", minimum: 1 },
name: { type: "string" },
email: { anyOf: [{ $ref: "#/definitions/Email" }, { type: "null" }] }, // nullable VO
manager: { $ref: "#/definitions/User" }, // recursive — fine
},
required: ["id", "name"],
},
Admin: {
type: "object",
properties: {
id: { type: "integer", minimum: 1 },
name: { type: "string" },
email: { $ref: "#/definitions/Email" }, // required, non-null
role: { const: "admin" },
},
required: ["id", "name", "email", "role"],
},
};
const registry = createRegistry(defs);
const ref = (n: string) => ({ $ref: `#/definitions/${n}` });
match(ref("Admin"), ref("User"), { registry }).verdict; // "compatible" (Admin specializes User)
match(ref("User"), ref("Admin"), { registry }).verdict; // "incompatible"Aggregates compose naturally and stay covariant:
// An Order whose customer is an Admin ⊆ an Order whose customer is a User.Reasons & error codes
Every incompatible/unknown result carries structured reasons with a
stable, machine-readable code — switch on code, never parse message
(which is for display / i18n):
interface Reason {
verdict: "incompatible" | "unknown";
code: ReasonCode; // e.g. "object/required", "number/minimum", "undecidable/union"
schemaPath: string; // JSON Pointer, e.g. "/properties/age"
keyword?: string; // the JSON Schema keyword at fault
message: string; // human-readable
source?: JSONValue; // the producer value/constraint
target?: JSONValue; // the consumer value/constraint
}const r = match(a, b, opts);
for (const reason of r.reasons) {
switch (reason.code) {
case "object/required": highlightMissingField(reason.target); break;
case "number/minimum": highlightRange(reason.schemaPath); break;
case "undecidable/union": showWarning("can't verify — your responsibility"); break;
// …
}
}Codes are namespaced <category>/<detail>. undecidable/*, unsupported/*,
ref/unresolved, and error/internal always mean verdict unknown; the rest
mean incompatible. (Full list in
docs/ARCHITECTURE.md §8.)
The supported profile (Draft-07 subset)
The engine targets a decidable, well-behaved subset of Draft-07 — exactly
what typed data ports need. Supported: type (incl. nullable unions), enum,
const, number ranges + multipleOf (integers), string length + pattern +
format, arrays (items list-form, length, uniqueItems), objects
(properties, required, additionalProperties, cardinality), allOf/anyOf,
and $ref to a registry.
Out of profile (rejected by validateProfile, treated as unknown if they
slip in): oneOf, not, if/then/else, fractional multipleOf, remote
$ref. Conservatively unknown (sound, future increments): tuple arrays,
multi-branch right unions, patternProperties/propertyNames/contains,
divergent regex pattern.
No coercion, by design. Schema containment compares what a value is, not what it could be converted to.
integer ⊆ numberis genuine subtyping;string ↔ numberwould be coercion and is out of scope.
See docs/IMPLEMENTATION-PROPOSAL.md §5 for
the full profile and decision log.
Guarantees (production)
- Soundness —
compatibleis a proof ofA ⊆ B. Validated against Ajv at 1,000,000 self-contained pairs + 100,000 entity/ref pairs + a full DDD scenario suite: zero unsound verdicts. - Bounded time — comparison is polynomial in schema size (identity
coinduction + memoization); an absolute step budget caps even adversarial
input →
unknown. No hang / DoS. - Fail-closed —
matchnever throws;isSubschemareturnsfalseon anything not provably compatible.
For the algorithms and the safety analysis, see
docs/ARCHITECTURE.md.
Types
export type Schema = boolean | SchemaObject;
export type JSONType = "null" | "boolean" | "integer" | "number" | "string" | "array" | "object";
export type JSONValue = /* any JSON value */;
export type MatchVerdict = "compatible" | "incompatible" | "unknown";
export interface MatchResult { verdict: MatchVerdict; reasons: Reason[]; }
export interface Reason { verdict; code; schemaPath; keyword?; message; source?; target?; }
export interface ProfileResult { ok: boolean; violations: Reason[]; }
export interface Registry { resolve(ref: string, baseUri?: string): Schema | undefined; }
export interface MatchOptions { registry?: Registry; onUnsupported?: "unknown" | "throw"; }
export function match(source: Schema, target: Schema, opts?: MatchOptions): MatchResult;
export function isSubschema(source: Schema, target: Schema, opts?: MatchOptions): boolean;
export function isCompatible(out: Schema, in_: Schema, opts?: MatchOptions): MatchResult;
export function validateProfile(schema: Schema): ProfileResult;Development
pnpm install
pnpm test # unit + laws + differential (Ajv) + DDD scenarios + ref stress (100×100)
pnpm test:stress # heavy tiers: 1,000,000 self-contained + 100,000 entity-ref pairs
pnpm typecheck
pnpm lint
pnpm build # esm + cjs + d.ts (tsup)License
MIT © Anderson D. Rosa
