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:
- Type-safe property accessors for every property in the schema -
person.Name,person.Age, etc. - Validation via
EvaluateSchema()with full support for whatever schema draft your$schemakeyword indicates - Parsing from strings, byte arrays, streams, and sequences via
ParsedJsonDocument<T>.Parse() - Serialization via
WriteTo(Utf8JsonWriter)andToString() - Implicit conversions to and from .NET primitive types
- A mutable builder via
CreateBuilder(JsonWorkspace)for in-place modification (we'll cover this in a later post) - Pattern matching via
Match()foroneOf/anyOfdiscriminated unions - 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.