Guide

Generating JSON Schema

JSON Schema documents generated from your types at build time.

Every type the library can validate can also describe itself as a JSON Schema document (draft 2020-12). The document is generated at build time, exactly like the validators, so reading it at runtime is a plain function call with no reflection and no schema assembly.

Use it to publish an OpenAPI description of your request bodies, to hand a tool-calling AI the shape of a function's arguments, or to let any schema-aware tool understand your types without knowing anything about this library.

Getting the document

createJsonSchemaFn<T>() returns a function that produces the document for T. It accepts the same three call forms as every other factory (type-first, value-first, run-type).

// createJsonSchemaFn -> a function that returns the JSON Schema document
// describing T. The document is generated at build time; calling it costs
// nothing at runtime.
const orderSchemaDoc = createJsonSchemaFn<Order>();

orderSchemaDoc();
// {
//   type: 'object',
//   properties: {
//     id: {type: 'string'},
//     total: {type: 'string', pattern: '^-?[0-9]+$', jsType: 'bigint'},
//     placed: {type: 'string', format: 'date-time', jsType: 'Date'},
//     note: {type: 'string'},
//   },
//   required: ['id', 'total', 'placed'],
// }

The standard keywords describe the JSON that travels on the wire. Where a value is JavaScript-only (a Date, a bigint, a Map), an extra keyword sits next to them and says what the JSON becomes after decoding; those extra keywords are the JSON Schema JS extension, and every document that carries them is still a valid standard schema (validators ignore keywords they do not know).

Plain standard documents

If a consumer should see only standard vocabulary, pass portable:

// {portable: true} strips the extension keywords, leaving plain draft
// 2020-12 any tool can consume.
orderSchemaDoc({libraryOptions: {portable: true}});
// placed is now {type: 'string', format: 'date-time'} and total is
// {type: 'string', pattern: '^-?[0-9]+$'}: only standard keywords survive.

Stripping the extension never changes which values the document accepts; it only removes the JavaScript annotations.

Closed objects follow the encoder

By default a generated document says nothing about extra keys, matching how validation treats them (an undeclared key is ignored, not an error). Whether extra keys can actually appear on the wire is decided by the JSON encoder's strategy: the clone and direct strategies build the output from the declared shape, so their wire never carries an undeclared key, while mutate passes extras through. Declare the strategy you pair the document with, and the document closes to match:

// Declaring the paired encoder strategy closes the document to the keys the
// wire can actually carry: clone and direct never emit undeclared keys, so
// every object with declared properties gains additionalProperties: false.
orderSchemaDoc({libraryOptions: {encoderStrategy: 'clone'}});
// {type: 'object', properties: {...}, required: [...], additionalProperties: false}

// A mutate pairing preserves extra keys on the wire, so its document stays
// open; records keep the index schema additionalProperties already carries.
orderSchemaDoc({libraryOptions: {encoderStrategy: 'mutate'}}); // unchanged

There is no separate option to force additionalProperties by hand. The key policy belongs to the codec, and deriving it keeps the document from ever contradicting what the wire really does. The compact strategy is refused here: its wire is positional arrays, which a keyed document does not describe.

The standard interface

createStandardSchema<T>() implements two interoperability standards from Standard Schema in one object: the validation interface (~standard.validate) and the JSON Schema interface (~standard.jsonSchema). Any framework that understands either standard can consume it directly.

// createStandardSchema returns ONE object implementing both standard
// interfaces: validation (validate) and JSON Schema conversion (jsonSchema).
const orderSchema = createStandardSchema<Order>();

orderSchema['~standard'].validate({id: 'o1'}); // {issues: [...]}
orderSchema['~standard'].jsonSchema.input(); // the same document as above
orderSchema['~standard'].jsonSchema.output({target: 'draft-2020-12'});

input() and output() return the same document: this library validates values without transforming them, so what goes in is what comes out. The optional target names the dialect; only draft-2020-12 is supported, and any other value throws rather than emitting a document that could be read wrong.

Unions follow the serialized wire

The documents describe what actually travels, and for unions that is this library's own wire. A union whose members are all plain JSON ('a' | 'b', string | null) travels as the bare value and gets the ordinary enum or anyOf document. A union with JavaScript-only or object members travels as a two slot array, [index, value], because plain JSON could not say which member a value belongs to once it is encoded: once a Date member and a string member have both encoded the wire holds just a string, so the encoder writes [0, "2026-08-10T09:00:00Z"] and the first slot names the member. The document spells out exactly those pairs (object members share one merged arm under index -1) and marks the node with jsType: 'union'. So a value that validates against the document is precisely a value the JSON decoder can decode, unions included.

Recursive types close over themselves with standard references: a type that contains itself emits {$ref: '#'} at the cycle, and shared inner cycles ride $defs. Whatever a document cannot express (a function member, a class identity) is left out or widened to {}, so a generated document may say less than the type, but never something wrong.

JSON Schema JS

