Skip to content

Consuming Generated Types

Overview

Corvus generates strongly-typed .NET models from JSON Schema, OpenAPI, and AsyncAPI (through the source generator, the CLI, and the runtime Validator). This guide is about reading and building those models in your own code, allocation free, without composing or parsing JSON strings by hand. The conventions below differ from System.Text.Json in a few ways that are worth knowing up front.

The types are Corvus's, not System.Text.Json's

A generated model and every value you read from it are Corvus.Text.Json types. JsonElement, JsonValueKind, and the typed accessors are Corvus's, operating on UTF-8 bytes. Reach for the Corvus members below rather than a System.Text.Json API of the same name.

Optional properties are Undefined, not null

An absent optional property returns an Undefined value, not null. A generated value is a non-nullable struct, so testing it against null never fires. Check with IsNotUndefined(), then convert to the native type.

string? baseWorkflowId = body.BaseWorkflowId.IsNotUndefined() ? (string)body.BaseWorkflowId : null;
long? seconds = body.RequestedDurationSeconds.IsNotUndefined() ? (long)body.RequestedDurationSeconds : null;

Reading values

Convert a scalar to its native type with a cast: (string)value, (long)value, (int)value, (bool)value. For a date or date-time, use the typed accessor rather than a cast plus DateTimeOffset.Parse, which is not JSON-Schema compliant.

DateTimeOffset when = element.GetDateTimeOffset();

To read a string value's bytes without allocating a managed string, use element.GetUtf8String() (see the UTF-8 section of Performance Techniques).

Arrays

EnumerateArray() gives the items of any array-valued type. A pure array type, one whose only type is array and which is not a tuple (prefixItems), is also an IReadOnlyCollection<T> of its item type, so it can be handed to an API that takes a collection, and foreach works on the value itself:

foreach (Order.LineItem item in order.LineItems)
{
    // ...
}

int count = order.LineItems.Count();          // LINQ, through IReadOnlyCollection<Order.LineItem>
IReadOnlyCollection<Order.LineItem> all = order.LineItems;

foreach over the value uses its own enumerator and does not allocate. LINQ and the interface box the value and its enumerator, as they do for any struct.

Discriminated unions: Match, or a switch

A oneOf schema generates a union. Read it with the generated Match<TResult>(...) (or the IsX / AsX accessors) rather than inspecting ValueKind by hand, so the compiler checks that you handled every case.

string description = shape.Match(
    static (in Shape.Circle c) => $"circle {(double)c.Radius}",
    static (in Shape.Square s) => $"square {(double)s.Side}",
    static (in Shape none) => "not a shape");

.NET 11 SDK or later, any target framework except .NET Framework: the generated type is also a C# union, so a switch or is pattern over the branch types is exhaustive without a fallback arm, and it does not box. A value that matches no branch has a null union value, so add a null arm when the input is untrusted, or test HasValue through the type's IUnionMembers interface. The requirement is the C# 15 compiler in the .NET 11 SDK; the project can still target net10.0. Older compilers ignore the union members and Match keeps working.

string description = shape switch
{
    Shape.Circle c => $"circle {(double)c.Radius}",
    Shape.Square s => $"square {(double)s.Side}",
    null => "not a shape",
};

Native enums: KnownValues and Flags

A pure string enum generates a nested native C# enum (KnownValues) and an object whose declared properties are all boolean generates a nested [Flags] enum, each with implicit conversions in both directions and a Source conversion for builders. Prefer these over hand-rolled mapping code; use the non-throwing TryGetKnownValue / TryGetFlags for external input, because the implicit conversions throw on data the schema would reject. Do not persist the enums' integer values; they follow schema ordering rules that can change when the schema evolves. See recipes 014-StringEnumerations and 043-FlagsEnums.

An absent optional object property throws on nested access

An absent optional object property returns a default value whose parent is null, so navigating into it throws a NullReferenceException. Gate nested access on the value's kind, which covers both the absent case and an explicit JSON null.

if (parent.Child.ValueKind == JsonValueKind.Object)
{
    // safe to read parent.Child.GrandChild here
}

