Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Polymorphism with discriminator properties

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.

Using discriminator properties for polymorphism

We've already seen the key approaches to polymorphism when we were extending and constraining base schema.

We took this further by using oneOf to create sum types and allow pattern matching

In this article we are going to see how these techniques combine to support what has become a common way for code-first serializers (like System.Text.Json serialization) to discriminate between types.

In this scenario, a family of schema all extend a base schema, which has a specific property (usually of type string) whose value is constrained to something unique to that extended schema. That constraint is applied using the const keyword.

In this example we are going to use the schema for operations provide by the JSON patch specification.

File: patch-operation.json

{
    "description": "A single JSON Patch operation",
    "oneOf": [
        { "$ref": "#/$defs/AddOperation" },
        { "$ref": "#/$defs/RemoveOperation" },
        { "$ref": "#/$defs/ReplaceOperation" },
        { "$ref": "#/$defs/MoveOperation" },
        { "$ref": "#/$defs/CopyOperation" },
        { "$ref": "#/$defs/TestOperation" }
    ],
    "$defs": {
        "PatchOperationCommon": {
            "required": [ "path", "op" ],
            "properties": {
                "path": { "$ref": "#/$defs/JsonPointer" },
                "op": {
                    "type": "string"
                }
            }
        },
        "AddOperation": {
            "description": "Add operation. Value can be any JSON value.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "add" }
            },
            "required": [ "value" ]
        },
        "RemoveOperation": {
            "description": "Remove operation. Only a path is specified.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "remove" }
            }
        },
        "ReplaceOperation": {
            "description": "Replace operation. Value can be any JSON value.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "replace" }
            },
            "required": [ "value" ]
        },
        "MoveOperation": {
            "description": "Move operation. \"from\" is a JSON Pointer.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "move" },
                "from": { "$ref": "#/$defs/JsonPointer" }
            },
            "required": [ "from" ]
        },
        "CopyOperation": {
            "description": "Copy operation. \"from\" is a JSON Pointer.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "copy" },
                "from": { "$ref": "#/$defs/JsonPointer" }
            },
            "required": [ "from" ]
        },
        "TestOperation": {
            "description": "Test operation. Value can be any JSON value.",
            "$ref": "#/$defs/PatchOperationCommon",
            "properties": {
                "op": { "const": "test" }
            },
            "required": [ "value" ]
        },
        "JsonPointer": {
            "type": "string",
            "format": "json-pointer"
        }
    }
}

Remember that we use "allOf": [{"$ref": "..."}] instead of the direct $ref in earlier drafts.

All of the individual operations ( TestOperation, CopyOperation, MoveOperation, ReplaceOperation, RemoveOperation, AddOperation) extend a common schema called PatchOperationCommon which has two properties - path and op.

Each individual operation constrains that op property to be a specific, constant string value which uniquely identifies the type of the operation.

Take a look at CopyOperation and MoveOperation for example. You will notice that they both extend PatchOperationCommon in the exact same way - they both add a from property with the same schema.

Without the const constraint on the op property they would be completely undiscriminated. So oneOf would always be invalid as two of the schema in the array would be valid.

With the const constraint we can discriminate between the two schema, and use pattern matching to dispatch the PatchOPeration to a handler function with the right semantics.

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api patch-operation.json
Generating: OpEntity
Generating: RemoveOperation
Generating: PatchOperation
Generating: MoveOperation
Generating: AddOperation
Generating: PatchOperationCommon
Generating: ReplaceOperation
Generating: OpEntity
Generating: OpEntity
Generating: OpEntity
Generating: CopyOperation
Generating: OpEntity
Generating: TestOperation
Generating: OpEntity

Notice that there is an OpEntity type generated corresponding to each of the operations.

Example code

using JsonSchemaSample.Api;

// Construct patch operations
PatchOperation.CopyOperation copyOp =
    PatchOperation.CopyOperation.Create("/some/path/to/copy", "/some/copy/destination");

PatchOperation.MoveOperation moveOp =
    PatchOperation.MoveOperation.Create("/some/path/to/move", "/some/move/destination");

// Implicit conversion to the discriminated union type
Console.WriteLine(
    ProcessJsonPatch(copyOp));

Console.WriteLine(
    ProcessJsonPatch(moveOp));

// Each operation has a const value associated with its `Op` value
// You would not normally need to use this directly.
Console.WriteLine(PatchOperation.MoveOperation.OpEntity.ConstInstance);
Console.WriteLine(PatchOperation.CopyOperation.OpEntity.ConstInstance);

// The processing function pattern matches on the types in the discriminated union
string ProcessJsonPatch(PatchOperation op)
{
    return op.Match(
        (in PatchOperation.AddOperation op) => $"Add: {op.Path} to {op.Value}",
        (in PatchOperation.RemoveOperation op) => $"Remove: {op.Path}",
        (in PatchOperation.ReplaceOperation op) => $"Replace: {op.Path} with {op.Value}",
        (in PatchOperation.MoveOperation op) => $"Move: {op.FromValue} to {op.Path}",
        (in PatchOperation.CopyOperation op) => $"Copy: {op.FromValue} to {op.Path}",
        (in PatchOperation.TestOperation op) => $"Test: {op.Value} at {op.Path}",
        (in PatchOperation op) => throw new InvalidOperationException($"Unknown JSON patch operation: {op}"));
}

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.