- Status: proposed
- Deciders: @gregsdennis, @jdesrosiers, @relequestual
- Date: 2022-04-07
Technical Story:
- Issue discussing feature - #1082
- PR to add to the spec - #1143
- ADR to extract from the spec and use feature life cycle - #1505
A common need in JSON Schema is to select between one schema or another to validate an instance based on the value of some property in the JSON instance. There are a several patterns people use to accomplish this, but they all have significant problems.
OpenAPI solves this problem with the discriminator
keyword. However, their
approach is more oriented toward code generation concerns, is poorly specified
when it comes to validation, and is coupled to OpenAPI concepts that don't exist
is JSON Schema. Therefore, it's necessary to define something new rather than
adopt or redefine discriminator
.
- Ease of use
- Readability
- Coverage of most common use cases
- Coverage of all use cases
- Ease of implementation
All of the following options have the same validation result as the following schema.
{
"if": {
"properties": {
"foo": { "const": "aaa" }
},
"required": ["foo"]
},
"then": { "$ref": "#/$defs/foo-aaa" }
}
The dependentSchemas
keyword is very close to what is needed except it checks
for the presence of a property rather than it's value. This option builds on
that concept to solve this problem.
{
"propertyDependencies": {
"foo": {
"aaa": { "$ref": "#/$defs/foo-aaa" }
}
}
}
- Good, because it handle the most common use case: string property values
- Good, because all property values are grouped together
- Good, because it's less verbose
- Bad, because it doesn't handle non-string property values
This version uses an array of objects. Each object is a collection of the
variables needed to express a property dependency. This doesn't fit the style of
JSON Schema. There aren't any keywords remotely like this. It's also still too
verbose. It's a little more intuitive than if
/then
and definitely less error
prone.
{
"propertyDependencies": [
{
"propertyName": "foo",
"propertySchema": { "const": "aaa" },
"apply": { "$ref": "#/$defs/foo-aaa" }
},
{
"propertyName": "foo",
"propertySchema": { "const": "bbb" },
"apply": { "$ref": "#/$defs/foo-bbb" }
}
]
}
- Good, because it supports all use cases
- Bad, because properties are not naturally grouped together
- Bad, because it's quite verbose
- Bad, because we have no precedent for a keyword which explicitly defines its own properties. This would be new operational functionality, which we try to avoid if we can.
A slight variation on that example is to make it a map of keyword to dependency object. It's still too verbose.
{
"propertyDependencies": {
"foo": [
{
"propertySchema": { "const": "aaa" },
"apply": { "$ref": "#/$defs/foo-aaa" }
},
{
"propertySchema": { "const": "bbb" },
"apply": { "$ref": "#/$defs/foo-bbb" }
}
]
}
}
- Good, because it supports all use cases
- Good, because all property values are grouped together
- Bad, because it's quite verbose
- Bad, because we have no precedent for a keyword which explicitly defines its own properties. This would be new operational functionality, which we try to avoid if we can.
This one is a little more consistent with the JSON Schema style (poor keyword naming aside), but otherwise has all the same problems as the other examples.
{
"allOf": [
{
"propertyDependencyName": "foo",
"propertyDependencySchema": { "const": "aaa" },
"propertyDependencyApply": { "$ref": "#/$defs/foo-aaa" }
},
{
"propertyDependencyName": "foo",
"propertyDependencySchema": { "const": "bbb" },
"propertyDependencyApply": { "$ref": "#/$defs/foo-bbb" }
}
]
}
- Good, because it supports all use cases
- Bad, because properties are not naturally grouped together
- Bad, because it's very verbose
- Bad, because it introduces a lot of inter-keyword dependencies, which we'd have to exhaustively define
This one is a variation of if
that combines if
, properties
, and required
to reduce boilerplate. It's also essentially a variation of the previous example
with better names. This avoids to error proneness problem, but it's still too
verbose.
{
"allOf": [
{
"ifProperties": {
"foo": { "const": "aaa" }
},
"then": { "$ref": "#/$defs/foo-aaa" }
},
{
"ifProperties": {
"foo": { "const": "bbb" }
},
"then": { "$ref": "#/$defs/foo-aaa" }
}
]
}
- Good, because it supports all use cases
- Good, because it's a familiar syntax
- Bad, because properties are not naturally grouped together
- Bad, because it's very verbose
- Bad, because
ifProperties
is very niche. Will this spawn a new series ofif*
keywords? How would it interact withif
?
All of the previous alternatives use a schema as the discriminator. This alternative is a little less powerful in that it can only match on exact values, but it successfully addresses the problems we're concerned about with the current approaches. The only issue with this alternative is that it's not as intuitive as the chosen solution.
{
"propertyDependencies": {
"foo": [
["aaa", { "$ref": "#/$defs/foo-aaa" }],
["bbb", { "$ref": "#/$defs/foo-bbb" }]
]
}
}
- Good, because it supports all use cases
- Bad, because it's an unintuitive syntax and easy to get wrong
- Bad, because properties are not naturally grouped together
Option 1 was chosen because it satisfies the most common use cases while being sufficiently readable and easy to implement, even though it does not satisfy all use cases, such as those where the property value is not a string. As these cases are significantly less common, the requirement to support all use cases carried a lower priority.
- Some level of built-in support for a
discriminator
-like keyword that aligns with the existing operation of JSON Schema.
- Properties with non-string values cannot be supported using this keyword and
the
allOf
-if
-then
pattern must still be used.
The pattern of using oneOf
to describe a choice between two schemas has become
ubiquitous.
{
"oneOf": [
{ "$ref": "#/$defs/aaa" },
{ "$ref": "#/$defs/bbb" }
]
}
However, this pattern has several shortcomings. The main problem is that it tends to produce confusing error messages. Some implementations employ heuristics to guess the user's intent and provide better messaging, but that's not wide-spread or consistent behavior, nor is it expected or required from implementations.
This pattern is also inefficient. Generally, there is a single value in the
object that determines which alternative to chose, but the oneOf
pattern has
no way to specify what that value is and therefore needs to evaluate the entire
schema. This is made worse in that every alternative needs to be fully validated
to ensure that only one of the alternative passes and all the others fail. This
last problem can be avoided by using anyOf
instead, but that pattern is much
less used.
We can describe this kind of constraint more efficiently and with with better
error messaging by using if
/then
. This allows the user to explicitly specify
the constraint to be used to select which alternative the schema should be used
to validate the schema. However, this pattern has problems of it's own. It's
verbose, error prone, and not particularly intuitive, which leads most people to
avoid it.
{
"allOf": [
{
"if": {
"properties": {
"foo": { "const": "aaa" }
},
"required": ["foo"]
},
"then": { "$ref": "#/$defs/foo-aaa" }
},
{
"if": {
"properties": {
"foo": { "const": "bbb" }
},
"required": ["foo"]
},
"then": { "$ref": "#/$defs/foo-bbb" }
}
]
}