Schema defaults materialise in the getter

If a property declares a non-null default in its schema, the generated getter returns that default when the property is absent. So express a server-assigned or optional-with-a-default value in the schema rather than hand-writing a null guard at every call site; the generated type carries the default for you.

The getter does not change the document: an absent property stays absent, and a system that reads the JSON without the schema does not see the default.

Writing the defaults into the document: ApplyDefaults()

When the JSON goes to a system that does not apply the schema's defaults itself, call ApplyDefaults() on the mutable element. It adds additional properties to the document: each property that is absent and has a default in the schema is added, with the schema's default as its value. It then does the same inside each property whose type has properties of its own, including an object it has just added from a default.

using JsonDocumentBuilder<Order.Mutable> builder = order.CreateBuilder(workspace);
builder.RootElement.ApplyDefaults();

// The document now holds the defaulted properties, ready to send upstream.
string json = builder.RootElement.ToString();

A property that is present keeps its value, and a property with no default is not added. The items of arrays are not visited, and the result is not validated: a default that does not satisfy its schema is written as it is. Calling it again changes nothing.

Building a value: convert and construct, never compose-then-parse

Do not build a body as a JSON string and ParseValue it (an interim string, a parse, and a re-validation). Convert a native value with the static From factory, and build a model with its generated factories.

JsonString token = JsonString.From(rawPageToken);                     // native value to a generated type
await client.ResumeRunAsync(runId, RewindResume.Build(targetCursor), ct);  // build a body, hand it straight over

Pick the factory by situation.

Factory Returns Use when
T.Build(field: v, …) T.Source (lazy) The default. Native values, including a JsonElement or array, passed straight in. Hand the Source to a consumer (a generated client or handler) that materialises it once. No workspace, no closure.
T.Build(static (ref T.Builder b) => …) T.Source (lazy) No fields to set, or you need imperative logic. The lambda must be static.
T.CreateBuilder(ws, field: v, …).RootElement T (materialised) Only when you need a materialised value in hand (to read it back or store it), not merely to pass it on.
T.CreateBuilder<TContext>(ws, ctx, static (in TContext ctx, ref T.Builder b) => …).RootElement T Materialising while threading runtime values into a builder loop or conditional, closure free.

For a discriminated union, build the variant, not the union; the variant's Source converts implicitly to the union's, so you pass the builder straight to the consumer with no materialisation. A const-discriminated variant sets its own discriminator inside Build, so you never set it yourself.

The JsonElement.Source ref-safety trap

JsonElement.Source is a ref struct created from an in JsonElement; it holds a ref to the element. Passing a JsonElement directly as an argument to a Build(in …) or CreateBuilder(in …) factory is fine (the call's scope keeps the source document alive). What fails is capturing a JsonElement in a non-static builder lambda, where the captured ref escapes into the closure and the compiler rejects it (CS8168 / CS8347 / CS8350).

// wrong: outputs is captured; its ref escapes the closure
JsonElement outputs = doc.RootElement;
var src = new SkipResume.Source((ref SkipResume.Builder b) => b.Create(skipOutputs: outputs));

// right: pass it to the native-field factory, no lambda, no capture
var src = SkipResume.Build(skipOutputs: doc.RootElement);

If you genuinely need imperative building and a threaded JsonElement, use the CreateBuilder<TContext> form so the value flows through scoped in rather than a closure. Always make builder lambdas static and thread state through the context tuple; a non-static lambda allocates a closure and reintroduces the capture problem.

Cross-assembly type identity

Each generation root emits its own copy of the primitive types, so there is no single shared JsonString. When a generated type crosses an assembly boundary (a public seam typed on it), reference it by its fully-qualified name; the same short name from another assembly is a distinct type. See the source generator troubleshooting.

Lifetime

A materialised value from .RootElement is a view over its JsonWorkspace, and a lazy .Source references whatever it was built from. Keep the JsonWorkspace or source ParsedJsonDocument alive until the consumer has finished writing the value, for example across the await on a client call. JsonWorkspace, JsonDocumentBuilder<T>, and ParsedJsonDocument<T> are all using-disposable.

See also