JSON Schema can not describe all JavaScript values. A Date, a bigint or a Map still travels fine as JSON (a Date as an ISO string, a bigint as a string of digits, a Map as an array of pairs), but a standard schema can only describe the string or the array. It can not say that the string should come back as a Date once the JSON is decoded.

This extension adds that missing piece. A few extra keywords sit next to the standard ones and say what the JSON becomes in JavaScript after decoding. The standard keywords keep describing the JSON that travels.

The main goal of this extension is to store full JavaScript and TypeScript type information inside a JSON Schema. The generator above writes it: the documents it emits carry these keywords so a reader knows what the JSON decodes to.

Every document that uses the extension is still a valid JSON Schema (draft 2020-12). The standard requires validators to ignore keywords they do not know, so any validator reads the schema, skips the extra keywords, and checks exactly the same values. Delete every extension keyword and no validator changes its mind about any value.

{"type": "string", "format": "date-time", "jsType": "Date"}

Every validator agrees this accepts "2026-08-10T09:00:00Z" and rejects 42. A reader that knows the extension learns one more thing: the decoded value is a Date, not a string.

You never write these keywords by hand. The generator emits them in the documents it produces. If you need schemas with no extension at all, pass {portable: true} and it strips every extension keyword.

The keywords at a glance

Each keyword starts with a prefix that says where the described thing comes from: js is a JavaScript value, rt is a RunTypes type format, and ts is a TypeScript-only fact that no JSON value can show.

KeywordSits besideSays
jsTypetypewhich JavaScript value the JSON decodes to
jsResolvedjsType: 'Promise'the schema of the resolved value
rtFormatformatwhich type format the value belongs to
rtFormatParamsthe constraint keywordsthe format parameters, exactly as written
tsLabelsprefixItemsthe tuple slot names
tsReadonlyrequiredwhich members are readonly
tsIndexespropertyNames / patternPropertiesindex signatures whose key is not a plain string
tsTemplatepatternthe parts of a template literal type
tsFunctionnothing (functions have no JSON form)a function signature
tsMetanothing (metadata has no JSON form)a branded or metadata intersection type

Every keyword is optional. A schema that uses none of them is ordinary JSON Schema.

jsType: what the JSON decodes to

jsType names the JavaScript value the JSON turns into. It always sits next to the standard keywords that describe the JSON itself.

Values that travel as a string

TypeScript typeSchemaTravels as
Date{type: 'string', format: 'date-time', jsType: 'Date'}an ISO date-time string
bigint{type: 'string', pattern: '^-?[0-9]+$', jsType: 'bigint'}a string of digits
123n (a bigint literal){type: 'string', const: '123', jsType: 'bigint'}that exact digit string
RegExp{type: 'string', jsType: 'RegExp'}the regex as text, slashes and flags included ("/^ab?c$/gi")

Temporal values

TypeScript typeSchemaTravels as
Temporal.Instant{type: 'string', format: 'date-time', jsType: 'Temporal.Instant'}a date-time string
Temporal.PlainDate{type: 'string', format: 'date', jsType: 'Temporal.PlainDate'}a date string
Temporal.Duration{type: 'string', format: 'duration', jsType: 'Temporal.Duration'}a duration string
Temporal.ZonedDateTime, Temporal.PlainTime, Temporal.PlainDateTime, Temporal.PlainYearMonth, Temporal.PlainMonthDay{type: 'string', pattern: '...', jsType: '...'}the string the type's own toJSON produces

The last row uses pattern instead of format because those string shapes are not registered JSON Schema formats. A ZonedDateTime string carries a time zone name in brackets, which the standard date-time format does not allow, so claiming that format would make good data fail on other validators.

Values that travel as a container

TypeScript typeSchemaTravels as
Map<K, V>{type: 'array', items: {type: 'array', prefixItems: [K, V], items: false, minItems: 2}, jsType: 'Map'}an array of key and value pairs
Set<V>{type: 'array', items: V, uniqueItems: true, jsType: 'Set'}an array of unique items
a union with JavaScript-only or object members{anyOf: [...], jsType: 'union'}, each arm an [index, value] pair schemaa two slot array: the member index, then the member's own wire value

The key and value types are read from the schema itself: a Map's key and value are the two prefixItems, a Set's item is items. There is no separate argument list, because the schema already had to describe the same JSON.

Values with no JSON of their own

TypeScript typeSchemaTravels as
undefined{type: 'null', jsType: 'undefined'}null
void{type: 'null', jsType: 'void'}null
Promise<T>{jsType: 'Promise', jsResolved: T}whatever T travels as
symbol{jsType: 'symbol'}nothing, a symbol can not be encoded

undefined and void both travel as JSON null, so on the wire the three look the same; only the keyword says which one comes back after decoding. A Promise is awaited before encoding, so what travels is the resolved value, and jsResolved holds its schema. A symbol has no encoding at all; the keyword only records the type so the document still states the TypeScript it came from.

The two broad types

