Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FrontendBackend
Effect typeCmd msgTask a
Meaning“do this; message me the result”“a step that produces a value”
CompositionCmd.batch, messages through updateTask.map, Task.andThen, let <-
Who runs itthe MVU runtimethe request runtime
BridgeCmd.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.