Skip to content

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 corvusjson CLI tool (with --engine V4 for 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/anyOf union 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 required name property and an optional dateOfBirth (a date-formatted string)
  • PersonName — an object with a required familyName and optional givenName and otherNames
  • PersonNameElement — a string with length constraints (1–256 characters)
  • OtherNames — a oneOf union: either a single PersonNameElement string or a PersonNameElementArray
  • Link — a HAL-style web link (not referenced by Person, 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 --optionalAsNullable to the code generator to emit optional properties as Nullable<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-encoded XXXUtf8 properties 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/else and enum types.