JSON Schemas
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.
{$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.
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.
| Keyword | Sits beside | Says |
|---|---|---|
jsType | type | which JavaScript value the JSON decodes to |
jsResolved | jsType: 'Promise' | the schema of the resolved value |
rtFormat | format | which type format the value belongs to |
rtFormatParams | the constraint keywords | the format parameters, exactly as written |
tsLabels | prefixItems | the tuple slot names |
tsReadonly | required | which members are readonly |
tsIndexes | propertyNames / patternProperties | index signatures whose key is not a plain string |
tsTemplate | pattern | the parts of a template literal type |
tsFunction | nothing (functions have no JSON form) | a function signature |
tsMeta | nothing (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 type | Schema | Travels 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 schema | a 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 parameter | Standard keyword |
|---|---|
minLength / maxLength | minLength / maxLength |
pattern | pattern |
min / max | minimum / maximum |
gt / lt | exclusiveMinimum / exclusiveMaximum |
multipleOf | multipleOf |
minItems / maxItems / uniqueItems | the same names |
minProperties / maxProperties | the 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.