Introduction — V4 Engine
This hands-on tutorial walks you through JSON Schema-based code generation with the V4 engine (Corvus.Json.ExtendedTypes and Corvus.Json.JsonSchema.TypeGeneratorTool). It builds on System.Text.Json to provide rich serialization, deserialization, composition, and validation support.
Looking for the V5 engine? The V5 engine (
Corvus.Text.Json) offers source-generator integration, pooled-memory parsing, mutable documents, and improved performance. See the V5 Getting Started guide.
What you will learn
- Generate C# code from JSON Schema using the
corvusjsonCLI tool (with--engine V4for V4 output) - Serialize and deserialize JSON documents with the generated types
- Validate JSON documents against schema
- Navigate JSON documents — including additional properties, arrays, and union types
- Create new JSON documents using factory methods and composition
- Use pattern matching with
oneOf/anyOfunion types
Prerequisites
- .NET 8 SDK or later (or Visual Studio 2022)
- A shell with developer tools in the path (PowerShell, Terminal, etc.)
- A text editor or IDE (VS Code, Visual Studio, etc.)
Note: .NET 8.0 support on the V4 engine will be dropped in November 2026 when .NET 8 reaches end-of-life.
Some familiarity with C#, JSON Schema, and System.Text.Json will help, but is not required.
Getting started
First, install the code generator tool globally:
dotnet tool install --global Corvus.Json.JsonSchema.TypeGeneratorTool --prerelease
Create a console app and add the extended types package:
dotnet new console -o JsonSchemaSample -f net8.0
cd JsonSchemaSample
dotnet add package Corvus.Json.ExtendedTypes
Designing with JSON Schema
We will work with a JSON Schema document describing a Person. Here is the complete schema — save it as api/person-from-api.json in your project:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "JSON Schema for a Person entity coming back from a 3rd party API",
"$defs": {
"Person": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "$ref": "#/$defs/PersonName" },
"dateOfBirth": {
"type": "string",
"format": "date"
}
}
},
"PersonName": {
"type": "object",
"description": "A name of a person.",
"required": [ "familyName" ],
"properties": {
"givenName": {
"$ref": "#/$defs/PersonNameElement",
"description": "The person's given name."
},
"familyName": {
"$ref": "#/$defs/PersonNameElement",
"description": "The person's family name."
},
"otherNames": {
"$ref": "#/$defs/OtherNames",
"description": "Other (middle) names for the person"
}
}
},
"OtherNames": {
"oneOf": [
{ "$ref": "#/$defs/PersonNameElement" },
{ "$ref": "#/$defs/PersonNameElementArray" }
]
},
"PersonNameElementArray": {
"type": "array",
"items": {
"$ref": "#/$defs/PersonNameElement"
}
},
"PersonNameElement": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"Link":
{
"required": [ "href" ],
"type": "object",
"properties": {
"href": {
"title": "URI of the target resource",
"type": "string",
"description": "Either a URI [RFC3986] or URI Template [RFC6570] of the target resource."
},
"templated": {
"title": "URI Template",
"type": "boolean",
"description": "Is true when the link object's href property is a URI Template. Defaults to false.",
"default": false
},
"type": {
"title": "Media type indication of the target resource",
"pattern": "^(application|audio|example|image|message|model|multipart|text|video)\\\\/[a-zA-Z0-9!#\\\\$&\\\\.\\\\+-\\\\^_]{1,127}$",
"type": "string"
},
"name": {
"title": "Secondary key",
"type": "string"
},
"profile": {
"title": "Additional semantics of the target resource",
"type": "string",
"format": "uri"
},
"description": {
"title": "Human-readable identifier",
"type": "string"
},
"hreflang": {
"title": "Language indication of the target resource [RFC5988]",
"type": "string"
}
}
}
}
}
Key schema features
Person— an object with a requirednameproperty and an optionaldateOfBirth(adate-formatted string)PersonName— an object with a requiredfamilyNameand optionalgivenNameandotherNamesPersonNameElement— a string with length constraints (1–256 characters)OtherNames— aoneOfunion: either a singlePersonNameElementstring or aPersonNameElementArrayLink— a HAL-style web link (not referenced byPerson, included for later use)
The OtherNames union type allows backwards-compatible API evolution — a previous version might support only a single string, while a newer version adds the array form.
Generating C# code
Run the code generator, specifying the root namespace and the path to the Person definition:
corvusjson jsonschema --rootNamespace JsonSchemaSample.Api --rootPath #/$defs/Person person-from-api.json --engine V4
This produces files for each schema element reachable from Person:
| Schema location | Files |
|---|---|
#/$defs/Person |
Person.cs, Person.Object.cs, Person.Validate.cs |
#/$defs/PersonName |
PersonName.cs, PersonName.Object.cs, PersonName.Validate.cs |
#/$defs/PersonNameElement |
PersonNameElement.cs, PersonNameElement.String.cs, PersonNameElement.Validate.cs |
#/$defs/OtherNames |
OtherNames.cs, OtherNames.Array.cs, OtherNames.String.cs, OtherNames.Validate.cs |
#/$defs/PersonNameElementArray |
PersonNameElementArray.cs, PersonNameElementArray.Validate.cs |
Note that the Link schema was not generated — the tool only generates types reachable from the root path.
Build the project to verify everything compiles:
cd ..
dotnet build
Consuming JSON — "Deserialization"
Our generated types are readonly struct wrappers over System.Text.Json's JsonElement. They provide named property accessors with just-in-time conversion to .NET types — without copying the underlying UTF-8 bytes.
Parsing from a JSON string
Add using statements and parse a JSON document:
using System.Text.Json;
using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;
string jsonText =
"""
{
"name": {
"familyName": "Oldroyd",
"givenName": "Michael",
"otherNames": ["Francis", "James"]
},
"dateOfBirth": "1944-07-14"
}
""";
using JsonDocument document = JsonDocument.Parse(jsonText);
Person michaelOldroyd = new(document.RootElement);
Access properties with strongly-typed accessors:
string familyName = (string)michaelOldroyd.Name.FamilyName;
string givenName = (string)michaelOldroyd.Name.GivenName;
LocalDate dateOfBirth = michaelOldroyd.DateOfBirth;
Console.WriteLine($"{familyName}, {givenName}: {dateOfBirth}");
// Output: Oldroyd, Michael: 14 July 1944
Creating the Person wrapper allocates almost nothing — it stores a reference to the JsonElement on the stack. String allocations only happen when you convert to .NET types.
Using JsonAny.Parse() and Person.Parse()
You don't have to manage JsonDocument lifetime manually. JsonAny.Parse() clones the relevant segment and disposes the underlying document:
Person michaelOldroyd = JsonAny.Parse(jsonText);
Or use the type-specific Parse() method directly:
var michaelOldroyd = Person.Parse(jsonText);
Type conversions
All types in Corvus.Json implement IJsonValue. Every generated type has implicit conversions to and from JsonAny, which enables zero-ceremony interop:
// Any IJsonValue → JsonAny → Any other IJsonValue
JsonFoo myFoo = ...;
JsonBar myBar = myFoo.As<JsonBar>();
The code generator examines each schema and emits appropriate conversions. For example, PersonNameElement (a string schema) converts implicitly to string, and JsonDate converts implicitly to NodaTime.LocalDate.
Converting between types doesn't guarantee validity — always validate if correctness matters.
Serialization
All JSON types support WriteTo(Utf8JsonWriter) for efficient output:
Utf8JsonWriter writer = ...;
michaelOldroyd.WriteTo(writer);
For quick inspection, use the Serialize() extension method:
string serialized = michaelOldroyd.Serialize();
Console.WriteLine(serialized);
// {"name":{"familyName":"Oldroyd","givenName":"Michael","otherNames":["Francis","James"]},"dateOfBirth":"1944-07-14"}
Prefer
WriteTo()in production — it writes UTF-8 bytes directly to the output without string allocation.Serialize()is convenient for debugging but allocates a string.
Validation
JSON Schema uses a duck-typing model — it describes the shape of data rather than enforcing a rigid type system. When you construct a C# type from JSON data, it will be safe to use only if the data is valid according to the schema.
The code generator emits a Validate() method on each type. For simple pass/fail checks, use the IsValid() extension:
bool isValid = michaelOldroyd.IsValid();
Console.WriteLine($"michaelOldroyd {(isValid ? "is" : "is not")} valid.");
// Output: michaelOldroyd is valid.
Invalid data
Even when data is invalid, you can still access the parts that are present:
string invalidJsonText =
"""
{
"name": {
"givenName": "Michael",
"otherNames": ["Francis", "James"]
},
"dateOfBirth": "1944-07-14"
}
""";
Person invalidPerson = JsonAny.Parse(invalidJsonText);
Console.WriteLine($"Is valid: {invalidPerson.IsValid()}");
// Output: Is valid: False
// The missing familyName makes it invalid, but we can still read what's there
string givenName = (string)invalidPerson.Name.GivenName;
Console.WriteLine(givenName);
// Output: Michael
This forgiving approach is useful for diagnostics and self-healing scenarios. If you need to fail fast, validate first and throw an exception.
Values, Null, and Undefined
JSON properties have three states:
{ "foo": 3.14 } // Present with a value
{ "foo": null } // Present but null
{} // Not present (undefined)
Accessing a property that is undefined will throw when you try to convert it to a .NET type. Use the extension methods to check first:
string givenName =
michaelOldroyd.Name.GivenName.IsNotUndefined()
? (string)michaelOldroyd.Name.GivenName
: "[no given name specified]";
Related extensions: IsNull(), IsNullOrUndefined(), IsNotUndefined().
Nullable properties: You can pass
--optionalAsNullableto the code generator to emit optional properties asNullable<T>directly, giving a more idiomatic .NET experience.
Additional properties
JSON Schema allows additional properties by default. Access them with TryGetProperty():
if (michaelOldroyd.TryGetProperty("occupation", out JsonAny occupation) &&
occupation.ValueKind == JsonValueKind.String)
{
Console.WriteLine($"occupation: {occupation.AsString}");
}
Enumerating properties
Use EnumerateObject() to iterate all properties. Filter out well-known properties using the generated JsonPropertyNames constants:
foreach (JsonObjectProperty property in michaelOldroyd.EnumerateObject())
{
if (property.NameEquals(Person.JsonPropertyNames.DateOfBirthUtf8) ||
property.NameEquals(Person.JsonPropertyNames.NameUtf8))
{
continue; // Skip well-known properties
}
Console.WriteLine($"{(string)property.Name}: {property.Value}");
}
The
NameEquals()method with the pre-encodedXXXUtf8properties avoids allocating strings for property name comparisons.
Working with arrays
Array types expose EnumerateArray(), Length, and indexer access:
foreach (PersonNameElement otherName in michaelOldroyd.Name.OtherNames.EnumerateArray())
{
Console.WriteLine(otherName);
}
// Output:
// Francis
// James
Preserving information
Unlike code-first serializers, the V4 type model preserves all information through roundtrips — including additional properties, unknown extensions, and structural details — even when converting between types.
Creating JSON
Creating primitives
Primitive types support both implicit conversion and explicit construction:
JsonString myString = "hello";
JsonNumber myNumber = 3.14;
JsonBoolean myBool = true;
JsonNull myNull = JsonAny.Null;
Explore the extended types like JsonDateTime, JsonInteger, JsonEmail, and others for format-specific schemas.
Creating arrays
Array types with a simple item schema emit FromItems() and FromRange() factory methods:
// From individual items (with implicit conversion from string)
var otherNames = PersonNameElementArray.FromItems("Margaret", "Nancy");
// From an existing collection
var nameList = new List<PersonNameElement> { "Margaret", "Nancy" };
PersonNameElementArray array = PersonNameElementArray.FromRange(nameList);
Creating objects with Create()
The code generator emits Create() factory methods that understand required vs. optional properties:
Person audreyJones =
Person.Create(
name: PersonName.Create(
givenName: "Audrey",
otherNames: PersonNameElementArray.FromItems("Margaret", "Nancy"),
familyName: "Jones"),
dateOfBirth: new LocalDate(1947, 11, 7));
A minimal valid Person needs only the required name with a familyName:
var minPerson = Person.Create(PersonName.Create("Jones"));
Optional vs. Null in Create()
When creating objects, .NET null means do not set the property (undefined), while JsonAny.Null means set the property to JSON null:
// dateOfBirth is undefined — not present in JSON
Person.Create(
name: PersonName.Create("Jones"),
dateOfBirth: null);
// Produces: { "name": {"familyName": "Jones"} }
// dateOfBirth is explicitly null
Person.Create(
name: PersonName.Create("Jones"),
dateOfBirth: JsonAny.Null);
// Produces: { "name": {"familyName": "Jones"}, "dateOfBirth": null }
Union types
The OtherNames property uses oneOf to represent a union of PersonNameElement (string) and PersonNameElementArray (array). The code generator emits Is... and As... members for each variant:
OtherNames otherNames = michaelOldroyd.Name.OtherNames;
if (otherNames.IsPersonNameElementArray)
{
PersonNameElementArray array = otherNames.AsPersonNameElementArray;
// Use the array
}
There is also a TryGetAs... pattern:
if (michaelOldroyd.Name.OtherNames.TryGetAsPersonNameElementArray(
out PersonNameElementArray otherNamesArray))
{
otherNamesArray.EnumerateArray();
}
Pattern matching with Match()
Any union type (oneOf, anyOf, allOf, if/then/else) emits an exhaustive Match() method that takes a delegate for each variant:
string result = audreyJones.Name.OtherNames.Match(
static (in PersonNameElement otherNames) =>
$"Other names: {otherNames}",
static (in PersonNameElementArray otherNames) =>
$"Other names: {string.Join(", ", otherNames)}",
static (in OtherNames value) =>
throw new InvalidOperationException($"Unexpected type: {value}"));
Because the match is exhaustive, you avoid common errors with missing cases.
There are similar match methods for
if/then/elseandenumtypes.