Hi!
When modelling an offline-first application, I stumbled twice upon a pattern regarding entities that start with a globally non-unique String ID ("client" entities) that after being stored globally are promoted to "server" entities, which have a globally unique Integer ID provided by the server.
TL;DR: We are modelling an entity that has a state machine with different information in each state, and other information common to all states. We want to enforce the process flow through the states but we also need a way to access the common data regardless of the state, while keeping the internals private to our module. Current patterns in the catalog only solve part of the problem (the closest one being "Process flow using phantom types", so I describe here the problem and the pattern I found:
The problem
This is a process flow I want to be able to restrict:
- Without API interaction, only Client entities can be created.
- The API decoders are the only ones that can create Server entities from Client entities.
- The API accepts only Client entities for creation and only Server entities for modification.
It is a simple state machine:
ClientEntity {id: String} -> ServerEntity {id: Int}
But it has a small peculiarity that makes the pattern "Process flow using phantom types" a bad idea: Some functions are not restricted to a specific state, but act differently on each state.
In my case, client IDs are Strings but server IDs are Integers, but we still need one function to get the ID as string no matter the state.
Anti-patterns
Phantom types
If we try to solve it with phantom types:
type Entity a
= Entity Id GenericInternals
type alias GenericInternals = { someData: String, ... }
type Id
= ClientId String
| ServerId Int
type Client = Client
type Server = Server
we will find out that we need to manage invalid cases in our strict functions, because there is no relationship between the phantom type and our Id
type :
insertEntity : Entity Client -> (Entity Server -> msg) -> Cmd msg
insertEntity (Entity id _) msg =
let
oldId = case id of
ServerId id -> -- Invalid case!! An Entity Client should never contain a ServerId!
"Process flow using phantom types" pattern
If we instead try to solve it with "Process flow using phantom types" pattern:
type alias Internals =
{ clientId : String
, serverId : Int
...
}
type Entity a = Entity Internals
type Client = Client
type Server = Server
Then we can't implement strategy functions (functions that work with all states but differently with each one):
getIdAsString : Entity a -> String
getIdAsString (Entity internals) =
-- Should we use clientId or serverId??
We should implement a different getId function for each state, so we would be forcing the consumer to store the state of each of the entities itself, thus rendering our phantom type useless or redundant.
Polymorphic type
Finally, if we try to solve it with a polymorphic type:
type Entity a
= Entity a GenericInternals
type Client = Client String
type Server = Server Int
Then we have no way to access the state-specific internals in strategy functions, like getIdAsString : Entity a -> String
, because Elm has no way to infer that a
will be Client | Server
.
Pattern
We expose the state machine type with all its states. Meanwhile the states, containing both state-specific and state-generic internals, are represented with opaque types:
module Entity exposing (Entity(..), ClientEntity, ServerEntity)
type Entity
= Client ClientEntity
| Server ServerEntity
-- Private types
type alias GenericInternals = { someData: String, ... }
-- Opaque types
type ClientEntity = ClientEntity String GenericInternals
type ServerEntity = ServerEntity Int GenericInternals
This allows both generic functions and restricted functions:
getIdAsString : Entity -> String
getIdAsString entity =
case entity of
Client (ClientEntity s _) -> s
Server (ServerEntity i _) -> String.fromInt i
insertEntity : ClientEntity -> (ServerEntity -> msg) -> Cmd msg
Tradeoffs
This pattern forces the consumer to be wrapping and unwrapping the entity in the generic Entity
type, depending on which function is being used. This can become tedious, so use it only when the acquired guarantees make this worth it.
This is not meant to be the pattern redaction, I would make a PR for that.
What do you think? Is this problem usual? Do you find the pattern idiomatic? Would you solve it differently?
Thank you for reading this.