Effects & Task
Everything so far has been pure: given the same inputs, a function returns the same output and touches nothing else. But real programs read files, call HTTP APIs, query databases, ask the clock what time it is. Sky handles all of that with one rule.
The one rule
Every observable side effect returns Task Error a.
That gives you three tiers, and the type tells you which one you're looking at:
-- pure — bare value: String.length, List.map, Crypto.sha256
-- fallible-pure — Maybe: String.toInt : String -> Maybe Int
-- Result: Encoding.base64Decode : String -> Result Error String
-- effect — Task Error a: File.read, Http.get, Db.query, Time.now
A Task Error a is a description of an effect that, when run, either produces an
a or fails with an Error. Holding a Task doesn't do anything yet — it's a
recipe, not the cooking.
Sequencing effects
You build bigger tasks out of smaller ones with Task.map and Task.andThen,
exactly like Result:
-- read a file, then upper-case its contents
shout : Task Error String
shout =
File.read "note.txt"
|> Task.map String.toUpper
-- andThen: feed one task's result into the next effect
-- Task.andThen : (a -> Task e b) -> Task e a -> Task e b
When does a task actually run? At an entry point — a CLI main, an HTTP handler
returning its response, or Cmd.perform in a web app. Those are the boundaries
where Sky executes the recipe. In between, effects stay values you can pass
around, sequence, and test.
Forcing an effect for its side effect
When you want to run a task just for what it does (log a line, print progress),
bind it with let _ = and Sky fires it:
let
_ = println "step 1"
_ = println "step 2"
in
keepGoing
The one footgun: memoised top-level bindings
A top-level binding with no arguments is evaluated once and cached (it's a constant). That's usually exactly right — a shared database pool should be opened once and reused:
-- opened once, shared by every query — correct
db =
Task.run (Db.connect ())
But it bites if you expect a fresh value each time:
-- ✗ freezes to ONE uuid for the whole program — colliding ids!
newId =
Task.run Uuid.v4
If you need a new value per use, make it a function (take a unit argument), and call it:
-- ✓ a fresh uuid every call
newId : () -> String
newId _ =
Task.run Uuid.v4 |> Result.withDefault ""
-- call site: newId ()
Sky warns you when a memoised binding forces a fresh-value effect like Uuid.v4,
Random.*, or Time.now, so this never bites silently — but it's worth
understanding why the warning fires.
The same warning covers a frozen read: a top-level
posts = Task.run (Store.all db postStore) caches that row set for the life of
the process, so a post written later never appears. It fires when evaluating the
binding actually performs the read — directly, or one hop behind a helper.
It does not fire on a table that merely references handlers:
-- ✓ a registration table: nothing is read while it is built
apiRoutes =
[ Live.api "GET /healthz" handleHealthz
, Live.api "GET /admin/login" handleLogin
]
Passing handleLogin as a value stores a computation for the framework to run
per request. Its Db.query fires then, against the shared pool — not now, and
nothing about the list is a snapshot.
That's the whole effect story. With pure values, Result/Maybe, and Task, you
can express any program — and the types keep effects honest.