TypeDeclaration: An Abstraction for Understanding JSON Schema
At endjin, we maintain Corvus.JsonSchema, a high-performance JSON library for .NET. One of the less obvious things in the library is TypeDeclaration. It is the intermediate representation that sits between a raw JSON Schema and the C# code we generate from it.
The corvusjson CLI tool and the Roslyn source generator both use it to generate code. And the online Blazor playground uses it too: not to generate code, but to build an interactive schema explorer in the browser.
This post is about that abstraction: what it does, how it maps schema patterns to code patterns, and why the same model that drives code generation turns out to be equally useful for UI, documentation, and tooling.
The problem with raw JSON Schema
JSON Schema is a constraint language. It tells you what keywords are present on a schema and what values they have. But if you're building a code generator, a form builder, a documentation tool, or any other tooling that needs to understand a schema, raw keywords aren't enough.
Consider: is this schema a tuple?
{
"type": "array",
"prefixItems": [
{ "type": "number" },
{ "type": "number" },
{ "type": "number" }
],
"items": false
}
There is no "tuple" keyword in JSON Schema. The answer is yes. But only because prefixItems defines typed positional elements and items: false disallows additional elements. If you remove the items constraint, it becomes an array with prefix items but not a tuple.
Or: is this schema a rank-2 numeric tensor?
{
"type": "array",
"items": {
"type": "array",
"items": { "type": "number" }
}
}
Again, no keyword says "tensor." You have to follow the nested items chain, count the depth, and check whether the leaf type is numeric.
These are the kinds of questions that code generators, UI tools, and documentation generators need to answer. They all need to answer them the same way. That's the job of TypeDeclaration.
From schema keywords to code patterns
TypeDeclaration is the abstraction that sits between JSON Schema keywords and the code that consumes them. When you pass a schema to JsonSchemaTypeBuilder.AddTypeDeclarationsAsync(), the library analyses the combination of keywords at each schema location and produces a tree of TypeDeclaration objects, each exposing a set of high-level capabilities - not keywords.
This distinction matters. The model is defined in terms of what the schema can do (has typed positional items, has a fallback property type, implies numeric core types) rather than which particular keywords express it. Different vocabularies use different keywords for the same capability, but the TypeDeclaration API is the same regardless.
Some of these mappings are direct. JSON Schema's properties keyword describes named fields on an object; the TypeDeclaration model exposes those as PropertyDeclarations, each with a JSON name, a resolved type, and a required/optional flag. One schema keyword, one code concept.
But many of the most useful patterns are inferred.
Tuples
TupleType() returns a TupleTypeDeclaration with typed Item1, Item2, Item3 - but only when the schema has prefixItems and disallows additional items. The code generator uses this to emit tuple-style accessors. If the schema omits items: false, TupleType() returns null but ExplicitTupleType() still exposes the prefix items. The distinction matters: a pure tuple has a fixed shape; a prefixed array has typed leading elements but can grow.
Tensors and numeric arrays
ArrayRank() walks the nested items chain recursively. For the 2D matrix schema above it returns 2; for a flat number[] it returns 1. IsNumericArray() checks whether the innermost items type implies CoreTypes.Number or CoreTypes.Integer. Together, these let the code generator emit span-based tensor indexers. They also let any other consumer distinguish a flat list from a multidimensional numeric structure.
Enums and consts
JSON Schema's enum keyword defines allowed values directly. But many schemas express the same concept as anyOf with const members. The TypeDeclaration model normalises both patterns: AnyOfConstantValues() collects const values from composition members, while ExplicitSingleConstantValue() handles the direct const keyword. Consumers see a uniform API regardless of how the schema author chose to express the constraint.
Composed properties
When a schema uses allOf to compose sub-schemas, properties from each constituent are merged into the parent type. A property that arrives via composition rather than being declared locally gets a LocalOrComposed.Composed flag:
{
"allOf": [
{ "$ref": "#/$defs/Address" },
{ "properties": { "deliveryNotes": { "type": "string" } } }
]
}
The TypeDeclaration exposes all properties - both the address fields and deliveryNotes - in a single PropertyDeclarations collection, but each carries provenance metadata. The code generator uses this to determine inheritance hierarchies. A UI tool can use it to show which properties came from which schema.
The full mapping
| Schema pattern | TypeDeclaration API | Mapping |
|---|---|---|
properties + required |
PropertyDeclarations |
Direct |
prefixItems + items: false |
TupleType() |
Inferred |
prefixItems (allows additional items) |
ExplicitTupleType() |
Inferred |
Nested items of type: array |
ArrayRank(), IsNumericArray() |
Inferred |
enum or anyOf with const |
AnyOfConstantValues() |
Normalised |
const |
ExplicitSingleConstantValue() |
Direct |
allOf / anyOf / oneOf |
AllOfCompositionTypes() etc. |
Direct |
additionalProperties |
FallbackObjectPropertyType() |
Direct |
type keyword |
ImpliedCoreTypes() |
Normalised (flags) |
Notice that the table describes capabilities. These are things like "has typed positional items" and "has a fallback property type", not specific keywords. This is a deliberate design choice. Different JSON Schema vocabularies express the same capability with different keywords: Draft 4 uses additionalItems where Draft 2020-12 uses items; Draft 4 puts tuple items in items (as an array) where Draft 2020-12 uses prefixItems. The vocabulary analysers map draft-specific keywords onto the same set of capabilities, and TypeDeclaration exposes only those capabilities. Consumers never need to know which draft the schema was written in. They call TupleType() or FallbackObjectPropertyType() and get the same answer regardless.
This is how the library supports multiple vocabularies without duplicating consumer logic. The code generator, playground, and CLI tool all work against the capability model without draft-specific branches, and the vocabulary analysers handle the translation.
Building a TypeDeclaration tree
Creating a TypeDeclaration tree takes three steps: register your schemas, register the vocabulary analysers for the drafts you want to support, and call AddTypeDeclarationsAsync.
// 1. Register schemas in an in-memory document resolver
using PrepopulatedDocumentResolver documentResolver = new();
documentResolver.AddDocument("schema://my/person.json", personSchemaDoc);
// 2. Register vocabulary analysers
VocabularyRegistry vocabularyRegistry = new();
Draft202012.VocabularyAnalyser.RegisterAnalyser(documentResolver, vocabularyRegistry);
Draft201909.VocabularyAnalyser.RegisterAnalyser(documentResolver, vocabularyRegistry);
Draft7.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry);
Draft4.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry);
OpenApi30.VocabularyAnalyser.RegisterAnalyser(vocabularyRegistry);
// 3. Build the type tree
IVocabulary defaultVocabulary = Draft202012.VocabularyAnalyser.DefaultVocabulary;
JsonSchemaTypeBuilder typeBuilder = new(documentResolver, vocabularyRegistry);
TypeDeclaration root = await typeBuilder.AddTypeDeclarationsAsync(
new JsonReference("schema://my/person.json"),
defaultVocabulary,
rebaseAsRoot: false);
The PrepopulatedDocumentResolver holds documents in memory. You can load them from disk, from an HTTP response, or from a text editor. If a schema declares $id, you can register it under that URI too, enabling cross-file $ref resolution.
The defaultVocabulary is the fallback used when a schema doesn't declare $schema. The library examines the $schema keyword and selects the correct analyser automatically; the default is only used when it's absent.
AddTypeDeclarationsAsync walks the schema recursively, resolves all $ref pointers, collects properties from composition keywords, determines implied types, and builds the tree. Each node is a TypeDeclaration carrying all the metadata any consumer might need.
Walking the tree
Once you have a TypeDeclaration tree, the extension methods on TypeDeclarationExtensions give you everything:
// Core type (Object, Array, String, Number, Integer, Boolean, Null)
CoreTypes types = root.ImpliedCoreTypes();
// Object properties
foreach (PropertyDeclaration prop in root.PropertyDeclarations)
{
string jsonName = prop.JsonPropertyName;
TypeDeclaration propType = prop.ReducedPropertyType;
bool required = prop.RequiredOrOptional == RequiredOrOptional.Required;
bool composed = prop.LocalOrComposed == LocalOrComposed.Composed;
}
// Composition
var allOfTypes = root.AllOfCompositionTypes();
var anyOfTypes = root.AnyOfCompositionTypes();
var oneOfTypes = root.OneOfCompositionTypes();
// Arrays
ArrayItemsTypeDeclaration? items = root.ArrayItemsType();
TupleTypeDeclaration? tuple = root.TupleType();
int? rank = root.ArrayRank();
bool numeric = root.IsNumericArray();
// Constants and enums
var enumValues = root.AnyOfConstantValues();
JsonElement constValue = root.ExplicitSingleConstantValue();
// Schema location (for navigation back to source)
string location = root.LocatedSchema.Location.ToString();
When recursing into the tree, use ReducedTypeDeclaration() to flatten reducible references. It unwraps annotation-only and intermediate schemas (including bare $ref targets) so you work with the "real" type underneath. Circular references are your responsibility: maintain a HashSet<TypeDeclaration> of types you've already visited, and skip any type already in the set before recursing into its children.
The metadata model
Each TypeDeclaration carries an open metadata dictionary. It is a ConcurrentDictionary<string, object?> keyed by string. Any processor can attach its own keyed data to a type declaration, and any later consumer can read it back.
// Set a metadata value (any processor can do this)
typeDeclaration.SetMetadata<string>("MyTool.DisplayName", "Person");
// Read it back later
if (typeDeclaration.TryGetMetadata<string>("MyTool.DisplayName", out var displayName))
{
Console.WriteLine(displayName);
}
This is the extensibility mechanism that makes TypeDeclaration useful beyond the built-in capabilities. Three layers of processors populate metadata:
Schema analysis metadata
The core library populates metadata lazily as you call extension methods. When you call ArrayRank(), IsNumericArray(), AllOfCompositionTypes(), or any of the other capability methods, the result is computed once and cached in the metadata dictionary. The next call returns the cached value. This means the tree is cheap to walk repeatedly. The heavy analysis happens at most once per type per capability.
Language provider metadata
The CSharpLanguageProvider (or any language provider) adds its own processor-specific metadata during code emission. For C#, this includes:
| Metadata | Example | Set by |
|---|---|---|
| Fully qualified .NET type name | Corvus.Examples.Person |
CSharpLanguageProvider |
| Short type name | Person |
CSharpLanguageProvider |
| Namespace | Corvus.Examples |
CSharpLanguageProvider |
| Parent type (nesting) | reference to containing TypeDeclaration |
CSharpLanguageProvider |
| .NET property name | FirstName (from firstName) |
CSharpLanguageProvider |
| Preferred numeric type | double, int, long |
CSharpLanguageProvider |
| Accessibility | public, internal |
CSharpLanguageProvider |
This metadata doesn't exist on the tree until the language provider runs. That's why any consumer that needs .NET type names must run after code emission - the capability model is language-neutral, and the language-specific annotations are layered on top by the provider.
Custom processor metadata
You can add your own metadata in the same way. A documentation tool might annotate types with rendered descriptions. A form generator might attach UI hints. A migration analyser might tag breaking changes. The dictionary is open; keys are just strings, so different processors use different key prefixes to avoid collisions.
This layered design is what makes the same TypeDeclaration tree serve multiple consumers in a single pipeline. The core analysis produces the capability model. A language provider decorates it with target-language details. And a downstream consumer - the playground's type map builder, or your own tool - reads whichever metadata it needs.
Use case: the Blazor Playground
The Corvus.Text.Json Playground is a Blazor WASM app that lets you paste a JSON Schema, generate C# code, compile it in the browser with Roslyn, and run test expressions against it. It has a type map panel that presents the generated type hierarchy as an interactive tree - and that tree is built entirely from the TypeDeclaration model.
The pipeline
The playground's CodeGenerationService runs a five-phase pipeline:
- Parse and register - schemas are registered in a
PrepopulatedDocumentResolverunderschema://playground/URIs and by$id. No file I/O; everything is in-memory. - Register vocabularies - all supported drafts, OpenAPI 3.0, and the Corvus custom vocabulary.
- Build the type tree -
AddTypeDeclarationsAsyncfor each root schema. - Emit C# code -
GenerateCodeUsing()with aCSharpLanguageProvider. - Build the type map - a recursive walk of the same
TypeDeclarationtree, extracting a UI-focused data model.
Phase 5 comes after phase 4 because the CSharpLanguageProvider populates type-name metadata during code emission, as described above. The type map walk reads that metadata - .NET type names, property names, namespace - so it can only run after the language provider has annotated the tree.
From TypeDeclaration to tree nodes
The playground builds a flat list of TypeMapEntry records by walking the tree recursively. It only includes types that correspond to an actual GeneratedCodeFile - not every intermediate schema node:
var generatedTypes = new HashSet<TypeDeclaration>(
generatedFiles
.Where(f => f.TypeDeclaration is not null)
.Select(f => f.TypeDeclaration!));
For each type, it uses the same extension methods described above to extract properties, composition groups, array items, tuple items, enum values, and const values. It infers a human-readable "kind" label - object, array, tuple, tensor, enum, const - using ImpliedCoreTypes(), TupleType(), ArrayRank(), and the const/enum APIs.
Bidirectional navigation
Every TypeDeclaration carries a LocatedSchema with a full URI that includes a JSON Pointer fragment:
schema://playground/person.json#/properties/address/properties/city
When the user clicks a type or property in the tree, the playground extracts this pointer and walks the raw JSON text to find the corresponding line in the Monaco editor. It's a text search, not a JSON parse - deliberately so. The user may have invalid JSON while they're typing; a text search still finds the right line in most cases and never throws.
When the schema spans multiple files, the navigation switches to the correct editor tab before scrolling. The SourceSchemaName extracted from the LocatedSchema URI tells the UI which tab to activate.
What the playground demonstrates
The playground's type map is a clean example of the TypeDeclaration model serving a purpose beyond code generation. The same tree that the CSharpLanguageProvider walks to emit structs and validation methods, the playground walks to build an interactive tree with expand/collapse, search filtering, and click-to-navigate. No special analysis pass, no schema re-parsing - just a different consumer of the same abstraction.
Building your own consumer
If you're building tooling that needs to understand JSON Schema - form generators, documentation tools, migration analysers, visual editors - the TypeDeclaration model gives you a ready-made analysis layer. You don't need to write your own schema parser or keyword interpreter.
using PrepopulatedDocumentResolver resolver = new();
resolver.AddDocument("schema://my/schema.json", schemaDoc);
VocabularyRegistry registry = new();
Draft202012.VocabularyAnalyser.RegisterAnalyser(resolver, registry);
JsonSchemaTypeBuilder builder = new(resolver, registry);
TypeDeclaration root = await builder.AddTypeDeclarationsAsync(
new JsonReference("schema://my/schema.json"),
Draft202012.VocabularyAnalyser.DefaultVocabulary,
rebaseAsRoot: false);
// Now walk root.PropertyDeclarations, root.AllOfCompositionTypes(), etc.
The playground source code is the working reference implementation for this pattern.