Rimu

Shuang Rimu

A blog about random stuff

Extensible unions in Elm

Image Attribution:

David Benbennick with contributions from Illes, and Koko90. Circle_graph_C5.svg. 2010. Public Domain (NOT a CC-BY 4.0 License).

August 3, 2020

elm

This is an exploratory post of what extensible unions (a.k.a. polymorphic variants) might look like in Elm. This mainly a post to plant a seed in the minds of the collective Elm community and is not a call to action that they need to be implemented in Elm (although I would really really really love if they were).

One-line headline: I think extensible unions not only fill in a missing abstract corner of Elm’s type system (rather than being a entirely novel extension to its type system), but also solve many real-world problems affecting Elm codebases today, all without compromising and indeed augmenting the Elm compiler’s excellent way of guiding users through refactors.

This post is jumping off a separate post here with some folks who were interested in a deeper explanation of extensible unions/polymorphic variants.

This post ended up being really long, so I apologize for its length in advance, but I do hope that people find it useful.

Because of its length I’ll just lead off with a high-level summary.

Polymorphic variants or extensible union types are to normal Elm union types (e.g. type MyUnionType = UnionVariant0 | UnionVariant1 | UnionVariant2) what records and extensible records are to normal Elm product types (e.g. type MyProductType = ProductType TypeOfElement0 TypeOfElement1 TypeOfElement2).

As such they allow for many of the same benefits that records and extensible records bring to input types of functions and modularizing Elm models, to output types of functions and modularizing Elm messages and update functions.

This post is split up into the following sections:

What are extensible union types

Let’s begin with a very short primer on union (a.k.a. sum types) and product types. We’ll approach them individually even though in Elm we can mix and match them together in a single type declaration. This hopefully will make some of the later presentation of extensible union types a little easier to follow.

Union types are those types that express an “or” relationship among its type constructors.

-- MyColor is Red *or* Green *or* Blue
type MyColor = Red | Green | Blue

Product types are those types that express an “and” relationship in its (single) type constructor.

-- MyUserType is a MyUser that consists of a String *and* another String (maybe
-- a first and last name)
type MyUserType = MyUser String String

These two kinds of types are dual, i.e. complements, to one another, where you’ll see each of them playing “opposite” roles in a lot of circumstances. We’ve already seen one example here, whereas a union type can have multiple type constructors, only one of which can be used at any given time for a single value (i.e. only one of Red, Green, or Blue can be in use for a single value of type MyColor), a product type has a single constructor with multiple fields, all of which must be filled in for a single value (for any value of MyUserType both strings must be provided to MyUser). Notice the inverted relationship between multiple and single between the two (i.e. multiple type constructors vs single type constructor and single variant in use vs multiple fields must all be filled in). This sort of “dual” inversion will show up a lot more later on.

Even though the two are complements to one another, statically typed programming languages in general seem to favor product types over union types. You see this with languages like Java that allow for the creation of product types (i.e. Java classes), but have no union types. Statically typed FP languages such as Elm address this imbalance by also allowing union types. This unlocks a host of data modeling goodies and is the core of how Elm allows us to create “correct by construction” models that make errors unrepresentable.

Elm, however, goes further than some other statically typed FP languages and bestows more power to product types through record types and extensible record types. These essentially are product types on steroids. To see that records are fundamentally product types, note that you could in theory replace every record type with a product type, it’d just make for really annoying code because all of a sudden your fields are all anonymous and position-based.

However, Elm, in a fashion that somewhat echoes Java’s choice to disadvantage union types, doesn’t provide equivalent power to union types. The rest of this post is an exploration in what that might look like if Elm did.

To understand the relationship between extensible union types and normal union types, it’s perhaps illuminating to first consider the relationship between record types and normal product types.

Notice that Elm records differ from Elm’s normal product types by being

  1. Structural: Normal product types in Elm are nominal, that is name-based. Every product type you use must have been declared somewhere and is given an explicit, unique name that is used for typechecking. On the other hand record types are anonymous. Though they can be given a name with type alias that name is non-canonical (the same record type can be given multiple different names) and plays no role in typechecking. In other words the structure of a record type is all that matters to the type checker, not its name.
  2. Extensible: Normal product type signatures must specify all possible fields it supports. Extensible records only need to specify a subset of fields and then leave the others unspecified.

Just to make this clear, here are the three versions of a product type that Elm supports at the current moment.

