Sum vs. product types in models

Hi! We have a Swift app that uses something akin to the Elm architecture. One thing I still often struggle with is choosing between sum and product types to model state:

type Onboarding = 
    CreatingAccount AccountCreationModel |
    AddingPaymentCards CardCreationModel

This simple model can also be expressed with a record and a separate state variable:

type OnboardingState = CreatingAccount | AddingPaymentCards
type alias OnboardingModel = {
    accountCreation: AccountCreationModel, -- or even Maybe AccountCreationModel
    cardCreation: CardCreationModel, -- or even Maybe CardCreationModel
    state: OnboardingState
}

The second option is less safe, but arguably easier to work with, since one doesn’t have to switch over the state all the time – account or card creation state can be accessed directly when it’s “obviously safe” from the context to do so.

I think the issue is quite similar in Elm, where having a sum type as the model forces you to switch over the cases in update, which is safer but harder to read.

Is there a generally accepted way of doing this?

2 Likes

I’m no expert, but I was just faced with a similar decision in my current project. What I’ve decided to do is go for full type-safety with a sum type in the model, and then create helper functions like Model -> Maybe AccountCreationModel to do the pattern match and extract data that may or may not exist depending on the state of the app. That way, at least I’m only matching against the full pattern once (in the helper function), and I only have to match against a Maybe in update.

2 Likes

How often is all of the time? Wouldn’t you have a high level view function which in turn calls smaller view functions?

view : Model -> Html Msg      -- App's view function
view model =
    case model of
        CreatingAccount accountCreationModel ->
            Account.view accountCreationModel

        AddingPaymentCards cardCreationModel ->
            Card.view cardCreationModel

At which point Account.view would work with AccountCreationModel and Card.view would work with CardCreationModel and you wouldn’t need to switch over the state anymore in those views.

1 Like

Here is an Ellie of what I mean: https://ellie-app.com/bsPLsrF4Ma1/4

Having seen code written in the “product style” (described as its author as being written in the recommended style for Elm — see footnote) and having written a lot of code in the sum style, I can make the following observations:

• The sum style has a lot of plumbing to dispatch to sub-models since the message types also tend to end up as sums. It’s straightforward plumbing — I’ve found I can write it really quickly — but there can be a lot of it once you get subscriptions and update involved and it gets worse if there are more standard glue connections like we have.

• The product style avoids this and handles keeping parts separate by using separate update functions for subsets of the messages. This, however, has a couple of problems. If the message type is kept flat, then the update pattern is just to invoke all of the partial updates with the message and let the ones that actually apply do their work. That’s cool but it means that you lose the completeness checking on update case statements leading to “what happened to this message” questions. The signature of the update functions can also make it fuzzy as to what data can be changed by the update and what is just read but this has more to do with issues of how context is conveyed.

Refactoring is also an interesting question in these styles. The sum style leads to code in which it is pretty easy to move the sub-models around, have two copies of a single sub-model, etc. The product style has to go through the separation process if you find you need that. YAGNI argues for not putting all that boilerplate upfront. On the other hand, refactoring often takes careful planning for how to make the move in small steps while writing according to a plan doesn’t require as much focused concentration.

Finally, a related issue not addressed directly with either of these styles is that if your sub-models can come and go and come again, then in an async world, you really need to put in place some guards against receiving messages intended for a previous incarnation of the sub-model. I tend to handle this by storing the sub-models in either form with an Int generation number and a Maybe around the sub-model. Transitioning from Nothing to a sub-model increases the generation number. Updating an existing sub-model leaves the generation number unchanged. I use a sub-message type to go with the sub-model type and include the target generation number in the tagger constructor wrapping the sub-message — i.e., ToSubModel Int SubMsg.

Mark

Footnote: Actually the style I’ve seen advocated as being “recommended Elm” flattens sub-models into the model itself and use record extensions to identify the sub-model fields. This tends to push the pros and cons of the product style even further.

1 Like

It is also worth mentioning that one approach does not have to exclude the other.

I like to use what I call a PSP model, which stands for Product of Sums of Products. My top-level model will be a product made up of various state machines, each state of which contains a product of the fields that the state needs. I do this because it seems to most naturally fit a single page application (with multiple pages), when taking a make impossible state unrepresentable approach, and I like modelling application behaviour with state machines.

Can you link an example of this approach? It sounds very appealing but I want to make sure I understand it.

Sure, here is an example:

The top-level Model is a product. Within it find Session which is a sum. Within that find that the LoggedIn state is a product with the user authentication state inside it.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.