Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Pattern matching and discriminated unions

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.

Discriminated unions

One of the most requested features in .NET is sum types or discriminated unions.

Generally speaking, the request is for a value that can take on several different, but fixed types. There is some tag or other mechanism which uniquely discriminates between instances of the types, allowing pattern matching to dispatch the value to the correct handler for its type, from an exhaustive list.

One way to achieve a form of this in C# is via inheritance - through a base class (which represents the discriminated union type) and its derived classes, which represent the different types that could be dispatched.

public class UnionType { }

public class FirstType : UnionType { }
public class SecondType : UnionType { }

string Process(UnionType type)
{
    return type switch
    {
        FirstType f => "The first type",
        SecondType s => "The second type",
        _ => "I don't know this type",
    };
}

Console.WriteLine(Process(new SneakyThirdTypeYouDidNotKnowAbout()));

public class SneakyThirdTypeYouDidNotKnowAbout : UnionType { }

However, this has two issues.

  1. it is invasive
    • you have to implement the base class (or interface).
  2. there is no "exhaustive list"
    • our Process() function has no way of knowing it has dealt with all the available cases. Someone might have added another type without us looking - like SneakyThirdTypeYouDidNotKnowAbout.

The good news is that we can achieve a more flexible sum type using JSON Schema, with the oneOf keyword.

This defines a list of schema, and asserts that an instance is valid for exactly one of those possible schema.

This addresses the two issues above.

  1. The schema in the list do not need to have anything common. It is just a list of arbitrary schema.

    Specifically, the schema in the union do not need to know that the union exists. Therefore it is not invasive in that sense.

    However, they must have something which uniquely discriminates them such that only one of the schema in the oneOf array is valid for the instance. It is the responsibility of the person defining the oneOf union schema to ensure that is the case.

  2. The oneOf keyword exhaustively lists the types in the union, so pattern matching guarantees that it will cover all valid cases.

In our example, we are discriminating between a string, an int32, an object (conforming to the person-open schema) and an array of items (that also conform to the person-open schema).

File: discriminated-union-by-type.json

{
    "oneOf": [
        { "type": "string" },
        {
            "type": "integer",
            "format": "int32"
        },
        { "$ref": "./person-open.json" },
        { "$ref": "#/$defs/people" }
    ],
    "$defs": {
        "people": {
            "type": "array",
            "items": { "$ref": "./person-open.json" }
        }
    }
}

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 this example, we have an entirely heterogeneous set of schema in our discriminated union.

In the next article in the series, we will look at a tagged union of schema, discriminated by a property that indicates type, similar to that found in OpenAPI's somewhat redundant polymorphism feature, and System.Text.Json's polymorphic serialization.

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api discriminated-union-by-type.json
Generating: DiscriminatedUnionByType
Generating: HeightEntity
Generating: ConstrainedString
Generating: People
Generating: PersonOpen

Example code

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

// Create an instance of a type in the discriminated union.
PersonOpen personForDiscriminatedUnion = PersonOpen.Create(
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.57);

// Implicit conversion to the discriminated union type from
// the matchable-types
Console.WriteLine(
    ProcessDiscriminatedUnion(personForDiscriminatedUnion));

Console.WriteLine(
    ProcessDiscriminatedUnion("Hello from the pattern matching"));

Console.WriteLine(
    ProcessDiscriminatedUnion(32));

Console.WriteLine(
    ProcessDiscriminatedUnion([personForDiscriminatedUnion]));

// A function that processes the discriminated union type
string ProcessDiscriminatedUnion(in DiscriminatedUnionByType value)
{
    // Pattern matching against a discriminated union type
    // requires you to deal with all known types and the fallback (failure) case
    return value.Match(
        (in JsonString value) => $"It was a string. {value}",
        (in JsonInt32 value) => $"It was an int32. {value}",
        (in PersonOpen value) => $"It was a person. {value.FamilyName}, {value.GivenName}",
        (in DiscriminatedUnionByType.People value) => $"It was an array of people. {value.GetArrayLength()}",
        (in DiscriminatedUnionByType unknownValue) => throw new InvalidOperationException($"Unexpected instance {unknownValue}"));
}

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.