Generating JSON Schema
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.
{$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.
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.
| 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.
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 type | Schema | Travels 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 type | Schema | Travels 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 type | Schema | Travels 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 schema | a 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 type | Schema | Travels 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 type | Schema | Travels 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 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 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.