Json Schema Patterns in .NET - Open vs. Closed Types
In this series we are cataloging common patterns with JSON Schema and the .NET code generated by Corvus.JsonSchema.
It is especially useful for developers who want to move to a schema-first approach, but are unsure how that will map to their .NET code.
We are focusing on draft 2020-12 and the dotnet 8.0 code generation, but very similar patterns apply for older versions of both .NET and draft 2019-09, draft 7, and even draft 6. We will highlight the key differences as we go.
If you have no experience of JSON Schema at all, I would recommend you read the getting started step-by-step documentation provided by the JSON Schema team.
Extensibility with open and closed types
Open and closed types in JSON Schema
Open Types
In this context, an open type is an
object
type that is still valid when it contains properties other than those explicitly defined in its own schema (such as those under theproperties
keyword).By default, JSON Schema assumes an open content model, meaning that unless specified otherwise, objects can have additional properties not declared in the schema.
This is useful when you want your objects to be extensible or when you don’t want to strictly validate every property.
Allowing this extensibility is a common approach to "versioning by evolution" or "backwards compatibility" where it is intended that new versions of a schema are still accepted by old consumers that don't know about (or use) the new extensions.
Closed Types
If you want to create a closed type in JSON Schema, you would set
unevaluatedProperties
tofalse
.This means that the object can only contain properties that are explicitly defined in the schema, and no others.
This is commonly used in "parallel versioning" where old consumers do not support new versions of schema, and typically new versions have new names to avoid confusion.
File: person-open.json
{
"title": "The person schema https://schema.org/Person",
"type": "object",
"required": [ "familyName", "givenName", "birthDate" ],
"properties": {
"familyName": { "$ref": "#/$defs/constrainedString" },
"givenName": { "$ref": "#/$defs/constrainedString" },
"otherNames": { "$ref": "#/$defs/constrainedString" },
"birthDate": {
"type": "string",
"format": "date"
},
"height": {
"type": "number",
"format": "double",
"exclusiveMinimum": 0.0,
"maximum": 3.0
}
},
"$defs": {
"constrainedString": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
}
}
Generate the code:
generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-open.json
Generating: ConstrainedString
Generating: PersonOpen
Generating: HeightEntity
However, you can close the type by specifying "unevaluatedProperties": false
Prior to draft 2020-12, unevaluatedProperties
was not available and you would use additionalProperties
. The semantics are slightly different (and a little complex!), and you would generally prefer unevaluatedProperties
today.
This will ensure that a JSON document containing properties not explicitly declared in the schema will not be valid.
File: person-closed.json
{
"title": "The person schema https://schema.org/Person",
"type": "object",
"required": [ "familyName", "givenName", "birthDate" ],
"properties": {
"familyName": { "$ref": "#/$defs/constrainedString" },
"givenName": { "$ref": "#/$defs/constrainedString" },
"otherNames": { "$ref": "#/$defs/constrainedString" },
"birthDate": {
"type": "string",
"format": "date"
},
"height": {
"type": "number",
"format": "double",
"exclusiveMinimum": 0.0,
"maximum": 3.0
}
},
"unevaluatedProperties": false,
"$defs": {
"constrainedString": {
"type": "string",
"minLength": 1,
"maxLength": 256
}
}
}
Generate the code:
generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-closed.json
Generating: ConstrainedString
Generating: PersonClosed
Generating: HeightEntity
using Corvus.Json;
using JsonSchemaSample.Api;
// A person with an additional property: "JobRole".
var extendedPersonJsonString =
"""
{
"familyName": "Brontë",
"givenName": "Anne",
"birthDate": "1820-01-17",
"height": 1.57,
"jobRole": "Author"
}
""";
using var parsedPersonOpen = ParsedValue<PersonOpen>.Parse(extendedPersonJsonString);
PersonOpen personOpen = parsedPersonOpen.Instance;
// An object is, by default, open - allowing undeclared properties.
if(personOpen.IsValid())
{
Console.WriteLine("personOpen is valid");
}
else
{
Console.WriteLine("personOpen is not valid");
}
using var parsedPersonClosed = ParsedValue<PersonClosed>.Parse(extendedPersonJsonString);
PersonClosed personClosed = parsedPersonClosed.Instance;
// An object with unevaluatedProperties: false does not allow undeclared properties.
if (personClosed.IsValid())
{
Console.WriteLine("personClosed is valid");
}
else
{
Console.WriteLine("personClosed is not valid");
}
// We can still use an invalid entity - and fix it; for instance, by removing the invalid property
PersonClosed personFixed = personClosed.RemoveProperty("jobRole"u8);
if (personFixed.IsValid())
{
Console.WriteLine("personFixed is valid");
}
else
{
Console.WriteLine("personFixed is not valid");
}
// Equally we can make a valid entity invalid
// Setting an unknown property to a particular value.
PersonClosed personBroken = personFixed.SetProperty<JsonInteger>("age", 23);
if (personBroken.IsValid())
{
Console.WriteLine("personBroken is valid");
}
else
{
Console.WriteLine("personBroken is not valid");
}