-- Normal Elm product type
type SomeProductType = ProductTypeConstructor Int String

-- Normal Elm record, notice the type alias rather than type
type alias SomeRecord = { field0 : Int, field1 : String }

-- Extensible Elm record, notice the type variable
type alias ExtensibleRecord a = { a | field0 : Int, field1 : String }

Extensible union types introduce these two features, namely making a type structural and extensible, to union types. I find actual code to often be hepful here, so I’m going to introduce some hypothetical Elm syntax here.

-- Normal Elm sum type
type User0 = RegularUser Int | AdminUser String

-- Hypothetical extensible union type, again note that this is a type alias
type alias User1 = @RegularUser Int or @AdminUser String

-- Hypothetical extensible union type actually using the extensible portion,
-- note the type variable
type alias User2 a = a or @RegularUser Int or @AdminUser String

Let’s go over User1 and User2 in more detail. Names prefixed with a an @ symbol such as @RegularUser are extensible union tags. These play an analogous role to field names in a record type. That is @RegularUser Int is an independent type in the same way { field0 : Int } is an independent type, rather than just a value like RegularUser Int.

@SomeVariantTag MyType
^ tag name      ^ tagged type

{ recordField : MyType }
  ^ tag name    ^ tagged type

Likewise, just like record field names can be used as the value level to directly create a value, extensible union tags can be used at the value level to directly create a value.

someRegularUser : User1
-- Equivalently
-- someRegularUser : @RegularUser Int or @AdminUser String
-- or
-- someRegularUser : @AdminUser String or @RegularUser Int
-- Notice just like for record fields the order doesn't matter
-- This is different from a normal sum type where Result String Int is
-- different from Result Int String
someRegularUser = @RegularUser 0

Now we could also have assigned this type

someOtherRegularUser : @RegularUser Int
someOtherRegularUser = @RegularUser 0

or

lotsOfIrrelevantVariants : @Blah String or @Blahblah Bool or @RegularUser Int
lotsOfIrrelevantVariants = @RegularUser 0

For now let’s leave aside why all these type signatures work other than to hand-wave it and say they all make sense if you think of “or”. We’ll return to that in just a bit when we look close at extensible types. Instead, let’s look at how to actually work with extensible unions.

Fundamentally the only thing you can do with a product type is pull out its fields. On the flip side, fundamentally the only thing you can do with a union type is pattern match on its variants with a case statement.

This remains true for extensible unions, which have their own associated case statement for pattern matching on them, introduced by case# instead of case (you could unify them as a single case statement, but I’m separating them for clarity here).

toInt : @RegularUser Int or @AdminUser String -> Int
toInt user = case# user of
    @RegularUser x -> x

    @AdminUser str -> length str

-- We still have exhaustivity checking
failsToCompile : @RegularUser Int or @AdminUser String -> Int
failsToCompile user = case# user of
    @AdminUser str -> length str
    -- Compile failure, failed to handle the case of @RegularUser

-- Does compile and is automatically inferred as
-- inferred : @AdminUser String -> Int
inferred user = case# user of
    @AdminUser str -> length str

-- Inferred as
-- inferred1 : @AdminUser a -> a
inferred1 user = case# user of
    @AdminUser a -> a

Now let’s turn our attention back to the type signatures of someOtherRegularUser and someRegularUser.

Now what’s the inferred type signature of @RegularUser 0 if we don’t provide an explicit type signature?

-- Inferred as
-- yetAnotherRegularUser : a or @RegularUser Int
yetAnotherRegularUser = @RegularUser 0

This is similar to what happens with extensible records if they’re a function input.

-- Inferred as an extensible record if input to a function
-- f : { a | field0 : b } -> b
f record = record.field0

Notice again the dual relationship between extensible records and extensible unions! In particular outputs of a function using extensible union types are inferred by default to be extensible union types. If we think of an Elm value as a zero-argument function, this also suggests why yetAnotherRegularUser is by default inferred to be extensible.

-- Inferred as
-- g : b -> a or @RegularUser b
g x = @RegularUser x

Since this is our first exposure to the actual extensibility of an extensible union, let’s explain it in a bit more detail. An extensible union type means that any other extensible union type tags can be put in the type variable and things will still type check.

-- Notice unlike g, g0 has a concrete type as an output where a has disappeared 
-- and been filled in by @SomeOtherTag Int
g0 : b -> @SomeOtherTag Int or @RegularUser b
g0 x = g x

This is exactly analogous to doing the same thing for extensible record inputs.

-- Notice that a has been filled in here by someOtherField : Int
f0 : { someOtherField : Int, field0 : b } -> b
f0 record = f record

Again, just as for extensible records different functions can all unify with the same concrete input type, for extensible unions different functions can all unify with the same concrete output type by “filling in” the type variable.

-- Again we can think of value as zero-argument functions
user0 : a or @RegularUser Int
user0 = @RegularUser 1

user1 : a or @AdminUser String
user1 = @AdminUser "admin"

thisTypechecks : Bool -> a or @RegularUser Int or @AdminUser String
thisTypechecks bool = case bool of 
    True -> user0

    False -> user1

thisTypeChecksToo : @RegularUser Int or @AdminUser String or @SomeOtherTag Bool
thisTypeChecksToo = thisTypechecks True

Indeed with regards to the relationship between inputs and outputs we can say something even stronger. Extensible records never appear in the output of a function signature unless that exact type showed up in an input.

typeInInput : { a | someField0 : Int } -> { a | someField0 : Int }
typeInInput x = x

impossibleFunction : () -> { a | someField0 : Int }
impossibleFunction = Debug.crash "We cannot conjure up an arbitrary a"

Analogously, extensible union types never appear in the input of a function signature unless that exact type shows up in the output.

typeInOutput : a or @SomeTag Int -> a or @SomeTag Int
typeInOutput x = x

anotherImpossibleFunction : a or @SomeTag Int -> ()
anotherImpossibleFunction = Debug.crash "We cannot match an arbitrary a"

Well why is this impossible? Let’s return to pattern matching.

anotherImpossibleFunction : a or @SomeTag Int -> ()
anotherImpossibleFunction x = case# x of
    @SomeTag _ -> ()
    -- Now what do we do? We don't have a tag like @SomeTag that we can use on
    -- an arbitrary a. So we have an incomplete pattern match, which is
    -- disallowed by Elm.

We’re not allowed to pattern match on a because it could be anything so we can’t actually do anything useful with it. You could imagine that you’re allowed to ignore it, e.g. _ -> (), but just as is the case with normal union types, _ -> () can be thought of as purely syntactic sugar that saves us from writing out all the other cases when in theory we could’ve, it just would be tedious. Because for a generic a, we couldn’t, in theory, write out all the other cases, we cannot use _ -> () here.

This is analogous to the situation with why we can’t generate an extensible record as an output because its additional fields could be anything and we can’t generate arbitrary fields on demand.

This concludes the basic nuts and bolts of how to use extensible unions. Now let’s take a look at some use cases.

What are the benefits of extensible unions

At a high level extensible unions generally bring two things to the table:

  1. The ability to make a type’s size “just right” when a union type is the output of a function.
  2. The ability to use flat hierarchies of union types rather than being forced into nested hierarchies.

These two things may not seem like a lot, but they’re actually really powerful! (Just like how ordinary union types might not seem like a lot vs product types, but they shape the essence of how data modeling works in Elm). We’ll dig into concrete examples soon, but first a bit of an explanation of what I mean.

What I mean by 1 is that in ordinary union types, the output type of a function is often “too wide.” That is there are values that satisfy the type signature of the function that the function cannot actually generate. For example, a pitfall new Elm programmers sometimes fall into is to use Maybe too much.

-- An exaggerated example that people don't do, but illustrative
addOne : Int -> Maybe Int
addOne x = Just (x + 1)

Maybe Int is too wide of a type for addOne. addOne never returns Nothing. In fact, not only does it make addOne unnecessarily hard to work with, in a sense it also throws away information, namely that addOne can never actually fail, which means we’re going to have an unnecessary and confusing pattern match on Nothing later on (and potentially a lot of code structured around an entirely impossible error condition).

Now in this case, we can fix things by removing the Maybe because it’s a wrapper type with a type variable, but a similar thing can occur for any union type, and if it’s not a wrapper type with a type variable, we can’t fix it in the same way.

Let’s look at an example using types from earlier.

createRegularUser : Int -> UserType0
createRegularUser x = RegularUser x

UserType0 is “too wide” here, since we know we can never get an AdminUser. However, if you call createRegularUser, you must now handle AdminUser later on in your code, even if that value can never actually show up.

If we use extensible unions, we can make the function’s output size “just right”.

