Deserializing to Custom Types with System.Text.Json

This blog post contains information about using System.Text.Json (STJ) to deserialize JSON to custom types rather than simple primitives such as string.

I am in the process of migrating from Newtonsoft.Json to System.Text.Json (STJ).

I am not necessarily well-informed, so this is an overview of my understanding. Deserialization is the process of instantiating or populating an object with values from a JsonDocument/JsonElement. The deserializer sets properties in the object that have names or attributes that correspond to identifiers of values in the JSON. A string property in the class can support any scalar value in the JSON, while types such as double and DateTime or DateTimeOffset are more appropriate for other values. The deserializer converts the string representation in the JSON to the type of the property in your class.

Nested structures in the JSON require properties based on classes that model those structures, including nested properties. For example, to model a nested collection of simple values, you could implement a class with fields that correspond to their identifiers and add a property of that type to the class used for deserialization named or attributed after the identifier of that collection in the JSON.

It is helpful to model certain types of data represented as strings in JSON as types other than string. For web applications that render strings from the JSON as HTML, it may be best to implement a class that stores the string value but provides convenient access to that value as an HtmlString. If a JSON value contains markdown, in addition to storing the raw markdown as a string, the type used to represent that value could provide facilities to convert the markdown to other formats, most notably HTML, also potentially exposed as an HtmlString.

With Newtonsoft.Json, I implemented a struct for this purpose, and deserialization always seemed to instantiate properties in the deserialized object automatically.

Because I allow the markdown field named markdown to repeat, the class for deserialization contains a list of the MarkdownField type for deserialization to populate.

public List<MarkdownField> Markdown { get; set; }

The JSON looks like this.

"markdown": [
    "First **markdown**",
    "**Second** Markdown"
  ],

With Newtonsoft, this worked; with STJ, I get an exception.

JsonException: The JSON value could not be converted to Deliverystack.Markdown.MarkdownField. Path: $.markdown[0] | LineNumber: 0 | BytePositionInLine: 299.
System.Text.Json.ThrowHelper.ThrowJsonException_DeserializeUnableToConvertValue(Type propertyType)

The line number is pretty useless when the JSON is compacted, but the token $.markdown[0] indicates that the first value for something named markdown in the JSON.

To work around this issue, I implemented a JsonConverter that does what the NewtonSoft deserializer had apparently done by default. Here is the current code for properties that represents a string of markdown and the JsonConverter apparently required to support it with System.Text.Json.

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

using Microsoft.AspNetCore.Html;

using Markdig;

using Deliverystack.Core.Attributes;

[AutoLoadJsonConverter(true)]
public class MarkdownFieldJsonConverter : JsonConverter<MarkdownField>
{
    public override MarkdownField Read(
        ref Utf8JsonReader reader, 
        Type typeToConvert, 
        JsonSerializerOptions options)
    {
        return new MarkdownField(reader.GetString());
    }

    public override void Write(
        Utf8JsonWriter writer, 
        MarkdownField value, 
        JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

public readonly struct MarkdownField
{
    private readonly string _markup;

    public MarkdownField(string markdown)
    {
        RawMarkdown = markdown;

        if (String.IsNullOrEmpty(RawMarkdown))
        {
            _markup = String.Empty;
        }
        else
        {
            _markup = Markdown.ToHtml(
                RawMarkdown,
                new MarkdownPipelineBuilder().UseAdvancedExtensions().Build());
        }
    }

    public static implicit operator MarkdownField(string input)
    {
        return new MarkdownField(input);
    }

    public static implicit operator string(MarkdownField input)
    {
        return input.ToString();
    }

    public static implicit operator HtmlString(MarkdownField input)
    {
        return new HtmlString(input.ToString());
    }

    public string RawMarkdown { get; }

    public HtmlString Markup
    {
        get
        {
            return new HtmlString(_markup);
        }
    }

    public override string ToString()
    {
        //TODO: WebUtility.HtmlEncode?
        return Markup.ToString();
    }
}

Here is retrieving the rendered markdown from a razor view template (.cshtml file).

foreach (MarkdownField field in @Model.EntryModel.Markdown)
{
    @: Markdown @field.Markup;
}

There is probably a better way around this, especially as this requires me to remember to add the JsonConverter to a JsonSerializerOptions instance and to pass that to the deserializer. Most structures are simple strings or other simple scalars or highly reusable components such as this markdown field. Rather than using deserialization, solutions can pass raw JSON down to clients such as JavaScript frameworks or even parse JSON manually with .NET. Still, to avoid hard-coding a list of JsonConverters, I implemented the AutoLoadJsonConverter attribute and the logic to add converters with that attribute to a JsonSerialzierOptions that I needed to pass for deserialization anyway to allow case-insensitive property name matching and to mimic dependency injection for JsonConverters that do not support dependency injection otherwise.

2 thoughts on “Deserializing to Custom Types with System.Text.Json

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: