Search code examples
f#type-providersf#-data

Pattern matching on provided types


Firstly, obtain a schema and parse:

type desc = JsonProvider< """[{"name": "", "age": 1}]""", InferTypesFromValues=true >
let json = """[{"name": "Kitten", "age": 322}]"""
let typedJson = desc.Parse(json)

Now we can access typedJson.[0] .Age and .Name properties, however, I'd like to pattern match on them at compile-time to get an error if the schema is changed.

Since those properties are erased and we cannot obtain them at run-time:

let ``returns false``() = 
  typedJson.[0].GetType()
    .FindMembers(MemberTypes.All, BindingFlags.Public ||| BindingFlags.Instance, 
                 MemberFilter(fun _ _ -> true), null) 
  |> Array.exists (fun m -> m.ToString().Contains("Age"))

...I've made a runtime-check version using active patterns:

let (|Name|Age|) k = 
  let toID = NameUtils.uniqueGenerator NameUtils.nicePascalName
  let idk = toID k
  match idk with
  | _ when idk.Equals("Age") -> Age
  | _ when idk.Equals("Name") -> Name
  | ex_val -> failwith (sprintf "\"%s\" shouldn't even compile!" ex_val)

typedJson.[0].JsonValue.Properties()
|> Array.map (fun (k, v) -> 
     match k with
     | Age -> v.AsInteger().ToString() // ...
     | Name -> v.AsString()) // ... 
|> Array.iter (printfn "%A")

In theory, if FSharp.Data wasn't OS I wouldn't be able to implement toID. Generally, the whole approach seems wrong and redoing the work.

I know that discriminated unions can't be generated using type providers, but maybe there's a better way to do all this checking at compile-time?


Solution

  • Quoting your comments which explain a little better what you are trying to achieve:

    Thank you! But what I'm trying to achieve is to get a compiler error if I add a new field e.g. Color to the json schema and then ignore it while later processing. In case of unions it would be instant FS0025.

    and:

    yes, I must process all fields, so I can't rely on _. I want it so when the schema changes, my F# program won't compile without adding necessary handling functionality(and not just ignoring new field or crashing at runtime).

    The simplest solution for your purpose is to construct a "test" object.

    The provided type comes with two constructors: one takes a JSonValue and parses it - effectively the same as JsonValue.Parse - while the other requires every field to be filled in.

    That's the one that interests us.

    We're also going to invoke it using named parameters, so that we'll be safe not only if fields are added or removed, but also if they are renamed or changed.

    type desc = JsonProvider< """[{"name": "SomeName", "age": 1}]""", InferTypesFromValues=true >
    
    let TestObjectPleaseIgnore = new desc.Root (name = "Kitten", age = 322)
    // compiles
    

    (Note that I changed the value of name in the sample to "SomeName", because "" was being inferred as a generic JsonValue.)

    Now if more fields suddenly appear in the sample used by the type provider, the constructor will become incomplete and fail to compile.

    type desc = JsonProvider< """[{"name": "SomeName", "age": 1, "color" : "Red"}]""", InferTypesFromValues=true >
    
    let TestObjectPleaseIgnore = new desc.Root (name = "Kitten", age = 322)
    // compilation error: The member or object constructor 'Root' taking 1 arguments are not accessible from this code location. All accessible versions of method 'Root' take 1 arguments.
    

    Obviously, the error refers to the 1-argument constructor because that's the one it tried to fit, but you'll see that now the provided type has a 3-parameter constructor replacing the 2-parameter one.