-- We can either give it a concrete type or an extensible type. Often we'll
-- prefer the latter in a function signature for flexibility, just as in the case
-- of input records to functions.
createRegularUser0 : Int -> @RegularUser Int
createRegularUser0 x = @RegularUser x

createRegularUser0 : Int -> a or @RegularUser Int
createRegularUser0 x = @RegularUser x

Of course we can always try to get around this in normal Elm by simply nesting our union types further, e.g. in this case making a separate RegularUser and AdminUser types and then tagging them as Regular and Admin in UserType0.

But this has the same downsides as deeply nested records in normal Elm: baking in a certain access pattern and forcing tons of wrapping and unwrapping. Which brings us to number 2.

For number 2, let’s look at the following example with normal union types.

type BasicColor = Red | Green | Blue

type ExtendedColor = Pink | Lime | Aqua

-- Notice that FullColor is a nested hierarchy with two elements at the top
-- that then splits into three elements each, rather than a flat hierarchy
type FullColor = Basic BasicColor | Extended ExtendedColor

highlightColorForCrucialInformation : String -> BasicColor
highlightColorForCrucialInformation str = case str of
    "error" -> Red
    "success" -> Green
    _ -> Blue

highlightColorForNoncrucialInfo : String -> ExtendedColor
highlightColorForNoncrucialInfo str = case str of
    "error" -> Pink
    "success" -> Lime
    _ -> Aqua

highlightStr : Bool -> String -> FullColor
highlightStr isInFocus str = if isInFocus
    then 
        str
            |> highlightColorForCrucialInformation 
            |> Basic
    else
        str
            |> highlightColorForNoncrucialInfo
            |> Extended

Again, a nested hierarchy bakes in a single structure on how to arrange these colors. If your code needs to arrange those colors in another way, the type system stops helping you and you can start to make mistakes with extraneous Maybes in the way.

type Reddishness = VeryReddish | KindOfReddish

-- I would like to just select `Red` and `Pink`, but I can't and am forced to 
-- make something a `Maybe` simply because `FullColor` is too wide.
reddishness : FullColor -> Maybe Reddishness
reddishness color = case color of
    Basic Red -> Just VeryReddish
    Extended Pink -> Just KindOfReddish
    _ -> Nothing

-- Later on I have to deal with a `Maybe` that has no real reason to exist

With extensible unions, we can have a flat hierarchy and take arbitrary subsets of it instead of being locked-in.

type alias CustomColor = @Red ()
    or @Green ()
    or @Blue ()
    or @Pink ()
    or @Lime ()
    or @Aqua ()

-- We can choose to break down our types the exact same as before
type alias BasicColor = @Red () or @Green () or @Blue ()

type alias ExtendedColor = @Pink () or @Lime () or @Aqua ()

-- But we can also break it up in more than one way!
type alias Reddish = @Red () or @Pink ()

reddishness : Reddish -> Reddishness
reddishness color = case# color of
    @Red () -> VeryReddish

    @Pink () -> KindOfReddish

-- We can recover our original function as well
reddishnessMaybe : CustomColor -> Maybe Reddishness
reddishnessMaybe color = case# color of
    -- This colon syntax is syntactic sugar for the following:
    -- @Red x -> Just (reddishness @Red x)
    -- @Pink x -> Just (reddishness @Pink x)
    reddish : Reddish -> Just (reddishness reddish)
    _ -> Nothing

Just to keep driving home the point that extensible unions are analogous to extensible records, let’s look at the equivalent statements for extensible records.

  1. The ability to make a type’s size “just right” when a product type is the input of a function.
  2. The ability to use flat hierarchies of product types rather than being forced into nested hierarchies.

For an example of number 1, see

-- An ordinary product type
-- The first string is the bird's name, the second string is the bird's
-- species.
type Bird0 = Bird0 String String

-- Bird0 is too "wide" for getLengthOfBirdName, since we only care about its
-- name. It's also unnecessarily tied to the specific type `Bird0` when in theory
-- it works for any product type with a name.
getLengthOfBirdName : Bird0 -> Int
getLengthOfBirdName bird = case bird of
    Bird0 name _ -> length name

-- Extensible records lets us make the input type size "just right"
-- Again notice the duality, extensible records affect the input of a function,
-- extensible unions affect the output of a function
getLengthOfName : { a | name : String } -> Int
getLengthOfName { name } = length name

