Guide

JSON Schemas

JSON Schema documents generated from your types at build time.

Every type you can validate also has a JSON Schema document (draft 2020-12). It is generated at build time, so reading it at runtime is a plain function call.

Use it to publish an OpenAPI description of your request bodies, to give a tool-calling AI the shape of a function's arguments, or to let any schema-aware tool read your types.

Generating a JSON Schema

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).

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'],
// }

Where a value is JavaScript-only (a Date, a bigint, a Map), an extra keyword says what the JSON becomes after decoding. These keywords are the JSON Schema JS extension.

A type that contains itself emits {$ref: '#'} at the cycle, and shared inner cycles go under $defs. What a document cannot express (a function member, a class identity) is left out or widened to {}, so a document may say less than the type, but never something wrong.

Standard-Only JSON Schema

Pass portable to strip every extension keyword.

orderSchemaDoc({libraryOptions: {portable: true}});
// placed is now {type: 'string', format: 'date-time'} and total is
// {type: 'string', pattern: '^-?[0-9]+$'}

The document still accepts the same values.

Extra Keys in the Document

By default the document does not restrict extra keys, just as validation ignores them. Pass the JSON encoder strategy you encode with and the document matches it.

// clone and direct send no extra keys, so every object with declared properties is closed
orderSchemaDoc({libraryOptions: {encoderStrategy: 'clone'}});
// {type: 'object', properties: {...}, required: [...], additionalProperties: false}

// mutate keeps extra keys, so it stays open; records keep the index schema in additionalProperties
orderSchemaDoc({libraryOptions: {encoderStrategy: 'mutate'}}); // unchanged

No option sets additionalProperties by hand. The compact strategy throws: a keyed document cannot describe its positional arrays.

Standard Schema Interface

createStandardSchema<T>() returns one object with both Standard Schema interfaces: validation (~standard.validate) and JSON Schema (~standard.jsonSchema). Any framework that understands either one can use it.

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, because validation does not transform values. The optional target names the dialect: only draft-2020-12 is supported, any other value throws.

Unions in the Generated Schema

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 the JSON alone cannot say which member a value came from. The document spells out those pairs, merges object members into one arm under index -1, and marks the node with jsType: 'union'. A value the document accepts is a value the JSON decoder can decode.

Describing JavaScript Features in a JSON Schema

A Date, a bigint or a Map travels as JSON (an ISO string, a string of digits, an array of pairs), but a standard schema can only describe that string or array. This extension adds keywords next to the standard ones that say what the JSON becomes in JavaScript after decoding.

The extension stores full JavaScript and TypeScript type information inside a JSON Schema. The generator writes these keywords into every document it emits.

A document that uses the extension is still valid JSON Schema (draft 2020-12). The standard tells validators to ignore unknown keywords, so every validator checks the same values.

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

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

Keywords

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 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.

What the JSON Decodes To

jsType always sits next to the standard keywords that describe the JSON.

TypeScript typeSchemaTravels as
Date{type: 'string', format: 'date-time', jsType: 'Date'}an ISO date-time string
URL{type: 'string', format: 'uri', jsType: 'URL'}its href 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
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
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
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
any{jsType: 'any'}any JSON value
object{type: ['object', 'array'], jsType: 'object'}any object or array

The ZonedDateTime row uses pattern, not format, because those strings are not registered JSON Schema formats. A ZonedDateTime string holds a time zone name in brackets, which date-time does not allow, so that format would fail good data on other validators.

A FormattedSet or FormattedMap puts its constraints on the outer array, under the standard names minItems / maxItems, uniqueItems and contains. A Map's entry is its pair, so its contains prints as a two slot array schema.

undefined and void both travel as null, so only jsType says which one comes back. A Promise is awaited before encoding. The object type accepts arrays too, so its schema lists both.

Named Type Formats

rtFormat holds the name of a type format, like an email whose local part has a maximum length. Every parameter a standard validator can check also goes on a standard keyword, so other validators still enforce 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 holds 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.

{
  "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.

TypeScript-Only Facts

The ts keywords record what leaves no trace in JSON, like a readonly modifier, a tuple slot name or a function signature. They check nothing: without them, every validator accepts and rejects the same values.

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.

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 describes write access to a resource. The two can appear together and mean different things.

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}. additionalProperties covers a plain string-key signature. tsIndexes covers the rest: numeric keys, template literal keys, and shapes with more than one signature. The key constraint still goes on propertyNames or patternProperties, so other validators check the keys too.

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.

Function Signatures

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

Decodes to (message: string) => boolean. The parameters are an ordinary tuple schema, so optional parameters, rest parameters and parameter names all work.

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'}. Only the base describes JSON. The metadata exists in the type, never in the data.

Combining Keywords

A schema node can hold several of these keywords next to the standard ones. The standard keywords always describe the JSON, and the extension keywords the type that comes back: format: 'date-time' describes the string, jsType: 'Date' makes the decoded value a Date.

A schema may declare the dialect with $schema set to https://mion.io/runtypes/schema/2020-12-javascript, but it does not have to. It is only a hint for tooling.

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

Copyright © 2026