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.
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}"));
}