Applying Subschemas Conditionally

dependentRequired

The dependentRequired keyword conditionally requires that certain properties must be present if a given property is present in an object. For example, suppose we have a schema representing a customer. If you have their credit card number, you also want to ensure you have a billing address. If you don't have their credit card number, a billing address would not be required. We represent this dependency of one property on another using the dependentRequired keyword. The value of the dependentRequired keyword is an object. Each entry in the object maps from the name of a property, p, to an array of strings listing properties that are required if p is present.

In the following example, whenever a credit_card property is provided, a billing_address property must also be present:

schema
1
{
2
"type": "object",
3

4
"properties": {
5
"name": { "type": "string" },
6
"credit_card": { "type": "number" },
7
"billing_address": { "type": "string" }
8
},
9

10
"required": ["name"],
11

12
"dependentRequired": {
13
"credit_card": ["billing_address"]
14
}
15
}
data
1
{
2
"name": "John Doe",
3
"credit_card": 5555555555555555,
4
"billing_address": "555 Debtor's Lane"
5
}
compliant to schema

This instance has a credit_card, but it's missing a billing_address.

data
1
{
2
"name": "John Doe",
3
"credit_card": 5555555555555555
4
}
not compliant to schema

This is okay, since we have neither a credit_card, or a billing_address.

data
1
{
2
"name": "John Doe"
3
}
compliant to schema

Note that dependencies are not bidirectional. It's okay to have a billing address without a credit card number.

data
1
{
2
"name": "John Doe",
3
"billing_address": "555 Debtor's Lane"
4
}
compliant to schema

To fix the last issue above (that dependencies are not bidirectional), you can, of course, define the bidirectional dependencies explicitly:

schema
1
{
2
"type": "object",
3

4
"properties": {
5
"name": { "type": "string" },
6
"credit_card": { "type": "number" },
7
"billing_address": { "type": "string" }
8
},
9

10
"required": ["name"],
11

12
"dependentRequired": {
13
"credit_card": ["billing_address"],
14
"billing_address": ["credit_card"]
15
}
16
}

This instance has a credit_card, but it's missing a billing_address.

data
1
{
2
"name": "John Doe",
3
"credit_card": 5555555555555555
4
}
not compliant to schema

This has a billing_address, but is missing a credit_card.

data
1
{
2
"name": "John Doe",
3
"billing_address": "555 Debtor's Lane"
4
}
not compliant to schema
Draft-specific info
Previously to Draft 2019-09, dependentRequired and dependentSchemas were one keyword called dependencies. If the dependency value was an array, it would behave like dependentRequired and if the dependency value was a schema, it would behave like dependentSchema.

dependentSchemas

The dependentSchemas keyword conditionally applies a subschema when a given property is present. This schema is applied in the same way allOf applies schemas. Nothing is merged or extended. Both schemas apply independently.

For example, here is another way to write the above:

schema
1
{
2
"type": "object",
3
"properties": {
4
"name": { "type": "string" },
5
"credit_card": { "type": "number" }
6
},
7
"required": ["name"],
8
"dependentSchemas": {
9
"credit_card": {
10
"properties": {
11
"billing_address": { "type": "string" }
12
},
13
"required": ["billing_address"]
14
}
15
}
16
}
data
1
{
2
"name": "John Doe",
3
"credit_card": 5555555555555555,
4
"billing_address": "555 Debtor's Lane"
5
}
compliant to schema

This instance has a credit_card, but it's missing a billing_address:

data
1
{
2
"name": "John Doe",
3
"credit_card": 5555555555555555
4
}
not compliant to schema

This has a billing_address, but is missing a credit_card. This passes, because here billing_address just looks like an additional property:

data
1
{
2
"name": "John Doe",
3
"billing_address": "555 Debtor's Lane"
4
}
compliant to schema
Draft-specific info
Previously to Draft 2019-09, dependentRequired and dependentSchemas were one keyword called dependencies. If the dependency value was an array, it would behave like dependentRequired and if the dependency value was a schema, it would behave like dependentSchema.

If-Then-Else

New in draft 7

The if, then and else keywords allow the application of a subschema based on the outcome of another schema, much like the if/then/else constructs you've probably seen in traditional programming languages.

If if is valid, then must also be valid (and else is ignored.) If if is invalid, else must also be valid (and then is ignored).

If then or else is not defined, if behaves as if they have a value of true.

If then and/or else appear in a schema without if, then and else are ignored.

We can put this in the form of a truth table, showing the combinations of when if, then, and else are valid and the resulting validity of the entire schema:

ifthenelsewhole schema
TTn/aT
TFn/aF
Fn/aTT
Fn/aFF
n/an/an/aT

For example, let's say you wanted to write a schema to handle addresses in the United States and Canada. These countries have different postal code formats, and we want to select which format to validate against based on the country. If the address is in the United States, the postal_code field is a "zipcode": five numeric digits followed by an optional four digit suffix. If the address is in Canada, the postal_code field is a six digit alphanumeric string where letters and numbers alternate.

