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
- Source Generator Code Generation and CLI Code Generation for generating the types.
- Parsing & Reading JSON for the read-only document model.
- Building & Mutating JSON for building and modifying arbitrary documents.
- Performance Techniques for the UTF-8 and allocation model underneath.