Search code examples
c#jsonjson.netdeserialization

Json.NET - handle unknown derived types when using TypeNameHandling


The context

I have a Web API that returns a list of derived types (all with the same base type) serialized as JSON using Json.NET. Client code then parses that JSON back into a list of derived types. This is done via an API Client that has references to the same class objects used by the Web API. And both the client and server are using Json.NET's TypeNameHandling.Objects feature.

The problem

When a new derived type is added to the Web API, this causes the client-side deserialization to throw an exception due to the client not having a reference to the new derived type's class.

The goal

Instead of throwing an exception, the deserializer should default to a specific "Unknown" derived class when the type is unknown to the client.

The constraint

I don't want to introduce deserialization logic that requires a code change every time a new derived type is added, i.e., a switch statement on the type. I want to continue to use Json.NET's built-in type handling feature, but with the "default to Unknown" logic.

The code

Here's sample code highlighting the problem:

public abstract class Animal { }
public class Unknown : Animal { }
public class Dog : Animal { }
public class Cat : Animal { }
public class Dummy : Animal { } // to be swapped out of the JSON with a "new" derived type
var animals = new List<Animal>()
{
    new Dog(),
    new Cat(),
    new Dummy()
};

var settings = new JsonSerializerSettings()
{
    TypeNameHandling = TypeNameHandling.Objects,
};
var json = JsonConvert.SerializeObject(animals, settings);

// simulate unknown derived type in JSON
json = json.Replace("Dummy", "NewAnimal"); 

// throws serialization exception
var deserializedAnimals = JsonConvert.DeserializeObject<List<Animal>>(json, settings);

// goal: deserializedAnimals should contain 3 objects of type { Dog, Cat, Unknown } 


Solution

  • Here's a custom JsonConverter that seems to solve the problem:

    public class DefaultToUnknownConverter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return typeof(Animal).IsAssignableFrom(objectType);
        }
    
        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            if (reader.TokenType == JsonToken.Null)
            {
                return null;
            }
    
            JObject jObject = JObject.Load(reader);
            try
            {
                // attempt to deserialize to known type
                using (JsonReader jObjectReader = CopyReaderForObject(reader, jObject))
                {
                    // create new serializer, as opposed to using the serializer parm, to avoid infinite recursion
                    JsonSerializer tempSerializer = new JsonSerializer()
                    {
                        TypeNameHandling = TypeNameHandling.Objects
                    };
                    return tempSerializer.Deserialize<Animal>(jObjectReader);
                }
            }
            catch (JsonSerializationException)
            {
                // default to Unknown type when deserialization fails
                return jObject.ToObject<Unknown>();
            }
        }
    
        public override bool CanWrite => false;
    
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            throw new NotImplementedException();
        }
    }
    
    public static JsonReader CopyReaderForObject(JsonReader reader, JToken jToken)
    {
        // create reader and copy over settings
        JsonReader jTokenReader = jToken.CreateReader();
        jTokenReader.Culture = reader.Culture;
        jTokenReader.DateFormatString = reader.DateFormatString;
        jTokenReader.DateParseHandling = reader.DateParseHandling;
        jTokenReader.DateTimeZoneHandling = reader.DateTimeZoneHandling;
        jTokenReader.FloatParseHandling = reader.FloatParseHandling;
        jTokenReader.MaxDepth = reader.MaxDepth;
        jTokenReader.SupportMultipleContent = reader.SupportMultipleContent;
        return jTokenReader;
    }
    

    Add the converter to the deserializer settings:

    var settings = new JsonSerializerSettings()
    {
        TypeNameHandling = TypeNameHandling.Objects,
        Converters = { new DefaultToUnknownConverter() } 
    };
    

    The DefaultToUnknownConverter class could be made more generic by passing in the json settings, using generics, etc..

    Reference for implementing a custom JsonConverter: https://stackoverflow.com/a/21632292/674237