shape

How to export and import a JSON Schema#

Goal: hand the values a shape accepts to a tool that speaks JSON Schema—an editor, a form generator, an API description—without writing the schema twice; and go the other way, so a schema you already have becomes a shape.

Export#

Every compiled shape renders a JSON Schema document (draft 2020-12).

TS

const { Shape, Min, Optional, Email, Exact } = require('shape')

const shape = Shape({
  name:  Min(1, String),
  age:   Optional(Number),
  email: Email,
  role:  Exact('admin', 'user'),
  tags:  [String],
})

shape.jsonSchema()

Go

s := shape.MustShape(map[string]any{
    "name":  shape.Min(1, shape.String),
    "age":   shape.Optional(shape.Number),
    "email": shape.Email(),
    "role":  shape.Exact("admin", "user"),
    "tags":  []any{shape.String},
})

s.JSONSchema()   // map[string]any

Both give:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name":  { "type": "string", "minLength": 1 },
    "age":   { "type": "number", "default": 0 },
    "email": { "type": "string", "minLength": 1, "format": "email" },
    "role":  { "enum": ["admin", "user"] },
    "tags":  { "type": "array", "items": { "type": "string", "minLength": 1 } }
  },
  "required": ["email", "name"],
  "additionalProperties": false
}

The two implementations render the same document for the same shape; the differential harness compares the exports alongside the validation results.

What is rendered#

Shape Schema
String, Number, Integer, Boolean, null, Date type (Date is a date-time string); a string gets minLength: 1 unless Empty
a literal, Default, Optional(token) default—the value an absent property is given
a required property listed in required (sorted)
a closed object additionalProperties: false; Open omits it; Child(shape) sets it to that shape
[shape], a tuple, Rest, Closed([shape]) items, prefixItems (+ items: false when nothing may follow)
Min / Max / Above / Below / Len minimum…, minLength…, minItems…, minProperties… by the node's kind—every family when it has none
Exact enum
Email, Url, Uuid, DateTime, Ipv4, Ipv6 format; Ip is an anyOf of the two
a bare /re/, Check(/re/) pattern
One, Some / All anyOf / allOf
Discriminated oneOf, each branch with the tag as a const and required
Nullable type: [t, "null"]
Define(name) / Refer(name) the definition under $defs; { "$ref": "#/$defs/name" }
Describe description
Never { "not": {} }
Any, Func, a function Check {}—nothing to say

Catch, Transform and Ignore are transparent: the schema describes the shape inside them. Coerce, Rename and Key change what comes out, not what goes in, so they have no rendering.

Import#

fromJsonSchema (TS) and FromJSONSchema (Go) build a spec from a JSON Schema document—draft 2020-12, and the common keywords of the earlier drafts. Compile it with Shape, or compose it further with the builders as you would any spec.

TS

const { Shape, fromJsonSchema, Extend } = require('shape')

const schema = {
  type: 'object',
  properties: {
    name:  { type: 'string', minLength: 1 },
    age:   { type: 'integer', minimum: 0, default: 0 },
    email: { type: 'string', format: 'email' },
    role:  { enum: ['admin', 'user'] },
    tags:  { type: 'array', items: { type: 'string' } },
  },
  required: ['name', 'email', 'role'],
  additionalProperties: false,
}

const shape = Shape(fromJsonSchema(schema))
shape({ name: 'Ann', email: 'ann@example.com', role: 'user' })
// → { name: 'Ann', email: 'ann@example.com', role: 'user', age: 0 }

// A spec composes: add a property, then compile.
const wider = Shape(Extend({ nick: String }, fromJsonSchema(schema)))

Go

var schema any
json.Unmarshal(doc, &schema)                 // map[string]any, as encoding/json decodes it

spec, err := shape.FromJSONSchema(schema)    // or shape.MustFromJSONSchema
s := shape.MustShape(spec)

What is read#

Schema Shape
type: string, number, integer, boolean, null String, Number, Integer, Boolean, a required null; a string with no minLength may be empty (Empty(String))
type: [t, "null"], type: [a, b] Nullable(t), One(a, b)
properties, required an object; a listed property is Required, one with a default optional with it, and any other Skip (absent stays absent); a required name with no schema is Required()
additionalProperties absent or true: Open; false: closed; a schema: Child(shape)
items, prefixItems [shape]; a tuple, closed when items is false, open (Rest(Any)) when absent, Rest(shape) otherwise
minimum… maxProperties, exclusiveMinimum / exclusiveMaximum (number or draft-4 boolean) Min, Max, Above, Below on the typed node; on an untyped schema a bare bound, which applies to whatever kind the value is
pattern, format (email, uri, uuid, date-time, ipv4, ipv6) a regexp; Email, Url, Uuid, DateTime, Ipv4, Ipv6; the export's anyOf of the two address formats is Ip. An unknown format is ignored
enum, const Exact(...)
anyOf, oneOf, allOf One, One, All; a oneOf of objects that each require a distinct string const on one property is Discriminated on it
not: {}, true, false Never; Any; Never (a boolean is read as a subschema—the document itself must be an object)
default, description Default, Describe
$ref: "#/$defs/name" (or definitions), $ref: "#" the definition, inlined where it is referenced; a definition that refers to itself is Defined at its outermost use and Referred within, so recursion validates

Keywords with no counterpart (patternProperties, uniqueItems, dependentRequired, if/then, $ref to another document…) are ignored; a wrong type, an unknown reference, a bad pattern, or a keyword of the wrong shape (items: null) is an error naming the location, such as JSON Schema: unknown type "strng" at /properties/a.

Round trips#

Export → import → export gives the same document for every rendering in the preceding table, with two exceptions to know about: a definition used more than once comes back inlined at each use (no $defs), and a Date comes back as a DateTime string. A default the shape itself would reject (such as Optional(String)'s "" against minLength: 1) round-trips unchanged: it is read back as a default, since a default is a value the shape produces rather than one it checks. The differential harness compares the re-export in both languages on every case, and the corpus has {"$jsonschema": …} rows.

See also#