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 |
|---|---|---|
| any | what this node is: an |
|
| a map of known field name to schema |
|
| schema every element must match |
|
| schema every value must match (open map / record) |
| literal | the one value a literal schema accepts, when the literal needs a |
|
| the schema the pointed-at block or document is expected to conform to (see references) |
| any | a union: the value must match one of the listed schemas |
| any | declares type parameters (generics), each with a default |
| any | a reference to a type parameter: |
| reference | applies a generic, binding its parameters |
| any | a human-readable name for the schema (metadata; ignored when validating data) |
| 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 |
|---|---|---|
|
| minimum length, counted in code points |
|
| maximum length, counted in code points |
|
| an unanchored ECMAScript regular expression the value must match; an uncompilable pattern is ignored |
|
| value must be ≥ this number |
|
| value must be ≤ this number |
|
| minimum number of elements |
|
| 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 |
|---|---|
| declares type parameters, each with a default: |
| a type-variable reference: |
| applies a generic, binding its params: |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| has |
|
| has |
|
| has |
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
Hypermedia Schemas: the meta-schema and the index of schema pages.
The data model: the nine kinds every value is built from.
References and naming: include, typed link, and why references are hm:// names.
Encoding: how a schema becomes canonical DAG-CBOR.
Typed documents: how a document names its schema.
Quick reference: the whole system on one page.
Examples: every example schema, grouped by feature.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime