iyi

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 1972

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

samples/iyi/errors.iyilines 39 to 42
pub def read(path : String) : String | IOError
  return IOError.new(path) if path == "missing"
  path == "bad" ? "twelve" : "12"
end
samples/iyi/errors.iyilines 44 to 47
pub def to_number(text : String) : Int32 | ParseError
  return ParseError.new(text) unless text == "12"
  12
end

read 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#

samples/iyi/errors.iyilines 54 to 57
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#

samples/iyi/errors.iyilines 63 to 69
["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
end

it 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

records/break/errors_non_exhaustive.iyi30 lines
# 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
III.1Errors are ordinary union members. No 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 .or and .or_panic are
SPEC.md
III.1, the section this step teaches, and III.1.2 for what ! expands to