Page title

Section title

Learn JSON Typedef in 5 Minutes

This article is a tutorial that will teach you everything you need to know to understand any JSON Type Definition schema.

If you’re the sort of person who really loves extreme specificity and/or standards-ese, you may find RFC 8927, where JTD is formally defined, to be interesting. But for most folks, this tutorial will be easier to understand.

Let’s get started!

What is a JSON Type Definition schema?

JSON Type Definition (aka “JSON Typedef”, or just “JTD”) schemas are just JSON documents. Here is a JTD schema:

{
    "properties": {
        "name": { "type": "string" },
        "isAdmin": { "type": "boolean" }
    }
}

You might also see schemas written as YAML instead, for example:

properties:
    name:
        type: string
    isAdmin:
        type: boolean

Technically, only the JSON representation of a schema is valid. But writing schemas in YAML, and then converting them to JSON at the last minute, is a pretty common practice.

Schemas can take on one of eight forms. You know which form a schema is using based on what keywords are in the schema. The eight forms are:

  • The empty form is like a Java Object or TypeScript any.
  • The type form is like a Java or TypeScript primitive type.
  • The enum form is like a Java or TypeScript enum.
  • The elements form is like a Java List<T> or TypeScript T[].
  • The properties form is like a Java class or TypeScript interface.
  • The values form is like a Java Map<String, T> or TypeScript { [key: string]: T}.
  • The discriminator form is like a tagged union.
  • The ref form is for re-using schemas, usually so you can avoid repeating yourself.

Schemas have to be exactly one of these forms. You can’t mix keywords from one form with keywords from another.

“Empty” schemas

Here’s a valid schema:

{}

This is an “empty” schema. It accepts any JSON value, and rejects nothing.

“Type” schemas

You can use type in a schema to specify that something is a primitive JSON value. For example,

{ "type": "boolean" }

Accepts true or false, and rejects everything else.

Here are all the values you can put for type:

Value of type What it accepts Example
boolean true or false true
string JSON strings "foo"
timestamp JSON strings containing an RFC3339 timestamp "1985-04-12T23:20:50.52Z"
float32 JSON numbers 3.14
float64 JSON numbers 3.14
int8 Whole JSON numbers that fit in a signed 8-bit integer 127
uint8 Whole JSON numbers that fit in an unsigned 8-bit integer 255
int16 Whole JSON numbers that fit in a signed 16-bit integer 32767
uint16 Whole JSON numbers that fit in an unsigned 16-bit integer 65535
int32 Whole JSON numbers that fit in a signed 32-bit integer 2147483647
uint32 Whole JSON numbers that fit in an unsigned 32-bit integer 4294967295

“Enum” schemas

You can use enum in a schema to say that something has to be a string in a given list. For example,

{ "enum": ["FOO", "BAR", "BAZ" ]}

Accepts only "FOO", "BAR", and "BAZ". Nothing else is accepted.

You can only do enums of strings; you can’t have an enum of numbers in JTD.

“Elements” schemas

To describe an array, use elements. The value of elements is another JTD schema. For example,

{ "elements": { "type": "string" }}

Accepts arrays where every element is a string. So ["foo", "bar"] and [] are OK, but "foo" and [1, 2, 3] are not.

“Properties” schemas

To describe a JSON object where each key has a separate type of value, use a “properties” schema. For example,

{
    "properties": {
        "name": { "type": "string" },
        "isAdmin": { "type": "boolean" }
    }
}

Accepts objects that have a name property (which must be a string) and a isAdmin property (which must be a boolean). If the object has any extra properties, then it’s invalid. So this is OK:

{ "name": "Abraham Lincoln", "isAdmin": true }

But neither of these are:

{ "name": "Abraham Lincoln", "isAdmin": "yes" }
{ "name": "Abraham Lincoln", "isAdmin": true, "extra": "stuff" }

Optional properties

If it’s OK for a property to be missing, then you can use optionalProperties:

{
    "properties": {
        "name": { "type": "string" },
        "isAdmin": { "type": "boolean" }
    },
    "optionalProperties": {
        "middleName": { "type": "string" }
    }
}

If there’s a middleName property on the object, it has to be a string. But if there isn’t one, that’s OK. So these are valid:

{ "name": "Abraham Lincoln", "isAdmin": true }
{ "name": "William Sherman", "isAdmin": false, "middleName": "Tecumseh" }

But this is not:

{ "name": "John Doe", "isAdmin": false, "middleName": null }

Extra properties

By default, properties / optionalProperties does not permit for “extra” properties, i.e. properties not mentioned explicitly in the schema. If you’re OK with extra properties, you can use "additionalProperties": true. For example:

