Skip to content

Source Generator Code Generation

Overview

Corvus.Text.Json.SourceGenerator is a Roslyn incremental source generator that produces strongly-typed C# models from JSON Schema at build time. Simply annotate a partial struct with [JsonSchemaTypeGenerator] pointing at a schema file, and the generator produces a complete implementation — type-safe property accessors, validation, serialization, implicit conversions, and mutable builder support — all without leaving your IDE.

The source generator produces identical output to the corvusjson CLI tool, but runs automatically during the build with full IntelliSense support as you type.

Tip: If you only need validation and annotation collection without the full type system, you can also generate a standalone schema evaluator by setting EmitEvaluator = true on the attribute.

Installation

Add both the source generator and the core runtime package to your project:

<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" />

Quick Start

1. Create a JSON Schema

Create a schema file in your project, for example Schemas/person.json:

{
    "$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. Register the schema as an AdditionalFile

In your .csproj, register the schema file so the source generator can find it:

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

3. Declare a partial struct

Annotate a partial struct with [JsonSchemaTypeGenerator], pointing at the schema file relative to the .cs file:

using Corvus.Text.Json;

namespace MyApp.Models;

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

4. Build and use

The generator fills in the struct at build time. You get full IntelliSense 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

The JsonSchemaTypeGenerator Attribute

The attribute accepts a schema location and an optional rebase flag:

[JsonSchemaTypeGenerator(string location, bool rebaseToRootPath = false)]
Parameter Type Required Default Description
location string Yes — Path to the JSON Schema file, relative to the .cs file declaring the struct. Can include a JSON Pointer fragment to target a specific definition (e.g., "schema.json#/$defs/Address").
rebaseToRootPath bool No false When true (and location includes a JSON Pointer), the document is rebased as if the element at the pointer is the root. This affects how $ref references within the extracted subtree are resolved.

Targeting a specific definition

If your schema file contains multiple definitions, use a JSON Pointer fragment to select one:

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

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

Rules for the target struct

  • Must be a partial struct (not a class, record, or interface)
  • Must be declared inside a namespace (file-scoped or block-scoped)
  • The struct name becomes the .NET type name for the root schema type
  • The struct's accessibility (public, internal) is preserved in the generated code

Registering Schema Files

The source generator discovers schemas through the MSBuild AdditionalFiles mechanism. Every JSON Schema file your types reference — including files referenced via $ref — must be registered:

<ItemGroup>
  <!-- The schema your type uses directly -->
  <AdditionalFiles Include="Schemas/person.json" />

  <!-- Schemas referenced via $ref -->
  <AdditionalFiles Include="Schemas/address.json" />
  <AdditionalFiles Include="Schemas/phone.json" />
</ItemGroup>

You can use glob patterns to include all schemas in a directory:

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

All registered JSON files are loaded into a pre-populated document resolver at build time. When the generator encounters a $ref, it resolves it against this resolver — no file system or network access occurs during generation.

MSBuild Configuration Properties

Control the generator's behaviour with MSBuild properties in your .csproj:

<PropertyGroup>
  <CorvusTextJsonFallbackVocabulary>Draft202012</CorvusTextJsonFallbackVocabulary>
  <CorvusTextJsonOptionalAsNullable>NullOrUndefined</CorvusTextJsonOptionalAsNullable>
  <CorvusTextJsonAlwaysAssertFormat>true</CorvusTextJsonAlwaysAssertFormat>
  <CorvusTextJsonFormatMode>date-time=disable;time=warning</CorvusTextJsonFormatMode>
</PropertyGroup>
Property Default Description
CorvusTextJsonFallbackVocabulary Draft202012 Fallback schema vocabulary when the $schema keyword is omitted. Values: Draft4, Draft6, Draft7, Draft201909, Draft202012, OpenApi30.
CorvusTextJsonOptionalAsNullable — When set to NullOrUndefined, optional properties generate as .NET nullable types (T?). JSON null or missing values map to C# null. When set to NullOrUndefinedExceptNonNullDefaulted, this applies to all optional properties except those that declare a non-null default, which are generated as the non-nullable type T (the default is returned when the property is absent); an optional property whose default is JSON null stays nullable. When omitted, optional properties use the full type and you check for Undefined explicitly.
CorvusTextJsonAlwaysAssertFormat true When true, the format keyword is enforced as a validation assertion. When false, it is treated as an annotation only.
CorvusTextJsonFormatMode — Per-format assertion mode overrides as semicolon-separated format=mode pairs, where mode is assert, disable, or warning (e.g. date-time=disable;time=warning). An override takes precedence over CorvusTextJsonAlwaysAssertFormat. disable makes the format annotation-only; warning validates but always succeeds, emitting a WARNING annotation on a mismatch. Warning mode applies to string formats only; for a numeric format it falls back to assert.
CorvusTextJsonUseImplicitOperatorString true When true, conversion operators to string are implicit. When false, explicit casting is required. Explicit casts make string allocations more visible.
CorvusTextJsonAddExplicitUsings true When true, generated files include using statements for standard implicit usings. Disable if your project already uses implicit usings and you want cleaner output.
CorvusTextJsonUseOptionalNameHeuristics true When true, applies naming heuristics to infer idiomatic C# names from JSON Schema property names and definitions.
CorvusTextJsonNativeEnums All Controls emission of native C# enums on generated types. StringEnums emits a nested KnownValues enum (with conversions) for pure string-enum schemas; FlagsObjects emits a nested [Flags] enum (with conversions) for objects whose declared properties are all boolean; All emits both; None emits neither.
CorvusTextJsonUnions true When true, a generated oneOf/anyOf type is also a C# union: with the C# 15 compiler (the .NET 11 SDK or later, any target framework except .NET Framework) switch and is patterns over its branch types work, exhaustively and without boxing. The generator also emits an internal UnionAttribute polyfill for target frameworks before .NET 11; define CORVUS_TEXT_JSON_NO_UNION_ATTRIBUTE_POLYFILL if the project already has one. Set to false to leave the union members out.
CorvusTextJsonRegexEngine Ecma262 The engine the generated regular expressions are written for. Ecma262 writes Corvus.Regex.Ecma262's compiled matchers, with the compiled regular expression as data (once each, in CorvusJsonSchemaRegularExpressions), for the program and for pattern-property fields, which are that library's Regex; the generator compiles them into the program, so the evaluator compiles none at run time. DotNet writes .NET's System.Text.RegularExpressions, as earlier versions did.
CorvusTextJsonRegexEdition each dialect's The edition of ECMA-262 whose grammar every pattern is read with: Current or Es2020. By default draft 2019-09 and draft 2020-12 read patterns with the grammar of ECMAScript 2020, which they cite, and the other dialects with today's. Ecma262 only.
CorvusTextJsonRegexUnicodeVersion Default The version of Unicode every pattern is read with: Default (V8's, 17), 13, 17, 18 or Latest. Ecma262 only.
CorvusTextJsonRegexMatchTimeout 1000 The limit, in milliseconds, on one match of a regular expression in generated code, or -1 for none. A match that runs for longer throws RegexMatchTimeoutException. A pattern proved to match in time proportional to the text has no limit whatever this says. Any other value is reported as an error of the generator. The V4 source generator's property is CorvusJsonSchemaRegexMatchTimeout.
CorvusTextJsonDisabledNamingHeuristics DocumentationNameHeuristic Semicolon-separated list of specific naming heuristics to disable (e.g., DocumentationNameHeuristic;PathNameHeuristic).
CorvusTextJsonSuppressAnalyzerDiagnosticsInGeneratedCode true When true, analyzer diagnostics located in the generated code are not reported (see Analyzers and generated code). Compiler diagnostics are unaffected. Set to false to have them reported.

Note on default: null. Under NullOrUndefined, an optional property whose default is JSON null returns its (null-valued) default instance — not C# null — when the property is absent. Under NullOrUndefinedExceptNonNullDefaulted, such a property instead returns C# null when absent, consistent with it remaining a nullable T?. Only a non-null default triggers generation as a non-nullable T.

What Gets Generated

For each [JsonSchemaTypeGenerator] attribute, the generator produces a complete implementation of the partial struct:

  • Type-safe property accessors for every property defined in the schema
  • Validation via EvaluateSchema() with full draft 2020-12 (or earlier draft) support. A pattern whose match runs for longer than 1 second (or the limit CorvusTextJsonRegexMatchTimeout sets) throws RegexMatchTimeoutException (see SchemaEvaluator.md). A pattern or patternProperties pattern that is not a valid ECMA-262 regular expression is reported as an error of the generator (CRV1001) and nothing is generated for the schema (see RuntimeEvaluator.md). A pattern over a limit of size is reported as CRV1003, which says that the pattern is too large and names the limit
  • Parsing from strings, byte arrays, streams, and sequences via ParsedJsonDocument<T>
  • Serialization via WriteTo(Utf8JsonWriter) and ToString()
  • Implicit conversions to and from .NET primitive types
  • Mutable builder via CreateBuilder(JsonWorkspace) for in-place modification
  • Immutable document factory via Create(...), returning a self-contained ParsedJsonDocument<T> built in a single pass — the create-but-don't-modify counterpart to CreateBuilder
  • Pattern matching via Match() for oneOf/anyOf discriminated unions, and MatchEvery() on anyOf compositions to visit every matching subschema with an accumulator
  • Equality operators and GetHashCode()

All generated types are readonly struct values — they are lightweight indexes into pooled JSON data, not heap-allocated objects.

Members that mirror a JsonElement member (TryGetProperty, the indexers, EnumerateObject, ParseValue, Clone, Freeze, the equality operators, the mutable partial's SetProperty and RemoveProperty) are documented with <inheritdoc cref="global::Corvus.Text.Json.JsonElement..."/>; IntelliSense resolves the reference from the Corvus.Text.Json.xml file in the package. Members without a runtime counterpart keep their own documentation.

Inspecting Generated Code

By default, generated code exists only in memory. To write it to disk for inspection:

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

Generated files appear under:

obj/Debug/{TargetFramework}/generated/Corvus.Text.Json.SourceGenerator/
  Person.g.cs
  Person.Mutable.g.cs
  ...

This is useful for debugging, code review, or understanding how the schema maps to C#.

Analyzers and generated code

Generated code is often most of what the compiler sees in a model project, and analyzers can spend most of a build on it. Two things are worth knowing.

Most analyzers skip generated code by themselves. An analyzer that calls ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) is never executed on a generated file: StyleCop, Roslynator, the Corvus analyzers and most of the .NET SDK's NetAnalyzers do. The generator's files qualify: every hint name ends in .g.cs and every file starts with // <auto-generated/>, which is how the analyzer driver classifies a file as generated.

A few analyze generated code, and they cost real time. An analyzer that never calls ConfigureGeneratedCodeAnalysis, or asks for GeneratedCodeAnalysisFlags.Analyze, executes on the generated files too. In the .NET 10 SDK these include CA2252 (preview features), CA1418, CA1420 and CA1421, and the Roslyn meta-analyzers (RS rules) that arrive with any Microsoft.CodeAnalysis reference. Building a project with -p:ReportAnalyzer=true -v:d prints a table of the time each analyzer took; in the model projects of this repository the analyzers that analyse generated code were 20 of 27 seconds of analyzer time before they were configured away.

The package hides analyzer diagnostics in the generated code by default: its build targets copy a small analyzer config file next to the generator's output paths and pass it to the compiler, with a section over this generator's files setting dotnet_analyzer_diagnostic.severity = none. A bulk severity hides reporting only. Compiler diagnostics in generated code (nullable warnings, CS0618, CS1591) are still reported, the analyzers still execute there, and the IDE, which applies no analyzer config to source-generated documents, still analyses them live. Set <CorvusTextJsonSuppressAnalyzerDiagnosticsInGeneratedCode>false</CorvusTextJsonSuppressAnalyzerDiagnosticsInGeneratedCode> to have those diagnostics reported.

To stop such an analyzer from executing on the generated files, which is where the time goes, configure it in your own repository:

  • A project whose every type is generated (a models-only project) can set <RunAnalyzers>false</RunAnalyzers>. Generators still run; no analyzer does.
  • A project that mixes generated and hand-written code can set every diagnostic of the analyzer to none in a global analyzer config (is_global = true) and restore each one for files on disk in .editorconfig, at the severity it has today:
# generated-code.globalconfig, passed to the compiler as an EditorConfigFiles item
is_global = true
dotnet_diagnostic.CA2252.severity = none
dotnet_diagnostic.CA1418.severity = none
dotnet_diagnostic.CA1420.severity = none
dotnet_diagnostic.CA1421.severity = none
# .editorconfig
[*.cs]
dotnet_diagnostic.CA2252.severity = error
dotnet_diagnostic.CA1418.severity = warning
dotnet_diagnostic.CA1420.severity = warning
dotnet_diagnostic.CA1421.severity = suggestion

The compiler gives source generator trees no per-file settings, so they see only the global none and the analyzer driver skips the analyzer on them, while files on disk keep their restored severity and every diagnostic they had. Write the restored value explicitly: a build never runs an analyzer whose diagnostics are all silent or suggestion, and default would count as enabling such a rule. This repository keeps its own list current with update-generated-code-analyzer-config.ps1, which reads the analyzers in use and writes both files.

Incremental Generation

The source generator is fully incremental — it only regenerates code when something relevant changes:

  • Modifying a .json schema file triggers regeneration for types using that schema
  • Changing a C# file that declares a [JsonSchemaTypeGenerator] attribute triggers regeneration for that type only
  • Changes to unrelated C# files (comments, other classes) do not trigger regeneration

This keeps build times fast even in large projects with many generated types.

Source Generator vs. CLI Tool

Both the source generator and the corvusjson CLI tool use the same code generation engine and produce identical output:

Aspect Source Generator CLI Tool
When it runs At build time, automatically On demand, from the command line
Triggered by [JsonSchemaTypeGenerator] attribute Explicit corvusjson jsonschema command
Output location In-memory (or obj/ with EmitCompilerGeneratedFiles) Any directory you specify
IDE integration Full IntelliSense as you type IntelliSense after generation + build
Configuration MSBuild properties in .csproj CLI arguments or JSON config file
Schema discovery <AdditionalFiles> in .csproj Command-line arguments or config file
Best for Day-to-day development CI pipelines, inspecting output, multi-schema configs

When to use the source generator

  • You want zero-configuration code generation that happens automatically during the build
  • You prefer generated code to stay out of source control
  • You want instant IntelliSense as you edit schema files
  • This is the recommended approach for most projects

When to use the CLI tool

  • You want to inspect or version-control the generated code
  • You have a multi-schema configuration file with shared settings
  • You need to pre-generate types in a CI/CD pipeline before the build
  • You want to compare V4 vs V5 output during migration

Troubleshooting

Symptom Cause Solution
Generated types not appearing Schema file not registered Add <AdditionalFiles Include="..." /> to your .csproj
$ref resolution errors Referenced schema not in AdditionalFiles Register all files in the $ref chain as AdditionalFiles
Type name collisions Multiple schemas define types with the same name Use [JsonSchemaTypeGenerator("schema.json#/$defs/Specific")] to target a specific definition
Unexpected property names Naming heuristic choosing a poor name Disable the heuristic with CorvusTextJsonDisabledNamingHeuristics
Generated code does not change after a schema edit The edited schema, or a document it references, is not an AdditionalFiles item, so the generator is not told about the change. (Releases before the fix for #963 also stopped generating after the first edit in a long-lived IDE or build-server session, reporting CS8785.) Add every schema the generation reads to AdditionalFiles; on an affected release, restart the IDE or build server, or clean and rebuild
Cross-assembly type mismatch on a generated type (CS0535, or a conversion that should exist but does not) Each generation root emits its own copy of the primitive types; there is no shared base JsonString, so the "same" concrete type from another assembly is a distinct type Fully-qualify the generated type at the cross-assembly seam rather than assuming a common base type
CS0029 on a model type named Source The generator emits a nested builder ref struct named Source on each type, which collides with a model of the same name Do not name a [JsonSchemaTypeGenerator] model Source
CS0246 for the shared primitive types A generation-root model was declared in a leaf namespace, so the shared primitives were emitted into that leaf instead of the assembly root Declare a generation-root model in the assembly's root namespace, even when its file sits in a subfolder