This is something that I recently discovered (thanks to @Janiczek and @pd-andy), but would like to write down to remember myself and potentially share it with everyone.
The problem is that you want to have a type, that has some operations (perhaps that you want to evaluate in some context, perhaps stateful, or at least requiring their own UI, etc), but that should also have a monadic interface (basically support andThen
+ succeed
; which also implies having map
- so basically a powerful way to structure computations in dynamic sequences).
For our example we’re going to presuppose 2 operations: Op1
returns an Int
, and Op2
returns a String
. The natural way to think about modelling these requirements would be:
type Operation a
= Op1 (Float -> a)
| Op2 (String -> a)
| Succeed a
| AndThen (b -> Operation a) (Operation b)
op1 : Operation Float
op1 =
Op1 identity
andThen : (b -> Operation a) -> Operation b -> Operation a
andThen =
AndThen
This won’t compile, since the AndThen
case has an undeclared type variable b
. Declaring it won’t help:
type Operation inp out
= Op1 (Float -> out)
| Op2 (String -> out)
| Succeed a
| AndThen (inp -> Operation ?? out) (Operation ?? inp)
Since we’re still missing an appropriate type variable.
Now for languages that support existential types and explicit quantification, we could write this as:
type Operation a
= Op1 (Float -> a)
| Op2 (String -> a)
| Succeed a
| forall b. AndThen (b -> Operation a) (Operation b)
Unfortunately, Elm doesn’t support this. For years I thought this was plain impossible in Elm’s type system and have changed designs correspondingly. However, recently I was inspired by @jhbrown Interface ideas to try again.
After much fiddling, I arrived at the following formulation:
type Operation a
= Op1 (Int -> Operation a)
| Op2 (String -> Operation a)
| Succeed a
op1 : Operation Int
op1 =
Op1 Succeed
andThen : (a -> Operation b) -> Operation a -> Operation b
andThen fn op =
case op of
Succeed res ->
fn res
Op1 cont ->
Op1 (\int -> andThen fn (cont int))
Op2 cont ->
Op2 (\str -> andThen fn (cont str))
You’ll notice that the AndThen
case is no longer present in the type explicitly, however each operation looks a bit like an AndThen
. The definition of our andThen
function is recursive as it needs to get to the “end of the chain” of operations where it can apply the relevant function and thus elongate the chain. Fundamentally this operation always flattens out into a nice chain of:
(Pseudocode)
Op (\arg1 -> Op (\arg2 -> Op (\arg3 -> Op (\arg4 -> Succeed someReturnValue))))
For example:
andThen
(\someReturnValue -> Op3 (\arg3 -> Succeed foo))
(Op1 (\arg1 -> Op2 (\arg2 -> Succeed someReturnValue)))
---
Op1 (\arg1 ->
andThen
(\someReturnValue -> Op3 (\arg3 -> Succeed foo))
(Op2 (\arg2 -> Succeed someReturnValue))
)
---
Op1 (\arg1 ->
Op2 (\arg2 ->
andThen
(\someReturnValue -> Op3 (\arg3 -> Succeed foo))
(Succeed someReturnValue)
)
)
--
Op1 (\arg1 ->
Op2 (\arg2 ->
Op3 (\arg3 ->
Succeed foo
)
)
)
Evaluation is fairly straightforward. For instance to simply compute the result synchronously and purely, one can write:
pureEval : Operation b -> b
pureEval op =
case op of
Op1 fun ->
pureEval (fun 3)
Op2 fb ->
pureEval (fun "foo")
Succeed res ->
res
Here's a slightly more involved evaluator if you want an asynchronous effectful one
type Model b
= InProgress (Operation b)
| DoneComputation b
type Msg
= MsgOp1 Int
| MsgOp2 String
getNextCmd : Operation b -> Cmd Msg
getNextCmd op =
case op of
Op1 _ ->
Random.generate MsgOp1 (Random.int 1 5)
Op2 _ ->
Random.generate MsgOp2 (Random.uniform "foo" [ "bar", "baz", "qux" ])
Succeed _ ->
Cmd.none
wrap : Operation a -> Model a
wrap op =
case op of
Succeed v ->
DoneComputation v
_ ->
InProgress op
effectfulEval : Msg -> Model b -> ( Model b, Cmd Msg )
effectfulEval msg op =
case ( msg, op ) of
( MsgOp1 int, InProgress (Op1 fb) ) ->
let
next =
fb int
in
( wrap next, getNextCmd next )
( MsgOp2 str, InProgress (Op2 fb) ) ->
let
next =
fb str
in
( wrap next, getNextCmd next )
_ ->
( op, Cmd.none )
One can easily then define some more common “library” functions:
map : (a -> b) -> Operation a -> Operation b
map fn =
andThen (fn >> Succeed)
map2 : (a -> b -> c) -> Operation a -> Operation b -> Operation c
map2 combiner op1 op2 =
op1
|> andThen
(\v1 ->
map (combiner v1) op2
)
And that’s it. You can see this in action in this Ellie: