Introduction
Looking for the V4 engine? See the V4 Getting Started guide.
In this tutorial, you will learn how to define a JSON Schema, generate strongly-typed C# structs from it, and use those types to parse, query, create, mutate, serialize, and validate JSON data — all with zero-allocation performance on the hot path.
We will cover:
- Defining a schema — writing a JSON Schema that describes a
Personwith nested objects, arrays, and composition types - Project setup — installing the NuGet package and configuring the source generator
- Using generated types — parsing JSON, accessing properties, working with arrays, pattern matching on
oneOfvariants, and converting to .NET types - Creating and mutating — building documents from scratch, modifying properties and arrays, copying values between documents, and serializing with pooled writers
- Validation — checking data against the schema and collecting detailed error reports
Our example schema
This is the JSON Schema file we will work with throughout this Getting Started guide. It defines a Person type with nested structures, constrained values, and formatted strings — a realistic example that exercises many of the features Corvus.Text.Json provides.
Create a JSON Schema file, for example Schemas/person.json:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Person",
"$defs": {
"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."
}
}
},
"OtherNames": {
"oneOf": [
{ "$ref": "#/$defs/PersonNameElement" },
{ "$ref": "#/$defs/PersonNameElementArray" }
]
},
"PersonNameElementArray": {
"type": "array",
"items": { "$ref": "#/$defs/PersonNameElement" }
},
"PersonNameElement": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"Address": {
"allOf": [
{ "$ref": "#/$defs/Location" },
{
"type": "object",
"properties": {
"street": { "type": "string" },
"zipCode": { "type": "string" }
}
}
]
},
"Location": {
"type": "object",
"properties": {
"city": { "type": "string" },
"country": { "type": "string" }
}
}
},
"type": "object",
"required": ["name"],
"properties": {
"name": { "$ref": "#/$defs/PersonName" },
"dateOfBirth": {
"type": "string",
"format": "date"
},
"age": {
"type": "integer",
"format": "int32",
"minimum": 0,
"maximum": 150
},
"email": {
"type": "string",
"format": "email"
},
"address": { "$ref": "#/$defs/Address" },
"hobbies": {
"type": "array",
"items": { "type": "string" }
}
}
}
Both draft 2020-12 and draft 2019-09 are supported. If your schema does not declare a $schema keyword, you can set the fallback vocabulary via MSBuild properties.
Understanding the schema
The Person type is an object with a required name property and several optional properties: dateOfBirth (a formatted date string), age (an integer from 0 to 150), email, address (a composed object), and hobbies (an array of strings). The name property references a PersonName definition via $ref.
PersonName has a required familyName and optional givenName — both constrained to 1–256 character strings via the PersonNameElement definition. The otherNames property uses oneOf to accept either a single string or an array of strings. This is a common JSON Schema pattern for backwards-compatible API evolution:
// A single string
{ "familyName": "Oldroyd", "givenName": "Michael", "otherNames": "Francis James" }
// Or an array of strings
{ "familyName": "Oldroyd", "givenName": "Michael", "otherNames": ["Francis", "James"] }
The Address type uses allOf to compose a base Location (with city and country) with address-specific properties (street and zipCode). The generated type merges all properties from both schemas into a single struct:
{ "street": "123 Main St", "zipCode": "SP1 1AA", "city": "Springfield", "country": "UK" }
How generated types work
Generated types are readonly struct values that act as thin wrappers over the underlying JSON data. When you parse a JSON document, the data is stored as UTF-8 bytes in pooled memory. The generated struct is essentially an index into that data — it doesn't copy or deserialize the JSON upfront.
Values are converted to .NET primitives like string, int, or LocalDate only at the point of use, when you access a property or perform a cast. This "just-in-time" model means:
- No allocation on construction — creating a
Personfrom a parsed document is essentially free (a small struct on the stack). - No redundant copying — the underlying UTF-8 bytes are shared, not cloned.
- Conversion cost is deferred — you only pay for what you access.
The code generator walks the schema tree from the root type and generates C# for every schema it encounters. Each schema element typically produces multiple partial-class files by concern (e.g., Person.cs, Person.JsonSchema.cs, Person.Mutable.cs). Nested entity types like PersonName become nested structs within the parent type (e.g., Person.PersonNameEntity).
Source generator (recommended)
Add the source generator and runtime packages to your .csproj:
<ItemGroup>
<PackageReference Include="Corvus.Text.Json.SourceGenerator" Version="5.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Corvus.Text.Json" Version="5.0.0" />
</ItemGroup>
Register your JSON Schema files as AdditionalFiles:
<ItemGroup>
<AdditionalFiles Include="Schemas\person.json" />
</ItemGroup>
Then declare a partial struct annotated with JsonSchemaTypeGenerator:
using Corvus.Text.Json;
namespace MyApp.Models;
[JsonSchemaTypeGenerator("Schemas/person.json")]
public readonly partial struct Person;
Build the project. The generator produces the full implementation, deriving the namespace, accessibility, and type name from this declaration.
CLI tool
If you prefer ahead-of-time code generation, install the corvusjson .NET tool globally:
dotnet tool install --global Corvus.Json.Cli
Generate code from a schema:
corvusjson jsonschema \
--rootNamespace MyApp.Models \
--outputPath Generated/ \
Schemas/person.json
The CLI tool and the source generator share the same code generation engine and produce identical output. Choose the CLI tool when you need to generate code outside of the build process, or when integrating with non-MSBuild workflows.
Parsing
ParsedJsonDocument (preferred)
ParsedJsonDocument<T> manages a pooled-memory document. Dispose it when you're done:
using ParsedJsonDocument<Person> doc =
ParsedJsonDocument<Person>.Parse(
"""{"name":{"familyName":"Oldroyd","givenName":"Michael"},"age":30}""");
Person person = doc.RootElement;
ParseValue (convenience)
Returns a self-owned value — no explicit disposal needed, but the backing memory is not shared:
Person person = Person.ParseValue(
"""{"name":{"familyName":"Oldroyd","givenName":"Michael"},"age":30}""");
Other sources
// From UTF-8 bytes
Person person = Person.ParseValue(
"""{"name":{"familyName":"Oldroyd"},"age":30}"""u8);
// From a stream
using FileStream stream = File.OpenRead("person.json");
using ParsedJsonDocument<Person> doc = ParsedJsonDocument<Person>.Parse(stream);
Property access
Each schema property generates a typed accessor returning a nested entity struct:
Person person = doc.RootElement;
// Required property — name is a nested PersonName object
// Strings require an explicit cast (or GetString())
string familyName = (string)person.Name.FamilyName;
// Value types like int support implicit conversion — no cast needed
if (person.Age.IsNotUndefined())
{
int age = person.Age;
}
if (person.Email.IsNotUndefined())
{
string email = (string)person.Email;
}
Property indexers
Access properties dynamically by name, returning a JsonElement:
// UTF-8 string literal (preferred — avoids transcoding)
JsonElement name = person["name"u8];
// String overload also available (transcodes to UTF-8 internally)
JsonElement age = person["age"];
TryGetProperty
if (person.TryGetProperty("email"u8, out JsonElement email))
{
Console.WriteLine((string)email);
}
Array access
Our Person schema has two array types: hobbies is a simple "type": "array" with "items": { "type": "string" }, and PersonNameElementArray (used by otherNames via oneOf) is an array of PersonNameElement strings. The code generator produces strongly-typed array wrappers for both.
Enumerating array elements
The generated type supports foreach via EnumerateArray():
foreach (var hobby in person.Hobbies.EnumerateArray())
{
Console.WriteLine((string)hobby);
}
When otherNames is an array (rather than a single string), you can enumerate it in the same way:
// otherNames uses oneOf — it can be a single string or an array
// Use AsPersonNameElementArray to access the array variant
foreach (var name in person.Name.OtherNames.AsPersonNameElementArray.EnumerateArray())
{
Console.WriteLine((string)name);
}
The enumerator also supports LINQ:
var hobbies = person.Hobbies.EnumerateArray()
.Select(h => (string)h)
.ToList();
Array length and indexing
int count = person.Hobbies.GetArrayLength();
// Access by index
var first = person.Hobbies[0];
string firstHobby = (string)first;
How schema properties map to .NET types
String types
| Schema type | Format | .NET type |
|---|---|---|
"type": "string" |
(none) | string via GetString() or explicit cast |
"type": "string" |
date |
LocalDate (NodaTime) |
"type": "string" |
date-time |
OffsetDateTime (NodaTime) / DateTimeOffset |
"type": "string" |
time |
OffsetTime (NodaTime) |
"type": "string" |
duration |
Period (NodaTime) |
"type": "string" |
uuid |
Guid |
"type": "string" |
uri |
Utf8UriValue |
"type": "string" |
uri-reference |
Utf8UriReferenceValue |
"type": "string" |
uri-template |
string |
"type": "string" |
iri |
Utf8IriValue |
"type": "string" |
iri-reference |
Utf8IriReferenceValue |
"type": "string" |
email |
string (RFC 5322 validated) |
"type": "string" |
idn-email |
string (internationalized email) |
"type": "string" |
hostname |
string (RFC 1123 validated) |
"type": "string" |
idn-hostname |
string (internationalized hostname) |
"type": "string" |
ipv4 |
string (IPv4 validated) |
"type": "string" |
ipv6 |
string (IPv6 validated) |
"type": "string" |
json-pointer |
string (RFC 6901 validated) |
"type": "string" |
relative-json-pointer |
string |
"type": "string" |
regex |
string (ECMAScript regex) |
Integer types
| Schema type | Format | .NET type |
|---|---|---|
"type": "integer" |
(none) | long via GetInt64() |
"type": "integer" |
byte |
byte |
"type": "integer" |
sbyte |
sbyte |
"type": "integer" |
int16 |
short |
"type": "integer" |
int32 |
int |
"type": "integer" |
int64 |
long |
"type": "integer" |
int128 |
Int128 (.NET 7+) |
"type": "integer" |
uint16 |
ushort |
"type": "integer" |
uint32 |
uint |
"type": "integer" |
uint64 |
ulong |
"type": "integer" |
uint128 |
UInt128 (.NET 7+) |
Number types
| Schema type | Format | .NET type |
|---|---|---|
"type": "number" |
(none) | double via GetDouble() |
"type": "number" |
half |
Half (.NET 5+) |
"type": "number" |
single |
float |
"type": "number" |
double |
double |
"type": "number" |
decimal |
decimal |
Other types
| Schema type | Format | .NET type |
|---|---|---|
"type": "boolean" |
(none) | bool |
"type": "object" |
(none) | Generated nested struct |
"type": "array" |
(none) | Generated array type with enumeration |
Converting to .NET types
Implicit conversions (value types)
For .NET value types (int, double, bool, DateTime, etc.), the generated types support implicit conversion — no cast syntax needed:
int age = person.Age;
double score = person.Score;
bool isActive = person.IsActive;
This is the most concise approach and works for all value types.
Explicit cast (strings)
string requires an explicit cast by default, because every conversion allocates a new string:
string familyName = (string)person.Name.FamilyName;
Tip: You can opt in to implicit
stringconversion via the--useImplicitOperatorStringflag on the CLI tool, or theCorvusTextJsonUseImplicitOperatorStringMSBuild property in your.csprojfor the source generator. This trades allocation safety for convenience — use it when you know you need the string and are not in a hot path.
TryGetValue
A safe, non-throwing alternative that returns false if the value is absent or cannot be converted:
if (person.Age.TryGetValue(out int age)) { ... }
if (person.Name.FamilyName.TryGetValue(out string? name)) { ... }
GetString, GetUtf8String, and GetUtf16String
For string-valued properties:
// As a .NET string (allocates)
string familyName = person.Name.FamilyName.GetString();
// As an UnescapedUtf8JsonString (avoids allocation — always dispose)
using UnescapedUtf8JsonString utf8Name = person.Name.FamilyName.GetUtf8String();
ReadOnlySpan<byte> bytes = utf8Name.Span;
// As an UnescapedUtf16JsonString (avoids allocation — always dispose)
using UnescapedUtf16JsonString utf16Name = person.Name.FamilyName.GetUtf16String();
ReadOnlySpan<char> chars = utf16Name.Span;
GetUtf8String() gives you the unescaped UTF-8 bytes without allocating a string. GetUtf16String() gives you a char span — useful when you need to interop with APIs that expect ReadOnlySpan<char> without paying for a string allocation. Both return disposable types that must be disposed to return their buffers to the pool.
Values, Null, and Undefined
JSON values exist in three states:
{ "foo": 3.14 } // Present with a value
{ "foo": null } // Present but null
{} // Not present ("undefined")
Generated types expose this three-state model:
if (person.Email.IsNotUndefined())
{
// The property exists in the JSON (may still be null)
string email = (string)person.Email;
}
person.Email.IsUndefined() // true if the property is absent
person.Email.IsNull() // true if the property is present but null
person.Email.IsNullOrUndefined() // true if null or absent
person.Email.IsNotNullOrUndefined() // true if present with a non-null value
When you attempt to cast an undefined or null value to a .NET type, it throws an InvalidOperationException. Always check before casting optional properties.
Equality and comparison
Generated types support value equality:
Person a = Person.ParseValue(json);
Person b = Person.ParseValue(json);
bool equal = a.Equals(b); // true — deep JSON equality
bool same = a == b; // operator overload
Composition types and pattern matching
JSON Schema supports composition keywords like oneOf, anyOf, and allOf that let a value match one of several shapes. In our schema, OtherNames uses oneOf — it can be either a single PersonNameElement string or a PersonNameElementArray:
"OtherNames": {
"oneOf": [
{ "$ref": "#/$defs/PersonNameElement" },
{ "$ref": "#/$defs/PersonNameElementArray" }
]
}
The generated type provides a Match() method that dispatches to a typed delegate for each variant. Each variant gets its own named parameter, and a defaultMatch fallback handles values that don't conform to any variant:
string result = person.Name.OtherNames.Match(
matchPersonNameElement: static (v) => $"Single name: {(string)v}",
matchPersonNameElementArray: static (v)
=> $"Multiple names: {string.Join(", ", v.EnumerateArray().Select(n => (string)n))}",
defaultMatch: static (_) => "Unknown format");
Console.WriteLine(result);
Match() evaluates each variant's schema in order, calls the first matching delegate, and returns the result. All delegates must return the same type (TResult), which the compiler infers from usage.
There is also a context-passing overload for when you need to pass state into the matchers without capturing:
string result = person.Name.OtherNames.Match(
separator, // context passed to each matcher
matchPersonNameElement: static (v, sep) => (string)v,
matchPersonNameElementArray: static (v, sep)
=> string.Join(sep, v.EnumerateArray().Select(n => (string)n)),
defaultMatch: static (_, sep) => string.Empty);
Creating objects from scratch
Use the convenience CreateBuilder() overload with named property parameters:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var builder = Person.CreateBuilder(
workspace,
name: Person.PersonNameEntity.Build(
(ref nb) => nb.Create(familyName: "Oldroyd"u8, givenName: "Michael"u8)),
age: 30,
email: "michael@example.com"u8);
Console.WriteLine(builder.RootElement.ToString());
// {"name":{"familyName":"Oldroyd","givenName":"Michael"},"age":30,"email":"michael@example.com"}
Required parameters must be provided; optional ones can be omitted. Always use named parameters for clarity and resilience to schema evolution.
Nested objects
Use NestedType.Build() to compose nested values as parameters:
using var builder = Person.CreateBuilder(
workspace,
name: Person.PersonNameEntity.Build(
(ref nb) => nb.Create(familyName: "Oldroyd"u8, givenName: "Michael"u8)),
age: 30,
address: Person.AddressEntity.Build((ref ab) => ab.Create(
street: "123 Main St"u8,
city: "Springfield"u8)));
Array properties
Build array properties by adding elements inside the builder delegate:
using var builder = Person.CreateBuilder(
workspace,
name: Person.PersonNameEntity.Build(
(ref nb) => nb.Create(familyName: "Oldroyd"u8, givenName: "Michael"u8)),
hobbies: Person.HobbiesEntity.Build((ref hb) =>
{
hb.AddItem("reading"u8);
hb.AddItem("hiking"u8);
hb.AddItem("coding"u8);
}));
Advanced: delegate pattern
For scenarios that require logic inside the builder (e.g., conditional properties, From() conversions), use the delegate overload:
using var builder = TargetType.CreateBuilder(workspace, (ref TargetType.Builder b) =>
{
b.Create(
fullName: TargetType.FullNameEntity.From(source.Name),
identifier: TargetType.IdentifierEntity.From(source.Id));
});
Mutating properties
Generated types are immutable by default. To mutate, create a JsonDocumentBuilder via a JsonWorkspace:
using JsonWorkspace workspace = JsonWorkspace.Create();
using ParsedJsonDocument<Person> doc =
ParsedJsonDocument<Person>.Parse(
"""
{
"name": { "familyName": "Oldroyd", "givenName": "Michael" },
"age": 30
}
""");
using var builder = doc.RootElement.CreateBuilder(workspace);
Person.Mutable root = builder.RootElement;
root.SetAge(31);
root.SetEmail("michael@example.com"u8);
Console.WriteLine(root.ToString());
// {"name":{"familyName":"Oldroyd","givenName":"Michael"},"age":31,"email":"michael@example.com"}
Tip: If you don't need to retain an immutable copy of the original document (e.g., for comparison or auditing), you can parse directly into the builder for better performance:
using var builder = JsonDocumentBuilder<Person.Mutable>.Parse(workspace, json);This avoids the intermediate document allocation and second pass over the data. See Building & Mutating JSON for details.
Version tracking
The builder tracks a version number, and every Mutable element reference records the version at which it was obtained. If the document structure changes after you captured a reference, attempting to use that stale reference throws an InvalidOperationException.
You can make multiple modifications to the same entity without re-obtaining it:
Person.Mutable root = builder.RootElement;
root.SetAge(31);
root.SetEmail("michael@example.com"u8);
root.Address.SetCity("London"u8);
A root element is always live, so it never needs to be re-obtained. Intermediate child references, however, will be invalidated by sibling mutations — always navigate from the root to access different children:
Person.Mutable root = builder.RootElement; // always live — cache freely
root.RemoveEmail(); // structural change
root.Address.SetCity("London"u8); // still valid — root is always live
To avoid expensive lookups, you can make multiple modifications to the same entity. Its own version is refreshed when it is modified.
Person.Mutable root = builder.RootElement; // always live — cache freely
root.RemoveEmail(); // structural change
Person.AddressEntity.Mutable address = root.Address;
address.SetCity("London"u8); // still valid — root is always live
address.SetZipCode("SE3"u8); // still valid — root is always live
Removing properties
Optional properties can be removed from mutable instances:
root.RemoveEmail();
Copying values between documents
When the source and target properties share the same generated type, you can assign the value directly:
using ParsedJsonDocument<Person> sourceDoc =
ParsedJsonDocument<Person>.Parse(sourceJson);
using var targetBuilder = targetDoc.RootElement.CreateBuilder(workspace);
Person.Mutable target = targetBuilder.RootElement;
// Both documents use the same PersonNameEntity type — direct assignment
target.SetName(sourceDoc.RootElement.Name);
In practice, you often need to map between types generated from different schemas. Imagine a CRM system that defines its own Employee schema:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"employeeId": { "type": "integer", "format": "int32" },
"fullName": { "type": "string" },
"workEmail": { "type": "string", "format": "email", "maxLength": 254 },
"department": { "type": "string" }
},
"required": ["employeeId", "fullName", "workEmail"]
}
Both schemas have an email property based on "type": "string", "format": "email", but the Employee schema adds a maxLength constraint. That extra constraint means the code generator produces a distinct Employee.WorkEmailEntity type rather than reusing the shared global email type. Because the underlying JSON is structurally compatible, you can use TTarget.From() to reinterpret the value without copying:
using ParsedJsonDocument<Employee> employeeDoc =
ParsedJsonDocument<Employee>.Parse(employeeJson);
using var personBuilder = personDoc.RootElement.CreateBuilder(workspace);
Person.Mutable person = personBuilder.RootElement;
// Employee.WorkEmailEntity and Person.EmailEntity are different C# types
// but both wrap "type": "string", "format": "email" — From() bridges them
person.SetEmail(Person.EmailEntity.From(employeeDoc.RootElement.WorkEmail));
// Simple string values can also be assigned directly via implicit conversion
person.Name.SetGivenName(employeeDoc.RootElement.FullName);
From() performs a zero-copy reinterpretation of the underlying JSON — no parsing or allocation occurs. It works for any structurally compatible types, including nested objects and arrays.
Important: Setting a value in the target document does not copy the backing JSON data. Under the covers, it creates a reference to the relevant segment of the original UTF-8 JSON text. This makes cross-document property transfer very efficient for typical document processing pipelines — even for large nested objects or arrays, the cost is constant regardless of the size of the value.
Composing objects with Apply()
When a schema uses allOf to compose multiple object definitions, the generated type exposes an Apply() method on its Mutable variant. This lets you merge properties from a composed type into the current object.
In our schema, Address is composed from a base Location (with city and country) and additional address properties (street, zipCode). You can build the location separately and apply it:
using JsonWorkspace workspace = JsonWorkspace.Create();
using ParsedJsonDocument<Person> doc =
ParsedJsonDocument<Person>.Parse(
"""
{
"name": { "familyName": "Oldroyd" },
"address": { "street": "123 Main St", "zipCode": "SP1 1AA" }
}
""");
using var builder = doc.RootElement.CreateBuilder(workspace);
// Parse a Location with city and country
using ParsedJsonDocument<Person.AddressEntity.LocationEntity> locationDoc =
ParsedJsonDocument<Person.AddressEntity.LocationEntity>.Parse(
"""
{
"city": "Springfield",
"country": "UK"
}
""");
// Apply merges the location properties into the address
Person.Mutable root = builder.RootElement;
root.Address.Apply(locationDoc.RootElement);
Console.WriteLine(root.Address.ToString());
// {"street":"123 Main St","zipCode":"SP1 1AA","city":"Springfield","country":"UK"}
Apply() iterates the properties of the composed type and merges them into the target object. If a property already exists, it is overwritten. You can call Apply() multiple times to layer properties from different composed types — this is the mutable equivalent of JSON Schema's allOf composition.
Mutating arrays
Array properties on a Mutable element support in-place modification:
Person.Mutable root = builder.RootElement;
// Add an item to the end
root.Hobbies.AddItem("gardening"u8);
// Insert at a specific index
root.Hobbies.InsertItem(0, "cooking"u8);
// Replace an item at an index
root.Hobbies.SetItem(1, "swimming"u8);
// Remove by index
root.Hobbies.RemoveAt(0);
// Add multiple items at once
root.Hobbies.AddRange(static (ref JsonElement.ArrayBuilder b) =>
{
b.AddItem("yoga"u8);
b.AddItem("hiking"u8);
});
// Insert multiple items at a specific index
root.Hobbies.InsertRange(1, static (ref JsonElement.ArrayBuilder b) =>
{
b.AddItem("painting"u8);
b.AddItem("music"u8);
});
The standard mutation workflow is:
- Parse JSON into a
ParsedJsonDocument<T> - Create a
JsonDocumentBuilder<T.Mutable>via.CreateBuilder(workspace) - Get the
Mutableroot element from the builder - Call
Set*()/Remove*()methods on the mutable element - Convert to immutable via
.Clone()or.Freeze(), or serialize viaroot.WriteTo(writer)orroot.ToString()
Converting to immutable: Clone() and Freeze()
After building or mutating a document, you may need an immutable copy. There are two ways to do this, each with different trade-offs. Both can be called on any element — root or nested — and both return the strongly-typed immutable form (e.g., Person from Person.Mutable), not JsonElement.
Clone()
Clone() allocates new backing storage on the heap. The result is completely independent — it outlives the workspace and the source document, and will be cleaned up by garbage collection when it goes out of scope:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<Person>.Parse(json);
using var builder = doc.RootElement.CreateBuilder(workspace);
builder.RootElement.SetAge(31);
// Clone produces a standalone immutable copy
Person cloned = builder.RootElement.Clone();
// cloned is valid even after the workspace is disposed
Use Clone() when you need a value to escape the scope of the workspace — for example, returning a result from a method, storing it in a cache, or passing it to another thread.
Freeze()
Freeze() creates a cheap immutable copy within the same workspace. It copies only the metadata and value backing arrays — no JSON serialization or re-parsing occurs. You can freeze any element, not just the root:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<Person>.Parse(json);
using var builder = doc.RootElement.CreateBuilder(workspace);
builder.RootElement.SetAge(31);
// Freeze the root element
Person frozen = builder.RootElement.Freeze();
// Or freeze a nested element to get an immutable copy of just that subtree
Person.PersonNameEntity frozenName = builder.RootElement.Name.Freeze();
// Both are valid for the lifetime of the workspace
Use Freeze() when you need an immutable reference that stays within the workspace lifetime — for example, caching intermediate results while building a complex document, or capturing the document state before further mutations.
Freeze() also works on immutable elements: if the element is already backed by an immutable document, it returns the same instance without any copying.
Choosing between Clone() and Freeze()
| Clone() | Freeze() | |
|---|---|---|
| Cost | O(JSON size) — heap allocation | O(metadata size) — cheap blit |
| Lifetime | Standalone — outlives the workspace | Workspace-scoped |
| Use when | Value must escape the workspace | Value stays within the workspace |
Saving and restoring builder state: CreateSnapshot() and Restore()
While Clone() and Freeze() produce immutable elements, CreateSnapshot() and Restore() operate at the builder level — they save and restore the builder's entire internal state.
CreateSnapshot() rents copies of the builder's backing arrays (metadata, values, property maps) from ArrayPool. Restore() copies the data back into the builder's existing buffers — a pure memcpy with no allocations.
This is useful when you need to make tentative changes and then roll back, or when processing multiple records through the same template:
using JsonWorkspace workspace = JsonWorkspace.Create();
using var doc = ParsedJsonDocument<Person>.Parse(json);
using var builder = doc.RootElement.CreateBuilder(workspace);
Person.Mutable root = builder.RootElement;
root.SetAge(31);
// Capture the builder's current state
using var builderSnapshot = builder.CreateSnapshot();
// Make experimental changes
root.SetAge(99);
root.SetEmail("temp@example.com"u8);
// Roll back the builder to the captured state
builder.Restore(builderSnapshot);
root = builder.RootElement;
// root.Age is 31 again; Email is removed
The snapshot must be disposed when no longer needed (it holds rented arrays). Restore() invalidates any previously cached non-root mutable element references, just like any other structural mutation.
Serialization
Any generated type — whether parsed, built from scratch, or mutated — can be written back to JSON. The simplest approach allocates a string:
// To a JSON string (allocates)
string json = person.ToString();
This is convenient for logging, debugging, or any situation where a managed string is required.
Zero-allocation writing with pooled writers
For high-throughput scenarios where you want to avoid allocating strings, rent a Utf8JsonWriter and buffer from the workspace. The workspace manages a thread-local cache of writers and buffers, so repeated rent/return cycles are allocation-free:
using JsonWorkspace workspace = JsonWorkspace.Create();
Utf8JsonWriter writer = workspace.RentWriterAndBuffer(
defaultBufferSize: 1024,
out IByteBufferWriter bufferWriter);
try
{
person.WriteTo(writer);
writer.Flush();
// bufferWriter.WrittenSpan contains the UTF-8 JSON bytes
ReadOnlySpan<byte> utf8Json = bufferWriter.WrittenSpan;
}
finally
{
workspace.ReturnWriterAndBuffer(writer, bufferWriter);
}
Always return the writer and buffer in a finally block to ensure they are returned to the cache even if an exception occurs.
Bring your own buffer
If you already have your own IBufferWriter<byte> (e.g. writing directly to a network stream or a pipeline), you can rent just the writer:
var buffer = new ArrayBufferWriter<byte>();
Utf8JsonWriter writer = workspace.RentWriter(buffer);
try
{
person.WriteTo(writer);
writer.Flush();
}
finally
{
workspace.ReturnWriter(writer);
}
Writer options such as indentation are configured once on the workspace via its Options property and applied to every rented writer automatically — no need to pass JsonWriterOptions each time.
Schema validation and error reporting
JSON Schema uses a duck-typing model rather than a rigid type system. It describes the shape of valid data with constraints like "it must have these properties", "it must match one of these shapes", or "this value must be between 0 and 150". When you construct a generated type from JSON data, you can safely use it through that type if and only if the data is valid according to the schema.
Constructing a generated type from invalid JSON does not throw — the type is permissive. You can still access the parts of the data that are present. This is valuable for error reporting, diagnostics, and self-healing systems where you need to inspect malformed data.
This permissive model is also what makes composition types work. As we saw in the Composition types and pattern matching section, a oneOf type like OtherNames can hold data matching any of its variants. The Match() method uses schema validation internally to determine which variant the data conforms to, and dispatches accordingly. Without permissive parsing, you wouldn't be able to hold the data in the first place before determining its shape.
Basic validation
bool isValid = person.EvaluateSchema();
Detailed results
Pass a JsonSchemaResultsCollector to collect error messages and locations:
using JsonSchemaResultsCollector collector =
JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed);
bool isValid = person.EvaluateSchema(collector);
if (!isValid)
{
foreach (JsonSchemaResultsCollector.Result r in collector.EnumerateResults())
{
if (!r.IsMatch)
{
Console.WriteLine(
$"Error at {r.GetDocumentEvaluationLocationText()}: {r.GetMessageText()}");
}
}
}
Validation levels
| Level | Description |
|---|---|
| (no collector) | Fastest — returns only bool |
JsonSchemaResultsLevel.Basic |
Records failures with location information, but without error messages |
JsonSchemaResultsLevel.Detailed |
Records failures with location information and error messages |
JsonSchemaResultsLevel.Verbose |
Records all events — successes, failures, and ignored keywords — with full messages |
Example: validating input
using ParsedJsonDocument<Person> doc =
ParsedJsonDocument<Person>.Parse("""{"age":200}""");
Person person = doc.RootElement;
using JsonSchemaResultsCollector collector =
JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed);
bool isValid = person.EvaluateSchema(collector);
// isValid is false — "name" is required and age exceeds maximum of 150
foreach (JsonSchemaResultsCollector.Result r in collector.EnumerateResults())
{
if (!r.IsMatch)
{
Console.WriteLine(
$"{r.GetDocumentEvaluationLocationText()}: {r.GetMessageText()}");
}
}
The collector provides:
r.IsMatch— whether this individual constraint passedr.GetMessageText()— the error or success messager.GetDocumentEvaluationLocationText()— JSON pointer to the failing location in the documentr.GetSchemaEvaluationLocationText()— JSON pointer to the constraint within the schema