Tasks: Effects on the Server
The frontend describes its effects with Cmd; the backend describes its own with Task. Same philosophy, different shape, and the difference is instructive: a Cmd fires work whose result comes back later, as a message, because a UI is an event loop. A backend handler instead computes one response per request, so its effect type is a value pipeline: a Task a is “a computation that, when run, produces an a”.
The shape of a handler
A handler’s job: given a request, produce a Task of the response. The runtime runs it.
toggleImpl : { id : Int } -> Shared.User -> Task (Maybe Shared.Task)
toggleImpl input user =
let
found <- Repo.findById tasks input.id
in
case found of
Just task ->
if task.userId == user.id then
Repo.update tasks input.id { done = task.done == False }
else
Task.succeed Nothing
Nothing ->
Task.succeed Nothing
The new syntax is let ... <-, pronounced “bind”: run the Task on the right, name its result, continue. It reads like straight-line code and is pure sugar for Task.andThen, the function that chains one Task’s output into the next Task. If you know async/await, the analogy is nearly exact: <- is await, Task a is Promise<a>, and the handler is an async function. The difference, as ever, is that a Task is an inert description: building one does nothing, running it is the runtime’s job, and your function stays a pure arrangement of steps.
The combinators
Task.succeed : a -> Task a -- a finished Task
Task.fail : String -> Task a -- abort the handler
Task.map : (a -> b) -> Task a -> Task b -- transform the result
Task.andThen : (a -> Task b) -> Task a -> Task b -- chain (what <- sugars)
Task.sequence : List (Task a) -> Task (List a) -- run a list of Tasks
Task.forEach : (a -> Task ()) -> List a -> Task () -- act on each element
Two everyday idioms. Transforming a read without ceremony:
Repo.all tasks |> Task.map (List.filter (\t -> t.done))
And branching into effects: because if and case are expressions, choosing which effect to run is ordinary code, no builder pattern required:
if String.trim input.name == "" then
Task.succeed Shared.NameEmpty
else
Repo.create tasks { name = input.name, done = False, userId = user.id }
|> Task.map Shared.Added
Sources of Tasks are exactly the things a server legitimately touches: the Repo.* operations, Time.now : Task Time, and Task.succeed/Task.fail. Notably absent: arbitrary HTTP calls, file IO, shell commands. The surface is small on purpose; every capability added to Task is a capability every reviewer must henceforth assume any handler might use.
Task.fail is an abort, not an error channel
Task.fail "message" stops the handler; the client receives the message as a generic server error. Its role is broken invariants: states that indicate a bug, not an outcome. “The row this session points to has vanished” is a Task.fail. “The name was empty” is not: that is a domain outcome the frontend must react to, so it belongs in the response type (NameEmpty above), where the exhaustiveness checker will make every calling page handle it.
Keep that split in mind; the next chapter builds Mar’s whole error doctrine on it. The one-sentence preview: if a page needs to branch on it, it is data; only if nobody can react to it is it an abort.
Time, and other honest effects
Why is even Time.now a Task, when reading a clock seems so harmless? Because “harmless” reads are precisely how nondeterminism seeps into systems. A handler that consults the clock in the middle of logic is a handler whose behavior differs at midnight. Making it a Task pins the read to an explicit, visible step:
addTaskImpl input user =
let
now <- Time.now
in
Repo.create tasks
{ name = input.name, done = False, createdAt = now, userId = user.id }
The value now is then an ordinary immutable value; the rest of the computation is deterministic given it. Computed-at-creation fields (timestamps, slugs) follow this pattern: read once, pass the value, keep the entity layer dumb.
The symmetry, completed
One mental table to carry forward:
| Frontend | Backend | |
|---|---|---|
| Effect type | Cmd msg | Task a |
| Meaning | “do this; message me the result” | “a step that produces a value” |
| Composition | Cmd.batch, messages through update | Task.map, Task.andThen, let <- |
| Who runs it | the MVU runtime | the request runtime |
| Bridge | Cmd.perform turns a Task into a Cmd |
They were once a single unified type, and Mar split them after real-world use showed the merge let a category of bug compile (a value-producing effect returned where a message was expected, silently doing nothing). It is a nice miniature of the language’s design process: unify when it clarifies, split when the type checker can catch more by telling things apart.
So far the happy path. Failure deserves its own chapter.