NEW Try Dasha in your browser with Dasha Playground!

Type discriminator converter - TypeScript to C#

Converting TypeScript types for use in C#
Converting TypeScript types for use in C#

At Dasha we use communication between applications written in weakly-typed and strong-typed languages. For example, TypeScript and C#. Sometimes we need to pass typed messages from TS to C#. This is usually a problem.

A Type discriminator converter is a converter which can be used to serialize/deserialize JSON/xml to a typed Object.

For example, we can describe a union typed interface with a complex indicator(discriminator) field in ts. When we pass an implementation of this message to a C# application we need to convert it, for example, to JSON string and deserialize to strongly typed hierarchy of class in C#.
We searched for an existing library to solve this task. We found some libraries, for example, JsonKnownTypes and JsonSubTypes. We did not look at the Json.NET because it does not provide a custom indicator(discriminator) field. It is not possible to create more than one indicator(discriminator) field in these libraries.

For this task, we created a converter named TypeIndicatorConverter. Not so long ago we used only one library for serialization named Newtonsoft.Json, but in some new components, we start using System.Text.Json. Because these serializers have different implementation and we created an abstraction under these converters and named it TypeIndicatorConverter.Core. We've separated all of the serializer-related information from the main logic by creating a helper abstraction.

Our solution makes it possible to select types while deserializing by more than one field. In addition, the indicator(discriminator) fields can be not a basic type but for example custom objects.
Other advantages our solution is respecting serializer settings such as case-sensitive and specific serializer attributes. Such as [JsonPropertyName] for System.Text.Json and [JsonProperty] and [DataMember] for Newtonsoft.Json.

Example of usage

Imagine we need to write a calculator. By design, calculations can be resource-dependent and very complex. We wrote a UI application to write some expressions. Then we need to send the user-written expression to the server and evaluate it. We also need to get a textual representation of the written expression. By design, we chose to communicate with the server using JSON. Let's start to develop from a very basic example and do it more complex.
We have four operation: Addition, Multiplication, Division and Subtraction. Also we have constants as operand. Describe this operation in C# classes. Introduce abstract class overall expression BaseExpression and say then base abstraction for the indicator(discriminator) converter. Only because before .net 6 System.Text.Json disallows use attribute [JsonConverter] on interface we use an abstract class. If you use Newtonsoft.Json then you can use interface implementation.

[JsonConverter(typeof(TypeIndicatorConverter<BaseExpression>))] // say use this interface as base for deserialize public abstract class BaseExpression { public abstract double Eval(); }

Now implement classes responsible for operations and constant value:

public class UserConst: BaseExpression { [TypeIndicator] public string Type => "Const"; public double Value { get; set; } public override double Eval() => Value; public override string ToString() => Value.ToString(); } public class Addition: BaseExpression { [TypeIndicator] public string Type => "Addition"; public BaseExpression LeftOperand { get; set; } public BaseExpression RightOperand { get; set; } public override double Eval() => LeftOperand.Eval() + RightOperand.Eval(); public override string ToString() => $"(({LeftOperand}) + ({RightOperand}))"; } public class Subtraction: BaseExpression { [TypeIndicator] public string Type => "Subtraction"; public BaseExpression LeftOperand { get; set; } public BaseExpression RightOperand { get; set; } public override double Eval() => LeftOperand.Eval() - RightOperand.Eval(); public override string ToString() => $"(({LeftOperand}) - ({RightOperand}))"; } public class Division: BaseExpression { [TypeIndicator] public string Type => "Division"; public BaseExpression LeftOperand { get; set; } public BaseExpression RightOperand { get; set; } public override double Eval() => LeftOperand.Eval() / RightOperand.Eval(); public override string ToString() => $"(({LeftOperand}) / ({RightOperand}))"; } public class Multiplication: BaseExpression { [TypeIndicator] public string Type => "Multiplication"; public BaseExpression LeftOperand { get; set; } public BaseExpression RightOperand { get; set; } public override double Eval() => LeftOperand.Eval() * RightOperand.Eval(); public override string ToString() => $"(({LeftOperand}) * ({RightOperand}))"; }

Now we can write the basic operation under constants. Here is a simple example:

