Skip to content
Matthew Adams By Matthew Adams Co-Founder · 3 min read
Introducing Corvus.Text.Json V5: Standalone Evaluator and Annotations

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at the mutable builder pattern.

Now let's look at a different code generation mode. You'll want this if you're building schema-driven tooling.

Not every tool needs a full type system

The source generator we covered in post 2 produces strongly-typed C# models: property accessors, validation, serialization, mutable builders, the works. That's exactly what you want when you're building an application that uses JSON data.

But some tools don't use the data. They describe it. A form generator reads the schema's title, description, and default keywords to render input fields. A documentation tool extracts examples and deprecated flags. A configuration editor shows readOnly and writeOnly hints.

These tools need annotations, not types.

What are JSON Schema annotations?

The JSON Schema specification defines a set of keywords that are annotation-producing. They carry metadata about the data rather than constraints on it:

Keyword Purpose
title Human-readable name for the schema or property
description Longer explanation of what the data means
default A default value for the property
examples An array of example values
deprecated Whether this property is deprecated
readOnly Whether this property is read-only
writeOnly Whether this property should not be returned in responses
format A format hint (e.g., "email", "date-time")
contentMediaType MIME type of string content
contentEncoding Encoding of string content (e.g., "base64")

These annotations flow through the validation process according to the specification. For example, annotations from a then branch are only collected if the if condition passes. Annotations from composition keywords (allOf, anyOf, oneOf) are merged according to the spec rules.

Getting this right requires a fully compliant evaluator. The V5 standalone evaluator provides exactly that.

Generating the standalone evaluator

With the source generator

Set EmitEvaluator = true on the attribute to generate both the typed model and the standalone evaluator:

using Corvus.Text.Json;

namespace MyApp.Models;

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

With the CLI tool

To generate only the evaluator (no typed models):

corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Evaluators \
    --outputPath Generated/ \
    --codeGenerationMode SchemaEvaluationOnly

Or generate both:

corvusjson jsonschema Schemas/person.json \
    --rootNamespace MyApp.Models \
    --outputPath Generated/ \
    --codeGenerationMode Both

The standalone evaluator is a single static class, considerably smaller than the full type hierarchy. It supports the same schema drafts (4, 6, 7, 2019-09, 2020-12, OpenAPI 3.0) and the same validation semantics.

Collecting annotations

To collect annotations, run the evaluator in Verbose mode and use JsonSchemaAnnotationProducer:

using var doc = ParsedJsonDocument<JsonElement>.Parse(jsonText);
JsonElement instance = doc.RootElement;

// Validate in Verbose mode - this collects all annotations
using var collector = JsonSchemaResultsCollector.Create(
    JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);

// Enumerate annotations - zero-allocation ref struct enumerator
foreach (JsonSchemaAnnotationProducer.Annotation annotation
    in JsonSchemaAnnotationProducer.EnumerateAnnotations(collector))
{
    Console.WriteLine(
        $"  {annotation.GetInstanceLocationText()} " +
        $"[{annotation.GetKeywordText()}] " +
        $"= {annotation.GetValueText()}");
}

Each Annotation has four pieces of information:

  • Instance location - the JSON Pointer to the value being annotated (e.g., "" for root, "/name" for a property)
  • Keyword - which annotation keyword produced it (e.g., "title", "description")
  • Schema location - where in the schema the annotation was defined
  • Value - the raw JSON value of the annotation

The Annotation type is a ref struct whose spans reference the internal buffers of the collector. It's only valid during the current iteration. If you need to capture values for later use, call the string accessors (GetKeywordText(), GetValueText(), etc.).

Writing annotations as JSON

For structured output, WriteAnnotationsTo produces a JSON object grouped by instance location, then keyword, then schema location. This is useful when feeding the output into other tools, such as a form renderer:

using var collector = JsonSchemaResultsCollector.Create(
    JsonSchemaResultsLevel.Verbose);
instance.EvaluateSchema(collector);

using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer,
    new JsonWriterOptions { Indented = true }))
{
    JsonSchemaAnnotationProducer.WriteAnnotationsTo(collector, writer);
}

This produces output like:

{
    "": {
        "title": {
            "#": "\"Person\""
        },
        "description": {
            "#": "\"A person with a name and optional age\""
        }
    },
    "/name": {
        "title": {
            "#/properties/name": "\"Full name\""
        }
    }
}

A form generator can walk this structure and render appropriate inputs for each annotated property. It can use each title as a label alongside the description as help text, pre-fill defaults where provided, and apply readOnly/writeOnly constraints to control editability.

When to use which mode

Scenario Mode
Application code that consumes JSON data TypeGeneration (default)
Form generator, documentation tool, schema-driven UI SchemaEvaluationOnly
Application code that also exposes a schema-driven API Both
Validation-only gateway or middleware SchemaEvaluationOnly

Next up

We've now covered the core V5 model - types, validation, pooled memory, mutation, and annotations. In the next post, we'll start looking at the query and transformation languages, beginning with JSONata.

FAQs

What are JSON Schema annotations? Annotations are metadata keywords like title, description, default, examples, deprecated, readOnly, and writeOnly. They carry information about the schema rather than validation constraints - useful for generating UIs, documentation, and tooling.
What is the difference between the standalone evaluator and the full type generator? The full generator produces a strongly-typed C# struct per schema type with properties, serialization, and builders. The standalone evaluator generates a single static class that validates and collects annotations - smaller footprint, fewer dependencies.
Can I generate both types and the evaluator? Yes. Set EmitEvaluator = true on the source generator attribute to get both, or use --codeGenerationMode Both with the CLI tool.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.