Number 2 is the oft-repeated advice you’ll see in Elm to “keep your models flat.” Extensible unions let you “keep your messages flat” which comes with a lot of the same benefits as keeping your model flat but we’ll get to that soon enough.

Now let’s look at some more “real-world” cases that show up all the time in Elm codebases.

Everyday Elm cases

Reminding you when you forget certain output cases for decoding

As is hopefully becoming apparent, extensible union types really shine when it comes to helping wrangle the output of a function.

Elm already has exhaustivity checking for the input of a function. This helps with making sure when we add a new variant to a union we don’t forget to add a new case for encoding that union to, say a string. Unfortunately, because Elm union types tend to be too “wide” already when they’re output types, the Elm compiler can’t remind us to add a new case when decoding (i.e. when the union is the output of a function).

Elm the language already has, in theory, the ability to check for unused rows in a record. This was for example implemented in the IntelliJ plugin for Elm (this was later removed because of some complications with record destructuring https://github.com/klazuka/intellij-elm/issues/488, this proposal doesn’t have the same record destructuring issues).

Similar functionality can exist for unused variants in an extensible union type, which allows to make sure that our types are exactly “wide enough,” giving us something analogous to Elm’s exhaustivity check on inputs for our outputs.

type T0 = A | B | C

decodeFromString0 : String -> Maybe T0
decodeFromString0 str = case str of
    "a" -> Just A
    "b" -> Just B
    "c" -> Just C
    _ -> Nothing

type alias T1 = @A () or @B () or @C ()

decodeFromString1 : String -> Maybe (a or T1)
decodeFromString1 str = case str of
    "a" -> Just <| @A ()
    "b" -> Just <| @B ()
    "c" -> Just <| @C ()
    _ -> Nothing
-- Let's edit T0 to add D
type T0 = A | B | C | D

-- Whoops, the compiler has no way of telling us that we need to update
-- decodeFromString0 as well!

-- Now let's edit T1 to add @D ()

type alias T1 = @A () or @B () or @C () or @D ()

decodeFromString1 : String -> Maybe (a or T1)
-- WARN: The inferred type of decodeFromString1 is
-- String -> Maybe (a or A' () or B' () or C' () or D' ())
-- which disagrees with the annotated type
-- String -> Maybe (a or A' () or B' () or C' ())
-- we look like we're missing a D' () in the output, maybe consider adding it as
-- an output value, or restrict decodeFromString1 to an output type of 
-- Maybe (a or A' () or B' () or C' ())
decodeFromString1 str = case str of
    "a" -> Just <| @A ()
    "b" -> Just <| @B ()
    "c" -> Just <| @C ()
    _ -> Nothing

Unifying different error types

Right now when you have many different kinds of errors that can show up in your Elm code, the usual method is to go with nested error union types, that unify all the error types under the same names. Apart from the superficial point of all the annoying boilerplate that this introduces (and all the associated wrapping and unwrapping required), more problematically this error structure forces us into contorting the rest of our code into a single, canonical error structure. This means that our error handlers must mirror the structure of our error sites and that all our code must throw errors along the same divisions (i.e. use the same hierarchy).

See for example https://discourse.elm-lang.org/t/how-to-represent-many-subsets-of-an-enum/6088.

Here’s a complete worked example of what the different error types would look like (as well as different error handlers) from that post.

type alias Endpoint1Error = @ResourceNotFoundException ()

endpoint1 : Input1 -> Request (Result (a or Endpoint1Error) Response1)
endpoint1 = ...

type alias Endpoint2Error = @ServiceException ()
    or @ResourceNotFoundException ()
    or @ResourceConflictException ()
    or @TooManyRequestsException ()

endpoint2 : Input2 -> Request (Result (a or Endpoint2Error) Response2)
endpoint2 = ...

type alias ServiceError = @ServiceException ()
    or @ResourceNotFoundException ()
    or @ResourceConflictException ()
    or @TooManyRequestsException ()
    or @InvalidParameterValueException ()
    or @PolicyLengthExceededException ()
    or @PreconditionFailedException ()

type alias ResourceError = @ResourceNotFoundException ()
    or @ResourceConflictException ()

-- Notice these handlers have a different structure than the endpoints! But the
-- compiler still has our back making sure we haven't missed handling any errors
resourceErrorHandler : ResourceError -> String
resourceErrorHandler error = case# error of
    @ResourceNotFoundException _ -> "handled resource not found"

    @ResourceConflictException _ -> "handled resource conflict"

type alias BeingABadUserError = @TooManyRequestsException ()
    or @InvalidParameerValueException ()
    or @PolicyLengthExceededException ()
    or @PreconditionFailedException ()

badUserErrorHandler : BeingABadUserError -> String
badUserErrorHandler = ... -- Imagine something similar to resourceErrorHandler

serviceErrorHandler : ServiceError -> String
serviceErrorHandler error = case# error of
    resourceError : ResourceError -> resourceErrorHandler resourceError

    badUserError : BeingABadUserError -> badUserErrorHandler badUserError

    @ServiceException () -> "AWS seems to be experiencing problems"

-- The free variable a here let's unify with any other error types, saving us
-- manual wrapping and unwrapping
valueFromEndpoint1 : Result (a or Endpoint1Error) Response1
valueFromEndpoint1 = ...

valueFromEndpoint2 : Result (a or Endpoint2Error) Response2
valueFromEndpoint2 = ...

combinedValues : Result 
    -- Note that Endpoint2Error is a strict superset of Endpoint1Error so
    -- Endpoint1Error is complete subsumed
    (a or Endpoint2Error) 
    (Response1, Response2)
combinedValues = map2 Tuple.pair valueFromEndpoint1 valueFromEndpoint2

finalString : String
finalString = case combinedValues of
    Err error -> serviceErrorHandler error

    Ok ( value1, value2 ) -> Debug.toString value1 ++ Debug.toString value2

Notice that this would be quite difficult to achieve in the current state of Elm. In particular, the way we split up the error hierarchy among the error handlers is in a different way than the way we split up the error hierarchy among the endpoints. We would need separate type hierarchies for each of these, plus functions to wrap and unwrap these hierarchies to play nice with each other.

Modular Elm code: Unifying NoMap, OutMsg, and Translator

When we have large Elm apps, we’re ofen confronted with how we should split up our Elm code into manageable chunks. Almost always, these conversations center around how to break up message types and update functions. (N.B. ever notice how straightforward it is to modularize your Elm model, either by breaking it up or by using extensible record input types, to the point that there’s never a need to talk about “model modularization patterns?” You have records and extensible records instead of just vanilla product types to thank for that.)

There’s a classic blog post that gets shared around whenever anyone asks about how to break up updates and messages: https://medium.com/@_rchaves_/child-parent-communication-in-elm-outmsg-vs-translator-vs-nomap-patterns-f51b2a25ecb1.

The post talks about three strategies for how to modularize Elm code: NoMap, OutMsg, and Translator. That post has the details on those approaches, but I’ll briefly summarize the pros and cons here. I’ll also add one more, that’ll call YesMap, which refers to the “component-first” approach that uses Html.map and Cmd.map all over the place.

So to summarize, we seem to be stuck between two general approaches. Either give up some type safety and compiler helpfulness for cleaner code and more organic growth a la NoMap or try to get back type safety at the expense of heavyweight architecture choices.

Having extensible unions lets us reconcile these two desires.

-- BEGIN: Selection submodule

type SelectionElementState = ...

type SelectionElementChange = ...

-- The Int indicates how many drinks we've ordered of this type
type alias DrinkSelectionMsg = @Soda Int or @Water Int or @Juice Int

-- Returns what kind of drink and quantity a user has selected
-- A DrinkSelectionMsg is returned when an actual selection is made
-- Other user actions may trigger other kinds of updates to the element (e.g.
-- mousing over the element may cause it to change color) which result in an
-- @ElementMsg SelectionElementChange message being fired. We can then use
-- SelectionElementChange to update our ElementState
viewDrinkSelectionElement : ElementState -> 
    Html (a or DrinkSelectionMsg or @ElementMsg SelectionElementChange)
viewDrinkSelectionElement = ...

updateDrinkSelectionElement : SelectionElementChange
    -> SelectionElementState
    -> SelectionElementState
updateDrinkSelectionElement = ...

initialDrinkSelectionElementState : SelectionElementState
initialDrinkSelectionElementState = ...

-- END: Selection submodule

-- BEGIN: Top-level module

-- Notice the symmetry! Both your model and your messages are now type aliases
type alias Msg = @ClearMyDrinkSelection ()
    or DrinkSelectionMsg
    or @ElementMsg SelectionElementChange

type alias Model =
    { sodas : Int 
    , waters : Int
    , juices : Int
    , selectionState: SelectionElementState
    }

initialModel : Model
initialModel =
    { sodas = 0
    , waters = 0
    , juices = 0
    , selectionState = initialDrinkSelectionElementState
    }

update : Msg -> Model -> Model
update msg model = case# msg of
    @ClearMyDrinkSelection () -> { model | sodas = 0 , waters = 0 , juices = 0 }

    @Soda n -> { model | sodas = model.sodas + n }

    @Water n -> { model | waters = model.waters + n }

    @Juice n -> { model | juices = model.juices + n }

    @ElementMsg elementMsg -> 
        { model 
        | selectionState = updateDrinkSelectionElement elementMsg model.selectionState 
        }

totalDrinks : { a | sodas : Int, waters : Int, juices : Int } -> Int
totalDrinks { sodas, waters, juices } = sodas + waters + juices

-- Fully expanded it's
-- view : Model -> Html (a or @ClearMyDrinkSelection () or DrinkSelectionMsg or @ElementMsg SelectionElementChange)
-- Note the complete lack of wrapping and unwrapping needed here
-- viewDrink plays nicely with our button because of the extensible type
-- variable parameter
view : Model -> Html (a or Msg)
view model = Html.div 
    [] 
    [ viewDrinkSelectionElement model.selectionState
    , Html.button 
        [ Html.onClick (@ClearMyDrinkSelection ()) ] 
        [ Html.text "Clear drinks" ]
    , Html.text <| "Total drinks: " ++ Int.toString (totalDrinks model)
    ]

-- END : Top-level module

Let’s review how this would stack up against NoMap, YesMap, OutMsg, and Translator. We’ve preserved the pros of the NoMap approach (notice the lack of Html.map and Cmd.map). In particular having a flat message type has given us many of the same benefits of a flat model type, namely less indirection and lack of lock-in into a single hierarchy. On the other hand, because extensible union type exhaustivity checks are still enforced in case# statements, we still have all the same type safety and “on-the-rails” guiding by the Elm compiler that we get from the other approaches.

Let’s also turn to the usual advice the Elm guide gives us around structuring Elm apps (I’ve paraphrased some of them here): https://guide.elm-lang.org/webapps/structure.html.

  1. Do Not Plan Ahead
  2. Building Modules Around Types
  3. Rely on the Elm Compiler to Have Your Back with Refactors
  4. Hard Component Divisions Are Unnecessary

NoMap, YesMap, OutMsg, and Translator all fail in various ways to address these four points. OutMsg and Translator fail at point 1, requiring architectural changes upfront, rather than arising organically from the usual impulses programmers have when code starts getting better. NoMap fails at 2. You need a giant message type for everything that lives in its own file. NoMap’s compromises around type safety also damage 3. YesMap fails on 4 and makes 2 harder.

Extensible union types give us all four. Even though I chose to give my selection element a separate model, update, and view function as well as a distinct message type, that is not a decision I’m locked into. It’s not an all-in decision, I can pick and choose. I could easily have undone any of those choices by simply inlining the function or type wherever it’s used. For example, I could decide that updateDrinkSelectionElement doesn’t need to be an independent function and only have the view function viewDrinkSelectionElement or vice versa. I have not written additional code for the sake of architecture, I’ve simply rearranged existing code.

Therein lies the core benefit that extensible types bring to modularizing large Elm apps.

With extensible union types we no longer need named patterns for how to modularize Elm messages and update functions. Modularizing Elm messages and updates becomes the same thing as modularizing a normal Elm function: just pull out a chunk of the message type or update function and give it a name. Done.

Drawbacks and things-you-might-think-are-drawbacks-but-aren’t (a.k.a. literature review of other implementations)

So with all this good news, what’s the bad news?

First let’s lead with things-you-might-think-are-drawbacks-but-aren’t. You might be worried about “fat-fingering” errors that are caught by Elm’s compiler for normal unions, but aren’t for extensible unions.

E.g.

type MyType0 = Hello | Bye

type alias MyType1 = @Hello () | @Bye ()

f0 myType = case myType of
    -- Typo of Hello, caught by compiler as error
    Helo -> "hello"

    Bye -> "bye"

f1 myType = case# myType of
    -- Oh no! This compiles just fine
    @Helo () -> "hello"

    @Bye () -> "bye"

However, for the same reasons that this isn’t really a problem for field names with extensible records, this isn’t really a problem for extensible unions either. As soon as you try to use the input type of f1, either by providing a type annotation for it with MyType1 or trying to pipe a function that returns MyType1 into f1, the compiler will yell at you, so fat-fingering isn’t really an issue.

Losing exhaustivity checking is also not an issue. I’ve demonstrated in some of the previous code examples that case# checks exhaustivity just fine if you provide a type annotation (i.e. it notifies you of missing cases). However, even if you don’t provide a type annotation and forget to handle a case, an analogous phenomenon to fat-fingering occurs where as soon as you try to use that function, the compiler will yell at you.

-- Reusing the previous types

-- Inferred as
-- incompleteFunction : @Hello () -> String
-- Note that because the extensible variant is an input, it is not inferred as
-- `a or @Hello ()` as would be the case if it was an output
incompleteFunction = case# myType of
    @Hello () -> "hello"

-- Inferred as
-- outputFunction : Int -> a or @Hello () or @Bye ()
outputFunction int = case int of
    0 -> @Hello ()

    _ -> @Bye ()

-- Compile error! 
-- Could not match `a or @Hello () or @Bye ()` with `@Hello ()`
incompleteFunction (outputFunction 0)

Okay, so what are the problems that you run into with extensible unions? The first and obvious one is that Elm users now have to learn one more concept. But what about other programming languages that have implemented them? What problems have they run into?

OCaml is an FP programming language where extensible unions (they call them polymorphic variants) are built into the language. And there “Real World OCaml” (RWO) has some pretty sobering advice https://dev.realworldocaml.org/variants.html.

In reality, regular variants are the more pragmatic choice most of the time. That’s because the flexibility of polymorphic variants comes at a price…. Variants are most problematic exactly where you take full advantage of their power; in particular, when you take advantage of the ability of polymorphic variant types to overlap in the tags they support.

So why am I so exuberant about extensible unions in Elm when the OCaml community seems so cautious about them? This is because in my presentation of extensible unions I’ve mirrored Elm’s current implementation of extensible records. In particular I’ve also limited them (compared to OCaml’s implementation), in the same way that Elm limits extensible records, i.e. disallowing the ability to add or delete variants to a pre-existing extensible union (likewise Elm’s extensible records cannot add or remove fields).

By limiting the power of extensible unions in the same way Elm already limits the power of extensible records, we can sidestep the issues that extensible variants pose for the OCaml community. Here it’s worth looking at the exact examples that RWO raises to illustrate some of the problems that RWO is talking about.

The following is OCaml code:

(* RWO states the following:
   Catch-all cases are error-prone even with ordinary variants, but they are
   especially so with polymorphic variants. *)
let is_positive_permissive = function
  | `Int   x -> Ok Int.(x > 0)
  | `Float x -> Ok Float.(x > 0.)
  | _ -> Error "Unknown number type"

The backtick ` in OCaml plays the same role as @ does in our earlier notation. Note that we explicitly cannot have the catch-all case with the current outline of extensible unions, side-stepping this problem.

RWO talks about greater-than and less-than bounds on polymorphic variants and attributes much of the drawbacks of polymorphic variants to them. However, those can only exist if we allow for extensible unions to add or subtract variants because greater-than and less-than bounds exactly represent variants that have been added or subtraced within a function.

weCannotWriteThis : a or @A () or @B () -> a or @B ()

If we could write weCannotWriteThis, we would need notation to express that we’ve removed @A from the output of weCannotWriteThis and therefore it cannot show up in any downstream consumers of weCannotWriteThis’s output. This then inexorably leads us to OCaml’s polymorphic variant bounds or some equivalent representation thereof. However, we nip it in the bud by entirely disallowing the construction of a function like weCannotWriteThis!

This also nicely deals with the issue of performance that RWO raises, namely that OCaml’s polymorphic variants can’t be compiled to the same efficient representation its normal variants can. If we remove the ability to add or remove variant tags the shape of a variant cannot change at runtime, allowing for the same runtime representation as normal variants. Again this mirrors the current situation with Elm records, where extensible records pose no runtime performance penalty.

At the end of the day, the drawbacks of extensible unions are exactly analogous to the drawbacks of extensible records, because they are the exact analogs of extensible records. So if Elm as a community is already willing to accept those, I argue that we’ve already accepted the same drawbacks of extensible unions.

So that about wraps up my post for extensible unions. Thanks for reading! I hope it’s given the Elm community some food for thought.