schema
1
{
2
"type": "object",
3
"properties": {
4
"street_address": {
5
"type": "string"
6
},
7
"country": {
8
"default": "United States of America",
9
"enum": ["United States of America", "Canada"]
10
}
11
},
12
"if": {
13
"properties": { "country": { "const": "United States of America" } }
14
},
15
"then": {
16
"properties": { "postal_code": { "pattern": "[0-9]{5}(-[0-9]{4})?" } }
17
},
18
"else": {
19
"properties": { "postal_code": { "pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" } }
20
}
21
}
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"country": "United States of America",
4
"postal_code": "20500"
5
}
compliant to schema
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"postal_code": "20500"
4
}
compliant to schema
data
1
{
2
"street_address": "24 Sussex Drive",
3
"country": "Canada",
4
"postal_code": "K1M 1M4"
5
}
compliant to schema
data
1
{
2
"street_address": "24 Sussex Drive",
3
"country": "Canada",
4
"postal_code": "10000"
5
}
compliant to schema
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"postal_code": "K1M 1M4"
4
}
compliant to schema

In this example, "country" is not a required property. Because the "if" schema also doesn't require the "country" property, it will pass and the "then" schema will apply. Therefore, if the "country" property is not defined, the default behavior is to validate "postal_code" as a USA postal code. The "default" keyword doesn't have an effect, but is nice to include for readers of the schema to more easily recognize the default behavior.

Unfortunately, this approach above doesn't scale to more than two countries. You can, however, wrap pairs of if and then inside an allOf to create something that would scale. In this example, we'll use United States and Canadian postal codes, but also add Netherlands postal codes, which are 4 digits followed by two letters. It's left as an exercise to the reader to expand this to the remaining postal codes of the world.

schema
1
{
2
"type": "object",
3
"properties": {
4
"street_address": {
5
"type": "string"
6
},
7
"country": {
8
"default": "United States of America",
9
"enum": ["United States of America", "Canada", "Netherlands"]
10
}
11
},
12
"allOf": [
13
{
14
"if": {
15
"properties": { "country": { "const": "United States of America" } }
16
},
17
"then": {
18
"properties": { "postal_code": { "pattern": "[0-9]{5}(-[0-9]{4})?" } }
19
}
20
},
21
{
22
"if": {
23
"properties": { "country": { "const": "Canada" } },
24
"required": ["country"]
25
},
26
"then": {
27
"properties": { "postal_code": { "pattern": "[A-Z][0-9][A-Z] [0-9][A-Z][0-9]" } }
28
}
29
},
30
{
31
"if": {
32
"properties": { "country": { "const": "Netherlands" } },
33
"required": ["country"]
34
},
35
"then": {
36
"properties": { "postal_code": { "pattern": "[0-9]{4} [A-Z]{2}" } }
37
}
38
}
39
]
40
}
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"country": "United States of America",
4
"postal_code": "20500"
5
}
compliant to schema
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"postal_code": "20500"
4
}
compliant to schema
data
1
{
2
"street_address": "24 Sussex Drive",
3
"country": "Canada",
4
"postal_code": "K1M 1M4"
5
}
compliant to schema
data
1
{
2
"street_address": "Adriaan Goekooplaan",
3
"country": "Netherlands",
4
"postal_code": "2517 JX"
5
}
compliant to schema
data
1
{
2
"street_address": "24 Sussex Drive",
3
"country": "Canada",
4
"postal_code": "10000"
5
}
not compliant to schema
data
1
{
2
"street_address": "1600 Pennsylvania Avenue NW",
3
"postal_code": "K1M 1M4"
4
}
not compliant to schema

The "required" keyword is necessary in the "if" schemas or they would all apply if the "country" is not defined. Leaving "required" off of the "United States of America" "if" schema makes it effectively the default if no "country" is defined.

Even if "country" was a required field, it's still recommended to have the "required" keyword in each "if" schema. The validation result will be the same because "required" will fail, but not including it will add noise to error results because it will validate the "postal_code" against all three of the "then" schemas leading to irrelevant errors.

Implication

Before Draft 7, you can express an "if-then" conditional using the Schema composition keywords and a boolean algebra concept called "implication". A -> B (pronounced, A implies B) means that if A is true, then B must also be true. It can be expressed as !A || B which can be expressed as a JSON Schema.

schema
1
{
2
"type": "object",
3
"properties": {
4
"restaurantType": { "enum": ["fast-food", "sit-down"] },
5
"total": { "type": "number" },
6
"tip": { "type": "number" }
7
},
8
"anyOf": [
9
{
10
"not": {
11
"properties": { "restaurantType": { "const": "sit-down" } },
12
"required": ["restaurantType"]
13
}
14
},
15
{ "required": ["tip"] }
16
]
17
}
data
1
{
2
"restaurantType": "sit-down",
3
"total": 16.99,
4
"tip": 3.4
5
}
compliant to schema
data
1
{
2
"restaurantType": "sit-down",
3
"total": 16.99
4
}
not compliant to schema
data
1
{
2
"restaurantType": "fast-food",
3
"total": 6.99
4
}
compliant to schema
data
1
{ "total": 5.25 }
compliant to schema

Variations of implication can be used to express the same things you can express with the if/then/else keywords. if/then can be expressed as A -> B, if/else can be expressed as !A -> B, and if/then/else can be expressed as A -> B AND !A -> C.

Since this pattern is not very intuitive, it's recommended to put your conditionals in $defs with a descriptive name and $ref it into your schema with "allOf": [{ "$ref": "#/$defs/sit-down-restaurant-implies-tip-is-required" }].