Skip to content
Ian Griffiths By Ian Griffiths Technical Fellow I
.NET JsonElement and Schema Validation

In a recent blog, I talked about how JsonElement has a trap for the unwary when it comes to handling invalid input. I also mentioned that our recommended way of dealing with that kind of problem is to use our Corvus.JsonSchema library to validate the input. In this post, I'll show what that looks like.

Accessing JSON properties

In the previous blog I showed this example input:

{
    "id": 1331,
    "action": "frobnicate"
}

Here's how we would validate that the input conforms to our expectations, and then go on to use it:

using JsonDocument doc = JsonDocument.Parse(json);

Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
    int id = c.Id;
    Console.WriteLine($"Command id {id} received)");
}

This begins with some pretty ordinary initialization of System.Text.Json's JsonDocument. The next line wraps this in a type called Command, which is generated from our schema. (I'll show how in a moment.) A single method call, IsValid, checks that the document is correctly formed, and then we can go on to work with it. Notice that the generated Command type provides an Id property corresponding to the id in the input JSON. (As you have probably guessed, it also offers an Action property.)

This looks a lot like ordinary JSON deserialization: we were able to access the id from the JSON by writing c.Id. But as you'll see later, this generated Command wrapper type can operate over twice as quickly as JsonSerializer, and can put considerably lower demands on the garbage collector. And it also gives us the ability to validate incoming data.

So how do we generate these wrapper types?

Define your schema (aka state your assumptions)

The key feature of our preferred approach to JSON handling is that we create a formal definition of what constitutes valid JSON input for our code. We use the language designed specifically for this job: JSON Schema.

This is the aspect most likely to encounter resistance from developers. The idea of introducing a new language to our codebase, JSON Schema, can put people off. Traditional serialization can seem more attractive because you can remain entirely within the world of C#. But we think this reaction is a mistake. JSON Schema offers many benefits, which can more than compensate for the friction.

Consider the example JSON input shown above. We might look at this and think we understand what's expected. There's a property called id that looks like an integer, and a property called action that's a string. So when you look at this schema definition that captures that, your first response might be to think that this is making a bit of a meal of things:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "JSON Schema for an incoming command",
  "$defs": {
    "Command": {
      "type": "object",
      "properties": {
        "id": {
          "type": "integer",
          "format": "int32",
          "minimum": 0
        },
        "action": {
          "type": "string",
          "enum": ["frobnicate", "kludgify"]
        }
      }
    }
  }
}

However, this is much less vague than the hand-wavey informal description. For example this clarifies that the action property should be one of a small fixed set of strings. It also specifies precisely what values are allowed for the id field. This explicitly rules out floating point numbers ("type": "integer") and negative numbers ("minimum": 0). It also states that the value will fit in a 32-bit signed integer storage location by specifying a format of int32.

This might prompt questions: are these appropriate restrictions? Might our system grow to a point where a 32-bit integer is too small?

It would have been so much easier just to ignore all this and pretend that it was good enough to say "it's just a number." But confronting these kinds of questions is one of the benefits of nailing things down formally. If a developer complains about being asked to be precise about what is or is not acceptable as input, we can reasonably ask: really? You're happy to leave these things to chance?

