What is the idiomatic way to deal with objects of the same type but of different shapes?

Viewed 100

I have a Map whose keys may take a variety of shapes (I am using the word "shapes" loosely). In order to handle this I created a discriminated union (DU):

type AuxDataKey =
    | OneString         of string
    | TwoStrings        of string * string
    | OneStringInt      of string * int
    | TwoStringsInt     of string * string * int
    | OneStringTwoInts  of string * int * int
    | TwoStringsTwoInts of string * string * int * int

type AuxData = Map<AuxDataKey,AuxDataItem>

A key can be created in this way:

let key = AuxDataKey.TwoStringsInt("Vol", ticker, count)

and information can be retrieved from a key created on-the-fly:

let vols = auxData.[AuxDataKey.TwoStringsInt("Vol", ticker, count)]

This works, but I find it quite cumbersome.

One alternative would be to create another DU:

type StringInt = String of string | Int of int

and then have the Map keys be lists of such objects. There would be no need to crate a DU for the Map keys and the StringInt DU is much simpler. On the other hand, this alternative looks less safe as the compiler could accept lists, for example, in which the first element is an Int and the second is a String, which would not fit any of the cases in the AuxDataKey DU. I have not tried this in practice, as there may be better alternatives.

Is there an idiomatic way to deal with objects that must be of the same type but have different shapes?

2 Answers

The way you are doing it is pretty idiomatic. Note that you don't need to prefix the type name AuxDataKey when referring to a DU case, so you can just write:

let key = TwoStringsInt("Vol", ticker, count)

It may be possible to decompose your type into meaningful parts, but it's hard to tell without knowing what the component parts really mean. This could get rid of some of the repetition. For example, just pulling out one of the strings, which all cases have in common would give you this:

type SubKey =
    | A of string
    | B of int
    | C of string * int
    | D of int * int
    | E of string * int * int

type AuxDataKey =
    { Key : string
      SubKey : SubKey }

Some other languages have features where the union cases are automatically inferred by the type system based on what different values are returned. I believe TypeScript can do this and OCaml has something similar with its polymorphic variants. However, F# is completely nominally typed in this regard, meaning that each DU case must be defined and named before it is used.

I understand that what you'd really want is something like this:

type AuxDataKey = 
    | Vol of string * int
    | AnotherKey of string * int * int * string
    | ...

but there are so many cases that this feels like a pointless exercise?

The way your question reads to me, you're feeling the pain of being on the wrong side of the expression problem. You have an open universe of disparate types, and you're trying to find a pattern in their shape that will make using a (closed) discriminated union to represent them feasible.

F# gives you an easy way of switching sides by using the OO features of the language. The pattern I used and seen used before is creating an open hierarchy of types that inherit a common base type (either an interface or an abstract class) and augmenting them with partial active patterns that give you an API for accessing them that feels very natural in F#.

type IAuxDataKey = interface end 

type VolKey =
    {
        arg1: string
        arg2: int
    }
    interface IAuxDataKey

let (|VolKey|_|) (value: IAuxDataKey) =
    match value with
    | :? VolKey as key -> Some (key.arg1, key.arg2)
    | _ -> None

And use them like this:

let x = { arg1 = "test"; arg2 = 42 } :> IAuxDataKey

match x with
| VolKey (arg1, arg2) -> printfn "arg1: %s, arg2: %d" arg1 arg2
| _ -> printfn "unknown"

IAuxDataKey is just a marker interface here, but you can add common members there if that makes sense in your context (e.g. a parse or create method, if the keys come from a homogenous source).

What you lose here is the ability to have exhaustive matches, you'll need the catch-all case. But I don't think that's much of a concern in your use case.

Related