Serializing and deserializing interface properties in C# to JSON using JsonDerivedType

TL;DR

When serializing/deserializing a class with an interface property like public record User(string Name, IUserEmail Email); in C#, you can achieve this using the JsonDerivedType attribute. (.NET 7 and above)

Background: Wanting to Program with Type Constraints

I am Tomohis Takaoka , CTO of J-Tech Creations, Inc. Recently, I've been thinking about various ways to efficiently program business requirements.

I recently read the book Domain Modeling Made Functional, which is about domain modeling in a functional way. This book explains how to perform domain modeling with F#. In F#, you can define a type that can be one of many named cases, known as discriminated unions .

type Shape =
    | Rectangle of width : float * length : float
    | Circle of radius : float
    | Prism of width : float * float * height : floatf

With the type above, you can express that shape can be of type Rectangle, Circle, or Prism. By applying types in this way, it can be very useful in business applications.

When using C#, you cannot write it in exactly the same way, but you can use interfaces to achieve a similar effect.

Example: Differentiating between verified and unverified email types

For instance, when representing an email address, converting it into a type can create a more natural representation. Consider a user with an email address that cannot be used without verification.

public record User(string Name, string Email, bool EmailVerified);

With the above implementation, you can check if the email has been verified. However, with this method, there's a possibility of creating a bug where you send an email even if EmailVerified is False, as long as an email address is present. If you resolve this using type definitions,

By separating the types for verified and unverified emails as shown below, it is possible to handle them appropriately.

public interface IUserEmail;
public record UnverifiedEmail(string Value) : IUserEmail;
public record VerifiedEmail(string Value) : IUserEmail;
public record User(string Name, IUserEmail Email);

Although the types become somewhat complex, it makes it easier to write processes that require VerifiedEmail, such as sending an email.

private void SendEmail(VerifiedEmail email)
{
    _testOutputHelper.WriteLine("sending email to " + email.Value + "...");
}
var user = new User("test", new UnverifiedEmail("test@example.com"));
var user2 = new User("test", new VerifiedEmail("test@example.com"));
if (user.Email is VerifiedEmail email)
{
    SendEmail(email);
}
if (user2.Email is VerifiedEmail email2)
{
    SendEmail(email2);
}

The above content is...

It was adapted from the [YouTube lecture](https://www.youtube.com/watch?v=2JB1_e5wZmU) by Scott Wlaschin, who wrote the book Modeling Made Functional.

As you will see in the lecture, Scott emphasized several times that when constraints are enforced by types, you can't even write incorrect code, making it unnecessary to write tests. Interesting, isn't it?

Due to these reasons, as I tried to implement such an interface in various places, a problem arose where saving it as JSON became a hassle.In this blog post, I will introduce how I was able to serialize and deserialize the interface properties using System.Text.Json.

Errors that Occur

When attempting to serialize and deserialize a User class with a IUserEmail property using System.Text.Json, an issue arises.

Serialization

[Fact]
public void SerializationSucceedsTest()
{
    var test = new User("test", new UnverifiedEmail("test@example.com"));
    var json = JsonSerializer.Serialize(test);
    Assert.NotNull(json);
    Assert.Equal("{\"Name\":\"test\",\"Email\":{\"Value\":\"test@example.com\"}}", json);
}

When you try to serialize as shown above, you encounter the following error:

Xunit.Sdk.EqualException
Assert.Equal() Failure: Strings differ
                                  ↓ (pos 24)
Expected: ···"me":"test","Email":{"Value":"test@example"···
Actual:   "{"Name":"test","Email":{}}"

at Sekiban.Test.CosmosDb.Serializations.UserEmailTest.SerializationSucceedsTest() in

As the interface itself does not have a Value property, the contents of the Email property will be empty.

Deserialization

[Fact]
public void DeserializationNotThrowsTest()
{
    var test = JsonSerializer.Deserialize<User>("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}");
    Assert.NotNull(test);
    Assert.Equal(new User("test", new UnverifiedEmail("test@example.com")), test);
}

When attempting deserialization as shown above, the following error occurs.

System.NotSupportedException
Deserialization of interface types is not supported. Type 'Sekiban.Test.CosmosDb.Serializations.IUserEmail'. Path: $.Email | LineNumber: 0 | BytePositionInLine: 24.

This means that deserialization of interfaces is not supported.

Looking at this, it might seem that serializing/deserializing interfaces is a tough task.

Solution

Upon researching, I found a relatively simple solution. There is an article in Microsoft's documentation.

[How to serialize properties of derived classes with System.Text.Json](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism?pivots=dotnet-7-0)
Here
Here), there are multiple approaches, but for this particular issue, you can resolve it by using the JsonDerivedType attribute. Adjust the definition as follows:

[JsonDerivedType(typeof(UnverifiedEmail), nameof(UnverifiedEmail))]
[JsonDerivedType(typeof(VerifiedEmail), nameof(VerifiedEmail))]
public interface IUserEmail;
public record UnverifiedEmail(string Value) : IUserEmail;
public record VerifiedEmail(string Value) : IUserEmail;
public record User(string Name, IUserEmail Email);

Since "Derived" means "to be derived," you can specify that IUserEmail is derived by UnverifiedEmail and VerifiedEmail.

The second parameter determines how the type information should be recorded in JSON, so you pass the name of the class. By doing so, when serializing to JSON, it adds supplemental information, $type, inside the type, which is used when deserializing.

This makes the following test code pass.

[Fact]
public void SerializationSucceedsTest()
{
    var test = new User("test", new UnverifiedEmail("test@example.com"));
    var json = JsonSerializer.Serialize(test);
    Assert.NotNull(json);
    Assert.Equal("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}", json);
}

[Fact]
public void DeserializationNotThrowsTest()
{
    var test = JsonSerializer.Deserialize<User>("{\"Name\":\"test\",\"Email\":{\"$type\":\"UnverifiedEmail\",\"Value\":\"test@example.com\"}}");
    Assert.NotNull(test);
    Assert.Equal(new User("test", new UnverifiedEmail("test@example.com")), test);
}

The generated JSON will be as follows.

{
    "Name": "test",
    "Email": {
        "$type": "UnverifiedEmail",
        "Value": "test@example.com"
    }
}

{
    "Name": "test",
    "Email": {
        "$type": "VerifiedEmail",
        "Value": "test@example.com"
    }
}

Since type information is included in the $type of the Email object, it can be deserialized back to the correct type.

While using this method, it is necessary to be aware of all the derived types of the interface.Within a domain, it is often possible to know about derived types, which can be useful in many cases.

💡
Note: JsonDerivedType is a new syntax available from .NET 7 onwards.

By doing this, while it may not reach the level of F#, we aim to specify type information in C# programming to enhance the safety of business logic.

1
Subscribe to my newsletter

Read articles from Tomohisa Takaoka directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tomohisa Takaoka
Tomohisa Takaoka

CTO of J-Tech Creations, Inc. Recently working on the development of the event sourcing and CQRS framework Sekiban. Enthusiast of DIY keyboards and trackballs.