Skip to content

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

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.

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)
  • or raise — pass the error up to your caller. For that your function is itself fallible(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, err is 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 sees err bound 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 rejects or discard on a value-bearing call and suggests or <fallback> instead.

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;
}

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.

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

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.

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.

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.