Look at almost any internet standard that has passed the test of time, and you will find that it specifies very precisely how data should be structured. IP, TCP, the various email standards, HTTP—they all use formal grammars that describe unambiguously exactly how communication should occur. (There's a notorious exception. Early versions of HTML were infamously loosely defined. This did eventually get fixed, but unfortunately, the resulting standards were quite a long way away from how the majority of websites were by then actually using HTML in practice. Browsers had to guess whether sites were built for the long-established de facto informal standards, or the more recently defined formal standards. It took the best part of a decade to resolve the resulting mess.)

A popular online messaging platform formerly known as Twitter went through a period of upheaval when the number of messages in the system grew into the billions, meaning that a 32-bit identifier was too small: since they had never fully specified what a message identifier really was, numerous client libraries had made assumptions that caused things to break when those assumptions proved false.

Of course, even if they had clarified up front exactly what an id is they might still have made the mistake of choosing an unsuitable representation. In the schema I've shown above, I've baked in the decision that the number of things being identified here will never grow into multiple billions. But the fact that I've explicitly stated that assumption in a schema did at least force me to consider whether this is likely to be correct. Waiting until after you've published an API to think about what its messages mean is likely to cause pain if your API becomes successful.

Another potential benefit of using JSON Schema is that you're not tied to any one programming language. I'm using C#, but there's no reason the exact same schema couldn't be used to generate adapters for any number of other languages.

Generating C# wrappers

Next, we will generate C# wrappers from this schema using the Corvus.Json.JsonSchema.TypeGeneratorTool tool. You can install that with this command:

dotnet tool install --global Corvus.Json.JsonSchema.TypeGeneratorTool

or if you already have it and want to ensure that it's up to date you can run this:

dotnet tool update --global Corvus.Json.JsonSchema.TypeGeneratorTool

We can now run this tool against our schema file. In addition to the filename, we need to provide an argument specifying the namespace into which we'd like our C# wrappers to be generated (--rootNamespace). The tool also needs to know which type within the schema we want it to generate code for. Schema files often contain multiple types, so we can say which one we want with the --rootPath option:

generatejsonschematypes --rootNamespace JsonSchemaSample.Api --rootPath '#/$defs/Command' .\CommandSchema.json

This displays the following:

Generating: Command
Generating: ActionEntity
Generating: IdEntity

This shows that the tool has recognized that there's actually more than one type here. Only one of them is explicitly named in the schema: Command. But the two properties that type defines, action and id, each have their own type. They are not merely a string and a number. They have additional constraints. Although these are anonymous types in the schema, the tool generates named types to represent them in C#. Here are all of the types it creates for the schema shown above:

Type Purpose
Command Wrapper type for the schema's Command type
Command.ActionEntity Wrapper representing the Command type's action property's type
Command.ActionEntity.EnumValues Describes the values the action property can have
Command.IdEntity Wrapper representing the Command type's id property's type
Command.JsonPropertyNames Provides UTF-8 representations of each of the Command type's property names

The tool creates a surprising number of files. The code generator creates partial types, putting different aspects of its handling into different files, but this is just an implementation detail. What matters is the actual types it creates.

To use these types, you can just include these generated source files in a project. (We normally do this by putting schema files in a folder inside your project dedicated to that, and we normally create a Generated folder beneath that to hold the generator tool output. The .NET build tools will treat generated source files in this folder like any other source code in the project.)

The generated code depends on the Corvus.Json.ExtendedTypes library so you'll need to add a reference to that NuGet package. We can then use these generated wrappers to work with JSON that we expect to conform to that schema.

Using the wrappers

The tool generates wrappers that work on top of JsonElement values. So to use them we must first parse the JSON into a JsonDocument. The generated Command wrapper offers a FromJson, into which we can pass the document RootElement. In fact I showed all of that earlier, but I'm going to expand a little on that first example:

using JsonDocument doc = JsonDocument.Parse(json);

Command c = Command.FromJson(doc.RootElement);
if (c.IsValid())
{
    int id = c.Id;

    switch ((string)c.Action) // This allocates a string on the GC heap!
    {
        case "frobnicate":
            // App-specific processing would go here...
            Console.WriteLine($"Frobnicating {id}");
            break;

        case "kludgify":
            // App-specific processing would go here...
            Console.WriteLine($"Frobnicating {id}");
            break;
    }
}

Notice that I've called c.IsValid(), and I only go on to process the JSON if this returns true. This means I can be confident that the JSON fully conforms to the schema. There will be no missing properties, and everything will have the correct JSON type (e.g., no strings where numbers are expected) and will be within the range of values deemed acceptable by the schema. (For example, the id will not be negative, it will be a whole number, and it will not be too large to fit in an int.)

If your application wants to report any validation problems it discovers, there's also a Validate method that enables you to obtain a list of all the ways in which the input did not conform to the schema.

Note: IsValid is not defined directly by the generated code. It's an extension method defined in the Corvus.Json.ExtendedTypes library, and you will need a using Corvus.Json; directive to be able to use it. This calls the more flexible but more complex Validate method for you.

By the way, these wrapper types do not insist on valid input. If IsValid returns false you are still free to retrieve the properties (or you could just not call either IsValid or Validate at all). It's just that you can't then be sure you won't get invalid values (such as JsonValueKind.Undefined, or a numeric value that is outside of the required range) which could then result in exceptions when you try to convert those values to a .NET type.

Implicit conversions

I was able to write the obvious code for retrieving the id property:

int id = c.Id;

However, if you look at the Id property, you'll see that its type is Command.IdEntity. The assignment to int works because the schema specifies that the id field in the JSON must be an integer with a format of int32. This causes the code generator to emit an implicit conversion operator to int. What if we had not specified that constraint, and had instead stated only that we require the number to be a positive integer?

"id": {
  "type": "integer",
  "minimum": 0
},
The Introduction to Rx.NET 2nd Edition (2024) Book, by Ian Griffiths & Lee Campbell, is now available to download for FREE.

With this schema, we could not safely assume that id property values would fit in an int32. It allows positive integers larger than int.MaxValue. The generated wrappers would still provide a way to retrieve the value as an int. They would just make the relevant conversion operator explicit instead.

The idea here is to emulate the implicit conversions that C# makes available for the built-in numeric types. C# will silently convert between numeric types if it is certain that the conversion will not fail. For example, it allows an int to be converted to a long because long can represent every possible int value. It won't convert int to short though because short is a 16-bit integer, and can't represent most of the values int can. More subtly, C# also won't implicitly convert an int to ulong, even though the 64-bit ulong has a much wider range than the 32-bit int. That's because ulong's range does not completely contain int's range—they merely overlap. (E.g., int can represent -1 but ulong can't.)

If the schema requires that a particular property will always have values that are compatible with a built-in numeric type, the Corvus.JsonSchema generator will generate an implicit conversion to that type. This means that if you find yourself needing to write casts to get the property values out, it's possible that the conversion will fail at runtime (just like casting an int with (ulong) might fail at runtime) even if the JSON passes schema validation. This might be an indication that your schema is not as specific as you need it to be (or that your code is not as general as it should be).

Note: these implicit conversion operators can fail if the JSON is not valid. One of the goals of the Corvus.JsonSchema library is to allow programs to work with invalid data if they wish. If IsValid returns false you are free to attempt to proceed anyway (because the real world is messy, and often data doesn't quite conform to the constraints we would like). But if you do this, you lose the guarantee that implicit conversions will always succeed. This is arguably a violation of the usual guideline that cast operators should only be implicit if they can never fail. However, we felt that it was useful and sufficient to guarantee this only for known-valid documents. In cases where you work only with validated data, the availability of implicit conversions provides you with useful information from the schema, and they also enable the most natural code.

Strings and allocations

You might have noticed that we did need to cast c.Action to string:

switch ((string)c.Action) // This allocates a string on the GC heap!

Why would we need that? The schema type for action definitely guarantees that if the JSON is valid, that property will have a value that is a valid, non-null string. (It's more specific: it guarantees that it will be one of two particular string values. But the key point is that there's no way conversion to string can fail if the JSON is valid.)

"action": {
    "type": "string",
    "enum": ["frobnicate", "kludgify"]
}

You might have expected an implicit conversion to string to be available here. In fact, only an explicit conversion is defined, even though this conversion is guaranteed to succeed if the document is valid. Why is that?

We handle string differently from numeric types because conversion to string entails allocation. A central goal of Corvus.JsonSchema to enable extremely efficient low-allocation JSON processing. Silently generating string instances due to implicit conversions would not be consistent with that goal—it would make it too easy to cause allocations by accident. So we require a deliberate statement of intent before we will allocate a string.

In fact, it's not necessary to convert the action property to a .NET string in this case. It has only two possible values and we can test to see which it is without allocating anything on the GC with the following rather clunky code:

if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
{
    // App-specific processing would go here...
    Console.WriteLine($"Frobnicating {id}");
}

When Corvus.JsonSchema makes a string-like property available, it will always provide this EqualsUtf8Bytes method. Remember that System.Text.Json works directly against UTF-8-encoded JSON. It will not convert it into the UTF-16 encoding used by .NET's string type unless you force it to. This ability to process the data directly in its native representation without copying or re-encoding is a critical factor in the very high performance System.Text.Json is able to achieve.

This EqualsUtf8Bytes method lets us ask "is this property's value equal to this UTF-8 encoded text?" Asking the question in this way offers a couple of advantages. First, it removes any need to create a string representation of the action property's value, so we avoid allocating anything on the GC heap. Second, it means the string comparison doesn't need to deal with two different encodings—we are comparing the UTF-8 encoding that is present in the source JSON document with another UTF-8-encoded string. (It still might not be as simple as a byte-for-byte comparison because JSON text values may include escaped characters, e.g. "Json string with \"quotes\" in it". but it's still going to be faster to compare a JSON string value without also having to deal with different encodings.)

The Command.ActionEntity.EnumValues.FrobnicateUtf8 returns a UTF-8 byte sequence representing the text frobnicate. Corvus.JsonSchema generates properties like this when a string-type schema is constrained to be one of the values in an enum array. It generates one such property for each string entry in the enum. (Be aware that JSON schema also allows an enum to include null as a possible value, and also the boolean true and false values. The generated EnumValues does not include properties for these when an enum schema type uses them, because they aren't strings.). These properties return a ReadOnlySpan<byte>, and they do so in a way that exploit's C#'s ability to create these directly from raw pointers into the part of your .dll that contains the UTF-8 bytes, meaning these properties never need to allocate anything on the GC heap, and they are extremely efficient in practice.

An alternative way to switch

The fact that JSON Schema enum types are more general than .NET ones is just one instance of a more general capability: we can write schemas where a particular property might be any of several different types. (The anyOf combining keyword is a more powerful example of the same basic idea.) That's why we don't just generate a .NET enum type. It would only be able to handle quite a narrow subset of what schemas can do.

Instead, whenever a property might be one of several different things, the generated code includes a Match method that determines which particular one of those things is present. (This works both for enum and anyOf types.) You supply Match with a callback for each case. Conceptually this is similar to a switch statement but allows for the more complex tests that may need to be performed when a property won't merely be one of a fixed set of strings. Corvus.JsonSchema tends to encourage a functional programming style, so it presumes that these callbacks will return values, instead of just having some side effect. So imagine you had something like this:

private async Task<IActionResult> FrobnicateAsync(int id)
{
    this.logger.LogInformation("Frobnicating {id}", id);
    await this.messaging.QueueFrobnicateCommandAsync(id);

    return Accepted();
}

and a similar KludgifyAsync method, you could write this:

if (c.IsValid())
{
    int id = c.Id;
    IActionResult result = await c.Action.Match(
        id,
        matchFrobnicate: FrobnicateAsync,
        matchKludgify: KludgifyAsync,
        defaultMatch: _ => throw new InvalidOperationException(
            "Match should succeed when validation succeeded"));
}

A useful feature of this is that the generated Match method's signature requires you to cover all the possibilities defined by the schema, something a switch statement does not enforce. (In code where we are aiming to minimize allocations, we might do something a little more complex to avoid allocating a delegates that implicitly capture the this reference, but that's a topic for another blog.)

Wrapper performance

I've mentioned performance a few times now, so I should present some supporting evidence. I've written a benchmark comparing the various options which produced the following results:

Method Mean Error Ratio Gen0 Allocated Alloc Ratio
ClassicSerialization 289.96 ns 5.697 ns 1.00 0.0105 88 B 1.00
CorvusSchemaStringSwitch 109.60 ns 0.398 ns 0.38 0.0057 48 B 0.55
CorvusSchemaStringSwitchValidate 798.26 ns 6.143 ns 2.75 0.0057 48 B 0.55
CorvusSchemaNoStringSwitch 98.50 ns 1.152 ns 0.34 - - 0.00
CorvusSchemaNoStringSwitchValidate 788.65 ns 3.867 ns 2.72 - - 0.00
CorvusSchemaMatchSwitch 90.67 ns 1.087 ns 0.31 - - 0.00
CorvusSchemaMatchSwitchValidate 764.45 ns 10.360 ns 2.64 - - 0.00

The full code is shown at the end of this article. The ClassicSerialization benchmark uses the System.Text.Json JsonSerializer to deserialize the JSON into a .NET object. It takes under a third of a microsecond to process the example document, which is not too shabby, and it allocates 88 bytes on the GC heap to do so. This includes the output object, a CommandJsonSerializable, which has two properties: an int and an enum, so that should be just 8 bytes of data and another 16 bytes of object header, a total of 24 bytes. The deserializer also constructs a BitArray (12 bytes of data, plus 8 bytes of header and another 4 of padding, which is 32 bytes), which in turn wraps an int[] of length 1. It appears that uses 8 bytes for the length and it pads out the data to 8 bytes for alignment, so that's another 32. So those three objects—the deserialized result, the BitArray and the int[] that it wraps, come to 88 bytes.

The CorvusSchemaStringSwitch benchmark uses code using the wrapper generated by Corvus.JsonSchema but is otherwise written to look as similar as possible to the classic serialization example. It operates at a slight disadvantage: because enumerations in JSON schema can list values that have a mixture of values—you're allowed to write "enum": ["text", 42, false] for example—they don't in general map onto .NET enum types and Corvus.JsonSchema does not currently attempt to present them as a .NET enum even though that would be viable in this particular schema. That means that the Corvus.JsonSchema code must work directly with the action property as it appears in the JSON: as text. As you've seen above, we can just ask for the string so we can use an ordinary switch, which means Corvus.JsonSchema has no choice but to allocate memory in that case. Even so, it is well over twice as fast, and allocates considerably less memory than JsonSerializer did: 48 bytes. That's the "frobnicate" string, which is 10 characters long, so it will require 20 bytes, except .NET sticks a trailing NUL (\0) on the end so with that plus alignment, in practice that's going to get rounded up to 24 bytes, plus another 4 for the length. On a 64-bit process that will be rounded up to the nearest multiple of 8, 32 bytes, and then another 16 bytes for the object header in a 64-bit process, which brings us to the 48 bytes for the string.

But notice the CorvusSchemaNoStringSwitch benchmark a little further down. This executes the same logic, it just uses that slightly more convoluted if/else construct. That enabled it to avoid allocating any memory on the GC heap at all! (We're still working with the value in its native JSON form, so it's still text, it's just that we avoided using .NET's string type.) It was also usefully faster than the CorvusSchemaStringSwitch benchmark, and almost three times faster than classic JSON serialization.

Programming C# 12 Book, by Ian Griffiths, published by O'Reilly Media, is now available to buy.

The CorvusSchemaMatchSwitch benchmark uses the Match method also described earlier instead of a C# switch statement. Like the CorvusSchemaNoStringSwitch version, this avoids allocating a string, and also seems to work out marginally faster still. That's good because we prefer this approach. It makes it harder to accidentally leave out handling for some cases.

I've also included three benchmarks that use the IsValid method. These do slow things down considerably, but realistically, unless your JSON input is coming from a source where, for some reason, it's safe to assume that it is in exactly the form you require, you should be doing validation anyway. We can't easily compare that with JsonSerialization because it doesn't have any directly equivalent functionality. It will have to be performing some basic checks to make sure that the int input really is a number in the JSON. But it's not actually at all obvious what JsonSerializer will and won't check for you, or what the behaviour will be in cases where the input isn't quite what you expect.

Conclusion

JSON Schema enables us to define precisely how a JSON document should be structured. The Corvus.JsonSchema libraries and tools can take a schema and generate code that makes it nearly as easy to work with incoming JSON as ordinary JSON deserialization, but with significantly better performance. And if you choose, this generated code can also validate incoming documents, enabling you to be certain that input is in the correct form before you try to process it.

Although it can take a bit more effort to write a schema than just diving straight in, the process can help to reveal ambiguities in your design. Also, because schemas are not specific to any single language, they can be helpful when you are using multiple languages. For example, if a web service back end is written in C# while the UI uses TypeScript, there are tools able to generate TypeScript type definitions from a schema, similar to the tool I showed in this article for creating C# types. With a schema, both languages can share the same underlying type definitions. (See https://json-schema.org/implementations#generators-from-schemas for a list of such tools.)

Benchmark code

Here is the source code used to produce the benchmark results shown earlier.

public class CommandJsonSerializable
{
    public required int Id { get; init; }
    public required ActionEnum Action { get; init; }
}

public enum ActionEnum
{
    Frobnicate,
    Kludgify
}

[MemoryDiagnoser]
public class JsonProcessingPerf
{
    private static readonly byte[] json = """
    {
        "id": 1331,
        "action": "frobnicate"
    }
    """u8.ToArray();

    private static readonly JsonSerializerOptions options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
    {
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    };
    private static readonly JsonDocument doc = JsonDocument.Parse(json);

    static JsonProcessingPerf()
    {
        options.Converters.Add(new JsonStringEnumConverter());
    }

    [Benchmark(Baseline = true)]
    public int ClassicSerialization()
    {
        CommandJsonSerializable js = JsonSerializer.Deserialize<CommandJsonSerializable>(doc, options)!;
        int id = js.Id;
        switch (js.Action)
        {
            case ActionEnum.Frobnicate: return id + 1;
            case ActionEnum.Kludgify: return id + 2;
        }
        return 0;
    }

    [Benchmark]
    public int CorvusSchemaStringSwitch()
    {
        Command c = Command.FromJson(doc.RootElement);
        int id = c.Id;
        switch ((string)c.Action)
        {
            case "frobnicate": return id + 1;
            case "Kludgify": return id + 2;
        }
        return 0;
    }

    [Benchmark]
    public int CorvusSchemaStringSwitchValidate()
    {
        Command c = Command.FromJson(doc.RootElement);
        if (c.IsValid())
        {
            int id = c.Id;
            switch ((string)c.Action)
            {
                case "frobnicate": return id + 1;
                case "Kludgify": return id + 2;
            }
        }
        return 0;
    }

    [Benchmark]
    public int CorvusSchemaNoStringSwitch()
    {
        Command c = Command.FromJson(doc.RootElement);
        int id = c.Id;
        if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
        {
            return id + 1;
        }
        else if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.KludgifyUtf8))
        {
            return id + 2;
        }
        return 0;
    }

    [Benchmark]
    public int CorvusSchemaNoStringSwitchValidate()
    {
        Command c = Command.FromJson(doc.RootElement);
        if (c.IsValid())
        {
            int id = c.Id;
            if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.FrobnicateUtf8))
            {
                return id + 1;
            }
            else if (c.Action.EqualsUtf8Bytes(Command.ActionEntity.EnumValues.KludgifyUtf8))
            {
                return id + 2;
            }

        }        return 0;
    }

    [Benchmark]
    public int CorvusSchemaMatchSwitch()
    {
        Command c = Command.FromJson(doc.RootElement);
        int id = c.Id;
        return c.Action.Match(
            id,
            matchFrobnicate: static id => id + 1,
            matchKludgify: static id => id + 2,
            defaultMatch: static _ => 0);
    }

    [Benchmark]
    public int CorvusSchemaMatchSwitchValidate()
    {
        Command c = Command.FromJson(doc.RootElement);
        int id = c.Id;
        if (c.IsValid())
        {
            return c.Action.Match(
            id,
            matchFrobnicate: static id => id + 1,
            matchKludgify: static id => id + 2,
            defaultMatch: static _ => 0);
        }
        return 0;
    }
}

Ian Griffiths

Technical Fellow I

Ian Griffiths

Ian has worked in various aspects of computing, including computer networking, embedded real-time systems, broadcast television systems, medical imaging, and all forms of cloud computing. Ian is a Technical Fellow at endjin, and 17 times Microsoft MVP in Developer Technologies. He is the author of O'Reilly's Programming C# 12.0, and has written Pluralsight courses on WPF fundamentals (WPF advanced topics WPF v4) and the TPL. He's a maintainer of Reactive Extensions for .NET, Reaqtor, and endjin's 50+ open source projects. Ian has given over 20 talks while at endjin. Technology brings him joy.