TypeScript typeSchemaTravels as
any{jsType: 'any'}any JSON value
object{type: ['object', 'array'], jsType: 'object'}any object or array

object here is the TypeScript keyword that means "not a primitive". It accepts arrays too, which is why its JSON side lists both types.

rtFormat: named type formats

Type formats give a value a named type with parameters, like an email whose local part has a maximum length. rtFormat writes the format name into the schema, and every parameter a standard validator can check rides on a standard keyword, so other validators keep enforcing it:

Format parameterStandard keyword
minLength / maxLengthminLength / maxLength
patternpattern
min / maxminimum / maximum
gt / ltexclusiveMinimum / exclusiveMaximum
multipleOfmultipleOf
minItems / maxItems / uniqueItemsthe same names
minProperties / maxPropertiesthe same names

rtFormatParams carries the full parameter set exactly as written, so the format type can be rebuilt without loss. That matters for parameters the standard has no keyword for: bigint bounds travel as digit strings, and parameters like mock samples or trim describe no validation at all, so no standard keyword can carry them.

{
  "type": "string",
  "format": "email",
  "rtFormat": "email",
  "rtFormatParams": {"localPart": {"maxLength": 64}}
}

A standard validator checks the email format. A RunTypes reader also recovers the exact format type, bounded local part included.

ts keywords: TypeScript-only facts

Some TypeScript facts leave no trace in JSON. A readonly modifier, a tuple slot name or a function signature does not change a single byte on the wire. The ts keywords record those facts so a schema still describes the exact type it came from. They check nothing: strip every keyword that starts with ts and every validator still accepts and rejects exactly the same values.

tsLabels: tuple slot names

{
  "type": "array",
  "prefixItems": [{"type": "number"}, {"type": "number"}],
  "minItems": 2,
  "items": false,
  "tsLabels": ["x", "y"]
}

Decodes to [x: number, y: number]. The list names every slot in order, the rest slot included; a list that does not cover every slot is ignored whole.

tsReadonly: readonly members

{
  "type": "object",
  "properties": {"id": {"type": "string"}, "hits": {"type": "number"}},
  "required": ["id", "hits"],
  "tsReadonly": ["id"]
}

Decodes to {readonly id: string; hits: number}. This is not the standard readOnly keyword, which is an annotation about write access to a resource; the two can appear together and mean different things.

tsIndexes: index signatures beyond string keys

{
  "type": "object",
  "propertyNames": {"pattern": "^(?:0|[1-9][0-9]*)$"},
  "tsIndexes": [{"key": {"type": "number"}, "value": {"type": "string"}}]
}

Decodes to {[key: number]: string}. JSON object keys are always strings, so additionalProperties already covers the plain string-key signature. tsIndexes covers the rest: numeric keys, template literal keys, and shapes with more than one signature. The JSON side of the constraint still rides propertyNames or patternProperties, so other validators check the keys too.

tsTemplate: template literal types

{
  "type": "string",
  "pattern": "^api/[\\s\\S]*/v[\\s\\S]*$",
  "tsTemplate": {
    "texts": ["api/", "/v", ""],
    "placeholders": [{"type": "string"}, {"type": "number"}]
  }
}

Decodes to `api/${string}/v${number}`. The pattern beside it lets any validator check the fixed parts of the string; the parts under tsTemplate let a RunTypes reader rebuild the exact type, which a pattern alone can not do.

tsFunction: function signatures

{
  "tsFunction": {
    "params": {
      "type": "array",
      "prefixItems": [{"type": "string"}],
      "minItems": 1,
      "items": false,
      "tsLabels": ["message"]
    },
    "return": {"type": "boolean"}
  }
}

Decodes to (message: string) => boolean. A function has no JSON form, so there are no standard keywords beside it. The parameters are written as an ordinary tuple schema, which is how optional parameters, rest parameters and parameter names all come for free.

tsMeta: branded and metadata types

{
  "tsMeta": {
    "base": {"type": "string"},
    "meta": [{
      "type": "object",
      "properties": {"__brand": {"const": "UserId"}},
      "required": ["__brand"],
      "tsReadonly": ["__brand"]
    }]
  }
}

Decodes to string & {readonly __brand: 'UserId'}, the usual way a branded type is written. Only the base describes JSON that actually travels; the metadata half exists in the type and never in the data.

How the pieces stack

A schema node can carry several of these keywords at once, next to the standard ones. The rule is simple: the standard keywords always describe the JSON that travels, and the extension keyword decides the type that comes back. In the opening example, format: 'date-time' describes the string on the wire and jsType: 'Date' decides that the decoded value is a Date.

A schema may declare the dialect with $schema set to https://runtypes.pages.dev/schema/2020-12-javascript, but it does not have to. Other validators ignore the keywords either way, so the declaration is a hint for tooling, not a requirement.

The formal version of the extension, one precise rule per keyword, lives in the repository at json-schema-2020-12-javascript.md.

Copyright © 2026