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:
import "std/text"
word = text/trim " many "
ratio = text/to_int word
print "ratio: {ratio}"
error[endpoint]: unhandled err reached the entry: ""many" is not an integer"
born in text/to_int at std/text/text.kso:92
this is not a crash. to_int 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, here a line of the standard library, 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 share_of _ 0
err "no one to share the bill with"
fn share_of cents people
cents / people
fn subtotal cents people
share = share_of cents people
with_tip share
fn with_tip share
share + share / 10
error[endpoint]: unhandled err reached the entry: "no one to share the bill with"
born in share_of at railway.kso:8
passed through receipt ← with_tip
count the lines of error handling in this program: zero. share_of decides that a bill split no ways is a failure and says so; 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 a station stops the train only where the checker can see it coming. functions state their happy-path logic and nothing else.
one rule sits beside this, and the checker enforces it. an err is data, and a function that is handed one either names it with an (err _) arm or does not take it. where the checker can see a raised err arriving—the argument is itself a call to a function with an err arm, as with_tip (share_of cents people) would be—it refuses the program and names the function that has no arm. share above is a name, and the checker reads calls, not the names they are bound to, so the failure rides past with_tip at run time and the endpoint reports it. an operator and an index are held to the same rule: share_of cents people + 1 does not compile, because + wants a value and an err is not one.
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.
nothing is asked of the signature
readers arriving from languages with checked exceptions or result types usually ask the same question here: if a failure can show up in an argument, doesn't the function's signature have to say so? if i pass an err as one of five arguments, is the function now forced to return one? the answer is no. the signature says nothing, and the checker asks its question at the call instead: can this argument be an err the program raised? where it can prove one, the function needs an (err _) arm at that position, or the caller dispatches before calling, or the program does not compile. where it cannot prove one, the call short-circuits before the body runs, so the function never receives the failure—and a function that never receives one has no signature to infect. err-in, err-out is a fact about calls the checker cannot see into, not a contract anybody writes.
seasonal = price "flan"
print (label "soup" (price "soup")) .> (_ -> print (label "flan" seasonal))
fn price "flan"
err "flan is seasonal"
fn price _
450
fn label dish cents
"{dish}: {cents} cents"
error[endpoint]: unhandled err reached the executor: "flan is seasonal"
born in price at unasked.kso:5
passed through label
soup: 450 cents
label takes two arguments. on the first line both are values and it runs. on the second, one of the two is a failure, and it arrives through the name seasonal: the checker reads calls, and a name is not a call, so this is the case it cannot prove. the call answers the failure and the body never starts—the trace says passed through label, which is the language reporting exactly that. written as label "flan" (price "flan"), with the call in plain view, the program would not compile. one function, compiled once, serving both lines. nothing about label changed to make the second line legal, and nothing could: there is no annotation to add, no wrapper type to return, no ? to sprinkle. the plumbing that other languages ask every signature to carry is done once, at every call site, by the rule itself.
when more than one argument fails, a call keeps the first failure; an operator keeps both, because neither operand caused the other. the full accounting of what each construct does with an arriving failure—which merge, which take the first, which hold, which pass through—is a pinned table on the compiler page, and every row of it is a fixture that runs on all three engines.
an arm has to name it
chapter 03 taught you that dispatch is the only switch, so the first question is what an err does when it reaches a function with no arm written for it. it rides past the whole overload group, including the catch-all arm that answers for everything else in the language:
fn describe none
"nothing"
fn describe x
"got {x}"
fn steeped 0
err "nothing steeped"
fn steeped n
n
bad = steeped 0
print (describe bad)
error[endpoint]: unhandled err reached the entry: "nothing steeped"
born in steeped 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. a catch-all arm binds a value, and an err is the one thing it declines. what claims an err is an arm written for it, (err _), or (err r) to bind the reason, the same way an arm claims none. that arm is ordinary dispatch, and it works wherever it is written: in the module that raised the failure, or in one that imported it. std/testing asserts which failure a package produced with an (err reason) arm of its own. so an err rises carrying its receipts until it reaches an arm that names it, and if none does, the endpoint reports it.
reading a failure that reached you from another module has a word, and chapter 05 introduces it properly: rescue, usually written .?. the word is licensed on foreign failures only. the same .? aimed at your own err does nothing at all, and one program shows both. teahouse raises; kitchen imports it, raises one of its own, and hands both to the same callback:
pub fn brew _
err "the kettle is cold"
import "../teahouse"
fn apologise (err _)
"sorry, no tea"
pub fn served _
teahouse/brew "sencha" .? apologise
fn scorched _
err "i burnt it myself"
pub fn mine _
scorched "sencha" .? apologise
import "./kitchen"
print (kitchen/served "go") .> (_ -> print (kitchen/mine "go"))
served answers sorry, no tea. mine reaches the endpoint, and the report never mentions the callback:
error[endpoint]: unhandled err reached the executor: "i burnt it myself"
born in kitchen/scorched at kitchen/kitchen.kso:10
sorry, no tea
one callback, two .? steps, opposite answers. an err carries where it was born, and a .? written in the module that raised it hands the failure on without entering the callback, which is why apologise is absent from the report. inside your own module the arm is the tool, and .? is for the failures that reach you from elsewhere.
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 travels as a value—none, a marker, a record that names the verdict, or a bare err an arm can name—and a failure nobody should cope with rides an effect, where only a .? in another module can end it. the language makes "handleable" and "value" the same word.
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." the read answers an effect, a box holding the element, and .> opens it. a miss is a broken promise—a failure that bubbles through the box to the endpoint:
flavors = ["matcha" "ube" "hojicha"]
flavors[9]! .> (f -> print "ninth: {f}")
error[endpoint]: unhandled err reached the executor: "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 a failure 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. it answers a box, and the failure bubbles from it. 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 }
menu["pocky"]! .> (yen -> print "pocky: {yen}")
error[endpoint]: unhandled err reached the executor: "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. that description is a value with a type of its own, <string>effect, which is what chapter 05 spends its length on. 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/os"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
pub play = os/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/os"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
os/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:92
and once more, pointed at a file that does not exist:
import "std/os"
import "std/text"
fn announce text
print "major version {major_of text}"
fn major_of text
text/to_int text
pub play = os/read_file! "no-such-file.txt" .> announce
error[endpoint]: unhandled err reached the executor: "cannot read no-such-file.txt: no such file"
born in os/insisted at std/os/os.kso:113
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 that a bind, .>, is what puts one step after another. an err arriving at a bind skips 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"
sencha = text/trim " one "
hojicha = text/trim " two "
print "sencha: {text/to_int sencha} steeps left"
print "hojicha: {text/to_int hojicha} steeps left"
error[endpoint]: unhandled err reached the entry: [""one" is not an integer" ""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"
sencha = text/trim " one "
hojicha = text/trim " two "
print "sencha: {text/to_int sencha} steeps left"
.> (_ -> print "hojicha: {text/to_int hojicha} steeps left")
error[endpoint]: unhandled err reached the entry: ""one" is not an integer"
born in text/to_int at std/text/text.kso:92
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/os"
fn show p
print "read: [{p.stdout}]"
pub play =
os/run "sh" ["-c" "sleep 1; printf written > slow.txt"]
os/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.
a bind says what the compiler could not work out:
import "std/os"
fn show p
print "read: [{p.stdout}]"
pub play = os/run "sh" ["-c" "sleep 1; printf written > slow.txt"]
.> (_ -> os/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.
an arm written for me claims me, in the module that raised me or any other; a catch-all does not, and i walk straight past it. put me on an effect and only a stranger's .? can end me: the module that raised me hands me on. if you ever wish you could catch me some other way, 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: rising past every arm that does not name it, with its birthplace and its passed-through receipts, claimed by an (err _) arm or 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.