Skip to content
Matthew Adams By Matthew Adams Co-Founder
Json Schema Patterns in .NET - Maps of strings to strongly typed values

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.

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.

Turning an object into a strongly typed map using unevaluatedProperties

One of the more popular data structures in the API landscape is the map (or dictionary) of a string-based key to some strongly-typed value.

In .NET you might traditionally use a type that implements IDictionary<string, T> such as Dictionary<string, MyObject> or, for the readonly case an IReadOnlyDictionary<string, T> such as ImmutableDictionary<string, MyObject>.

It's very easy to represent that type in JSON schema. We simply define a schema with type: "object", then set the unevaluatedProperties to the schema for the type that is the target of map.

File: map-of-string-to-type.json

{
    "type": "object",
    "unevaluatedProperties": { "$ref": "./person-closed.json" }
}

File: person-closed.json

{
  "title": "The person schema https://schema.org/Person",
  "type": "object",
  "required": [ "familyName", "givenName", "birthDate" ],
  "properties": {
    "familyName": { "$ref": "#/$defs/constrainedString" },
    "givenName": { "$ref": "#/$defs/constrainedString" },
    "otherNames": { "$ref": "#/$defs/constrainedString" },
    "birthDate": {
      "type": "string",
      "format": "date"
    },
    "height": {
      "type": "number",
      "format": "double",
      "exclusiveMinimum": 0.0,
      "maximum": 3.0
    }
  },

  "unevaluatedProperties": false,

  "$defs": {
    "constrainedString": {
      "type": "string",
      "minLength": 1,
      "maxLength": 256
    }
  }
}

As in previous examples, you should use additionalProperties instead of unevaluatedProperties for older drafts.

Generate the code:

generatejsonschematypes --outputPath Model --rootNamespace JsonSchemaSample.Api map-of-string-to-type.json
Generating: HeightEntity
Generating: MapOfStringToType
Generating: ConstrainedString
Generating: PersonClosed

The code generator recognizes this as a strongly typed map.

In addition to providing you with strongly typed accessors and enumerators, it implements IReadOnlyDictionary<JsonPropertyName, T> so you can use an instance with standard LINQ operators if required.

Notice that we use JsonPropertyName instead of string for the key. This is convertible to string (e.g. with a cast), but is more allocation-friendly. For example, you can do zero-allocation comparisons with string and UTF8 byte spans.

Example code

using Corvus.Json;
using JsonSchemaSample.Api;
using NodaTime;

string jsonMapString =
    """
    {
      "beans": {
        "familyName": "Smith",
        "givenName": "John",
        "otherNames": "Edward,Michael",
        "birthDate": "2004-01-01",
        "height": 1.8
      },
      "bangers": {
        "familyName": "Johnson",
        "givenName": "Alice",
        "birthDate": "2000-02-02",
        "height": 1.6
      },
      "bacon": {
        "familyName": "Williams",
        "givenName": "Robert",
        "otherNames": "James,Thomas",
        "birthDate": "1995-03-03",
        "height": 1.7
      },
      "burgers": {
        "familyName": "Brown",
        "givenName": "Jessica",
        "birthDate": "1990-04-04",
        "height": 1.9
      }
    }
    """;

// Parse a map from JSON
using ParsedValue<MapOfStringToType> parsedMap = ParsedValue<MapOfStringToType>.Parse(jsonMapString);
MapOfStringToType map = parsedMap.Instance;

// Enumerate the strongly typed items
foreach (JsonObjectProperty<PersonClosed> item in map.EnumerateObject())
{
    PersonClosed val = item.Value;
    Console.WriteLine($"{item.Name}: {item.Value}");
}

// Get a known value from the map (throws if not present)
PersonClosed bangers = map["bangers"];

// Try to get a known value from the map
if (map.TryGetProperty("burgers", out PersonClosed burgersPerson))
{
    Console.WriteLine(burgersPerson);
}

// Update the map with a strongly typed value
map = map.SetProperty(
    "fish",
    PersonClosed.Create(new LocalDate(1991, 3, 7), "Irving", "Henry"));

// Construct a map from name/value tuples
var newMap = MapOfStringToType.FromProperties(
    ("one", map["bacon"]),
    ("two", map["fish"]));

// Manipulate a map with LINQ operators
FrozenDictionary<string, string> dictionaryOfStringToString =
    map
        .Where(kvp => kvp.Value.BirthDate > new LocalDate(1995,1,1))
        .ToFrozenDictionary(
            kvp => (string)kvp.Key,
            kvp => $"{kvp.Value.FamilyName}, {kvp.Value.GivenName}");

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.