{
    "properties": {
        "name": { "type": "string" },
        "isAdmin": { "type": "boolean" }
    },
    "additionalProperties": true
}

Would accept:

{ "name": "Abraham Lincoln", "isAdmin": true, "extra": "stuff" }

“Values” schemas

To describe a JSON object that’s like a “dictionary”, where you don’t know the keys but you do know what type the values should have, use a “values” schema. The value of the values keyword is another JTD schema. For example,

{ "values": { "type": "boolean" }}

Accepts objects where all the values are booleans. So it would accept {} or {"a": true, "b": false}, but not {"a": 1}.

“Discriminator” schemas

To describe a JSON object that works like a tagged union (aka: “discriminated union”, “sum type”), use a “discriminator” schema.

A “discriminator” schema has two keywords: discriminator tells you what property is the “tag” property, and mapping tells you what schema to use, based on the value of the “tag” property.

For example, let’s say you have messages that look like this:

{ "eventType": "USER_CREATED", "id": "users/123" }
{ "eventType": "USER_CREATED", "id": "users/456" }

{ "eventType": "USER_PAYMENT_PLAN_CHANGED", "id": "users/789", "plan": "PAID" }
{ "eventType": "USER_PAYMENT_PLAN_CHANGED", "id": "users/123", "plan": "FREE" }

{ "eventType": "USER_DELETED", "id": "users/456", "softDelete": false }

Basically, there are three kinds of messages: USER_CREATED messages look like this:

{
    "properties": {
        "id": { "type": "string" }
    }
}

USER_PAYMENT_PLAN_CHANGED messages look like this:

{
    "properties": {
        "id": { "type": "string" },
        "plan": { "enum": ["FREE", "PAID"]}
    }
}

And USER_DELETED messages look like this:

{
    "properties": {
        "id": { "type": "string" },
        "softDelete": { "type": "boolean" }
    }
}

With a “discriminator” schema, you can tie all three of those schemas together, and tell JTD that you decide which one is relevant based on the value of eventType. So here’s the schema for our messages:

{
    "discriminator": "eventType",
    "mapping": {
        "USER_CREATED": {
            "properties": {
                "id": { "type": "string" }
            }
        },
        "USER_PAYMENT_PLAN_CHANGED": {
            "properties": {
                "id": { "type": "string" },
                "plan": { "enum": ["FREE", "PAID"]}
            }
        },
        "USER_DELETED": {
            "properties": {
                "id": { "type": "string" },
                "softDelete": { "type": "boolean" }
            }
        }
    }
}

That schema would accept all of the messages in our example above. If the input doesn’t have a eventType property, or if the eventType property isn’t one of the three values mentioned in the mapping, then the input is rejected.

You can only use properties / optionalProperties / additionalProperties in the schemas you put directly in mapping. You can’t use any other kind of schema, otherwise things would become ambiguous.

“Ref” schemas

Sometimes, you want to re-use a sub-schema multiple times, or you want to give some sub-schema a particular name. You can use a “ref” schema to do this.

This is easiest to explain with an example. This schema:

{
    "definitions": {
        "coordinates": {
            "properties": {
                "lat": { "type": "float32" },
                "lng": { "type": "float32" }
            }
        }
    },
    "properties": {
        "userLoc": { "ref": "coordinates" },
        "serverLoc": { "ref": "coordinates" }
    }
}

Will accept things like:

{ "userLoc": { "lat": 50, "lng": -90 }, "serverLoc": { "lat": -15, "lng": 50 }}

The {"ref": "coordinates"} basically gets “replaced” by the coordinates schema in definitions.

Note that definitions can only appear at the root (top level) of a JTD schema. It’s illegal to have definitions anywhere else.

The nullable keyword

You can put nullable on any schema (regardless of which “form” it takes), and that will make null be an acceptable value for the schema.

For example,

{ "type": "string" }

Will accept "foo" and reject null. But if you add "nullable": true,

{ "type": "string", "nullable": true }

That schema will accept both "foo" and null.

Note: you can’t put nullable on a schema in a discriminator mapping. If you want a discriminator to be nullable, you have to put it at the same level as the discriminator and mapping keywords.

The metadata keyword

The metadata keyword is legal on any schema, and if it’s present it has to be a JSON object. There are no constraints on what you can put in metadata beyond that, and metadata has no effect on validation.

Usually, metadata is for putting things like descriptions or hints for code generators, or other things tools can use.

That’s it

That’s all you really need to know about JTD to be productive. If you want to get started using JTD, your next step would be to find an implementation in your preferred programming language.