What's new in .NET 9: System.Text.Json improvements

.NET 9 is releasing in mid-November 2024. Like every .NET version, this introduces several important features and enhancements aligning developers with an ever-changing development ecosystem. In this blog series, I will explore critical updates in different areas of .NET.  For this post, I will look through advancements in System.Text.Json.

What is System.Text.Json in .NET?

System.Text.json is a .NET package that delivers a powerful, high-performance solution for handling JSON in .NET applications. It offers fast, efficient, and low-memory serialization and deserialization of .NET objects to and from JSON. The package supports universal UTF-8 encoding and can work with any application and browser.

Equipped with robust types for reading and writing JSON, it offers both a high-performance, read-only JSON document model for random access and a mutable DOM that works seamlessly with the JSON serializer. Developers benefit from advanced features, including async serialization with IAsyncEnumerable support and a highly customizable contract model, making it an essential tool for any .NET application working with JSON data.

.NET 9 brings a few novelties in Sytem.Text.Json. To install the package, run the following command:

dotnet add package System.Text.Json

JSON Schema Exporter

The new JsonSchemaExporter class allows you to extract schema information for any .NET type.  JsonSerializerOptions or JsonTypeInfo instances help you with that.

Code example

Let's create new files to hold all the types:

Inside Student.cs define the record type:

record Student(string Name, int Age, string? Department = null);

In Program.cs:

using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;

JsonSerializerOptions options = JsonSerializerOptions.Default;
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Student));
Console.WriteLine(schema.ToString());

Output:

{
  "type": [
    "object",
    "null"
  ],
  "properties": {
    "Name": {
      "type": "string"
    },
    "Age": {
      "type": "integer"
    },
    "Department": {
      "type": [
        "string",
        "null"
      ],
      "default": null
    }
  },
  "required": [
    "Name",
    "Age"
  ]
}

You get a JSON-specialized schema of any type. As seen, it provides information about whether a field is nullable or non-nullable and specifies required fields. You can alter the schema response by configuring JsonSerializerOptions or JsonTypeInfo instances.

Code example 

public class Mobile
{
    public int ModelNumber { get; init; }
}

In Program.cs:

JsonSerializerOptions options = new(JsonSerializerOptions.Default)
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    NumberHandling = JsonNumberHandling.WriteAsString,
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};

JsonNode schema = options.GetJsonSchemaAsNode(typeof(Mobile));
Console.WriteLine(schema.ToString());

Output:

{
  "type": [
    "object",
    "null"
  ],
  "properties": {
    "model_number": {
      "type": [
        "string",
        "integer"
      ],
      "pattern": "^-?(?:0|[1-9]\\d*)$"
    }
  },
  "additionalProperties": false
}

Here, I altered JsonSerializationOptions. In the PropertyNamingPolicy, I defined the case of the output property name as SnakeCaseLower, which is model_number. Other possible values are:

CamelCase
SnakeCaseLower
SnakeCaseUpper
KebabCaseLower
KebabCaseUpper

Secondly NumberHandling = JsonNumberHandling.WriteAsString, serializes numbers as strings in JSON. ModelNumber will be represented as a string. However, it will also take numeric data matched with the regex, although it will be input as a string.

Lastly, JsonUnmappedMemberHandling.Disallow throws an exception if the JSON contains properties not mapped to the Mobile type.

JsonNode saves the schema to represent it as a JSON document.

With new updates, you can also customize the schema generation using JsonSchemaExporterOptions. In the coding example, I will apply Description attributes to a generated schema for the Student. Start but including the System.ComponentModel namespace:

using System.ComponentModel;

The Student record is simplified for this example:

[Description("A student")]
record Student([property: Description("The name of the student")] string Name);

In Program.cs:

using System.ComponentModel;
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;

JsonSchemaExporterOptions exporterOptions = new()
{
    TransformSchemaNode = (context, schema) =>
    {
        // Determine if processing a type or property and retrieving attribute information
        ICustomAttributeProvider? attributeProvider = context.PropertyInfo is not null
            ? context.PropertyInfo.AttributeProvider
            : context.TypeInfo.Type;

        // Look up any DescriptionAttribute
        DescriptionAttribute? descriptionAttr = attributeProvider?
            .GetCustomAttributes(inherit: true)
            .Select(attr => attr as DescriptionAttribute)
            .FirstOrDefault(attr => attr is not null);

        // Apply description attribute to the schema
        if (descriptionAttr != null)
        {
            if (schema is not JsonObject jObj)
            {
                // Handle boolean schema case
                JsonValueKind valueKind = schema.GetValueKind();
                Debug.Assert(valueKind is JsonValueKind.True or JsonValueKind.False);
                schema = jObj = new JsonObject();
                if (valueKind is JsonValueKind.False)
                {
                    jObj.Add("not", true);
                }
            }

            jObj.Insert(0, "description", descriptionAttr.Description);
        }

        return schema;
    }
};

