@cleverbrush/schema-json

Bidirectional JSON Schema (Draft 7 / 2020-12) interop — convert existing JSON Schemas to fully-typed @cleverbrush/schema builders and back.

Before
  • JSON Schema files and TypeScript types drift apart
  • OpenAPI schemas need hand-maintained validators
  • Migration from external schemas becomes all-or-nothing
with @cleverbrush/schema-json→
After
  • JSON Schema literals infer exact TypeScript values
  • Builders emit schemas for OpenAPI and form tooling
  • Existing schema catalogues can move incrementally
# @cleverbrush/schema-json
$npm install @cleverbrush/schema-json
# peer dependency
$npm install @cleverbrush/schema

💡 What it does

JSON Schema → typed builder

Pass any JSON Schema literal to fromJsonSchema() and get back a @cleverbrush/schema builder whose TypeScript type exactly mirrors the schema structure — required vs optional properties, literal types from const and enum, nested objects and arrays, all inferred at compile time. No code generation, no extra build step.

Builder → JSON Schema

Pass any @cleverbrush/schema builder to toJsonSchema() and get a plain JSON Schema object. Use it in OpenAPI specs, form generators, or any JSON Schema consumer. All declarative constraints (formats, min/max, patterns, enums) round-trip cleanly.

When to use this library

  • Consuming external APIs — you have a JSON Schema from an OpenAPI spec or third-party service and want to validate data with full TypeScript inference.
  • OpenAPI round-trip — generate JSON Schemas from your validators to embed in API specs.
  • Incremental migration — convert an existing JSON Schema catalogue to @cleverbrush/schema builders one step at a time.

fromJsonSchema(schema)

Converts a JSON Schema literal to a @cleverbrush/schema builder. Pass the schema with as const — this is required for TypeScript to infer precise builder and value types.

import { fromJsonSchema } from '@cleverbrush/schema-json';
import type { InferFromJsonSchema } from '@cleverbrush/schema-json';

// Define the JSON Schema once — use as const for precise inference
const UserJsonSchema = {
  type: 'object',
  properties: {
    id:    { type: 'string', format: 'uuid' },
    name:  { type: 'string', minLength: 1, maxLength: 100 },
    email: { type: 'string', format: 'email' },
    age:   { type: 'integer', minimum: 0 },
    role:  { enum: ['admin', 'user', 'guest'] },
  },
  required: ['id', 'name', 'email'],
} as const;

// TypeScript infers the value type from the JSON Schema — no duplication!
type User = InferFromJsonSchema<typeof UserJsonSchema>;
// {
//   id: string;
//   name: string;
//   email: string;
//   age?: number;
//   role?: 'admin' | 'user' | 'guest';
// }

const UserSchema = fromJsonSchema(UserJsonSchema);

// Validate data — result.object is fully typed as User
const result = UserSchema.validate({
  id: 'a1b2c3d4-e5f6-1234-8abc-000000000001',
  name: 'Alice',
  email: 'alice@example.com',
  role: 'admin',
});

Supported JSON Schema keywords

Reference table
KeywordBuilder equivalent
type: 'string'string()
type: 'number'number()
type: 'integer'number() with integer flag
type: 'boolean'boolean()
type: 'array' + itemsarray(itemBuilder)
type: 'object' + propertiesobject({ … })
required: […]required / optional per property
additionalProperties: true.acceptUnknownProps()
constliteral builder
enumunion(…) of const builders
anyOf / allOfunion(…) / fallback
discriminatorauto-emitted for discriminated union() branches
minLength / maxLength.minLength() / .maxLength()
pattern.matches(regex)
minimum / maximum.min() / .max()
exclusiveMinimum / exclusiveMaximum.min() / .max() exclusive
multipleOfnumber().multipleOf(n)
minItems / maxItems.minLength() / .maxLength() on array
format: 'email'.email() extension
format: 'uuid'.uuid() extension
format: 'uri' / 'url'.url() extension
format: 'ipv4'.ip({ version: 'v4' })
format: 'ipv6'.ip({ version: 'v6' })
format: 'date-time'.matches(iso8601 regex)
readOnly: true.readonly()
description.describe(text)

toJsonSchema(schema, opts?)

