failure is a value
kanso has no exceptions and no panics. it also has no Ok, no Some, no .unwrap()—none of the wrapper ceremony that result types usually charge. success is the bare value. failure is err, an ordinary value carrying a reason. absence is none, a different ordinary value. the two are not interchangeable, and most of this chapter is about the line between them: none is the failure you handle, err is the failure you don't—can't, in fact, and that inability turns out to be the design.
start by breaking something:
steeps = []
ratio = 10 / length steeps
print "ratio: {ratio}"
error[endpoint]: unhandled err reached the entry: "division by zero"
born in the entry at ratio.kso:2
this is not a crash. division by zero produced an err value, the value flowed to main, and the endpoint rule fired: an err reaching the top of the program is an error in its own right. nothing was thrown and nothing unwound; a value arrived somewhere that had no plan for it, and kanso considers arriving unplanned-for the bug worth reporting. the second line is part of the value itself—every err carries its birthplace, and we will read longer versions of that receipt shortly.
the railway
this is what deletes error handling from real programs. an err flows through functions, not into them. when one shows up as an argument, the function body never runs—the err passes by, untouched, and keeps moving. watch a failure born two calls deep ride all the way out:
total = subtotal 120 0
print (receipt total)
fn receipt total
"order total: {total}"
fn subtotal cents people
share = cents / people
with_tip share
fn with_tip share
share + share / 10
error[endpoint]: unhandled err reached the entry: "division by zero"
born in subtotal at railway.kso:8
passed through receipt ← with_tip
count the lines of error handling in this program: zero. the division by zero happens inside subtotal; the err flows into with_tip, which never runs; back out to main; into receipt, which never runs; through print; and off the end of the program, where the endpoint rule files the report. this is the railway: the sad track runs parallel to the happy one, and no station on the line can flag the train down. functions state their happy-path logic and nothing else, ever.
the err built the report as it traveled. the first line is the reason. the second is the birthplace—function, file, line—baked in at the moment of failure. the third is the trace: every function the err rode past, newest on the left, so receipt ← with_tip reads "past with_tip first, then past receipt." in a language where failure never unwinds a stack, the trace is how the stack story survives—carried on the value itself, printed only if the err makes it all the way out.
no arm can catch it
chapter 03 taught you that dispatch is the only switch, so your first instinct is to write an arm for the err. that instinct is exactly what kanso refuses. an err is not one more shape for dispatch to consider—it rides past the entire overload group, including the catch-all arm that answers for everything else in the language:
fn describe none
"nothing"
fn describe x
"got {x}"
steeps = []
bad = 10 / length steeps
print (describe bad)
error[endpoint]: unhandled err reached the entry: "division by zero"
born in the entry at unhandled.kso:8
passed through describe
describe x answers for ints, strings, records, markers—anything. it still never sees the err: passed through describe, says the trace. an err is a true exception, and kanso's position is that a true exception is by definition the case you did not plan for—so no function gets to turn one back into a value. not a helper, not a library, not main. there is no rescue block to write and none to import, which means there is also no dependency that can quietly eat your failures on the way up. an err rises, carrying its receipts, until the endpoint reports it.
what the strictness buys shows up in the alternative. in languages with catchable exceptions, every failure is implicitly addressed to whoever catches it first, and you find out who that is at runtime. in kanso the addressing is decided in the kind of value you ship: if a failure is something callers should cope with, it must travel as a value—none, or a marker, or a record that names the verdict—because a value is the only thing an arm can claim. the language makes "handleable" and "value" the same word. if you want something that can be handled, don't use an exception.
none is for asking
so meet the handleable one. the standard library is opinionated about where you meet it. indexing with the sigil, xs[i]!, is strict: it means "i know this element exists," and a miss is a broken promise—an err:
flavors = ["matcha" "ube" "hojicha"]
print "ninth: {flavors[9]!}"
error[endpoint]: unhandled err reached the entry: "missing index 9"
born in the entry at ninth.kso:2
(indexing is 1-based, everywhere, no exceptions. the first flavor is flavors[1]. we are at peace with this.)
when absence is expected—you're asking, not promising—drop the sigil: plain xs[i] returns none on a miss. and none, unlike err, is a shape dispatch fully understands. it sits on the marker rung of the specificity ladder, so handling absence is one more arm, written like any other:
fn describe none
"no fourth flavor"
fn describe x
"fourth flavor: {x}"
flavors = ["matcha" "ube" "hojicha"]
first = flavors[1]
fourth = flavors[4]
print "first: {first}" >> print (describe fourth)
first: matcha
no fourth flavor
the same pair of tools covers maps—literal syntax { "dango":350 }, strict lookup with m[k]!, polite lookup with plain m[k]:
fn describe none
"not on the menu"
fn describe price
"{price} yen"
menu = { "dango":350 "taiyaki":500 }
print (describe menu["pocky"])
not on the menu
handleable does not mean invisible. a none no arm ever claims does not melt into the output as a plausible blank—it renders as <none>, a sentinel that is loud on purpose:
flavors = ["matcha" "ube"]
third = flavors[3]
print "third: {third}"
third: <none>
you asked a question with the plain subscript and never resolved the answer, and the output says so: <none> is absence printed where a value should be. (a none that ends up as the program's final value, rather than inside a string, still trips the endpoint rule—appendix a has that diagnostic.) so the difference between the two values is not that one surfaces and the other hides. both surface. the difference is that none can be claimed by an arm and err cannot. asking for something you might not get leaves you two honest endings: an arm that claims the none, or a <none> standing plainly in the output.
access is a decision
the last section handed you a choice. the same missing key can be an err that rises or a none you dispatch on—and you pick, per call site, by one character. the sigil xs[i]! documents an invariant: this key exists, and if it doesn't, that's a bug, fail here. the plain xs[i] documents a question: this may be absent, and absence is a case my code handles. you can't assume by accident, and you can't cope by accident:
menu = { "dango":350 "taiyaki":500 }
print "pocky: {menu["pocky"]!}"
error[endpoint]: unhandled err reached the entry: "missing index "pocky""
born in the entry at pocky.kso:2
same map, same missing key as menu.kso—but this call site claimed pocky was on the menu, so the miss is a broken promise and the report says so. the distinction other languages leave to comments and code review, kanso puts in the call itself.
underneath this is a yagni argument, and it is the whole of the error model: not-handling is the correct default. a handler for a failure that never recurs is speculative complexity—defensive code pays that tax on every line, up front, to guard against things that mostly never happen. kanso inverts it: assume by default, let the err rise with its receipts, and add coping reactively, one call site at a time, when a real failure has earned it. the happy path stays happy not because failures can't occur, but because you refused to complicate it for failures too rare to warrant it—and the endpoint rule guarantees that refusing to plan stays loudly visible the day the rare thing happens.
designing the sad path
the same line—handle, or rise—runs through every function you design, because you now have two different tools to fail with. the question that picks between them: did the function accomplish its goal, or not?
a parser that reads "banana" and decides it is not a flag has not failed. it was asked a question about some text and it produced the answer: no. that verdict is an accomplishment—a value—and it should ship as one, because callers will want to dispatch on it:
type malformed
fn describe malformed
"not a flag"
fn describe b
"flag: {b}"
fn parse_flag "off"
false
fn parse_flag "on"
true
fn parse_flag _
malformed
print (describe (parse_flag "on"))
>> print (describe (parse_flag "banana"))
flag: true
not a flag
malformed is a marker from chapter 03 doing failure work: it names the verdict, it dispatches like anything else, and no caller can forget it exists—an unclaimed marker rides to the endpoint just like an unclaimed none. this is the shape to reach for whenever bad input is an answer your callers care about.
minting an err is the opposite declaration: my goal is unmet and i decline to make that my caller's chore. err takes a reason and builds the value by hand, and the natural place to write one is an arm that recognizes a state you refuse to continue from. here a none arrives where absence means the input itself is broken, and the function escalates it:
import "std/text"
fn first_major lines
major_of lines[1]
fn major_of none
err "no version line"
fn major_of line
text/to_int line
print "major: {first_major []}"
error[endpoint]: unhandled err reached the entry: "no version line"
born in major_of at escalate.kso:7
notice the direction of that conversion. a none became an err—a question's answer became a broken contract—because major_of decided an absent version line is not a case anyone downstream should be coping with. the reverse direction does not exist: nothing turns an err back into a none, because that would be handling the unhandleable. escalation is a one-way door, so choose the floor you start on carefully: none and markers when callers should decide, err when there is nothing sensible for them to decide. and since no caller can catch your err, minting one is a real commitment—the failure will surface as your function's name in their report, with your reason attached. write the reason for the stranger who reads it at the endpoint.
the two failures
every failure so far was born in pure code, and that fact is visible right on the report: a birthplace, file and line. the taxonomy underneath deserves to be explicit, because it decides what fixing a failure even means. the next programs read a file, which makes them a small preview of chapter 05—read_file hands the executor a description of the read, and . feeds the result to a function. take the plumbing on credit for two pages; the failures are today's subject.
a file named version.txt holds the text 7:
import "std/io"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
pub play = io/read_file "version.txt" . announce
major version 7
now the same program pointed at garbage.txt, whose content is the text seven point four:
import "std/io"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
io/read_file "garbage.txt" . announce
error[endpoint]: unhandled err reached the executor: ""seven point four" is not an integer"
born in text/to_int at std/text/text.kso:84
and once more, pointed at a file that does not exist:
import "std/io"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
pub play = io/read_file "no-such-file.txt" . announce
error[endpoint]: unhandled err reached the executor: "cannot read no-such-file.txt: no such file or unreadable"
two failures from the same program shape, and the reports differ in one telling way: the garbage run has a birthplace and the missing run does not. that absent line is the taxonomy, printed. the garbage failure is a wrong: born in major_of, in pure code, turning a value into a verdict. to_int "seven point four" does not sometimes fail—it equals that err the way 2 + 2 equals 4, and running it again would compute the same err at the same line, forever. the missing-file failure is a not-yet: born at the executor, out in the world, at an io that couldn't complete. it has no line of your code to point at because no line of your code is wrong—and the world may have moved by the next attempt.
the trap most people fall into: "but the garbage came from a file—files can change, so isn't that failure contingent too?" trace it carefully. the read succeeded; it delivered seven point four intact, which is why execution ever reached major_of. the parse then failed in code that never touched the disk. a failure's kind is fixed by where it is born, not by where its inputs came from. the changeability you feel lives one step upstream, at the read—and re-running the read is a different event, not a retry of the parse. this is also why retry logic belongs only at the shell: retrying a not-yet is a genuinely new question to a world that may answer differently; retrying a wrong is asking the same arithmetic to come out different, three times, at three times the latency. when kanso grows a retry vocabulary for effects, this born-at-the-executor distinction is the fact it will stand on—the runtime already stamps it on every err.
failures in company
one question remains: what happens when two failures happen at once? chapter 01 established that plain consecutive statements are unordered—parallel by default—and >> is the wall that sequences. an err arriving at a wall kills everything after it, exactly as the railway predicts. but between statements with no order there is no "after," so neither failure can preempt the other. both sides run, and both failures arrive:
import "std/text"
steeps = []
word = text/trim " two "
print "sencha: {3 / length steeps} steeps left"
print "hojicha: {text/to_int word} steeps left"
>> print "serving"
error[endpoint]: unhandled err reached the entry: ["division by zero" ""two" is not an integer"]
the two errs merge into one whose reason is the list of both. a merged err trades the single birthplace line for that list—two failures, two birthplaces, one report—which is the honest accounting: neither failure caused the other, so neither deserves top billing. sequence the same two lines and the story collapses back to the railway—first failure wins, and the second print never runs:
import "std/text"
steeps = []
word = text/trim " two "
print "sencha: {3 / length steeps} steeps left"
>> print "hojicha: {text/to_int word} steeps left"
error[endpoint]: unhandled err reached the entry: "division by zero"
born in the entry at seq.kso:6
short-circuit where there is order, accumulate where there is none. you never choose between those policies, because they are not policies—each is the only behavior its structure permits. dependence decides.
the compiler cannot always see the dependence. these two statements are adjacent, so they are a group, so they run together:
import "std/io"
fn show p
print "read: [{p.stdout}]"
pub play =
io/run "sh" ["-c" "sleep 1; printf written > slow.txt"]
io/run "cat" ["slow.txt"] . show
read: []
the write takes a second. the read does not wait for it, because nothing tells it to—no value passes from the first statement to the second. the dependency between them runs through the filesystem, which is outside the program, and no analysis can follow it there. the read finishes first and finds nothing.
the wall says what the compiler could not work out:
import "std/io"
fn show p
print "read: [{p.stdout}]"
pub play =
io/run "sh" ["-c" "sleep 1; printf written > slow.txt"]
>> io/run "cat" ["slow.txt"] . show
read: [written]
this is the edge of "dependence decides." the compiler orders what it can prove is ordered, and the rest is yours to say. when two statements touch the same file, the same socket, the same directory, you are the only one who knows.
people confuse me with my cousin none. none is what you get when you asked—a lookup that may miss, a question whose answer can be no. i am what you get when something broke. seat us at different tables and your program's intent reads itself.
nobody catches me—no rescue block, no clever arm, and i walk straight past the catch-all. if you ever wish you could, you wished for the wrong value: ship a none, or a marker that names the verdict. i exist for the failures you refused to plan for, and i always file a full report on the way out.
you now hold the whole failure model. err for what broke: unhandleable, rising past every arm with its birthplace and its passed-through receipts, reported at the endpoint. none for what was merely absent: one more shape, claimed by one more arm, and just as impossible to silently ignore. access as a per-call-site decision between promising and asking; your own apis choosing between verdicts shipped as values and contracts broken with a minted err; the wrong/not-yet line that tells you whether a failure lives in your code or in the world; and short-circuit versus accumulate falling out of dependence rather than policy. chapter 05 turns the same instinct outward: if failure is just a value, so is talking to the world.
exercises
- add a fourth station to
railway.kso—a function betweensubtotalandwith_tip—and write down, before running, the exact report you expect: reason, birthplace, and the full passed-through chain in order. then run it and grade yourself. escalate.ksoturns a none into an err. redesign it the other way: makemajor_ofship a marker (sayunversioned) instead of minting an err, and dispatch on it at the print site. write one sentence on which caller the failure now belongs to, and whether that is an improvement here.- take
fourth.ksoand flip both access decisions—make the first lookup a question and the fourth a promise. predict what each run prints or reports before checking. for each of the two call sites, state in one sentence which meaning the original program actually wanted and why. - run
version.ksoagainst three worlds: the file holding7, the file holding garbage, and the file absent. before each run, name the failure kind you expect—wrong or not-yet—and say whether the report will carry a birthplace line. explain what retrying would mean, if anything, in each world.