How to flatten a model

#1

I’ve been looking at Elm a bit and thought I’d try my hand at implementing a game to get a feel for it. Only thing is, I’ve run in to the notorious nested record / union types update problem, and looking around I keep seeing people saying ‘flatten your model’ etc., but I’m lacking imagination on how to do this. I was hoping someone here might have a suggestion.

The rules of the game are as follows:

  • Two players, although could be more.
  • Each has their own 5x5 grid.
  • They take it in turns to choose a letter, which both players then proceed to place on their own grid.
  • Once both players have filled up their grid with letters, they then find as many words more than three letters long as possible in a set time - say 1 minute.
  • 1 point for each letter in each unique word found.
  • Player with the most points wins.

The data model seems clear enough to me. Something like:

type alias Player =
  { name: String
  , grid: Grid
  , words: List String }

type alias Grid = List Cell

type alias Cell =
  { coordinates: Point
  , contents: Cell }

type Cell
  = EmptyCell
  | FullCell String

type alias Point = ( Int, Int )

type Model =
  { players: List Player
  , dragging: Bool
  , dragStart: Point
  , dragEnd: Point }

There’s a bunch of handwaving in there as well - e.g. the dragging stuff captures drag to select words, turn management missing, maybe words is implemented as list of points to guarantee uniqueness etc. but I think that’s enough detail to capture the core issue: what would people suggest to flatten this model and eliminate the nesting of model -> players -> player -> grid -> cells -> cell -> contents.

I’ve written working code for a single grid that manages dragging and stuff, and working on grid -> cells -> cell -> contents is manageable, although clunky, but when I wrap players and a larger model to introduce the other elements, it just seems to get way out of hand.

I would be truly greatful for some insight on how others would tackle this.

#2

Refactoring types is a subject of interest to me; although your model does not look particularly flattenable.

Flattening

There is a purely mechanical process by which a model can be flattened, described here: Mapping Tagged Union types to other languages?.

Essentially you just expand out the products and sums to collapse a nested type down. If you only have products and sums you can always do this and get down to a model which is just a sum of products. If there are other non-sum-or-products in the model, usually List, Dict, Set, etc. then the math cannot be pulled through these, so they can prevent getting down to a flat model. As your model contains a few Lists, there is a limit to which this process can be applied, and even where it can be applied, does it result in a better model?

At any rate, I worked it through (note: you have 2 Cell definitions, I think I guessed right which is which):

type alias Player =
  { name: String
  , grid: Grid
  , words: List String }

type alias Grid = List Cell

type Cell
  = EmptyCell { coordinatesX: Int, coordinatesY: Int }
  | FullCell { coordinatesX: Int, coordinatesY: Int, val : String }

type Model =
  { players: List Player
  , dragging: Bool
  , dragStartX: Int
  , dragStartY: Int
  , dragEndX: Int
  , dragEndY: Int }

I can’t get it any flatter than that.

And did expanding out Point improve the model? I expect you are likely to have helper functions that work with Points doing stuff like calculating distance and so on, so I don’t think it was so helpful to do it.

Nested Updates

Having established that there can be limits to flattening, there is a great piece of advice on writing nested updates here: Updating nested records, again

For some record type Foo:

asBarIn : Foo -> Bar -> Foo
asBarIn foo bar =
    { foo | bar = bar }

Note also, it can often be applied to sum types too:

type Foo
  = First
  | Second { bar : Bar, whatever : Whatever }

asBarIn : Foo -> Bar -> Foo
asBarIn foo bar =
  case foo of
    First -> First
    Second second -> Second { second | bar = bar }
3 Likes
#3

Rather than trying to flatten the model you may want to introduce a Grid module. Something that gives you a simple API to work with in your update function e.g.

updatedGrid = Grid.set location point grid

Evan has a great talk on gradually introducing abstraction which I think you’d find easy to follow and very helpful for this sort of problem, https://www.youtube.com/watch?v=XpDsk374LDE

1 Like
#4

Your types look pretty dang flat to me.

Maybe the “flatten your model” stuff is speaking to problems in applications different from yours. Here are two kinds of problems I think can be improved by “flattening your model”

  1. You have an SPA with many pages. Each page has a lot of state, and that state only really exists while the user is on that page. However, you have some state that transcends all pages, like the users login state. Its better to not put the login state “above” the page state, and instead have each page model contain the same data types (such as the users login state).
-- GOOD
type Model
    = Home User Home.Model
    | Dashboard User Dashboard.Model

-- BAD
type alias Model =
    { user : User
    , page : Page
    }

type Page 
    = Home Home.Model
    | Dashboard Dashboard.Model
  1. You have some widget or UI component thats mostly re-usable, and conceptually its kind of its own thing. Its better for this widget to be written in a way thats very open and transparent, and relies on parent state, than it is for it to be stateful and fully encapsulated
-- GOOD
type alias Model =
    { dropdownSelection : Maybe String
    , dropdownOptions : List String
    }

view model =
    Dropdown.view 
        [ Dropdown.selection model.dropdownSelection ]
        model.dropdownOptions

-- BAD
type alias Model =
    { dropdown : Dropdown.Model }

view model =
    Dropdown.view model.dropdown

Or maybe we are all starting to over-do it on the “flatten your model” stuff; if the message is getting lost.

#5

Interested in why you consider the first option to be better than the second? Sum-of-Products (maximum flatness) over Product-of-Sums-of-Products (factored out the common product).

Reason I ask, is that I tended to go for the first option, and then make my update and view functions for the page take the User parameter explicitly:

update : User -> Msg -> Model -> (Model, Cmd Msg)

view : User -> Model -> Html Msg

I arrived at thinking that was the better option, since I didn’t have to copy the value of User over each time the page changed (not that it was such a big deal to do that!).

2 Likes
#6

This is the approach I generally take. I saw that @rtfeldman switched to the approach @Chadtech is suggesting in https://github.com/rtfeldman/elm-spa-example/blob/master/src/Main.elm and I’d be interested to hear the reasoning for this choice.

#7

If the only thing you need is to use a User, passing it in explicitly is fine. I think other problems show up, most notably that pages often need to change the user, so a User needs to get passed back out again as well. Like…

update : User -> Msg -> Model -> (Model, Cmd Msg, User)

But, you can see that the type signature has gotten a lot more complicated, just so that it can change this one piece of data. Every case in the update has also grown. You need an explicit step in the higher update function to take the User in the higher level and set it.

I need to correct something in my previous code snippets. I wrote…

type Model
    = Home User Home.Model

with User and Home.Model separate to try and illustrate my point (maybe that was a mistake). In practice I would put the User inside the Home.Model

-- Home.elm
type alias Model =
    { user : User 
    ..
    }
-- Main.elm

type Model
    = Home Home.Model

This does a lot. Now, you can write the Home update function as Msg -> Model -> (Model, Cmd Msg), while still being able to both use the User, and modify the User. This is done with no greater complexity in either the type signature or the actual update function.

2 Likes
#8

I see.

The reason I went the other way is that I put all my User related functionality in another module. That module exposes functions to make changes to the User, like login, logout, refresh and so on. Those produce Cmds.

But I think you are right, if you want to make changes to the User in the child pages, its a better way to do it.

#9

One reason for going with the

update : User -> Msg -> Model -> (Model, Cmd Msg)

is if you want User to be read only. In my app it’s actually

update : Context -> Msg -> Model -> (Model, Cmd Msg)

where

type alias Context =
    { jwt : Jwt
    , apiBasePath : Url 
    }

I don’t want the pages to modify these and by putting them in the Context I can enforce that the pages don’t make any changes.

#10

Also if you put that “global” data on the pages model, now they need to be on all pages. So for example if you have pages that don’t care about the user, like the about page, you still have to add it to the to the model even if not used just to pass it around from page to page.

For those cases keeping that data on the app level and passing it to the individual functions of the pages would be a better fit, in my opinion.

1 Like
closed #11

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