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 failure

You 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 upward
let b = parse_count(s) or 0; // substitute a value
let c = parse_count(s) or handle(err); // hand off to a helper
let d = parse_count(s) or fail OtherErr { }; // translate the error
some_unit_call() or discard; // ignore (unit result only)

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 failure
let r: Int = std::io::fs::write_file(path, text); // ERROR: the legacy Int status
match parse_count(s) { 0 -> { }, _ -> { } } // ERROR: a match does not handle it

The 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; // propagate
std::io::fs::write_file(path, text) or discard; // deliberately ignore
let text = std::io::fs::read_file(path) or ""; // substitute
let n = std::str::parse_int(s) or handler(err); // handle

Migrating. 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.