When a call can fail
Hale's value-level error model — and why you can't ignore it. Some calls can't always succeed. Parsing "banana" as an integer, reading a file that isn't…
Hale’s value-level error model — and why you can’t ignore it.
Some calls can’t always succeed. Parsing "banana" as an
integer, reading a file that isn’t there, connecting to a host
that’s down. In Hale these calls have a type that says so, and
the compiler requires you to deal with the failure right at the
call site. There are no exceptions, no surprise control flow, and
no silently-ignored error codes.
The fallible type
Section titled “The fallible type”A function that can fail declares it with fallible(E), where
E is the type of the error payload:
type ParseError { kind: String; input: String; }
fn parse_count(s: String) -> Int fallible(ParseError) { if !std::str::can_parse_int(s) { fail ParseError { kind: "not_int", input: s }; } return std::str::parse_int(s) or 0;}fail <payload>; exits the function through the error path,
carrying the payload. The function’s result is now “either an
Int, or a ParseError” — and the caller can’t just use it as
an Int:
let n = parse_count(input); // ERROR: says nothing about failureYou have to address the error. You do that with an or
clause, and only with one: a match over the call, passing it as
an argument or comparing it are all the same error.
The five or motions
Section titled “The five or motions”let a = parse_count(s) or raise; // propagate upwardlet b = parse_count(s) or 0; // substitute a valuelet c = parse_count(s) or handle(err); // hand off to a helperlet d = parse_count(s) or fail OtherErr { }; // translate the errorsome_unit_call() or discard; // ignore (unit result only)or raise— pass the error up to your caller. For that your function is itselffallible(E)with the same error type (a different one is refused). In a function that isn’t fallible the error has nowhere to go, so it ends the program:Hale panic: unhandled E escaping main locus.or <expression>— substitute a fallback value of the success type. Inside the expression,erris bound to the payload, so you can inspect it:let port = std::str::parse_int(arg) or 8080;or handler(err)— call a function that takes the error and returns the success type. Good when several call sites share one recovery policy.or fail <payload>— fail with a new error of your own type, instead of forwarding the inner one. Use it so a library doesn’t leak a stdlib error type through its own surface. The payload seeserrbound to the inner error, so a translation can carry fields across:let n = std::str::parse_int(s) or fail AppErr { msg: err.kind };or discard— throw the error away. Only allowed when the successful result is()(nothing to substitute). The compiler rejectsor discardon a value-bearing call and suggestsor <fallback>instead.
A real example
Section titled “A real example”Reading a file is fallible — the file might not exist:
fn load_greeting() -> String { return std::io::fs::read_file("welcome.txt") or "(no welcome)";}If the read fails, we substitute a default. If instead we wanted
the failure to stop us, we’d make load_greeting fallible and
or raise:
fn load_greeting() -> String fallible(...) { return std::io::fs::read_file("welcome.txt") or raise;}The bare form is an error
Section titled “The bare form is an error”Any function that can fail, yours or the stdlib’s, must be called with
an or:
std::io::fs::write_file(path, text); // ERROR: says nothing about failurelet r: Int = std::io::fs::write_file(path, text); // ERROR: the legacy Int statusmatch parse_count(s) { 0 -> { }, _ -> { } } // ERROR: a match does not handle itThe compiler refuses every such call in hale check, hale verify
and hale build alike (for stdlib calls since v0.22.0), naming the
function, what it can fail with, and the four ways to say what should
happen:
std::io::fs::write_file(path, text) or raise; // propagatestd::io::fs::write_file(path, text) or discard; // deliberately ignorelet text = std::io::fs::read_file(path) or ""; // substitutelet n = std::str::parse_int(s) or handler(err); // handleMigrating. Pick the disposition per site: or raise where the
caller can act on the failure, or <fallback> where a default is
right, or handler(err) where several sites share a policy, and
or discard only where ignoring the failure is the intent. A code
that read the legacy Int status (let r: Int = write_file(..))
moves the call into a fallible helper that or raises and takes
the status from its caller’s or. The --strict-fallible flag is
removed; drop it from scripts.
Chaining
Section titled “Chaining”or clauses chain right-to-left — each one disposes of one
failure:
let id = parse_count(primary) or parse_count(fallback) or 0;“Try the primary; if that fails, try the fallback; if that fails, use 0.”
Through an interface
Section titled “Through an interface”An interface method can say it fails, the same way:
type StoreError { kind: String; }
interface Store { fn put(key: String) -> Int fallible(StoreError);}
locus Strict { fn put(key: String) -> Int fallible(StoreError) { if key == "" { fail StoreError { kind: "empty key" }; } return len(key); }}
fn store_or_zero(s: Store, key: String) -> Int { return s.put(key) or 0;}A call through the interface is addressed like any other fallible
call. A locus whose put cannot fail still satisfies Store; one
whose put fails with a different error type, or that fails where
the interface says it cannot, does not — hale check names both
signatures.
Why it works this way
Section titled “Why it works this way”This is the only failure channel you need at the basics level,
and it has a single rule: every fallible call is addressed at
the immediate call site. That means when you read a function
body, every place that can fail is visibly marked with or. No
error propagates invisibly through three stack frames; no try
wraps a whole block in ambiguity.
There’s a second failure channel for a different situation — a
long-running component whose internal invariant breaks, where the
right response is a supervisor’s policy rather than a return
value. That’s the structural channel, and it belongs to the
services tier (When things fail). For
everything you’ll write at this level, fallible + or is the
whole story.
When the handler can fail too
Section titled “When the handler can fail too”A recovery handler is often itself a fallible operation — read a fallback file, query a secondary source. You can write that directly:
fn load(primary: String, backup: String) -> String fallible(IoError) { return std::io::fs::read_file(primary) or (std::io::fs::read_file(backup) or raise);}If the backup read succeeds, its value substitutes. If it also
fails, or raise routes the error out through YOUR function’s
error path — which is why load must itself be fallible with a
compatible error type.
For your own fallible functions the inner or raise is implicit —
db_read(k) or self.rebuild(k) propagates the handler’s failure
automatically. Stdlib calls, generic functions, interface methods and
@form methods used as handlers still need the explicit nested
spelling above (the compiler will tell you, with the exact rewrite, if
you forget).
Next, we put the pieces together: Your first program.