The “I’m stupid” elm language nugget #13

My final thoughts on polymorphism and virtual dispatch in elm: although you need to write the boilerplate for virtual dispatch yourself, it’s not as bad as it seems, and you can couple objects to a compelling vocabulary.

I think on this a bit at a time, every once in a while. Recently, after writing about 2000 lines of kotlin, I thought to myself: ok, why again can’t you just do polymorphism in elm? Why not objects?

I previously wrote on one aspect of this topic here:

Which addresses what happens when one uses object methods to dispatch different behaviors on the same data type (or a dynamic data type), but doesn’t address what happens when you have a collection of different concrete data types that need similar behaviors. It turns out that in elm, it’s necessarily different because a parameterized “base class” can’t reference its own type. Something has to “launder” the type via a non-alias type, and that requires a match or destructure in code that can’t currently be automated or generalized.

It always comes down to the inability for a function member of a record to return the record’s own type:

type alias A = { toInt : A -> Int } — Would be recursive

My pondering came up with this, which combines both techniques to a degree:

-- An object type that has some operation we want to generalize
type alias A = { x : Int }
addWithA a n = { x = a.x + n }

Pretty standard so far. Let’s define our generalized container:

-- Represents an extractable result from a generic operation,
-- also breaks the type cycle
type IntExtractResult = AR A
type IntMethods = IntMethods { toInt : () -> Int, add : Int -> IntMethods, return : () -> IntExtractResult }
toInt : IntMethods -> Int
toInt (IntMethods w) = w.toInt ()
result : IntMethods -> IntExtractResult
result (IntMethods r) = r.return ()
addWith : Int -> IntMethods -> IntMethods
addWith n (IntMethods m) = m.add n

Ok so far. We’ve got a type with methods that extract information or return a new object and exhibit type erasure:

-- Close the type loop, exactly as expected.
addIntMethodsToA a = { toInt = always a.x, add = addWithA a >> addIntMethodsToA, return = always (AR a) }

Can we add more candidates for IntMethods?

type alias B = { f : Float }
type IntExtractResult = AR A | BR B -- Updated
addWithB b n = { f = b.f + (toFloat n) }
addIntMethodsToB b = { toInt = always (round b.f), add = addWithB b >> addIntMethodsToB, return = always (BR b) }

And any code that uses IntMethods is isolated from this relatively small change, except where coercion takes place.

l = [addIntMethodsToA { x = 1 }, addIntMethodsToB { f = 3.2 }, addIntMethodsToA { x = 10 }]
eol n p = p ++ “\n” ++ n
main = Html.pre [] [
(l |> List.map toInt |> toString)
|> eol (l |> List.map result |> toString)
|> eol (l |> List.map (addWith 3) |> List.map result |> toString)
|> Html.text
]

Outputs:

[1,3,10]
[AR { x = 1 },BR { f = 3.2 },AR { x = 10 }]
[AR { x = 4 },BR { f = 6.2 },AR { x = 13 }]

So we succeeded in having an interface with some vocabulary methods on it, and the usage was pretty natural.

And speaking of coercion, it takes extra code but this method would allow you to coerce between interfaces and get the original types back, using the result type.

Perhaps this way of generalizing code over some underlying types is pretty obvious, but I examined a whole bunch of candidate alternatives for the organization of the types in here before the obvious one stuck out as completely inevitable from my perspective.