Schema Language
The full schema vocabulary, covering closed maps, unions, generics, extension, and how the meta-schema describes itself.

The schema language

A Hypermedia schema is a value of kind map built from twelve core keys, all optional, plus a few optional value constraints (below). It can also be a literal: a bare null, boolean, integer, or string, which accepts exactly that value. That is the whole language.

key

applies to

meaning

type

any

what this node is: an hm:// URL naming one of the nine kinds (see the data model) or naming another schema (see references)

properties

map

a map of known field name to schema

items

list

schema every element must match

values

map

schema every value must match (open map / record)

value

literal

the one value a literal schema accepts, when the literal needs a description (see below)

target

link, reference string

the schema the pointed-at block or document is expected to conform to (see references)

anyOf

any

a union: the value must match one of the listed schemas

params

any

declares type parameters (generics), each with a default

var

any

a reference to a type parameter: { "var": "B" }

args

reference

applies a generic, binding its parameters

name

any

a human-readable name for the schema (metadata; ignored when validating data)

description

any

a human-readable description (metadata; ignored when validating data)

name and description are metadata about the schema. The validator ignores them when it checks a value, and the schema explorer shows them as each schema's title and blurb. A schema's name has nothing to do with a field called name inside its properties.

A type value is always an hm:// URL, whether it names a kind or another schema, so every type is clickable. The real value is "hm://hyper.media/map". For readability these docs shorten hm://hyper.media/map to map. Examples use the same library name: example/person is hm://hyper.media/example/person.

Literals

A literal schema accepts exactly one value, and is written as that value: "draft", 1, true, null. A literal can be a string, an integer, a boolean, or null (the value union). It can never be a float, a map, or a list. A union of literals restricts a field to a fixed set of choices. Each choice can carry a description in the long form {value, description}:

// a status field: one of three values, one of them explained "status": { "value": { "anyOf": [ "draft", { "value": "published", "description": "Visible to everyone" }, "archived" ] } } // a tag field pinned to one value — the whole schema is the literal "type": { "value": "Change", "required": true }

Every signed blob type pins its type this way, and every RPC method schema pins its key. In TypeScript they become literal types (type: 'Change', 'draft' | 'published' | 'archived').

One rule governs the whole language: type names what a node is, and every other key refines what it names. If type names one of the nine kinds, the schema is grounded there. If it names another schema, the node is an include. With nothing else on the node, it becomes whatever that schema says. Add any refinement and it becomes an extension (below). The refinement can be structural (properties, values, items) or a leaf constraint (format, pattern, minLength, target, …), and the rule is the same. A node with type:"link" and a target is a typed link: a link whose target should match the named schema.

Extension (subtyping)

A node whose type names another schema and that also carries refinements extends what it names. The result is a subtype with the parent's fields plus new ones. The worked example is example/employee, which extends example/person:

// example/employee = example/person, plus employeeId and department { "type": "hm://hyper.media/example/person", "properties": { "employeeId": { "value": { "type": "hm://hyper.media/string" }, "required": true }, "department": { "value": { "type": "hm://hyper.media/string" } } } }

Open example/employee in the schema explorer to see the merged result, with every field marked inherited or added.

The rules reuse existing keywords, so there is no extends keyword:

    properties are merged: the parent's plus the extension's, and same-named keys override. Each is a property, so a field's required flag travels with it.

    values / items on the extension override the parent's.

    the result keeps the parent's kind and closedness. An employee must have name (required on the parent) and employeeId (required on the extension), may use any inherited field, and still rejects unknown keys.

A bare { "type": X }, where X names another schema and the node has nothing else, is a pure include. It becomes an extension only when a refinement is present. validate.mjs checks this (see the example/employee.schema checks and an extension can pin a field to a literal).

Structs and maps

A struct names its fields in properties, one property per field. properties[name] is {value, required?, description?}: the schema the field's value must match, whether a value must include the field, and what the field is for. A struct is closed, so keys not listed are rejected. Add values and it is open: extra keys are allowed as long as their values match the values schema. A map has no named fields, and every key's value matches values. So:

    struct with properties and no values is a closed struct (fixed field set)

    map with values is a map (uniform value type, any keys)

    struct with both has known fields in properties, and everything else must match values

    a bare map or struct accepts any map

// closed struct — {name, age} and nothing else { "type": "struct", "properties": { "name": { "value": { "type": "string" }, "required": true, "description": "Full name" }, "age": { "value": { "type": "integer" } } } }
// open map — arbitrary keys, integer values { "type": "map", "values": { "type": "integer" } }

Closedness lets the meta-schema reject malformed schemas with extra keys (see below).

Value constraints

Beyond the kind, a schema can narrow the values a leaf accepts. Every constraint is optional, and a missing constraint means no limit. validate.mjs checks all of them (see its Value constraints section), and example/constrained uses them together.

