Skip to content

Data Object Validation

recipe JSON Schema C# data object validation type required properties minLength maxLength format minimum maximum EvaluateSchema IsValid CreateBuilder

This recipe demonstrates how to validate JSON data against schema constraints using the generated types from Corvus.Text.Json, including fast boolean-only validation and detailed diagnostic output with the JsonSchemaResultsCollector.

The Pattern

JSON Schema lets you define structural rules constraining the shape of the JSON documents you expect to conform to that schema. The schema statically defines those rules, and a validation tool (in this case, the types generated by Corvus.Text.Json) applies them to an instance of a JSON document.

If the document is valid, you know you have structurally valid data over which you can safely reason, based on the constraints in the schema.

The rules of JSON Schema are powerful, but certainly do not support every kind of business rule you could imagine. In particular, they cannot deal with "non-local" context (e.g. "the current time", "this value somewhere else in the document", "some other system state from outside the document"). Schema validation operates on the document alone — it has no access to external databases, session state, the system clock, or any other runtime context.

So "validation" is typically in two phases:

  1. Structural validation with JSON Schema — does the data conform to the schema's type, format, and range constraints? Are the required properties present? Are the string lengths within bounds? JSON Schema handles this exhaustively.
  2. Semantic validation with traditional business rules in a stateful context — does this person's age match their birth date? Is this ID unique in the database? These questions require non-local context that JSON Schema cannot access, so they must be answered in your application code.

Essentially, you can dynamically apply your business rules to the document in some stateful context, safe in the knowledge that the document is structurally sound.

The Schema

We build on the Person schema from Recipe 001, adding constraints. We require familyName, givenName, and birthDate. String lengths are constrained to 1–256 characters. Height is constrained to a positive double with a maximum of 3.0.

File: person-constraints.json

{
  "title": "The person schema https://schema.org/Person",
  "type": "object",
  "required": [ "familyName", "givenName", "birthDate" ],
  "properties": {
    "familyName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 256
    },
    "givenName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 256
    },
    "otherNames": {
      "type": "string",
      "minLength": 1,
      "maxLength": 256
    },
    "birthDate": {
      "type": "string",
      "format": "date"
    },
    "height": {
      "type": "number",
      "format": "double",
      "minimum": 0.0,
      "maximum": 3.0
    }
  }
}

The constraints used in this recipe — required, minLength, maxLength, minimum, maximum, and format — are the most commonly used JSON Schema keywords in practice. There are many other type-specific constraints you can apply, from a regular expression pattern on a string, to numerous format values on strings or numbers such as date-time, uint16, uuid. It is worth getting familiar with the full set of constraints and the .NET types emitted when you use them.

Custom types are generated for the constrained properties:

  • GivenNameEntity for GivenName
  • FamilyNameEntity for FamilyName
  • OtherNamesEntity for OtherNames
  • HeightEntity for Height

These can be used as "specialized" versions of the primitive types on which they are based.

Because Height has "format": "double", an implicit conversion to double is available (no explicit cast needed).

Generated Code Usage

Example code

Creating an instance with invalid data

using JsonWorkspace workspace = JsonWorkspace.Create();
using var personDoc = PersonConstraints.CreateBuilder(
    workspace,
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.52,
    otherNames: string.Empty);
PersonConstraints personConstraints = personDoc.RootElement;

Fast boolean-only validation

// Zero-allocation boolean-only validation
if (!personConstraints.EvaluateSchema())
{
    Console.WriteLine("Validation failed.");
}

Detailed validation with diagnostics

// Use a results collector for diagnostics
// (best used only when fast validation indicates an invalid condition)
using JsonSchemaResultsCollector collector =
    JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed);

personConstraints.EvaluateSchema(collector);

foreach (JsonSchemaResultsCollector.Result result in collector.EnumerateResults())
{
    if (!result.IsMatch)
    {
        Console.WriteLine($"  Message:  {result.GetMessageText()}");
        Console.WriteLine($"  Path:     {result.GetDocumentEvaluationLocationText()}");
        Console.WriteLine($"  Schema:   {result.GetSchemaEvaluationLocationText()}");
    }
}

Using format-based implicit conversions

