Learn · step 8 of 8
Errors you cannot ignore
A function that can fail returns a union. ! propagates an error member and narrows the rest, an exhaustive case is already total, and adding an error to a signature stops every caller compiling.
What the rule says
III.1 Errors are ordinary union members. No Result wrapper, no exception hierarchy, no new type machinery: unions already exist and already carry a type id.III.1 SPEC.md, lines 1971 to 1972Run it
This step is grounded in samples/iyi/errors.iyi, 186 lines in the repository, read out of the tree when this page is built.
Run it in your browser from the module the compiler built for it.
Count what iyi adds for error handling: no Result wrapper, no exception
hierarchy, no new type machinery. A union already exists and already carries a
type id. An error member is a member whose type implements Error, and that is
the entire mechanism.
So a function that can fail says so in its return type, and it looks like any other function.
pub def read(path : String) : String | IOError
return IOError.new(path) if path == "missing"
path == "bad" ? "twelve" : "12"
endpub def to_number(text : String) : Int32 | ParseError
return ParseError.new(text) unless text == "12"
12
endread answers with a String or an IOError. to_number answers with an
Int32 or a ParseError. Both are ordinary unions and you could have written
them in Crystal.
Error itself is not declared in this module. It belongs to the compiler, so
that ! can ask whether a member implements it without whatever happens to be in
your scope deciding the answer.
Propagating with one character#
pub def load(path : String) : Int32 | IOError | ParseError
text = read(path)!
to_number(text)!
end! does two things and neither is magic. On a non-error member the expression
evaluates to that value, narrowed, so to_number receives a String and not the
union. On an error member it returns from load right there.
Which is why load has to admit both errors in its own return type. Nothing
special enforces that. It is the ordinary return type check, applied to the
return the operator wrote for you.
Now look at what that signature is. load is pub, so step two's rule makes it
carry full parameter and return types, and those types are what land in the
artifact your consumers compile against. Its error members are part of its
published surface, not an implementation detail. Adding one is a change to the
contract, and the next section is what that change does to everybody who calls
it.
Handling, with nothing new to learn#
["config", "missing", "bad"].each do |path|
case load(path)
in Int32 then puts "read #{it}"
in IOError then puts "io: #{it.message}"
in ParseError then puts "parse: #{it.message}"
end
endit is the value being matched, narrowed per branch. The first branch sees an
Int32, the other two see one error type each, which is how .message resolves
without anything having to ask what the union is holding. It is an assignment the
compiler writes into each branch rather than new machinery, so it outlives the
case the way any variable assigned inside an if does, and a nested case
shadows it.
Here is the payoff, and it was free. An exhaustive case already checked totality
before any of this existed. So the moment you add an error member to load, every
incomplete case on it stops compiling.
When a case is too much#
Sometimes you want the value or a default. .or takes the default, .or_panic
takes the value or dies.
Neither can be an ordinary method: a real
method call on Int32 | IOError would demand that method from both members, and
a method every error type has to supply is exactly what this design avoids. So
the compiler knows these two by name, which is also why or and or_panic are
not names an iyi program can use for anything else.
.or is an is_a?(::Error) test and nothing more, so its result type falls out
instead of being computed: the default's type unioned with the non-error members.
.or_panic yields the non-error members alone, because what it calls never
returns.
Limits#
Panics. They are meant to be catchable only at task boundaries, and what a task
boundary is has not been specified. Until it is, .or_panic raises a Crystal
exception, and defer runs on that unwind either way.
Leave a branch out#
That claim about totality is the strongest argument for the whole approach, so it
should be the last thing you see fail. A case over a union with an error member,
and no branch for the error:
Break it
an error member left unhandled
# Breaks the error rule in SPEC.md III.1: an error is an ordinary member of a
# union, so handling one needs no new machinery, only the totality check a
# `case` already performs. `read` can return an `IOError` and this `case` has
# no branch for it, which is the same mistake as ignoring a returned error and
# is caught by the same check.
module diagnostics/errors_non_exhaustive
pub struct IOError
getter path : String
def initialize(@path : String)
end
end
# `Error` is the compiler's trait, which is what lets a union member be an
# error member without any name in scope deciding it.
impl Error for IOError
def message : String
"no such file: #{path}"
end
end
pub def read(path : String) : String | IOError
return IOError.new(path) if path == "missing"
"12"
end
case read("missing")
in String then puts it
end$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/errors_non_exhaustive.iyi Showing last frame. Use --error-trace for full trace. In records/break/errors_non_exhaustive.iyi:28:1 28 | case read("missing") ^ Error: case is not exhaustive. Missing types: - Diagnostics::ErrorsNonExhaustive::IOError
Result wrapper, no exception hierarchy, no new type machinery: unions already exist and already carry a type id.SPEC.md, lines 1971 to 1972. Recorded run, exit code 1, iyi 0.17.0 (built on Crystal 1.22.0-dev [fe3db8354] (2026-10-14)), commit fe3db8354f56. Regenerate with npm run record:diagnostics.What the rule bought
Nothing was built for this. Exhaustiveness was already in the language and unions already carried a type id, so adding an error member to an exported signature turns every incomplete case on it into a compile error, and an error a caller has not handled cannot reach production.
Standing on
samples/iyi/errors.iyi- the two producers, the propagating call, the exhaustive case, and what
.orand.or_panicare SPEC.md- III.1, the section this step teaches, and III.1.2 for what
!expands to