var jsonExpresion = "{\"Type\":\"Addition\",\"LeftOperand\":{\"Value\":1.0,\"Type\":\"Const\"},\"RightOperand\":{\"Type\":\"Multiplication\",\"LeftOperand\":{\"Type\":\"Division\",\"LeftOperand\":{\"Value\":6.0,\"Type\":\"Const\"},\"RightOperand\":{\"Value\":2.0,\"Type\":\"Const\"}},\"RightOperand\":{\"Value\":6.0,\"Type\":\"Const\"}}}"; var deserializedExpresion = JsonConvert.DeserializeObject<BaseExpression>(jsonExpresion); Console.WriteLine($"Expression: {deserializedExpresion}");// Expression: ((1) + (((((6) / (2))) * (6)))) Console.WriteLine($"EvalValue: {deserializedExpresion.Eval()}");// EvalValue: 19

Now extend our classes with predefined constants. For example, Pi.

public class PiConst: BaseExpression { [TypeIndicator] public string Type => "Const"; [TypeIndicator] public string ConstantName => "PI"; public override double Eval() => Math.PI; public override string ToString() => Math.PI.ToString(); }

Now, if we try to pass an ordinary constant, we will get an error during deserialization, because we have ambiguously determined which type to choose. There are two ways to solve this problem. Allow an ambiguous conclusion, and then the one will be deserialized into the type that has more satisfying indicator fields. Another way, which will be more correct, is to add an indicator field to ordinary constants without changing the already working structure.

Permission of an ambiguous choice of type is done with the help of an attribute [AmbiguousMatching(true)] for base interface:

[JsonConverter(typeof(TypeIndicatorConverter<BaseExpression>))] [AmbiguousMatching(true)] //allows amiguous matching types. public abstract class BaseExpression { public abstract double Eval(); }

In some situations, you cannot do without it. But in this situation, we can. To do this, we indicate in the Value field that it is discriminatory, but its values can be any. To do this, we will use the attribute [TypeIndicator] argument comparingOptions. Specifically, we use ComparingOptions.UnknownValue.

public class UserConst: BaseExpression { [TypeIndicator] public string Type => "Const"; [TypeIndicator(ComparingOptions.UnknownValue)] public double Value { get; set; } public override double Eval() => Value; public override string ToString() => Value.ToString(); }

Now we can allow users to work with constant value PI.

var jsonExpresion = "{\"Type\":\"Const\",\"ConstantName\":\"PI\"}"; var deserializedExpresion = JsonConvert.DeserializeObject<BaseExpression>(jsonExpresion); Console.WriteLine($"Expression: {deserializedExpresion}"); // Expression: 3,141592653589793 Console.WriteLine($"EvalValue: {deserializedExpresion.Eval()}"); // EvalValue: 3,141592653589793

We can rewrite the PiConst class as follows: If we have a ConstName field with a value of PI and not necessarily have a Type field with a value of Const.

public class PiConst : BaseExpression { [TypeIndicator(ComparingOptions.AllowNotExist)] public string Type => "Const"; [TypeIndicator] public string ConstantName => "PI"; public override double Eval() => Math.PI; public override string ToString() => Math.PI.ToString(); }

Attribute argument ComparingOptions.AllowNotExist allows fields to not exist in JSON representation. But we allowed that when serializing back, the field could have a null value. You can disable this action using the standard attributes for the serializer used. For Newtonsoft.Json can be used next attribute [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] or for System.Text.Json - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)].