key

applies to

meaning

minLength

string

minimum length, counted in code points

maxLength

string

maximum length, counted in code points

pattern

string

an unanchored ECMAScript regular expression the value must match; an uncompilable pattern is ignored

minimum

integer / float

value must be ≥ this number

maximum

integer / float

value must be ≤ this number

minItems

list

minimum number of elements

maxItems

list

maximum number of elements

// a lowercase handle, 3–12 code points, matching a pattern { "type": "hm://hyper.media/string", "minLength": 3, "maxLength": 12, "pattern": "^[a-z0-9_]+$" }

These constraints come from the "Seed Blob Schema v1" dialect. validate() reports each violation as an error string, for example $.username: expected at least 3 characters. The exported validateAdvisory() wrapper runs the same checks and is documented as warn, don't block: callers show its result as warnings and do not reject the write.

Unions

anyOf lists alternative schemas, and a value is valid if it matches any of them. It is the language's one composite construct. It makes the meta-schema a discriminated union: a value is one of a fixed set of shapes, told apart by a discriminant (here, the type tag).

{ "anyOf": [ { "type": "schema/map-schema" }, { "type": "schema/link-schema" } ] }

Generics

The schema language has both kinds of generic.

Applied generics supply a type parameter directly. items and values give them with no extra keys:

    list + items = List<T>, where items is T

    map + values = Map<V>, where values is V

So {"Apples":5,"Oranges":3} is Map<Integer>, written example/counts: { "type":"map", "values":{ "type":"integer" } }. This nests to any depth.

Generic abstraction defines a reusable parameterized type to instantiate later. It uses three keys:

key

meaning

params

declares type parameters, each with a default: { "params": { "B": <default> }, … }

var

a type-variable reference: { "var": "B" } matches whatever B is bound to

args

applies a generic, binding its params: { "type": X, "args": { "B": <schema> } }

The parameter passes through references: each level passes it down with args, so binding it at the top substitutes it everywhere. The worked example is change, a Change<Block> whose Block parameter flows through change, change/body, change/op and change/op/replace-block. Its instantiation example/myapp-change is Change<example/app-block>, which validates blocks strictly deep inside the op stack (see the Generics: Change<Block> checks in validate.mjs). A generic used bare falls back to its parameter defaults, so the common case needs no args.

How the language describes itself

schema is a discriminated union of nine variants, the nine map shapes a schema can take, plus the four bare kinds a literal can be. This makes it much stricter than a loose map with optional keys:

variant

matches

discriminant

schema/struct-schema

{type:"struct", properties?: {name: {value, required?, description?}}, values?}

type = struct

schema/map-schema

{type:"map", values?}

type = map

schema/list-schema

{type:"list", items?}

type = list

schema/scalar-schema

{type: null|boolean|integer|float|string|bytes, …constraints}

type = a scalar kind

schema/link-schema

{type:"link", target?}

type = link

schema/include-schema

{type: <another schema's URL>, …refinements?}

type names a schema, not a kind

schema/anyof

{anyOf:[schema, …]}

has anyOf

schema/var-schema

{var}

has var

schema/literal-schema

{value, description?}

has value

string, integer, boolean, null

a bare value

is not a map

Each variant is a closed map, so a nonsense schema like {type:"string", items:{…}} matches none of them. The closed schema/scalar-schema rejects the stray items key, and the type tag rules out the others. Run it:

node scripts/hypermedia/validate.mjs # ok scalar carrying `items` (rejected)

The loop still closes

schema is { "anyOf": [ …thirteen includes… ] }. Validate it against itself:

    It matches the schema/anyof variant, because it has an anyOf that is a list of schemas.

    Each item in that anyOf is a bare {type: …} naming a variant schema, which matches the schema/include-schema variant.

    Each variant file (e.g. schema/map-schema) is itself a {type:"struct", …}, which matches the schema/struct-schema variant.

The meta-schema is a union with a union variant among its variants, and it validates as that variant. This is its self-description.

Nothing defines the string "map". A variant pins type to the literal kind URL (schema/map-schema says type: {value: "hm://…/map"}), and the scalar variant lists its six kinds as a union of literals. string, link, and bytes get no special treatment there: the language names kinds and does not define them.

The proof is executable

validate.mjs validates schema against itself and every variant against the union. It also confirms the union rejects malformed schemas. You can run it:

node scripts/hypermedia/validate.mjs # ok hypermedia-schema.schema.json describes itself # ... # ok schema/map-schema.schema.json # ... # ok scalar carrying `items` (rejected)

If you extend the vocabulary, run it again. If the union can no longer describe its own new shape, the loop is broken and the check fails.

See also

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime