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. >>—the wall—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 when you pipe an io into a function, kanso wires the function to receive the value once it exists. piping into an io is bind:
matcha
ube
hojicha
import "std/io"
import "std/list"
import "std/text"
fn count_lines text
newlines = list/count (text/chars text) (c -> c == "\n")
"flavors.txt has {newlines} lines"
pub play = io/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." that's the difference between the wall and the dot in one screenshot: >> sequences io values that don't pass data (the plan lists every step up front), while . threads a value through, so later steps are functions waiting for input. together they are 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, no effect type to declare—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.
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 a wall sequences a write before a read of the same file, and a dot hands the read's contents onward:
import "std/io"
order = "one taiyaki, extra custard"
io/write_file "order.txt" order >> io/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 wall put the write before it. one line of source holds both operators doing their one job each—>> orders two effects that share no data, . moves the read's result into print.
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/io"
fn first xs
xs[1]!
fn greet name
"irasshaimase, {name}"
io/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 io/read_file, io/write_file, io/args, io/stdin behind one import. five verbs, zero new syntax, because the composition operators don't care what they compose.
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/io"
fn first xs
xs[1]!
fn greet name
"irasshaimase, {name}"
io/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/io"
io/read_file "yesterday.txt" . print
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file or unreadable"
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/io"
fn orders (err _)
"no orders yet"
fn orders text
"yesterday: {text}"
pub play = io/read_file "yesterday.txt" . orders . print
error[endpoint]: unhandled err reached the executor: "cannot read yesterday.txt: no such file or unreadable"
the err arm on orders is real, and it would fire on any err that flowed to it as a value during evaluation—chapter 04's pattern, unchanged. but the read's failure is the executor reporting that the world refused the program's contract, and the executor does not resume your pipeline to ask how you feel about it. the failure goes straight to the endpoint, message addressed to whoever ran the program—the one party who can actually put the file back. an execution failure is operational, not logical, and kanso declines to blur the two.
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 . 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.