# JSON Type Definition

This document informally describes JSON Type Definition (JTD) specification to help Ajv users to start using it. For formal definition please refer to RFC8927 (opens new window). Please report any contradictions in this document with the specification.

To use JTD schemas you need to import a different Ajv class:

# JTD schema forms

JTD specification defines 8 different forms that the schema for JSON can take for one of most widely used data types in JSON messages (API requests and responses).

All forms require that:

  • schema is an object with different members, depending on the form
  • each form can have:
    • an optional member nullable with a boolean value that allows data instance to be JSON null.
    • an optional member metadata with an object value that allows to pass any additional information or extend the specification (Ajv defines keyword "union" that can be used inside metadata)

Root schema can have member definitions that has a dictionary of schemas that can be references from any other schemas using form ref

# Type form primitive values

This form defines a primitive value.

It has a required member type and an optional members nullable and metadata, no other members are allowed.

type can have one of the following values:

  • "string" - defines a string
  • "boolean" - defines boolean value true or false
  • "timestamp" - defines timestamp ( accepting either an RFC3339 (opens new window) JSON string or a Date object, configurable via the timestamp Ajv option)
  • type values that define integer numbers:
    • "int8" - signed byte value (-128 .. 127)
    • "uint8" - unsigned byte value (0 .. 255)
    • "int16" - signed word value (-32768 .. 32767),
    • "uint16" - unsigned word value (0 .. 65535)
    • "int32" - signed 32-bit integer value
    • "uint32" - unsigned 32-bit integer value
  • type values that define floating point numbers:
    • "float32" - 32-bit real number
    • "float64" - 64-bit real number

Unlike JSON Schema, JTD does not allow defining values that can take one of several types, but they can be defined as nullable.

Example

{
  type: "string"
}

# Enum form

This form defines a string that can take one of the values from the list (the values in the list must be unique).

It has a required member enum and optional members nullable and metadata, no other members are allowed.

Unlike JSON Schema, JTD does not allow defining enum with values of any other type than string.

Example

{
  enum: ["foo", "bar"]
}

# Elements form arrays

This form defines a homogenous array of any size (possibly empty) with the elements that satisfy a given schema.

It has a required member elements (schema that elements should satisfy) and optional members nullable and metadata, no other members are allowed.

Unlike JSON Schema, the data instance must be JSON array (without using additional type keyword), and there is no way to enforce the restrictions that cannot be present on type level of most languages, such as array size and uniqueness of items.

Example

Schema:

{
  elements: {
    type: "string"
  }
}

Valid data: [], ["foo"], ["foo", "bar"]

Invalid data: ["foo", 1], any type other than array

# Properties form objects

This form defines record (JSON object) that has defined required and optional properties.

It is required that this form has either properties member, or optionalProperties, or both, in which case the cannot have overlapping properties. Additional properties can be allowed by adding an optional boolean member additionalProperties with a value true. This form, as all other, can have optional nullable and metadata members.

Unlike JSON Schema, all properties defined in properties schema member are required, the data instance must be JSON object (without using additional type keyword) and by default additional properties are not allowed (with the exception of discriminator tag - see the next section). This strictness minimises user mistakes.

Example 1.

Schema:

{
  properties: {
    foo: {
      type: "string"
    }
  }
}

Valid data: {foo: "bar"}

Invalid data: {}, {foo: 1}, {foo: "bar", bar: 1}, any type other than object

Example 2.

Schema:

{
  properties: {
    foo: {type: "string"}
  },
  optionalProperties: {
    bar: {enum: ["1", "2"]}
  },
  additionalProperties: true
}

Valid data: {foo: "bar"}, {foo: "bar", bar: "1"}, {foo: "bar", additional: 1}

Invalid data: {}, {foo: 1}, {foo: "bar", bar: "3"}, any type other than object

Example 3: invalid schema (overlapping required and optional properties)

{
  properties: {
    foo: {type: "string"}
  },
  optionalProperties: {
    foo: {type: "string"}
  }
}

# Discriminator form tagged union

This form defines discriminated (tagged) union of different record types.

It has required members discriminator and mapping and optional members nullable and metadata, no other members are allowed.

The string value of discriminator schema member contains the name of the data member that is the tag of the union. mapping schema member contains the dictionary of schemas that are applied according to the value of the tag member in the data. Schemas inside mapping must have "properties" form.

Properties forms inside mapping cannot be nullable and cannot define the same property as discriminator tag.

Example 1.

Schema:

{
  discriminator: "version",
  mapping: {
    "1": {
      properties: {
        foo: {type: "string"}
      }
    },
    "2": {
      properties: {
        foo: {type: "uint8"}
      }
    }
  }
}

Valid data: {version: "1", foo: "1"}, {version: "2", foo: 1}

Invalid data: {}, {foo: "1"}, {version: 1, foo: "1"}, any type other than object

Example 3: invalid schema (discriminator tag member defined in mapping)

{
  discriminator: "version",
  mapping: {
    "1": {
      properties: {
        version: {enum: ["1"]},
        foo: {type: "string"}
      }
    },
    "2": {
      properties: {
        version: {enum: ["2"]},
        foo: {type: "uint8"}
      }
    }
  }
}

# Values form dictionary

This form defines a homogenous dictionary where the values of members satisfy a given schema.

It has a required member values (schema that member values should satisfy) and optional members nullable and metadata, no other members are allowed.

Unlike JSON Schema, the data instance must be JSON object (without using additional type keyword), and there is no way to enforce size restrictions.

Example

Schema:

{
  values: {
    type: "uint8"
  }
}

