@louise-life/json-shape-compat
v0.1.1
Published
Check JSON backwards compatibility by inferring schemas from actual JSON samples
Maintainers
Readme
json-shape-compat
Check JSON backwards compatibility by inferring the shape from actual JSON samples — no schema definitions, no annotations, no decorators. Point it at the JSON your service produced yesterday and the JSON it produces today, and it tells you what broke.
The intended workflow: your CI saves a shape snapshot on every deploy. The next deploy compares the new response against that snapshot. If the diff contains breaking changes, CI fails.
Install
npm install -g json-shape-compat # CLI
npm install --save-dev json-shape-compat # libraryCLI
Compare two documents
json-shape-compat compare old.json new.json
json-shape-compat old.json new.json # shorthandExit codes:
0— no breaking changes1— breaking changes detected2— CLI / IO error
Snapshot workflow
# On deploy: capture the shape of a known-good response.
curl https://api.example.com/v1/users > sample.json
json-shape-compat snapshot sample.json -o shape.json
# On next deploy: check the new response against the snapshot.
curl https://api.example.com/v1/users > new.json
json-shape-compat check shape.json new.jsonThe snapshot is plain JSON — commit it to your repo.
Reading from stdin
Any input path can be -:
curl https://api.example.com/v1/users \
| json-shape-compat check shape.json -Output
JSON by default — --format text for terminal-friendly output.
json-shape-compat compare old.json new.json
# {
# "breaking": [
# { "path": "user.email", "type": "removed", "breaking": true,
# "detail": "required property removed" }
# ],
# "nonBreaking": [
# { "path": "user.createdAt", "type": "added", "breaking": false,
# "detail": "new required property added (string)" }
# ]
# }
json-shape-compat compare old.json new.json --format text
# BREAKING CHANGES (1):
# [removed] user.email: required property removed
#
# non-breaking changes (1):
# [added] user.createdAt: new required property added (string)Pipe-friendly:
json-shape-compat compare old.json new.json | jq '.breaking | length'Full help
json-shape-compat <command> [options]
json-shape-compat <old.json> <new.json> (shorthand for: compare)
COMMANDS
compare <old> <new> Diff two JSON documents
snapshot <input> -o <file> Write an inferred shape to a file
check <shape> <new> Diff a shape snapshot against a new document
OPTIONS
-f, --format <json|text> Output format (default: json)
-o, --out <path> Output path (snapshot only)
-h, --help Show help (use with a command for details)
-V, --version Print versionLibrary
import { inferShape, inferShapeFromSamples, checkCompat } from "json-shape-compat";
const oldShape = inferShape(oldJson);
const newShape = inferShape(newJson);
const result = checkCompat(oldShape, newShape);
result.breaking; // BreakingChange[]
result.nonBreaking; // NonBreakingChange[]Multiple samples
A single sample undercounts optional fields — anything missing from that one document looks "removed" forever. Pass several representative samples to capture which properties are sometimes-present:
const shape = inferShapeFromSamples([sample1, sample2, sample3]);
// or:
const shape = inferShape([sample1, sample2, sample3], { samples: true });inferShapeFromSamples merges across samples: properties present in every
sample are required: true, properties in only some are required: false,
and divergent types widen into a union.
Change records
Each entry in breaking / nonBreaking is a Change:
interface Change {
path: string; // dot/bracket path, e.g. "posts[].author.email"
type:
| "removed" | "added" | "typeChanged"
| "narrowed" | "widened" | "optional" | "required";
breaking: boolean;
detail: string; // human-readable explanation
}What counts as breaking
| Change | Breaking? |
| ----------------------------------------------- | --------- |
| Property removed | yes |
| Property type changed (e.g. string → number) | yes |
| Object replaced with primitive (or vice versa) | yes |
| Array element type changed incompatibly | yes |
| Property required → optional | yes |
| Union variant dropped (type narrowed) | yes |
| Property added | no |
| Property optional → required | no |
| Type widened (e.g. string → string \| null) | no |
| Array element gained new optional properties | no |
The asymmetry is from the consumer's perspective: anything a downstream parser might have relied on becoming unavailable is breaking. The producer emitting more than before is not.
How it works
inferShape(value) walks any JSON value once and builds a type tree:
- Primitives →
{ kind: "primitive", type: "string" | "number" | "boolean" | "null" } - Objects →
{ kind: "object", properties: { name: { shape, required } } } - Arrays →
{ kind: "array", element: <merged union of all elements> } - Merged variants of different kinds →
{ kind: "union", variants }
checkCompat(old, new) walks both shapes in parallel and emits one
Change per divergence, with the full JSON path.
There's no schema language to learn and no annotations to maintain. The spec for what your API returns is just the JSON it actually returns.
Limitations
- Renames look like add + remove. A property going from
nametodisplayNamereports two changes: the removal is breaking, the addition is non-breaking. The tool can't infer intent. - String formats aren't checked.
"2026-01-01"and"hello"are bothstring. If you change a UUID field to a free-form name, the tool sees no change. - Number ranges aren't checked. An int-only field becoming a float
field is invisible to the tool — both are
number. - Empty arrays infer to
array<unknown>. Compatibility against an unknown element type always passes. Feed in samples with non-empty arrays, or merge multiple samples.
License
MIT
