2

Consider an F# record that contains a list value such as this:

type MyRecord = {
    Name: string
    SomeList: string list
}

Using Netwonsoft.Json.JsonConvert to deserialise JSON to this record when the JSON does not contain a property for the list value Values of the record will lead to the deserialised record having a null value for the list instead of an empty list [].

That is,

open Newtonsoft.Json
JsonConvert.DeserializeObject<MyRecord>("""{ "Name": "Some name"}""" ) |> printfn "%A"
// Gives: { Name = "Some name"; SomeList = null; }

How can you deserialise using Netwonsoft.Json so that the list is initialised to an empty list? For example:

{ Name = "Some name"; SomeList = []; }
Sean Kearon
  • 10,987
  • 13
  • 77
  • 93

1 Answers1

5

You can do this with a custom contract resolver such as the following:

type ParameterizedConstructorInitializingContractResolver() =
    inherit DefaultContractResolver()

    // List is a module not a static class so it's a little inconvenient to access via reflection.  Use this wrapper instead.
    static member EmptyList<'T>() = List.empty<'T>

    override __.CreatePropertyFromConstructorParameter(matchingMemberProperty : JsonProperty, parameterInfo : ParameterInfo) =
        let property = base.CreatePropertyFromConstructorParameter(matchingMemberProperty, parameterInfo)
        if (not (matchingMemberProperty = null) && property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() = typedefof<_ list>) then
            let genericMethod = typeof<ParameterizedConstructorInitializingContractResolver>.GetMethod("EmptyList", BindingFlags.Public ||| BindingFlags.NonPublic ||| BindingFlags.Static)
            let concreteMethod = genericMethod.MakeGenericMethod(property.PropertyType.GetGenericArguments())
            let defaultValue = concreteMethod.Invoke(null, null)
            property.DefaultValue <- defaultValue
            property.DefaultValueHandling <- new System.Nullable<DefaultValueHandling>(DefaultValueHandling.Populate)
            matchingMemberProperty.DefaultValue <- defaultValue
            matchingMemberProperty.DefaultValueHandling <- new System.Nullable<DefaultValueHandling>(DefaultValueHandling.Populate)
        property

And then use it as follows:

let settings = JsonSerializerSettings(ContractResolver = new ParameterizedConstructorInitializingContractResolver())

let myrecord1 = JsonConvert.DeserializeObject<MyRecord>("""{ "Name": "Missing SomeList"}""", settings )
let myrecord2 = JsonConvert.DeserializeObject<MyRecord>("""{ "Name": "Populated SomeList", "SomeList" : ["a", "b", "c"]}""", settings )
let myrecord3 = JsonConvert.DeserializeObject<MyRecord>("""{ "Name": "null SomeList", "SomeList" : null}""", settings )

Notes:

  • The contract resolver works for any object that is deserialized via a parameterized constructor, which includes, but is not limited to, f# records. If any such object has a constructor argument with type T list for any T then the value will default to List.empty<T> when missing or null.

  • The contract resolver reuses the same instance of the default value List.empty<T> for all deserialized objects, which is fine here since f# lists are immutable (and List.empty<T> seems to be a singleton anyway). The same approach would not work for providing a default value for mutable collections as a constructor argument.

  • You may want to cache the contract resolver for best performance.

  • The constructor parameter must have the same name (modulo case) as the corresponding property.

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340