Skip to Content
Course content

144: The System.Text.Json Advanced Features: Custom Converters

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Ever hit a wall where System.Text.Json just refuses to play ball with a specific type? Most of the time, the built-in serializer is a beast—it handles strings, ints, and lists without you lifting a finger. But eventually, you'll encounter a legacy API or a specific data requirement where the JSON doesn't "shape" the way C# objects do. That's where custom converters come in.

Think of a custom converter like a specialized translator at a diplomatic summit. Most delegates speak a common trade language (that's your standard JSON serialization). But then you have one delegate who speaks an ancient, highly specific dialect. You can't just tell them to "speak standard"; they can't. Instead, you hire a translator who sits right next to them. When the delegate speaks, the translator converts it into the trade language for everyone else. When someone speaks to the delegate, the translator converts it back into that specific dialect. The rest of the summit doesn't even know the dialect exists; they just see the translated result.

In C#, the "dialect" is the weird JSON format, the "delegate" is your C# class, and the JsonConverter<T> is your translator.

Dealing with "Flattened" Data

Let's look at a real scenario. Imagine you're working with a mapping API. In your C# code, you want a Coordinate object because it's clean and type-safe. However, the API sends coordinates as a single string: "40.7128,-74.0060". If you try to deserialize that into a class with Latitude and Longitude properties, the serializer will throw an exception because it sees a string, not an object.

public class Coordinate
{
    public double Latitude { get; set; }
    public double Longitude { get; set; }
}

To fix this, we build the translator. We inherit from JsonConverter<Coordinate> and override two methods: Read (JSON to C#) and Write (C# to JSON).

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

public class CoordinateConverter : JsonConverter<Coordinate>
{
    // This handles the "Translation" from JSON string to C# Object
    public override Coordinate Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        string? value = reader.GetString();
        if (string.IsNullOrWhiteSpace(value)) return null;

        var parts = value.Split(',');
        return new Coordinate
        {
            Latitude = double.Parse(parts[0]),
            Longitude = double.Parse(parts[1])
        };
    }

    // This handles the "Translation" from C# Object to JSON string
    public override void Write(Utf8JsonWriter writer, Coordinate value, JsonSerializerOptions options)
    {
        writer.WriteStringValue($"{value.Latitude},{value.Longitude}");
    }
}

Wiring the Converter into the Pipeline

Just writing the converter isn't enough; the JsonSerializer doesn't automatically scan your project for every possible converter. You have to tell it to use yours. I usually see people do this in one of two ways, depending on how much control they want.

If you only want this converter to apply to one specific property in one specific class, you can use an attribute. This is the "surgical" approach:

public class UserProfile
{
    public string Username { get; set; }

    [JsonConverter(typeof(CoordinateConverter))]
    public Coordinate LastKnownLocation { get; set; }
}

But if Coordinate is used in fifty different classes across your project, adding attributes everywhere is a nightmare. In that case, you add it to the global options. I personally prefer this for domain-wide types because it keeps your POCOs (Plain Old CLR Objects) clean of serialization logic.

var options = new JsonSerializerOptions();
options.Converters.Add(new CoordinateConverter());

string json = "{\"Username\": \"DevUser\", \"LastKnownLocation\": \"40.7128,-74.0060\"}";
var profile = JsonSerializer.Deserialize<UserProfile>(json, options);

One quick tip: when implementing Read, be careful with reader.GetString(). In a high-performance production environment, you might want to work with the ReadOnlySpan<byte> directly to avoid allocations, but for 95% of use cases, the string approach I showed you is perfectly fine and much easier to maintain.




📋 Practical Task

Exercise: Implementing a Monetary Value Converter

You are integrating with a financial API that represents currency as a combined string in the format "USD 125.50" or "EUR 99.00". Your C# application uses a Money record to keep the currency code and the amount separate for calculation purposes.

Your Goal: Create a custom JsonConverter that allows System.Text.Json to seamlessly convert between the API's string format and your C# record.

Requirements:

  • Define a record: public record Money(string Currency, decimal Amount);
  • Create a MoneyConverter that inherits from JsonConverter<Money>.
  • The Read method should split the string (e.g., "USD 100.00") into the Currency ("USD") and Amount (100.00) parts.
  • The Write method should combine the properties back into the "CUR Amount" format.
  • Demonstrate the converter working by serializing and deserializing a class called Invoice which contains a Money TotalAmount property.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.