JsonSerializerOptions options = JsonSerializerOptions.Default;
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Student), exporterOptions);
Console.WriteLine(schema.ToString());

The TransformSchemaNode delegate lambda function modifies each schema node and applies DescriptionAttribute values for the generated JSON. If a DescriptionAttribute is found, as in our case, it is added to the generated schema.

Web Defaults Serialization Singleton

.NET 9 simplifies web-based serialization with the new JsonSerializerDefaults singleton. With this new feature, you can align your JSON with commonly used practices in web applications, such as camel case property naming and compatibility with JSON-based APIs.

Code example

var mobile = new Mobile()
{
    ModelNumber = 223
};

string json = JsonSerializer.Serialize(
    mobile,
    JsonSerializerDefaults.Web
);

Console.WriteLine(json);

Customizing Indentation with JsonSerializerOptions

One of the key additions to the library is indentation customization using JsonSerializerOptions. Previously, any response was not well-indented and readable, and developers may need to do it manually or through a third-party application. Fortunately, you can configure it within the application. 

Code example

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    IndentCharacter = '\t',
    IndentSize = 1,
};

string json = JsonSerializer.Serialize(
    new
    {
        Field1 = 1,
        Field2 = 2
    },
    options
);

Console.WriteLine(json);

Output

Or applying on an object

var mobile = new Mobile()
{
    ModelNumber = 223
};
var jsonMobile = JsonSerializer.Serialize(mobile, options);
Console.WriteLine(jsonMobile);

Output

Here, I defined the Indentation of size 1 tab for the fields.

Streaming multiple JSON documents

Utf8JsonReader now allows multiple whitespace-separated JSON documents to be read. Traditionally, you get an exception for trailing non-whitespace characters within a single buffer or stream. You can set the JsonReaderOptions.AllowMultipleValues flag of JsonReaderOptions. Consider the following example.

Code example

using System.Text.Json;

JsonReaderOptions options = new() { AllowMultipleValues = true };
Utf8JsonReader reader = new("null {} 7 \r\n [7,8,9]"u8, options);

reader.Read();
Console.WriteLine(reader.TokenType);

reader.Read();
Console.WriteLine(reader.TokenType);
reader.Skip();

reader.Read();
Console.WriteLine(reader.TokenType);

reader.Read();
Console.WriteLine(reader.TokenType);
reader.Skip();

Console.WriteLine(reader.Read());

Output

We keep forwarding the JSON and printing token types.

Customizing enum member names

The library adds the JsonStringEnumMemberName attribute, which allows you to provide a custom string representation of individual enum members when serializing enums to JSON. This update is useful when you need to specify a context-specific name for enum members.

[Flags, JsonConverter(typeof(JsonStringEnumConverter))]
enum MyEnum
{
    Value1 = 1,
    [JsonStringEnumMemberName("2nd value")]
    Value2 = 2,
}
var result = JsonSerializer.Serialize(MyEnum.Value1 | MyEnum.Value2);
// result will be: "Value1, 2nd value"

The [Flags] attribute indicates that the enum can be treated as a bit field, a set of flags. It allows combining multiple enum values using a bitwise OR operation (e.g., MyEnum.Value1 | MyEnum.Value2).

The JsonConverter(typeof(JsonStringEnumConverter)) attribute specifies that this enum should use JsonStringEnumConverter for serialization. This converter converts enum values to their string representations rather than numeric ones.

JsonObject property order manipulation

Another breakthrough which System.Text.Json brings with .NET 9 is the order manipulation of JSON objects. The JsonObject type is a DOM type that represents JSON objects. By default, It has an implicit property order that the user cannot modify. However, It now implements IList<KeyValuePair<string, JsonNode?>> that extends the JsonObject instance with the methods

public int IndexOf(string key);
public void Insert(int index, string key, JsonNode? value);
public void RemoveAt(int index);

You can leverage these methods to reorder JSON fields. Let's see how to do it.

Code example

// Creates an ordered JSON object schema from a POCO (Plain Old CLR Object)
var schema = (JsonObject)JsonSerializerOptions.Default.GetJsonSchemaAsNode(typeof(MyPoco));

// Check the index of the "$id" property
switch (schema.IndexOf("$id", out JsonNode? idValue))
{
    case < 0: // "$id" property is missing
        idValue = (JsonNode)"https://MyApi.com/schema1";
        schema.Insert(0, "$id", idValue); // Insert at the start
        break;

    case 0: // "$id" property is already at the start
        break; 

    case int index: // "$id" exists but is not at the start
        schema.RemoveAt(index); // Remove from the current position
        schema.Insert(0, "$id", idValue); // Insert at the start
}

Conclusion

serializationSystem.Text.Json is a robust .NET package that offers JSON operations such as serialization, deserialization, read/write streaming, options configurations, schema validation, and type conversions. With .NET 9, this library also brings many enhancements, including shema export, Web Defaults Serialization Singleton, and multiple JSON document support. In this post, I went through some of the critical features with coding examples. All these features take the package and .NET framework to a new height.