Dynamic Schema Validation
Overview
Corvus.Text.Json.Validator is a library for loading and validating JSON documents against JSON Schema at runtime. Unlike the build-time source generator, the Validator compiles schemas into an in-memory evaluator with the runtime evaluator — no code generation and no Roslyn — making it ideal for scenarios where schemas are not known at compile time, such as schema registries, configuration validation, or user-supplied schemas.
It supports all major JSON Schema drafts (Draft 4, 6, 7, 2019-09, and 2020-12), including custom metaschemas that declare their vocabularies with $vocabulary.
Key Features
- Millisecond Cold Start: Schemas compile into an in-memory evaluator in milliseconds, with no code generation and no compiler in the process
- Zero-Allocation Validation: Once warm, pass/fail validation of a parsed document allocates nothing
- Multiple Schema Drafts: Supports Draft 4, 6, 7, 2019-09, and 2020-12 with automatic draft detection
- Schema Caching: Compiled schemas are cached so repeated validations against the same schema are fast
- Multiple Input Formats: Validate from strings, byte arrays, streams,
ReadOnlyMemory,ReadOnlySequence, or pre-parsedJsonElement - Detailed Diagnostics: Optional results collector provides hierarchical validation failures with schema locations and error messages
- External Schema Resolution: Resolve
$refreferences via file system, HTTP, or pre-loaded additional schema files
Installation
dotnet add package Corvus.Text.Json.Validator
Quick Start
The main entry point is the JsonSchema struct. Load a schema, then validate JSON documents against it:
using Corvus.Text.Json.Validator;
// Load a schema from a file
JsonSchema schema = JsonSchema.FromFile("Schemas/person.json");
// Validate a JSON string
bool isValid = schema.Validate("""{"name": "Alice", "age": 30}""");
Loading Schemas
JsonSchema provides several factory methods for loading schemas from different sources:
From a File
JsonSchema schema = JsonSchema.FromFile("path/to/schema.json");
The file's location is the base URI for resolving relative $ref references unless the schema declares an absolute $id. The schema is cached under its normalised file path.
From a String
string schemaText = """
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/person",
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
}
}
""";
JsonSchema schema = JsonSchema.FromText(schemaText);
If the schema does not contain an $id property, you must provide a canonical URI explicitly:
JsonSchema schema = JsonSchema.FromText(schemaText, canonicalUri: "https://example.com/person");
From a Stream
using FileStream stream = File.OpenRead("schema.json");
JsonSchema schema = JsonSchema.FromStream(stream);
From a URI
Resolve a schema by its canonical URI. The document is retrieved through the additionalDocumentResolver, the additionalSchemaFiles, the embedded standard metaschemas, and then the file system or HTTP, depending on the URI scheme. A fragment selects a subschema within the document:
JsonSchema schema = JsonSchema.FromUri("https://example.com/schemas/person.json");
// Or use the shorthand alias
JsonSchema schema = JsonSchema.From("file:///C:/schemas/person.json");
// Validate against a subschema of the document
JsonSchema address = JsonSchema.FromUri("https://example.com/schemas/person.json#/$defs/address");
Validating Documents
Once you have a JsonSchema, call Validate() with the JSON document in any supported format:
// From a string
bool valid = schema.Validate("""{"name": "Alice"}""");
// From UTF-8 bytes
ReadOnlyMemory<byte> utf8 = Encoding.UTF8.GetBytes("""{"name": "Alice"}""");
bool valid = schema.Validate(utf8);
// From a stream (e.g., HTTP request body)
bool valid = schema.Validate(requestStream);
// From a pre-parsed JsonElement
using var doc = ParsedJsonDocument<JsonElement>.Parse(json);
bool valid = schema.Validate(in doc.RootElement);
All overloads return true if the document is valid, false otherwise.
Detailed Validation Results
For diagnostic output, pass an IJsonSchemaResultsCollector to Validate(). The collector records every validation step, including the schema location and evaluation path for failures:
using JsonSchemaResultsCollector collector =
JsonSchemaResultsCollector.Create(JsonSchemaResultsLevel.Detailed);
bool valid = schema.Validate(json, collector);
if (!valid)
{
foreach (var result in collector.EnumerateResults())
{
if (!result.IsMatch)
{
Console.WriteLine($" Failed: {result}");
}
}
}
Results Levels
| Level | What It Collects |
|---|---|
Basic |
Failure messages without location information |
Detailed |
Failure messages with schema location and evaluation path |
Verbose |
All evaluation steps, including successful validations |
Omit the collector for maximum performance when you only need a boolean result: flag validation fails fast and allocates nothing. Use Detailed or Verbose when diagnosing schema violations.
Configuration Options
Pass a JsonSchema.Options instance to any factory method to control schema compilation behaviour:
var options = new JsonSchema.Options(
alwaysAssertFormat: true,
allowFileSystemAndHttpResolution: true,
defaultDialect: JsonSchemaDialect.Draft202012,
additionalSchemaFiles: new[]
{
new AdditionalSchemaFile(
canonicalUri: "https://example.com/shared/address.json",
filePath: "Schemas/address.json")
});
JsonSchema schema = JsonSchema.FromFile("person.json", options: options);
Available Options
| Option | Default | Description |
|---|---|---|
alwaysAssertFormat |
true |
When true, the format keyword is enforced as a validation assertion. When false, it is treated as an annotation only (per the JSON Schema specification). |
allowFileSystemAndHttpResolution |
true |
Enable resolution of $ref references via file:// and http:///https:// URIs. Set to false to restrict resolution to pre-loaded schemas only. |
defaultDialect |
JsonSchemaDialect.Draft202012 |
The JSON Schema dialect to use when the schema does not include a $schema keyword. JsonSchemaDialect.OpenApi30 and JsonSchemaDialect.OpenApi20 are the OpenAPI Schema Object dialects: Draft 4, plus every format the validator knows (date, uuid and so on) and nullable (and x-nullable in 2.0). |
additionalSchemaFiles |
null |
Pre-load external schema files for $ref resolution. Each entry maps a canonical URI to a local file path; the file is also registered under its $id and its full path. |
additionalDocumentResolver |
null |
A JsonSchemaDocumentResolver delegate that supplies referenced documents from memory, consulted before the file system and HTTP. |
codeGeneration |
JsonSchemaCodeGeneration.Disabled |
Whether, and when, the schema is compiled to IL at run time. See Runtime Code Generation. |
regexMatchTimeout |
null (1 second) |
How long one regular expression match may run before it throws RegexMatchTimeoutException. See Regular Expression Match Timeout. |
Pre-loading Referenced Schemas
When your schema uses $ref to reference other schemas, you can pre-load them with AdditionalSchemaFile:
var options = new JsonSchema.Options(
additionalSchemaFiles: new[]
{
new AdditionalSchemaFile(
"https://example.com/schemas/address.json",
"Schemas/address.json"),
new AdditionalSchemaFile(
"https://example.com/schemas/phone.json",
"Schemas/phone.json")
});
JsonSchema schema = JsonSchema.FromFile("Schemas/person.json", options: options);
This avoids network calls for referenced schemas and ensures deterministic builds. It is particularly useful in CI/CD environments where external resolution may be unreliable or disallowed.
Runtime Code Generation
By default a schema is interpreted. The Validator can also compile it to IL at run time, which about halves the time of a warm validation that collects no results. Turn it on with the codeGeneration option:
using Corvus.Text.Json.RuntimeEvaluator;
using Corvus.Text.Json.Validator;
var options = new JsonSchema.Options(
codeGeneration: JsonSchemaCodeGeneration.AfterWarmUp);
JsonSchema schema = JsonSchema.FromFile("person.json", options: options);
bool valid = schema.Validate("""{"name": "Alice"}""");
| Value | Behaviour |
|---|---|
Disabled |
The default. Every validation is interpreted. |
AfterWarmUp |
The schema is compiled on a background thread once it has made 1,000 validations that collect no results. No validation waits for it. |
Eager |
The schema is compiled on the calling thread before its first validation that collects no results, which waits for it. |
Things to know:
- Generated code gives the same results as the interpreter. A validation with a results collector is always interpreted.
- Compiling takes about 20 ms for a typical schema and up to 0.7 s for a very large one.
AfterWarmUpsuits a cached schema that may be used a few times or many.Eagersuits a long-lived schema whose first validations should already run at full speed. - Code is generated only on .NET 9 and later, where the runtime can compile code at run time. On .NET Framework and under native AOT the option is accepted and the schema is interpreted, with no error.
The runtime evaluator guide has the measurements and the cases that are not compiled.
Regular Expression Match Timeout
A pattern or patternProperties keyword that the evaluator cannot match by itself is matched by a .NET Regex. Some patterns take .NET's engine longer than anyone will wait, so each match has a timeout. It is 1 second unless the regexMatchTimeout option says otherwise:
using System;
using Corvus.Text.Json.Validator;
var options = new JsonSchema.Options(
regexMatchTimeout: TimeSpan.FromSeconds(5));
JsonSchema schema = JsonSchema.FromFile("./person-schema.json", options);
- A match that runs for longer throws
RegexMatchTimeoutExceptionout ofValidate. The document is not reported as invalid. CallValidateinsidetrywhen the schema or the document is not trusted. System.Text.RegularExpressions.Regex.InfiniteMatchTimeoutmatches with no timeout. A value that is zero, negative, or longer than aRegexaccepts throwsArgumentOutOfRangeExceptionwhen the options are created.- Patterns are matched with Corvus.Regex.Ecma262, which matches a pattern with no backreference in time proportional to the pattern's size times the text, so the timeout matters only for a pattern with a backreference, such as
^(a|aa)+\1$. - The value is passed to the evaluator as
JsonSchemaEvaluatorOptions.RegexMatchTimeout. The runtime evaluator guide says more about it.
Invalid Patterns
A pattern or patternProperties pattern that is not a valid ECMA-262 regular expression is refused when the schema is compiled: the JsonSchema.From* method throws JsonSchemaCompilationException, whose message names the pattern and says what is wrong and where. See Patterns in the runtime evaluator guide.
A pattern over a limit of size is refused in the same place with the same exception, and its message says that the pattern is too large and names the limit. It does not say that the pattern is not valid. The limits are Corvus.Regex.Ecma262's, and bound the time and the memory one pattern of a schema can take when the schema is compiled.
Schema Caching
Compiled schemas are cached automatically by their canonical URI, alwaysAssertFormat flag, codeGeneration setting and regexMatchTimeout. Subsequent calls to any From* method with the same URI return the cached validator without recompilation:
// First call: compiles the schema (milliseconds)
JsonSchema schema1 = JsonSchema.FromFile("person.json");
// Second call: returns the cached evaluator
JsonSchema schema2 = JsonSchema.FromFile("person.json");
To force recompilation (for example, after updating a schema file), pass refreshCache: true:
JsonSchema schema = JsonSchema.FromFile("person.json", refreshCache: true);
How It Works
The Validator is a thin wrapper over the runtime evaluator:
- Load the JSON Schema document and any documents it references, using the registered resolvers
- Compile the schema graph into an in-memory evaluator: every
$refis resolved at compile time, regular expressions are compiled, and flag-mode fast paths (discriminators, type unions, unrolled objects) are precomputed - Cache the evaluator by canonical URI,
alwaysAssertFormat,codeGenerationandregexMatchTimeoutfor subsequent validations against the same schema
Validation walks the parsed document once against the compiled graph. It shares the format, number, and string helpers used by generated code, so it agrees with source-generated models instance for instance, and it produces the same results-collector output and annotations. No hosting configuration is required: the Validator has no dependency on Roslyn or on the host's compilation context.
Supported JSON Schema Drafts
| Draft | $schema URI |
|---|---|
| Draft 4 | http://json-schema.org/draft-04/schema |
| Draft 6 | http://json-schema.org/draft-06/schema |
| Draft 7 | http://json-schema.org/draft-07/schema |
| Draft 2019-09 | https://json-schema.org/draft/2019-09/schema |
| Draft 2020-12 | https://json-schema.org/draft/2020-12/schema |
Custom metaschemas are supported when they declare their vocabularies with $vocabulary; metaschemas without $vocabulary (Draft 7 and earlier) fall back to the dialect selected by $schema or defaultDialect.
Use Cases
- Schema Registry Validation: Validate messages against schemas fetched from a central registry at runtime
- Configuration Validation: Validate user-supplied configuration files against application-defined schemas
- API Gateway Validation: Validate HTTP request/response bodies against OpenAPI schemas
- Testing: Validate test fixtures against schemas without pre-generating types
- Dynamic Schema Selection: Choose schemas based on runtime conditions (e.g., document version, content type)