Valid data: {}, {"foo": 1}, {"foo": 1, "bar": 2}

Invalid data: {"foo": "bar"}, any type other than object

# Ref form reference definitions

This form defines a reference to the schema that is present in the corresponding key in the definitions member of the root schema.

It has a required member ref (member of definitions object in the root schema) and optional members nullable and metadata, no other members are allowed.

Unlike JSON Schema, JTD does not allow to reference:

  • any schema fragment other than root level definitions member
  • root of the schema - there is another way to define a self-recursive schema (see Example 2)
  • another schema file (but you can still combine schemas from multiple files using JavaScript).

Example 1.

{
  properties: {
    propFoo: {ref: "foo", nullable: true}
  },
  definitions: {
    foo: {type: "string"}
  }
}

Example 2: self-referencing schema for binary tree

{
  ref: "tree",
  definitions: {
    tree: {
      properties: {
        value: {type: "int32"}
      },
      optionalProperties: {
        left: {ref: "tree"},
        right: {ref: "tree"}
      }
    }
  }
}

Example 3: invalid schema (missing reference)

{
  ref: "foo",
  definitions: {
    bar: {type: "string"}
  }
}

# Empty form any data

Empty JTD schema defines the data instance that can be of any type, including JSON null (even if nullable member is not present). It cannot have any member other than nullable and metadata.

# JTDSchemaType

The type JTDSchemaType can be used to validate that the written schema matches the type you expect to validate. This type is strict such that if typescript compiles, you should require no further type guards. The downside of this is that the types that JTDSchemaType can verify are limited to the types that JTD can verify. If a type doesn't verify, JTDSchemaType should resolve to never, throwing an error when you try to assign to it. This means that types like 1 | 2 | 3, or general untagged unions (outside of unions of string literals) cannot be used with JTDSchemaType.

# Most Schemas

Most straightforward types should work with JTDSchemaType, e.g.

interface MyType {
  num: number
  optionalStr?: string
  nullableEnum: "v1.0" | "v1.2" | null
  values: Record<string, number>
}

const schema: JTDSchemaType<MyType> = {
  properties: {
    num: {type: "float64"},
    nullableEnum: {enum: ["v1.0", "v1.2"], nullable: true},
    values: {values: {type: "int32"}},
  },
  optionalProperties: {
    optionalStr: {type: "string"},
  },
}

will compile. Using schema with AJV will guarantee type safety.

# Ref Schemas

Ref schemas are a little more advanced, because the types of every definition must be specified in advance. A simple ref schema is relatively straightforward:

const schema: JTDSchemaType<{val: number}, {num: number}> = {
  definitions: {
    num: {type: "float64"},
  },
  properties: {
    val: {ref: "num"},
  },
}

note that the type of all definitions was included as a second argument to JTDSchemaType.

This also works for recursive schemas:

type LinkedList = {val: number; next?: LinkedList}
const schema: JTDSchemaType<LinkedList, {node: LinkedList}> = {
  definitions: {
    node: {
      properties: {
        val: {type: "float64"},
      },
      optionalProperties: {
        next: {ref: "node"},
      },
    },
  },
  ref: "node",
}

# Notable Omissions

JTDSchemaType currently validates that if the schema compiles it will verify an accurate type, but there are a few places with potentially unexpected behavior. JTDSchemaType doesn't verify the schema is correct. It won't reject schemas that definitions anywhere by the root, and it won't reject discriminator schemas that still define the descriminator in mapping properties. It also won't verify that enum schemas have every enum member as this isn't generally feasible in typescript yet.

# Extending JTD

# Metadata schema member

Each schema form may have an optional member metadata that JTD reserves for implementation/application specific extensions. Ajv uses this member as a location where any non-standard keywords can be used, such as:

  • union keyword included in Ajv
  • any user-defined keywords, for example keywords defined in ajv-keywords (opens new window) package
  • JSON Schema keywords, as long as their names are different from standard JTD keywords. It can be used to enable a gradual migration from JSON Schema to JTD, should it be required.

Extensions are non-portable

Ajv-specific extension to JTD are likely to be unsupported by other tools, so while it may simplify adoption, it undermines the cross-platform objective of using JTD. While it is ok to put some human readable information in metadata member, it is recommended not to add any validation logic there (even if it is supported by Ajv).

Additional restrictions that Ajv enforces on metadata schema member:

  • you cannot use standard JTD keywords there. While strictly speaking it is allowed by the specification, these keywords should be ignored inside metadata - the general approach of Ajv is to avoid anything that is ignored.
  • you need to define all members used in metadata as keywords. If they are no-op it can be done with ajv.addKeyword("my-metadata-keyword"). This restriction can be removed by disabling strict mode (opens new window), without affecting the strictness of JTD - unknown keywords would still be prohibited in the schema itself.

# Union keyword

Ajv defines union keyword that is used in the schema that validates JTD schemas (meta-schema (opens new window)).

This keyword can be used only inside metadata schema member.

Union keyword is non-portable

This keyword is non-standard and it is not supported in other JTD tools, so it is recommended NOT to use this keyword in schemas for your data if you want them to be cross-platform.

# User-defined keywords

Any user-defined keywords that can be used in JSON Schema schemas can also be used in JTD schemas, including the keywords in ajv-keywords (opens new window) package.

User-defined keywords are non-portable

It is strongly recommended to only use it to simplify migration from JSON Schema to JTD and not to use non-standard keywords in the new schemas, as these keywords are not supported by any other tools.

Parsing does NOT support non-standard JTD keywords

compileParser method does not support non-standard JTD keywords, you will have to use JSON.parse and then validates.

# Validation errors

TODO