I’ve written something like a dozen small Elm apps that are in production in internal tools, and I’m still struggling to find a pattern for managing API data that I’m happy with. The types always spiral out of control when I try to model every use case perfectly.
I am not a fan of the various “RemoteData” packages I have seen, since they are mostly about loading data. Most of my remote data needs revolve around persisting form data in the following contexts:
- Bound to a form as the form changes.
- New “blank” records, and un-persisted data that may or may not be valid.
- Data that we are in the process of attempting to save.
- Valid data that we just got from the server.
Here are some strategies I have tried in chronological order:
1. One naive record to rule them all.
Define a record type matching the API exactly, for example:
type alias Record =
{ id : Int
, name : String
, optional_value : Maybe String
}
If I need a blank one, I just stuff anything in it:
blank : Record
blank =
{ id = -1
, name = ""
, optional_value = Nothing
}
I also use some simple helper functions like: isPersisted { id } = id > 0
to check if the data has been persisted or not.
This felt un-Elmy, and I obviously had some problems with treating un-persisted records as persisted occasionally, or POST-ing a “-1” to the API accidentally. The “isPersisted” checks were all over the place and ugly.
2. Use a union type to indicate “Blank” values.
At first I used Maybe
, but then I decided to get more explicit:
type PrimaryKey
= PrimaryKey Int
| Unsaved
type alias Record = { id : PrimaryKey, ... }
isPersisted : { a | id : PrimaryKey } -> Bool
isPersisted {id} =
case id of
PrimaryKey _ ->
True
Unsaved ->
False
I’m using this a lot now, and if anything it’s honestly messier. I have to unwrap the ID every time I want to use it, and I can never assume that the record has an ID, even though it will only ever be Unsaved
once in its lifetime!
Calling toString
on my record ID has actually been a large source of bugs as well, in instances where I replaced Int
with PrimaryKey
. The compiler didn’t catch it. Lesson learned: toString is a bit unsafe. Use your own String conversion helpers that expect a specific type.
3. Separate types for “new” and “persisted” records and a union type to combine them.
type alias NewRecord =
{ name : String }
type alias PersistedRecord =
{ id : Int, name : String }
type RemoteRecord e a b =
= NotAttempted a
| Saving a
| Persisted b
| Failure e
I thought I’d really hit on something here, but it is a royal pain to deal with this type. I have to write code to handle every case everywhere it appears, because no helper I have come up with seems useful. The “Saving” state is particularly annoying, and only occasionally meaningful. The fact that the encapsulated record might be different is an incredible pain. It actually makes me miss just putting a “-1” in the ID and calling it a day.
That said, I never reference an ID when I don’t have one now!
But it really makes me ask, just how many of my Elm bugs had to do with handling persisted and un-persisted data with one record type in the first place? I think my answer has to be: Not many, it just felt ugly and object-oriented.
What do you guys think? I think I am still missing something.