Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Extending 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 properties

When you have an open type, you can extend it with additional properties. This is rather like deriving from an (unsealed) base type in an object-oriented language, and adding additional properties in the derived type.

In this case we use $ref to declare that we are basing a new schema on the person-open.json schema.

We then extend it with an additional wealth property, which is a 32-bit integer number. This happens to be declared as a required property.

File: person-wealthy.json

{
  "title": "A wealthy person",
  "$ref": "./person-open.json",
  "required": ["wealth"],
  "properties": {
    "wealth": {
      "type": "integer",
      "format": "int32"
    }
  }
}

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

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

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person-wealthy.json
Generating: HeightEntity
Generating: PersonOpen
Generating: PersonWealthy
Generating: ConstrainedString

You can see that it generates a type for both PersonOpen and PersonWealthy.

PersonWealthy includes all of the properties defined both on person and personWealthy.

  • BirthDate
  • FamilyName
  • GivenName
  • OtherNames
  • Height and
  • Wealth

Example code

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

// A wealthy person has an additional required property: "Wealth".
var wealthyPerson = PersonWealthy.Create(
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    wealth: 1_000_000,
    height: 1.57);

Console.WriteLine($"Wealth: {wealthyPerson.Wealth}");

// Implicit conversion to the type on which it is based (PersonOpen) is available.
PersonOpen basePerson = wealthyPerson;

// Although the property is not present on the PersonOpen .NET type, it is still available
// through the generic TryGetProperty() API. Note that we can use the JsonPropertyNames declared
// on PersonWealthy to get the appropriate name. We know this is backed by a .NET type, so it is
// marginally more efficient to use the string version of the property name. If we were backed by a
// JsonElement, it would be marginally more efficient to use the UTF8 property name.
if (basePerson.TryGetProperty(PersonWealthy.JsonPropertyNames.Wealth, out JsonInt32 baseWealth))
{
    // We will only get here if baseWealth is not undefined
    // We know our PersonOpen was derived from a valid
    // PersonWealthy so we do not need to re-validate; if it was
    // present, then it is known to be an int32.
    Console.WriteLine($"Wealth: {(int)baseWealth}");
}

// Or we could just use a literal string as the property name.
if (basePerson.TryGetProperty("wealth", out JsonInt32 baseWealth2))
{
    Console.WriteLine($"Wealth: {(int)baseWealth2}");
}

// Or a literal utf8 string as the property name.
if (basePerson.TryGetProperty("wealth"u8, out JsonInt32 baseWealth3))
{
    Console.WriteLine($"Wealth: {(int)baseWealth3}");
}

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.