// Height has format: "double", so implicit conversion to double is available
double heightValue = personConstraints.Height;

Verbosity levels

The JsonSchemaResultsCollector supports three levels:

  • Basic — failure messages only (lowest overhead, for production)
  • Detailed — failures with document path and schema location (for diagnostics)
  • Verbose — all events including successes (for debugging)

Key Differences from V4

V4 (Corvus.Json)

// Create directly
PersonConstraints person = PersonConstraints.Create(
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.52,
    otherNames: string.Empty);

// Boolean-only validation
bool isValid = person.IsValid();

// Detailed validation
ValidationContext result = person.Validate(ValidationContext.ValidContext);
if (!result.IsValid)
{
    foreach (ValidationResult error in result.Results)
    {
        Console.WriteLine($"  {error.Message} at {error.Location}");
    }
}

V5 (Corvus.Text.Json)

// Create using workspace and builder pattern
using JsonWorkspace workspace = JsonWorkspace.Create();
using var personDoc = PersonConstraints.CreateBuilder(
    workspace,
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.52,
    otherNames: string.Empty);
PersonConstraints person = personDoc.RootElement;

// Boolean-only validation (zero-allocation)
bool isValid = person.EvaluateSchema();

// Detailed validation with results collector
using JsonSchemaResultsCollector collector =
    JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed);
person.EvaluateSchema(collector);

foreach (JsonSchemaResultsCollector.Result result in collector.EnumerateResults())
{
    if (!result.IsMatch)
    {
        Console.WriteLine($"  {result.GetMessageText()} at {result.GetDocumentEvaluationLocationText()}");
    }
}

Key differences:

  • V5 uses EvaluateSchema() instead of V4's IsValid() for boolean validation
  • V5 uses JsonSchemaResultsCollector with configurable verbosity levels instead of V4's ValidationContext
  • V5 requires a JsonWorkspace for creation via CreateBuilder(workspace, prop: value, ...)
  • V5 results collector is disposable and uses using for deterministic cleanup
  • Both versions support a two-phase validation pattern: fast boolean check first, detailed diagnostics only on failure

Running the Example

cd docs/ExampleRecipes/002-DataObjectValidation
dotnet run

Frequently Asked Questions

How fast is schema validation compared to manual property checks?

The boolean-only EvaluateSchema() overload is optimized for the fast path: it performs zero allocations and short-circuits on the first failure. For most documents, it is comparable in cost to hand-written if checks. The detailed EvaluateSchema(collector) overload is more expensive because it must collect diagnostics for every constraint — use it only after the fast path indicates failure.

When should I use Basic vs. Detailed vs. Verbose results levels?

Use Basic in production when you only need failure messages (e.g., to return a 400 response). Use Detailed when diagnosing validation failures during development or in error-reporting middleware — it includes the JSON Pointer path to the failing property and the schema location of the constraint. Use Verbose only for debugging, as it logs every constraint evaluation including successes.

What is the difference between structural validation and semantic validation?

Structural validation (handled by JSON Schema) checks that the data conforms to type, format, and range constraints — e.g., "is familyName a string between 1 and 256 characters?" Semantic validation (handled by your application code) checks business rules that require external context — e.g., "is this email address unique in the database?" or "is the birth date in the past?" Always perform structural validation first, then apply semantic rules to structurally valid data.

Does Corvus.Text.Json validate format keywords automatically?

A: Yes. When a property has a format keyword (e.g., "format": "date", "format": "double", "format": "uuid"), EvaluateSchema() validates that the value conforms to that format. Note that Corvus.Text.Json enables format assertion by default, even though the JSON Schema 2020-12 specification treats format as an annotation (not an assertion) by default. This means invalid formats will cause validation to fail out of the box. If you need the spec-compliant annotation-only behaviour, you can disable format assertion via configuration options.

Can I add custom validation constraints beyond what JSON Schema supports?

JSON Schema does not support non-local constraints (database lookups, cross-property comparisons, etc.), but you can layer your own validation on top. First call EvaluateSchema() to ensure structural validity, then run your custom business rules against the typed properties. This two-phase approach keeps your custom validation code simple because you can rely on the structural guarantees already established by the schema.