iyi

Playground

samples/iyi/errors.iyi

Errors are ordinary union members — SPEC.md III.1.

samples/iyi/errors.iyi · iyi-errors.wasm · 134 KB
# Errors are ordinary union members — SPEC.md III.1.
#
#   ./bin/iyi run samples/iyi/errors.iyi
#
# No `Result` wrapper, no exception hierarchy, no new type machinery: unions
# already exist and already carry a type id. What makes a member an *error*
# member is that its type implements `Error`, a trait the compiler owns.

module samples/errors

pub struct IOError
  getter path : String

  def initialize(@path : String)
  end
end

# `Error` is not declared here. It is the compiler's, so that `!` can ask
# whether a member implements it without a name in scope deciding the answer.
impl Error for IOError
  def message : String
    "no such file: #{path}"
  end
end

pub struct ParseError
  getter text : String

  def initialize(@text : String)
  end
end

impl Error for ParseError
  def message : String
    "not a number: #{text}"
  end
end

pub def read(path : String) : String | IOError
  return IOError.new(path) if path == "missing"
  path == "bad" ? "twelve" : "12"
end

pub def to_number(text : String) : Int32 | ParseError
  return ParseError.new(text) unless text == "12"
  12
end

# `!` propagates. If the value is a non-error member the expression evaluates to
# it — narrowed, so `to_number` sees a `String` and not the union. If it is an
# error member it is returned from here, which is why `load` has to admit both
# errors in its own return type. Nothing enforces that separately: it is the
# ordinary return-type check on the return the operator writes.
pub def load(path : String) : Int32 | IOError | ParseError
  text = read(path)!
  to_number(text)!
end

# Handling needs nothing new. An exhaustive `case` already checks totality, so
# adding an error member to `load` turns every incomplete `case` on it into a
# compile error. That is the main ergonomic argument for the whole approach and
# it cost nothing to build.
["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, and it is narrowed per branch: the first sees
# an `Int32`, the other two see one error type each, which is how `.message`
# resolves without anything asking what the union holds. It is an assignment the
# compiler writes into each branch, not new machinery — so `it` outlives the
# `case` the way any variable assigned inside an `if` does, and a nested `case`
# shadows it.

# Where a `case` is overkill, `.or` takes a default and `.or_panic` takes the
# value or dies (III.1.3). These cannot be ordinary methods: by II.1 a real
# method call on `Int32 | IOError` would demand it from *both* members, and a
# method every error type has to supply is what this design avoids. So the
# compiler knows them by name — which is also why `or` and `or_panic` are not
# names an iyi program can use for anything else.
["config", "missing", "bad"].each do |path|
  puts "or:    #{load(path).or(0)}"
end

# `.or` is an `is_a?(::Error)` and nothing more, so the result type falls out
# instead of being computed: the default's type unioned with the non-error
# members. Here that is `Int32`, and the default is only evaluated when there is
# an error to recover from.
puts "or:    #{load("missing").or(-1)}"

# `.or_panic` yields the non-error members alone, because what it calls never
# returns. It reaches `message` on a value narrowed to `IOError | ParseError` —
# two unrelated types — which resolves by II.1: both implement `Error`, so their
# union does. Until panics exist (III.1.4), this unwinds as a Crystal exception.
puts "panic: #{load("config").or_panic}"

# ---------------------------------------------------------------------------
# defer — III.1.4
# ---------------------------------------------------------------------------
#
# Once errors are values returned early, the interesting exit from a body is an
# ordinary `return` in the middle of it, and `begin`/`ensure` makes you wrap
# everything after the acquisition to cover it. `defer` names the cleanup at the
# acquisition instead. It expands to that same `ensure`, so it runs on a normal
# exit, on a `!` propagation, and on an unwind — no new runtime machinery, only
# a different shape.
pub def session(path : String) : Int32 | IOError | ParseError
  puts "  open outer"
  defer puts "  close outer"

  text = read(path)!

  puts "  open inner"
  defer puts "  close inner"

  to_number(text)!
end

# Cleanup is LIFO, and that is the nesting rather than a rule: the second
# `defer` expands inside the first one's body, so its `ensure` is the inner one.
# The failing run proves the part that matters — `read(path)!` returns from the
# middle of the body and the cleanup still happens.
puts "success:"
puts session("config").or(-1)
puts "propagating:"
puts session("missing").or(-1)

# The scope is the block, not the function. This is the departure from Go, and
# it is what the expansion does rather than an extra rule: the cleanup lands
# inside the loop body, so it runs each iteration instead of piling up until the
# function returns — Go's best-known wart with the feature.
2.times do |i|
  defer puts "  iteration #{i} closed"
  puts "  iteration #{i}"
end

# ---------------------------------------------------------------------------
# What this sample would report as an error
# ---------------------------------------------------------------------------
#
# * Dropping the `in ParseError` branch — `case is not exhaustive`.
#
# * `def go : String` with `read(p)!` in it — the error names the return type
#   and the error being propagated into it.
#
# * `!` on something with no error member, such as an `Int32?`. Rejected rather
#   than given a surprising meaning: `Nil` does not implement `Error` (III.1.5),
#   so without the check this would compile and silently do nothing.
#
# * `!` on a value whose every member is an error. It could never produce one,
#   and a function that genuinely never succeeds returns `NoReturn`.
#
# * `def sort!` — `!` is not part of a name (III.1.7). This is the one place
#   that still explains the naming convention, because after a `def` name there
#   is nothing to propagate.
#
# * `.or` and `.or_panic` on the same two operands `!` rejects. With no error
#   member there is nothing to recover; with every member an error, `.or` can
#   only return its default and `.or_panic` can only panic.
#
# * `.or 0` without parentheses — `.or` takes the default in parentheses, so
#   the shape cannot be confused with a call to a method of that name.
#
# * `it` in a `case` over a tuple, such as `case {a, b}` — there is no single
#   value to name, so `it` stays undefined rather than being bound to one of
#   the elements.
#
# * `defer close(f)!` — a `defer` runs while the scope is already being left,
#   possibly carrying an error of its own, so a second one leaving from there
#   is the error-during-error problem. Handle it in the `defer` body.
#
# * `impl Error for String` here — the orphan rule (R-3). `Error` is the
#   compiler's and `String` is the prelude's, so neither belongs to a module
#   this impl could be at home in, and every module that can name both would be
#   free to write its own.
#
# ---------------------------------------------------------------------------
# Not yet implemented
# ---------------------------------------------------------------------------
#
# * Panics (III.1.4). They are meant to be catchable only at task boundaries,
#   and what a task boundary is belongs to Part V.5. Until then `.or_panic`
#   raises a Crystal exception, and `defer` runs on that unwind either way.
output
Press Run.

recorded with iyi 0.18.0 (built on Crystal 1.22.0-dev [2a9653052] (2026-10-18)) · Linux 7.0.0-38-generic x86_64, QEMU Virtual CPU version 2.5+ · 2026-10-19

You can type in the pane: nothing here compiles, so what you type cannot run, and the page says which bytes it is running the moment the text stops being the recorded file. There is no standard input, no filesystem and no threads on this target, and one sample fails on purpose for exactly that reason.

Run executes iyi-errors.wasm, the module the compiler built from this file, in your browser. How this works

Try it on your machine

Copy the program into errors.iyi, change something, and run it. Only a compiler on your own machine can run what you wrote.

sh, after installing
$ iyi run errors.iyi