Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Constraining a base type

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 dotnet8.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.

Adding constraints

Whether a type is open or closed, you can further constrain it in a schema.

Remember that a closed type in JSON Schema is not like a "sealed" type in an OO language like C#. You can still base another schema on that type - it's just that it will not allow extra properties that are not present on the base type.

In this case, we will use $ref to declare that we are basing our new schema on the person-closed.json schema.

Notice how we can reference schema in external documents, not just the local schema document.

This would work equally well with the person-open.json we used when extending a base type.

We then constrain it by defining a new version of the height property.

Again, we use $ref to base the new schema for the height property on the schema of the height property on the base schema. Then we add a new minimum value to constrain it to be greater than 2.0.

Notice how we don't just have to reference schemas defined in a $defs section. We can reference any schema defined in the document.

I don't generally encourage this pattern; ideally if a type is intended to be shared and composed in this way, I like to indicate that by embedding it in the $defs section and documenting it properly.

File: person-tall.json

{
  "title": "A tall person",
  "$ref": "./person-closed.json",
  "properties": {
    "height": {
      "$ref": "./person-closed.json#/properties/height",
      "minimum": 2.0,
    }
  }
}

In draft 6 and draft 7, $ref cannot be used in this way. It acts as a reference to the target type and ignores the adjacent values. This is often a bit surprising! Instead, you can use allOf with a single value to get the same effect. It is a little clunkier to read, but works in the same way. { "allOf": [{"$ref": "./person-closed.json"}]}

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
    }
  }
}

When we validate, the Height property on PersonTall will be constrained both by characteristics of the base type (such that 0 < height <= 3.0) and the new height >= 2.0. The new constraints are composed from both the base and the derived schema.

This "composition" behaviour is often surprising to people familiar with OO languages. You can't "turn off" the base constraints in the way you can with, say, C# virtual methods that do not call the base from the derived type, because they are composed together.

You may also be surprised to learn that it is not even necessary to include this $ref to the base "height" schema to get the desired validation behaviour! If you just added the minimum constraint to the new height property, it would validate as desired.

However, $ref is the way we describe to the code generator that we are basing this new HeightEntity type on the one from the base schema, rather than merely defining an isolated constraint. In that way, the generated type inherits all the other characteristics from the one on which it is based.

Are your constraints compatible?

Another consequence of this composition behaviour is that any additional constraints you apply should be compatible with the base type's existing constraints (if any).

Neither JSON Schema itself, nor Corvus.JsonSchema enforce this rule - but any schema that doesn't follow it will not be especially useful!

Imagine, for example, defining a schema that required an entity to be both an object and a string. That's perfectly possible in JSON Schema; and the code generator will emit "correct" code that happily compiles. But no entity will ever be valid.

(Contrast this with a constraint that an entity can be either an object or a string - that would be absolutely fine; this kind of "sum type" is common in e.g. typescript, but is unusual in .NET languages. We will see later how Corvus.JsonSchema enables these kinds of structures in dotnet.)

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-tall.json
Generating: PersonTall
Generating: HeightEntity
Generating: HeightEntity
Generating: PersonClosed
Generating: ConstrainedString

Notice that it has generated both PersonTall and PersonClosed; each has its own HeightEntity.

Example code

using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;

// Create a tall person, although Anne is not
// tall enough to be a valid tall person!
PersonTall personTall = PersonTall.Create(
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.57);

// Implicit conversion to the base type
PersonClosed personClosedFromTall = personTall;

// personTall is not valid because of the additional constraint.
if (personTall.IsValid())
{
    Console.WriteLine("personTall is valid");
}
else
{
    Console.WriteLine("personTall is not valid");
}

// But personClosedFromTall is valid because it does not have the
// additional constraint.
if (personClosedFromTall.IsValid())
{
    Console.WriteLine("personClosedFromTall is valid");
}
else
{
    Console.WriteLine("personClosedFromTall is not valid");
}

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.