Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Data Object

In this series we are cataloging common patterns with JSON Schema and the .NET code generated by Corvus.JsonSchema.

It is especially useful for developers who want to move to a schema-first approach, but are unsure how that will map to their .NET code.

We are focusing on draft 2020-12 and the dotnet8.0 code generation, but very similar patterns apply for older versions of both .NET and draft 2019-09, draft 7, and even draft 6. We will highlight the key differences as we go.

How does JSON Schema help us

If you have no experience of JSON Schema at all, I would recommend you read the getting started step-by-step documentation provided by the JSON Schema team.

Simple Data Object

It is very common to define a simple data object composed of primitive values for information exchange through an API.

For example, here's a subset of the Person schema from schema.org.

File: person.json

{
    "title": "The person schema https://schema.org/Person",
    "type": "object",
    "properties": {
        "familyName": { "type": "string" },
        "givenName": { "type": "string" },
        "otherNames": { "type": "string" },
        "birthDate": { "type": "string", "format": "date" },
        "height": { "type": "number" }
    }
}

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api person.json
Generating: Person

An object is generated for the root schema, whose name is derived from the file name: Person

It has .NET properties for each of the declared properties, with pascal-cased names

  • FamilyName
  • GivenName
  • OtherNames
  • BirthDate
  • Height

The string properties are of type JsonString.

The BirthDate property is of type JsonDate.

The Height property is of type JsonNumber.

These can be used in much the same way as primitive.NETtypes like string, double and NodaTime.LocalDate.

(Note that we use NodaTime for our basic date/time types).

In the generated code, most of the "JSON-like" behaviours follow the patterns in System.Text.Json (e.g. parsing and writing JSON).

But we try to make the accessors and usage patterns as dotnet-like as possible, with implicit conversions to.NETprimitives (where they do not allocate) so, by and large, everything "just works".

Example code

using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;
using System.Buffers;
using System.Text.Json;

// Create from.NETvalues
Person person = Person.Create(
    birthDate: new LocalDate(1820, 1, 17),
    familyName: "Brontë",
    givenName: "Anne",
    height: 1.52);

// Convert to a json-format string (allocates)
string jsonString = person.ToString();
Console.WriteLine(jsonString);

// Write to JSON Writer (does not allocate)
ArrayBufferWriter<byte> abw = new();
using Utf8JsonWriter writer = new(abw);
person.WriteTo(writer);
writer.Flush();

// Parse into a disposable entity using System.Text.Json behind the scenes.
using var parsedPerson = ParsedValue<Person>.Parse(jsonString);
using var parsedFromUtf8person = ParsedValue<Person>.Parse(abw.WrittenMemory);

// Retrieve the parsed instance from the disposable wrapper
var parsedPersonInstance = parsedPerson.Instance;

// Using property values

// Conversion to string requires an explicit cast because it allocates (throws if null or undefined)
string familyName = (string)person.FamilyName;

// Try to get a string value which may not be present (does not throw)
if (person.GivenName.TryGetString(out string? givenName))
{
    Console.WriteLine(givenName);
}

// Check if optional property is undefined
if (person.OtherNames.IsUndefined())
{
    Console.WriteLine("otherNames is not present.");
}
else
{
    Console.WriteLine("otherNames is present.");
}

// Conversion to LocalDate is allowed as an implicit conversion as it does not allocate
LocalDate date = person.BirthDate;

// Conversion to numeric types are available explicitly as the value may not be representable.

double heightValue = (double)person.Height;
int heightValueAsInt = (int)person.Height;

// Set properties (objects are immutable, so this returns a copy)
// Note that we have an implicit conversion from LocalDate to JsonDate
var updatedPerson = person.WithBirthDate(new LocalDate(1984, 6, 3));

Console.WriteLine(updatedPerson);

// Compare values

if (person == updatedPerson)
{
    Console.WriteLine("The same person.");
}
else
{
    Console.WriteLine("Different people.");
}

if (person == parsedPersonInstance)
{
    Console.WriteLine("The same person.");
}
else
{
    Console.WriteLine("Different people.");
}

// Custom zero-allocation comparison functions are available that avoid allocations for string types
// Comparison with string
person.GivenName.EqualsString("Hello");
// Comparison with UTF8 byte array
person.GivenName.EqualsUtf8Bytes("Anne"u8);

// Low allocation parsing of character data
// Our CountInstances() method counts the number of instances of the given characters in a JsonString value
// but it doesn't allocate a .NET string to do so.
(char FirstChar, char SecondChar) someCharContext = ('A', 'B');
if (person.GivenName.TryGetValue(CountInstances, someCharContext, out (int FirstCount, int SecondCount) charResult))
{
    Console.WriteLine(charResult);
}

// Count the instances in the given span
bool CountInstances(
    ReadOnlySpan<char> span,
    in (char FirstChar, char SecondChar) state,
    out (int FirstCount, int SecondCount) result)
{
    result = (span.Count(state.FirstChar), span.Count(state.SecondChar));
    return true;
}

// Similarly, we can perform the same operation directly on the (decoded) UTF8 data
(byte FirstUtf8Byte, byte SecondUtf8Byte) someUtf8Context = ((byte)'A', (byte)'B');
if (person.GivenName.TryGetValue(ParseStringUtf8, someUtf8Context, out (int FirstCount, int SecondCount) utf8Result))
{
    Console.WriteLine(utf8Result);
}

bool ParseStringUtf8(
    ReadOnlySpan<byte> span,
    in (byte FirstUtf8Byte, byte SecondUtf8Byte) state,
    out (int FirstCount, int SecondCount) result)
{
    result = (span.Count(state.FirstUtf8Byte), span.Count(state.SecondUtf8Byte));
    return true;
}

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.