public class PiConst : BaseExpression { // [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] // for Newtonsoft.Json // [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] // for System.Text.Json [TypeIndicator(ComparingOptions.AllowNotExist)] public string Type => "Const"; [TypeIndicator] public string ConstantName => "PI"; public override double Eval() => Math.PI; public override string ToString() => Math.PI.ToString(); }

Example usage:

var jsonExpresion = "{\"Type\":\"Const\",\"Value\":\"PI\"}"; var deserializedExpresion = JsonConvert.DeserializeObject<BaseExpression>(jsonExpresion); Console.WriteLine($"Expression: {deserializedExpresionNull}"); // Expression: 3,141592653589793 Console.WriteLine($"EvalValue: {deserializedExpresionNull.Eval()}"); // EvalValue: 3,141592653589793 var jsonExpresionWithoutConst = "{\"Value\":\"PI\"}"; var deserializedExpresionWithoutConst = JsonConvert.DeserializeObject<BaseExpression>(jsonExpresionWithoutConst); Console.WriteLine($"Expression: {deserializedExpresionWithoutConst}"); // Expression: 3,141592653589793 Console.WriteLine($"EvalValue: {deserializedExpresionWithoutConst.Eval()}"); /// EvalValue: 3,141592653589793

In server-side code we have a helper class for logging expressions. It is a descendant of BaseExpression and we want to exclude this class from deserialization. We can do this by attribute IgnoreIndicators. For example:

[IgnoreIndicators] public class SomeLoggingHelper : BaseExpression { private readonly BaseExpression _expression; public SomeLoggingHelper(BaseExpression expression) { _expression = expression; } public override double Eval() { var value = _expression.Eval(); Console.WriteLine($"Expression: {_expression} Result: {value}"); return value; } public override string ToString() => _expression.ToString(); }

Let's say we updated the UI and it began to send a new type of expressions, but we didn't have time to add them. In our current structure, we will get an error when deserializing. It so happened that such expressions turned out to be optional. In this case, we can create a separate class which will be responsible for such events. This can be done by adding the [FallbackIndicator] attribute to the class.

[FallbackIndicator] public class UnknownExpression: BaseExpression { public string Type { get; set; } public override double Eval() => double.NaN; public override string ToString() => "UnknownExpression"; }

Classes with this attribute are used last during deserialization. There can be only one such class and its attributes [TypeIndicator] are ignored.

Example of usage FallbackIndicator:

var jsonExpresion = "{\"Type\":\"ZeroFunction\"}"; var deserializedExpresion = JsonConvert.DeserializeObject<BaseExpression>(jsonExpresion); Console.WriteLine($"Expression: {deserializedExpresion}"); // Expression: UnknownExpression Console.WriteLine($"EvalValue: {deserializedExpresion.Eval()}"); // EvalValue: NaN

Resulting code structure we can find here tests.

More documentation and examples can be found here repository.

Benchmarks

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1237 (21H1/May2021Update) 11th Gen Intel Core i7-11700K 3.60GHz, 1 CPU, 16 logical and 8 physical cores .NET SDK=5.0.400 [Host] : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT MediumRun : .NET 5.0.9 (5.0.921.35908), X64 RyuJIT

Complexity to deserialize

The first benchmark is oriented on getting information about complexity to deserialize base types with different count indicator fields (1,2,4,8).

MethodMeanErrorStdDev
'TypeIndicatorConverter object with one indicator field'1.812 μs0.0826 μs0.1236 μs
'TypeIndicatorConverter object with two indicator fields'2.453 μs0.0885 μs0.1212 μs
'TypeIndicatorConverter object with four indicator fields'3.560 μs0.0204 μs0.0299 μs
'TypeIndicatorConverter object with eight indicator fields'5.962 μs0.0871 μs0.1303 μs

We see that the complexity grows linearly with the number of indicator fields.

The second benchmark is oriented to show information about complexity to deserialize types with a different count of descendants.

MethodMeanErrorStdDev
'Object with 1 indicator field'1.916 μs0.0193 μs0.0276 μs
'Object with 2 indicator fields'2.623 μs0.0300 μs0.0440 μs
'Object with 4 indicator fields'3.972 μs0.0635 μs0.0950 μs
'Object with 8 indicator fields'6.443 μs0.0351 μs0.0503 μs

We see that the complexity grows linearly with the number of descendants.

Comparison with other discriminating serializers

In this benchmark we look at three libraries. Ours, JsonKnownTypes and JsonSubTypes.

In these tests we have 4 descendants with one indicator field.

MethodMeanErrorStdDev
'Direct type deserialization 1 indicators field 4 descendants'523.3 ns4.05 ns5.68 ns
'JsonKnownTypes 1 indicators field 4 descendants'1,876.4 ns39.04 ns57.23 ns
'TypeIndicatorConverter 1 indicators field 4 descendants'3,369.2 ns32.69 ns46.88 ns
'JsonSubTypesConverter 1 indicators field 4 descendants'11,538.7 ns41.90 ns60.08 ns

All the compared serializers use inside Newtonsoft.Json. That means that mean time can not be less than in it. Our implementation is not the fastest but provides more features as a discriminator converter. Naturally, there are places that can be changed and our converter will work faster. The project is open-source and you can contribute to it.

Source code

The source code of the package and code for the examples used in this article can be found in this repository.

Nuget packages:

Authored by: Aleksashin Aleksandr (aleksandr-aleksashin)

Developed in Dasha.AI Inc (Human-like conversational AI for developers)

This project is under MIT license. You can obtain the license copy here.

Related Posts