Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
Introducing Corvus.Text.Json V5: Source-Generated Types

At endjin, we maintain Corvus.JsonSchema, and in the previous post we gave an overview of the V5 engine and what it brings to JSON in .NET.

In this post, we'll look at the heart of the developer experience: source-generated types from JSON Schema.

The idea: schema first, types for free

If you've been following our JSON Schema Patterns in .NET series, you'll know we're fans of a schema-first approach. You define your JSON document structure in a schema, and let tooling generate the C# for you.

With V5, that workflow runs at build time with a Roslyn incremental source generator. You annotate a partial struct, point it at a schema file, and get a complete implementation: properties, validation, serialization, implicit conversions, and mutable builders, all with full IntelliSense as you type.

There is no runtime reflection and no code-first attributes. You just need a schema and a struct.

Quick start

1. Create a JSON Schema

Here's a simple schema describing a person:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "required": ["name"],
    "properties": {
        "name": { "type": "string", "minLength": 1 },
        "age": { "type": "integer", "format": "int32", "minimum": 0 }
    }
}

2. Add the NuGet packages

<PackageReference Include="Corvus.Text.Json.SourceGenerator" Version="5.1.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Corvus.Text.Json" Version="5.1.0" />

3. Register the schema and declare a struct

In your .csproj:

<ItemGroup>
  <AdditionalFiles Include="Schemas/person.json" />
</ItemGroup>

In a .cs file:

using Corvus.Text.Json;

namespace MyApp.Models;

[JsonSchemaTypeGenerator("Schemas/person.json")]
public readonly partial struct Person;

4. Use it

Build, and everything is available immediately:

using var doc = ParsedJsonDocument<Person>.Parse(
    """{"name":"Alice","age":30}""");
Person person = doc.RootElement;

string name = (string)person.Name;      // "Alice"
int age = (int)person.Age;              // 30
bool valid = person.EvaluateSchema();   // true

What gets generated

For each [JsonSchemaTypeGenerator] attribute, the source generator produces:

  1. Type-safe property accessors for every property in the schema - person.Name, person.Age, etc.
  2. Validation via EvaluateSchema() with full support for whatever schema draft your $schema keyword indicates
  3. Parsing from strings, byte arrays, streams, and sequences via ParsedJsonDocument<T>.Parse()
  4. Serialization via WriteTo(Utf8JsonWriter) and ToString()
  5. Implicit conversions to and from .NET primitive types
  6. A mutable builder via CreateBuilder(JsonWorkspace) for in-place modification (we'll cover this in a later post)
  7. Pattern matching via Match() for oneOf/anyOf discriminated unions
  8. Equality operators and GetHashCode()

All generated types are readonly struct values. They're lightweight indexes into pooled JSON data, not heap-allocated objects. This is fundamental to the V5 memory model, and we'll explore it in detail in the post on pooled-memory parsing.

Duck typing: if it validates, it fits

Here's something that might surprise you if you're used to C#'s nominal type system.

Because every generated type is just a view into the same underlying JSON data - a struct containing a document reference and an integer index - you can reinterpret any value as any other type. The conversion doesn't copy data or change the JSON. It just creates a new view with different typed accessors.

// "address" is some JSON element - maybe it came from a JsonElement, maybe
// from a different schema type, maybe from a query result
Address address = Address.From(ref someElement);

// Did it actually validate as an Address?
if (address.EvaluateSchema())
{
    // Yes - we can safely use the typed accessors
    string city = (string)address.City;
}

This is duck typing in the JSON Schema sense: if the JSON data satisfies the schema, you can use it as that type - regardless of where it came from or what type it was originally parsed as.

This is possible because the types don't own or transform the data. They're views. Address.From(ref someElement) doesn't deserialize into an Address. It wraps the same document location in a struct that has City, Street, and Postcode accessors. The data hasn't moved. We've just put on a different pair of glasses.

Pattern matching with Match()

This duck-typing model is the foundation for our oneOf and anyOf support. When your schema says a value can be one of several types, Match() tries each variant's schema in turn and dispatches to the first one that validates:

// Schema: Shape = oneOf [Circle, Rectangle, Triangle]
string description = shape.Match(
    static (in Circle c) => $"Circle with radius {(double)c.Radius}",
    static (in Rectangle r) => $"Rectangle {(double)r.Width}×{(double)r.Height}",
    static (in Triangle t) => $"Triangle with {(int)t.Sides} sides",
    static (in Shape s) => "Unknown shape");

Each branch receives a view of the same JSON data, reinterpreted as the matching type. The lambda parameters are static to avoid accidental closure allocations. There's also a Match overload that accepts a context parameter if you need to pass state in without allocating.

This isn't just syntactic sugar. It's a direct expression of how JSON Schema composition works: oneOf means "exactly one of these schemas validates", and Match() finds which one.

Multi-schema projects

If your schema uses $ref to reference other schema files, you need to register all of them:

<ItemGroup>
  <AdditionalFiles Include="Schemas/**/*.json" />
</ItemGroup>

And you can generate types from multiple definitions within a single schema:

[JsonSchemaTypeGenerator("Schemas/api.json#/$defs/Address")]
public readonly partial struct Address;

[JsonSchemaTypeGenerator("Schemas/api.json#/$defs/PhoneNumber")]
public readonly partial struct PhoneNumber;

The #/$defs/Address part is a JSON Pointer fragment that targets a specific definition within the schema.

Tuning the generator

Several MSBuild properties let you control how types are generated:

<PropertyGroup>
  <!-- Use an explicit cast to string (makes allocations visible) -->
  <CorvusTextJsonUseImplicitOperatorString>false</CorvusTextJsonUseImplicitOperatorString>

  <!-- Treat optional properties as nullable .NET types -->
  <CorvusTextJsonOptionalAsNullable>NullOrUndefined</CorvusTextJsonOptionalAsNullable>

  <!-- Control format keyword enforcement -->
  <CorvusTextJsonAlwaysAssertFormat>true</CorvusTextJsonAlwaysAssertFormat>
</PropertyGroup>

Setting CorvusTextJsonOptionalAsNullable to NullOrUndefined means optional properties generate as T?. A JSON null or missing value maps to C# null. When this is omitted (the default), optional properties use the full type and you check for Undefined explicitly.

The CLI alternative

The source generator and CLI tool produce identical output. The CLI is useful when you want to pre-generate code, inspect it, or integrate into a pipeline that doesn't use Roslyn:

corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Models \
    --outputPath Generated/ \
    --outputRootTypeName Person

You can also use --engine V4 to target the V4 code generator instead. It's the same tool and the same schema analysis, but with different output.

Inspecting the generated code

If you want to see what the source generator produces, enable compiler-generated file output:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

The generated files will appear under obj/ in your build output. This is handy for debugging or just satisfying curiosity.

Next up

In the next post, we'll look at what happens when you call person.EvaluateSchema(). It's schema validation that runs over 10× faster than other .NET validators.

FAQs

How does the source generator differ from the CLI tool? They produce identical output. The source generator runs automatically during build with IntelliSense support. The CLI tool runs ahead of time and is useful for CI/CD pipelines, pre-generation workflows, or when you want to inspect and version-control the generated code.
What schema drafts are supported? The source generator supports JSON Schema Draft 4, 6, 7, 2019-09, 2020-12, and OpenAPI 3.0.
Do I need to register schema files in the .csproj? Yes. Every schema file your types reference - including files referenced via $ref - must be registered as AdditionalFiles in your project file.

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.