Converts any @cleverbrush/schema builder to a JSON Schema object. Declarative constraints (formats, min/max, patterns, enum/const literals, required/optional per property) round-trip cleanly. Descriptions set via .describe(text) are emitted as the description field on the corresponding JSON Schema node.

import { toJsonSchema } from '@cleverbrush/schema-json';
import { object, string, number, array } from '@cleverbrush/schema';

const ProductSchema = object({
  sku:   string().uuid(),
  name:  string().minLength(1).maxLength(255),
  price: number().min(0),
  tags:  array(string()).optional(),
});

// Default: JSON Schema Draft 2020-12 with $schema header
const spec = toJsonSchema(ProductSchema);
// {
//   "$schema": "https://json-schema.org/draft/2020-12/schema",
//   "type": "object",
//   "properties": {
//     "sku":   { "type": "string", "format": "uuid" },
//     "name":  { "type": "string", "minLength": 1, "maxLength": 255 },
//     "price": { "type": "number", "minimum": 0 },
//     "tags":  { "type": "array", "items": { "type": "string" } }
//   },
//   "required": ["sku", "name", "price"]
// }

// Embed in an OpenAPI spec (suppress $schema header)
const openApiSchema = toJsonSchema(ProductSchema, { $schema: false });

// Use Draft 07 format
const draft7Schema = toJsonSchema(ProductSchema, { draft: '07' });

// Use nameResolver to emit $ref pointers for named component schemas
// (used internally by @cleverbrush/server-openapi — see SchemaRegistry)
const registry = new Map([['Product', ProductSchema]]);
const withRefs = toJsonSchema(ProductSchema, {
    $schema: false,
    nameResolver: (schema) => {
        for (const [name, s] of registry) {
            if (s === schema) return name;
        }
        return null;
    },
});
// Instead of inlining, nodes matching a registry entry become:
// { "$ref": "#/components/schemas/Product" }

Named references and local annotations

Ordinary use-site modifiers preserve a named definition. Direct reuse emits a $ref; modified uses compose local annotations and nullability around it in both Draft 7 and Draft 2020-12:

const User = object({ name: string() }).schemaName('User');
const History = object({
    current: User,
    previous: User.optional().nullable().describe('Previous user')
});
const json = toJsonSchema(History, {
    $schema: false,
    nameResolver: schema => schema.introspect().schemaName ?? null
});
// json.required: ['current']
// current references User; previous adds local description and nullability.

Use-site modifiers are processed before name resolution, which receives their canonical target. The resolver does not create component definitions: supply those in the containing document or use @cleverbrush/server-openapi. Without a resolver, the target is converted inline.

Shape, rule, default, fallback and extension changes discard inherited names and export inline. Nested named children still reuse their definitions. Apply schemaName after those edits to name a new definition. See the schema naming rules.

Standard JSON Schema input() and output() keep their existing identical representation; no separate directional schemas are introduced.

🔷 TypeScript inference utilities

InferFromJsonSchema<S>

Derives the TypeScript value type from a statically-known JSON Schema — purely at the type level, no runtime call needed.

import type { InferFromJsonSchema } from '@cleverbrush/schema-json';

const S = {
  type: 'object',
  properties: {
    id:   { type: 'integer' },
    name: { type: 'string' },
  },
  required: ['id'],
} as const;

type Item = InferFromJsonSchema<typeof S>;
// { id: number; name?: string }

JsonSchemaNodeToBuilder<S>

Maps a statically-known JSON Schema node to the exact @cleverbrush/schema builder type. Use this when you want the builder type without calling fromJsonSchema at runtime.

import type { JsonSchemaNodeToBuilder } from '@cleverbrush/schema-json';

const S = { type: 'string', format: 'email' } as const;
type B = JsonSchemaNodeToBuilder<typeof S>;
// StringSchemaBuilder<string, true>

Limitations

Reference table
LimitationNotes
Custom validators (addValidator)Not representable in JSON Schema — omitted in toJsonSchema output; not recoverable by fromJsonSchema
$ref / $defsNot supported in fromJsonSchema
if / then / else, notNot supported
allOf in fromJsonSchemaFalls back to SchemaBuilder<unknown> (no deep schema merge)
Dual IP format (both v4 + v6)format omitted in toJsonSchema output; no single JSON Schema keyword covers both
nameResolver + $ref round-tripnameResolver emits $ref pointers based on an external registry; fromJsonSchema does not resolve $ref — they fall back to any()