effects are io
print has been doing nothing all book. every kanso function is pure—evaluating print "hi" performs no i/o. it returns an io, a value like any other, describing the printing it intends. your entire program is one big pure computation whose final answer is main: one io describing everything the program means to do. the runtime receives that value and walks it. evaluation decides; the runtime performs.
you can see the seam directly, because an io is data, and data can be rendered instead of executed. a bind whose callback ignores its argument, .> (_ -> next), glues io values into bigger ones, "this, then that", and the --plan flag from chapter 01 shows the glued-up value without running it:
print "brewing" .> (_ -> print "steeping") .> (_ -> print "pouring")
brewing
steeping
pouring
brewing
steeping
pouring
--plan falls out of the design. the plan already exists as a value—the flag just prints it instead of handing it to the executor. each print step carries the source span it came from, which is where the provenance comments come from. nothing else in this chapter is new machinery either. everything is a consequence of one sentence: an effect is a value.
an io is a value
follow that sentence around. first: if main is just a constant, a program whose main is a plain number should be legal. it is. it computes the number, tells no one, and exits cleanly—there is no io to walk, so the runtime has nothing to do:
6 * 7
kanso run quiet.kso prints nothing and exits zero. the answer 42 was computed and discarded, because a print is itself a value, and this program never built one. ask for the plan and the toolchain says so in as many words.
second: a value can be bound to a name, and a name can be used more than once. an io doesn't remember having run, because it hasn't—splice it in three times and the program means three prints:
knock = print "kon kon"
knock .> (_ -> knock) .> (_ -> knock)
kon kon
kon kon
kon kon
kon kon
kon kon
kon kon
in a language where print fires on evaluation, knock would print once, at definition, and the reuse would be a mystery. here the constant is an io, the three uses are three copies in the final value, and the plan shows all three—each pointing back at the one line that built it.
third: interpolate an io into a string and you get its face, not its result. there is no result yet; nothing has run:
step = print "steep"
print "an io renders as {step}"
an io renders as <io>
and fourth: because intent is a value, dropping intent on the floor is visible—and refused. bind an io and never use it, and the program has written down a thing it will never do. kanso treats that as a bug at compile time, the same way chapter 04 treated an unreceived err:
goodbye = print "sayonara"
print "the goodbye never ran"
error[unused]: unused binding `goodbye`
--> dropped_check.kso:1:1
1 | goodbye = print "sayonara"
^
four small programs, one lesson: nothing about print is special. it obeys binding, reuse, interpolation, and the unused-binding check like every other value in chapter 02, because it is one.
pipes, and pipes into effects
the other operator you've been glimpsing is .—the dot. x . f is f x, read left to right, so data flows the way your eye does:
fn greet name
"hello, {name}"
"kanso" . greet . print
hello, kanso
the dot does not stop at one argument. whatever follows the function is written after the piped value, which arrives first — so a function meant to be piped into takes its subject first and its settings after:
fn clamp n lo hi
return lo if n < lo
return hi if hi < n
n
print "{12 . clamp 0 10}" .> (_ -> print "{4 . clamp 0 10}")
10
4
12 . clamp 0 10 is clamp 12 0 10. read the arguments after the name as the ones you were always going to write, with the subject lifted out in front of them.
now pipe out of an io. read_file path doesn't return a string—it returns an io describing the read, and the file's contents don't exist until the runtime performs it. so an io is piped with .>, the bind dot, and kanso wires the function to receive the value once it exists. piping into an io is bind, and the spelling says so:
matcha
ube
hojicha
import "std/list"
import "std/os"
import "std/text"
fn count_lines text
newlines = list/count (text/chars text) (c -> c == "\n")
"flavors.txt has {newlines} lines"
pub play = os/read_file! "flavors.txt" .> count_lines .> print
flavors.txt has 3 lines
flavors.txt has 3 lines
look at that plan. the runtime can name the first step, but the rest is <continuation>—honest notation for "what happens next depends on a value i don't have yet." compare the plan for brew.kso, which listed every step. each of its callbacks was _ -> next, which ignores what it is handed, so the plan could call it without a value. count_lines reads the file's contents, so its step cannot be shown until the read has happened. that one operator is the complete vocabulary of "before" in kanso—everywhere else, order belongs to the runtime, which is chapter 06's whole subject.
note what count_lines is: an ordinary pure function that takes a string. it doesn't know files exist, and nothing in its signature says "i get used near i/o." there's no async keyword to sprinkle through every caller—effectfulness travels in the value itself. a function that returns an io is effectful; a function that returns a string is not; and the same function can be piped from a file today and called on a literal in a test tomorrow.
that is not the same as saying effects are untyped. an effect has a type, <t>effect, and the compiler tracks it through your program without being told: it is inferred from the io values you touch, the way every other type in kanso is inferred. what it buys you is that the compiler knows which of your expressions is a box, so it can refuse one wherever the thing inside is what's wanted:
import "std/os"
pub play = print (length os/args)
error[effect]: this is an effect — a box the words open — and `length` takes a value; open it with `.>`
--> boxed.kso:3:26
3 | pub play = print (length os/args)
^
os/args is an io, so length is being handed the box rather than the list inside it, and the compiler says so before the program runs. the same refusal covers +, if, an index, ==, and any function of your own that wants a value. the fix is the operator you already know: os/args .> (a -> print (length a)) opens the box and lets length see the list. this is the whole content of "the words are the only doors"—the dot applies, .> opens, and nothing opens a box by accident.
a box has a type
an io is a value, and a value can be handed around before anyone opens it. a function that wants the box itself, rather than what comes out of it, says so in its signature: <string>effect is the type of an io that will yield a string. the box arrives unopened, and the function opens it with .> the way play did above:
import "std/list"
import "std/os"
import "std/text"
fn count_lines text
newlines = list/count (text/chars text) (c -> c == "\n")
"flavors.txt has {newlines} lines"
fn counted e:<string>effect
e .> count_lines
pub play = counted (os/read_file! "flavors.txt") .> print
flavors.txt has 3 lines
counted holds the read and hands it on with a step attached. nothing has been read when it returns. its answer is another io, the same box with count_lines wired behind it, and the runtime performs the whole chain when play reaches the executor. the annotation does one job: it names the parameter as a box, so an arm can dispatch on "an io that yields a string" the way another arm dispatches on n:int.
the other direction is refused. length reads a string; hand it the io and kanso check stops before anything runs:
import "std/os"
pub play = print (length (os/read_file! "flavors.txt"))
error[effect]: this is an effect — a box the words open — and `length` takes a value; open it with `.>`
--> unopened_check.kso:3:27
3 | pub play = print (length (os/read_file! "flavors.txt"))
^
the message names the fix. a box travels wherever a value travels—into a parameter, a list, an interpolation—but where something reads the value inside, the box has to be opened first, and .> is how. the compiler refuses only what it can prove is a box: an io from the standard library, or a function of your own whose every arm ends in one.
holding an argument back
the dot threads a value into a function that is ready for it. sometimes the function is not ready — it wants two things and you only have one. & supplies what you have and waits for the rest:
fn tax rate price
price + price * rate / 100
fn quote pricer amount
"{amount} becomes {pricer amount}"
local = &tax 8
print "{quote local 250}" .> (_ -> print "and again: {local 100}")
250 becomes 270
and again: 108
&tax 8 fixes the rate and hands back something that still wants a price. it is a value like any other: bind it to a name, pass it to quote, call it twice. no lambda was written and no wrapper function was declared.
the sigil is doing real work, and it is required. tax 8 on its own is a call, and a call short of every arm is an error — so a bare application can never mean "wait for more". with overloading, whether an application has finished cannot be read off the text: if tax also had a one-argument arm, tax 8 would be a completed call, and the partial you meant would be unreachable. & is how you say which one you meant, and it means the answer cannot change when somebody adds an arm tomorrow.
& supplies arguments and never runs anything, and that stays true when there is nothing left to supply. &tax 8 250 has handed over both arguments and is still a value — one that is waiting to be called. () is what calls it:
fn tax rate price
price + price * rate / 100
on_250 = &tax 8 250
print "still waiting: {on_250}" .> (_ -> print "called: {on_250()}")
still waiting: <fn>
called: 270
so the two are complements: & gives without running, () runs without giving. A value that still wants arguments cannot be run this way — () brings none, and an arm that wants one does not match.
the rest of the i/o vocabulary
writing is an io too. write_file path content describes putting a string at a path, and it composes with everything you've seen—here one bind puts a write before a read of the same file, and a second hands the read's contents onward:
import "std/os"
order = "one taiyaki, extra custard"
os/write_file "order.txt" order .> (_ -> os/read_file! "order.txt") .> print
one taiyaki, extra custard
one taiyaki, extra custard
the round trip proves the sequencing: the read found the file because the first bind put the write before it. the two binds do different jobs: the first ignores what the write yields and only orders the two effects, and the second moves the read's result into print.
every effect that succeeds yields a value, including the ones with nothing to report. a write yields done, the fourth nullary alongside true, false and none. the _ in save.kso throws that yield away. give the callback a parameter it reads and you can look at it:
import "std/os"
fn name done
"done"
fn name none
"none"
fn report yield
print "the write yielded {yield}"
.> (_ -> print "an arm named {name yield} caught it")
.> (_ -> print "yield == none is {yield == none}")
pub play = os/write_file "receipt.txt" "one taiyaki" .> report
the write yielded <done>
an arm named done caught it
yield == none is false
it renders in brackets because it is not data, the same convention that prints <none> and <io>. otherwise it is an ordinary value: an arm can name it, a typeset can hold it, and it equals itself and nothing else. the word earns its place by the distinction it draws. none means something is absent—a key that was not in the map, an environment variable nobody set. a finished write is not an absence. while both yielded none, a chain that bound the result could not tell the two apart.
args is the argument list (everything after -- on the command line), and stdin is standard input. both are io values—the argument list and the input stream belong to the outside world, so touching them is an effect like any other, and they pipe like everything else:
import "std/os"
fn first xs
xs[1]!
fn greet name
"irasshaimase, {name}"
os/args .> first .> greet .> print
error[endpoint]: unhandled err reached the executor: "missing index 1"
born in first at welcome.kso:4
import "std/io"
pub play = io/stdin .> shout .> print
fn shout text
"{text}!!"
hello from a pipe!!
that is the whole i/o vocabulary of part i: print, ambient, and os/read_file, os/write_file, os/args, io/stdin behind two imports. five verbs, zero new syntax, because the composition operators don't care what they compose. the two modules split the way go splits them: std/os is the machine your program is standing on — its files, its environment, its arguments, the processes it starts — and std/io is what it reads and writes through.
failure at the edge
run welcome.kso with no argument and the strict index inside first fails—at execution time, because that's when the argument list exists:
import "std/os"
fn first xs
xs[1]!
fn greet name
"irasshaimase, {name}"
os/args .> first .> greet .> print
error[endpoint]: unhandled err reached the executor: "missing index 1"
born in first at welcome.kso:4
read the wording against chapter 04. there the message ended "reached the entry," because the entry was where evaluation stopped. here it ends "reached the executor": evaluation finished long ago and handed back a perfectly healthy io, and the failure happened while the runtime was walking it. the err rides the same railway, and the diagnostic still names where it was born. the endpoint has moved one stop later, and that is the whole difference.
a file that isn't there fails the same way:
import "std/os"
os/read_file! "yesterday.txt" .> print
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file"
born in os/insisted at std/os/os.kso:113
one distinction is worth pinning before you reach for a chapter 04 arm. an arm receives an err during evaluation, when the err is a value flowing through your functions. an err born at the edge—the missing file, the absent argument—arrives after evaluation is over, and it does not knock at the continuations waiting downstream, even ones with an arm ready:
import "std/os"
fn orders (file_not_found _)
"no orders yet"
fn orders text
"yesterday: {text}"
pub play = os/read_file "yesterday.txt" .> orders .> print
no orders yet
that arm fires, and no rescue was needed. a file that is not there is an outcome the program can anticipate, so os/read_file answers text | file_not_found and dispatch picks the arm the way it picks any other. the alternative is part of reading's vocabulary, so it is written in the vocabulary.
the failure channel is for what the program did not plan for—a permission it does not have, a device that went away, bytes that are not text. those still go straight to the endpoint, with a message addressed to whoever ran the program, the one party who can do anything about it.
a caller who knows the file is there says so with a bang. os/read_file! path is insisting, and absence bubbles from it as a failure like any other—the same choice foo[k] and foo[k]! make over a missing key. that is the whole rule: the bang chooses the channel.
a chain carries a failure past every step to the endpoint. one step is written to read it instead. rescue takes the effect and a callback, and the callback runs only when the effect failed—so the sample below insists, to have a failure for it to catch:
import "std/os"
fn orders (err _)
"no orders yet"
fn orders text
"yesterday: {text}"
pub play = rescue (os/read_file! "yesterday.txt") orders .> print
no orders yet
the group is the same one the previous program wrote, and it fires here. what changed is the step: orders sat downstream of a dot before, where nothing asks it about a failure, and now it is the callback of a word whose whole job is to ask. a decode that succeeded goes straight past rescue untouched, so the callback sees a failure or nothing at all.
rescue has two siblings, both ordinary two-argument functions taking the effect first. bind is the other side of the same rule—its callback sees the value and never the failure. annotate reads the failure channel like rescue and re-wraps whatever the callback answers, so it can say more about a failure and can never clear one. that leaves rescue as the only door out, which is the point: a reader looking for where a program recovers has one word to search for.
each of the three has a fused spelling, and those are the forms most code wears: .> for bind, .! for annotate, .? for rescue. the program below is the one above with both steps written that way, and it prints the same line:
import "std/os"
fn orders (err _)
"no orders yet"
fn orders text
"yesterday: {text}"
pub play = os/read_file! "yesterday.txt" .? orders .> print
no orders yet
rescue (os/read_file! "yesterday.txt") orders and os/read_file! "yesterday.txt" .? orders are the same call. the fused form reads left to right with the rest of the chain, which is why a chain of several steps usually wears it, and appendix C gives the wrapping rule for a statement that runs past eighty columns.
i work execution time too. a file that isn't there, an argument that wasn't passed—same me, same railway, but i board past the last arm. at the edge nobody dispatches on me, so i ride straight to the endpoint and name the line that bore me. you do not need a second error model for i/o, and kanso declines to sell you one.
the executor at the edge
so who actually performs? at the very edge of the toolchain sits the executor—the one component allowed to touch the world. in the interpreter it is a rust trait with one method per effect, and the shape of that trait is the shape of everything kanso can do to your machine:
pub trait Executor {
fn print(&mut self, text: &str);
fn args(&mut self) -> Vec<String>;
fn stdin(&mut self) -> Result<String, String>;
fn read_file(&mut self, path: &str) -> Result<String, String>;
fn write_file(&mut self, path: &str, content: &str) -> Result<(), String>;
}
impl Executor for RealExecutor {
fn print(&mut self, text: &str) {
println!("{text}");
}
fn read_file(&mut self, path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))
}
// ...
}
this is the classic functional-core, imperative-shell split, enforced by construction rather than discipline. your program—every function you write, every module you'll build in chapter 07—lives in the pure core. the shell is a short loop that walks the io and calls these trait methods. chapter 01 promised "two engines, one meaning," and this seam is why the promise is cheap to keep: kanso run compiles your program to a native binary while --plan and kanso test stay on the interpreter, and they can't disagree about what your program means, because the meaning is a value fixed before any executor gets involved.
testing without mocks
the architecture buys this, and it is the reason the whole design exists. in most languages, testing effectful code means interception: monkey-patch the i/o call, record what would have happened, hope the patch is faithful to the real thing. in kanso there is nothing to intercept, because evaluation never does i/o. the testing story has two floors, both mock-free.
the ground floor you already have. the logic in an effectful pipeline lives in pure functions—count_lines, shout, greet—and pure functions are tested by calling them, with the test_ constants from chapter 01:
import "std/list"
import "std/text"
fn count_lines text
list/count (text/chars text) (c -> c == "\n")
test_counts_lines = count_lines "matcha\nube\nhojicha\n" == 3
test_empty_text_has_no_lines = count_lines "" == 0
test_counts_lines ... ok
test_empty_text_has_no_lines ... ok
2 passed, 0 failed
no file was faked, because no file was involved: the function under test takes a string, and the fact that main pipes it from read_file is not the function's business. the design pushed the file dependency to the edge, and the test simply doesn't follow it there.
the second floor is the effects themselves. a test evaluates main—pure, fast, no side effects—and gets the program's entire intent as a value. the interpreter makes this concrete: Executor is a trait, so alongside the real executor there is a scripted one that performs nothing and appends each effect to a transcript, feeding the program canned args, canned stdin, and an in-memory map of files:
pub struct ScriptedExecutor {
pub transcript: Vec<String>,
pub script_args: Vec<String>,
pub script_stdin: String,
pub files: std::collections::HashMap<String, String>,
}
impl Executor for ScriptedExecutor {
fn print(&mut self, text: &str) {
self.transcript.push(format!("print {text:?}"));
}
fn read_file(&mut self, path: &str) -> Result<String, String> {
self.transcript.push(format!("read_file {path:?}"));
self.files.get(path).cloned().ok_or_else(|| format!("cannot read {path}"))
}
// ...
}
a test hands main's io to the scripted executor and asserts on the transcript with plain data equality—assert_eq!(executor.transcript, ["print \"a\"", "print \"b\""]). no stubbing framework, no argument matchers, no "verify this was called once." the program said what it would do, and the test reads the saying:
running 1 test
test eval::tests::scripted_executor_records_the_transcript ... ok
and this book eats the same cooking. every output panel in every chapter is a golden file that a harness re-runs against the real toolchain, and the goldens hold because a kanso program's transcript is a function of its source—even, as chapter 06 will show, when the program is concurrent and rolling dice.
when a program surprises you, reach for --plan before you reach for a debugger. the plan is what your program is; running it is merely what it does. most bugs are visible in the intent.
you can now split any program into the part that decides and the part that touches the world, and say precisely where the boundary sits: io values on one side, one executor on the other. you can read .> (_ -> ...) as pure sequence and a bind into a named parameter as a value crossing into the future, and predict a --plan from the source before you run it. you can trace an execution-time failure to the endpoint and explain why no arm caught it. and you can test the logic of an effectful program without faking a single i/o call. chapter 06 takes the one thing this chapter kept quiet—what happens when two io values don't need an order—and turns it into concurrency for free.
exercises
- write a program that binds one
printio to a name and splices it intomaintwice, separated by a different print. predict the full--planoutput on paper, then run it and compare. - write a file copier: pipe
read_file "flavors.txt"into a lambda that returnswrite_file "copy.txt" text. run it with--planfirst and explain why the write step doesn't appear in the plan, then run it for real and confirm the copy exists. - change
firstinwelcome.ksoto indexxs[2]and run it with one argument, then with two. for each run, say whether the failure (if any) happened during evaluation or during execution, and quote the part of the message that told you. - extract the pure function from
shout.ksointo a file of its own with twotest_constants—one for ordinary text, one for the empty string. decide what the empty-string result should be before you runkanso test, and reconcile if the toolchain disagrees.