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)
HereHere), 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.
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.
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.