@coldsmirk/caliper-core
v0.1.0
Published
Framework-agnostic JSON Schema tree model: parse, serialize, and infer the draft 2020-12 subset a visual schema builder can host losslessly.
Maintainers
Readme
@coldsmirk/caliper-core
The JSON Schema tree model behind a visual schema builder: project a schema document onto an id-tagged field tree, serialize it back, and infer one from a sample payload. Pure TypeScript — no React, no DOM, no UI framework, and zero runtime dependencies.
Part of caliper. For the visual builders on top of this model, add @coldsmirk/caliper-mantine.
What you get
- Parse a draft 2020-12 schema document into a field tree — or a structured refusal naming exactly what put it out of reach.
- Serialize that tree back to pretty-printed schema JSON, preserving what the source declared (and what it deliberately did not).
- Infer a schema from a sample payload, conservatively.
- Strong typing end to end, dual ESM / CJS, no dependencies to audit.
Install
pnpm add @coldsmirk/caliper-coreNode.js >= 24 for build / SSR hosts; browsers need ES2022 (Object.hasOwn) — the bundles ship untranspiled.
Quick start
import { inferSchema, parseSchemaTree, serializeSchemaTree } from "@coldsmirk/caliper-core";
const inferred = inferSchema({
amount: 120,
customer: { name: "Ada" },
tags: ["priority"]
});
const parsed = parseSchemaTree(JSON.stringify(inferred));
if (parsed.ok) {
parsed.tree.fields; // [{ name: "amount", type: "number", ... }, ...]
serializeSchemaTree(parsed.tree); // pretty-printed schema JSON, round-tripped
} else {
parsed.issue; // a structured, localizable SchemaTreeIssue
}The supported subset
The tree hosts an object-rooted draft 2020-12 subset. Anything else is refused rather than accepted-then-dropped, so a document that survives parseSchemaTree is one serializeSchemaTree can reproduce.
| Position | Keywords |
| -------------- | ---------------------------------------------------------- |
| Root schema | $schema, type ("object" only), properties, required, description |
| Sub-schema | type, properties, required, items, description |
| Field types | string, number, integer, boolean, object, array, plus type-less (any) |
$schema, when present, must be exactly SCHEMA_DIALECT_2020_12. A blank document parses as an empty tree. serializeSchemaTree emits pretty-printed JSON with a trailing newline.
Refusals
type SchemaTreeParseResult =
| { ok: true; tree: SchemaTree }
| { ok: false; issue: SchemaTreeIssue };Every SchemaTreeIssue carries a code, and names the offending part where one exists — enough for a host to render a localized explanation without string-matching a message:
| Code | Payload | Raised when |
| ------------------------- | ------------------- | -------------------------------------------------------------- |
| invalid-json | — | The text is not valid JSON. |
| root-not-object | — | The root is not a JSON object, or declares a non-object type. |
| unsupported-dialect | — | $schema is present but is not the 2020-12 dialect. |
| unsupported-keyword | keyword | A keyword outside the subset (oneOf, format, $ref, …). |
| unsupported-field-type | field, type | A field declares a type the tree has no row for ("null", …). |
| invalid-field-type | field | A field's type is not a single type name (e.g. an array). |
| invalid-field-definition| field | A field's definition is not an object. |
| invalid-properties | — | properties is not an object. |
| invalid-required | — | required is not an array of strings. |
| unknown-required-field | field | required names a property that is not declared. |
| misplaced-keyword | keyword, holder | items on a non-array, or properties / required on a non-object. |
| invalid-description | — | description is not a string. |
The field tree
interface SchemaTree {
fields: SchemaTreeField[];
dialect: boolean; // the source declared $schema (re-emitted verbatim)
description: string; // the root annotation; blank means none
explicitType?: boolean; // the source declared type: "object"
}
interface SchemaTreeField {
id: string; // stable row identity for React keys — never serialized
name: string; // the property name under its parent object
type: SchemaTreeFieldType;
required: boolean; // listed in the parent's `required`
description: string; // blank means none
children: SchemaTreeField[]; // meaningful when type is "object"
items: SchemaTreeField | null; // meaningful when type is "array"; null = untyped elements
explicitType?: boolean; // the source declared this object/array type
preserveBlankName?: boolean; // a blank name that came from a real property key
}
type SchemaTreeFieldType = "any" | "array" | "boolean" | "integer" | "number" | "object" | "string";explicitType preserves applicability, not just visible fields. A schema with properties or items but no type still permits instances of other types, so the parser records that omission and serialization does not add a type the source never had. A hand-built tree that omits the flag keeps the builder default and emits the explicit type.
Property names are arbitrary JSON strings. Empty, whitespace-only, and prototype-like names such as __proto__ round-trip intact. A parsed blank name carries preserveBlankName: true; a blank row from newSchemaTreeField() has no marker and stays an unfinished UI draft, omitted from serialization. Update parsed nodes immutably (object spread) so these source-preservation flags survive an edit.
Serialization is last-writer-wins for duplicate names. Two rows sharing a name collapse to one properties entry — the last one, for both the property and its required membership. A builder UI should flag the collision inline; the model does not invent a disambiguation.
An any-typed, description-less array element is not emitted. It constrains nothing, so it serializes as a bare { "type": "array" } — the same meaning, without a noise keyword.
Inference
function inferSchema(sample: Json): { [key: string]: Json };Deliberately conservative, because a single sample is weak evidence:
- Integer samples infer
number— a5rarely promises integers forever. - Nothing is marked
required— validation strictness is an authoring decision. nullsays nothing about the real type, so it infers{}(any).- Array elements merge to their loosest common schema: same-typed objects union their properties, same-typed arrays merge their items, and anything else collapses to
{}. An empty array leavesitemsopen.
The output is always inside the subset parseSchemaTree hosts, so inference feeds the tree directly.
API reference
function parseSchemaTree(text: string): SchemaTreeParseResult;
function serializeSchemaTree(tree: SchemaTree): string;
function inferSchema(sample: Json): { [key: string]: Json };
function newSchemaTreeField(overrides?: Partial<Omit<SchemaTreeField, "id">>): SchemaTreeField;
const SCHEMA_DIALECT_2020_12 = "https://json-schema.org/draft/2020-12/schema";- Functions —
parseSchemaTree,serializeSchemaTree,inferSchema,newSchemaTreeField - Constants —
SCHEMA_DIALECT_2020_12 - Types —
Json,SchemaTree,SchemaTreeField,SchemaTreeFieldType,SchemaTreeIssue,SchemaTreeParseResult
newSchemaTreeField mints the ids a keyed UI list needs — a tagged counter, not a UUID, because field identity is a UI concern with no persistence or cross-process meaning. The tag is randomized per module copy, so two copies of this package in one bundle cannot collide inside a single tree.
License
UNLICENSED — proprietary. All rights reserved; no use, copying, or redistribution without the author's permission.
