iyi

Learn · step 4 of 8

Traits, and the end of open classes

A trait names requirements and an impl satisfies them. The orphan rule decides where that impl is allowed to live, and forall binds the parameters an impl or a method quantifies over.

What the rule says

R-3 Open classes are gone. impl Trait for Type must live in the module defining the trait or the type.R-3 SPEC.md, line 40

Run it

This step is grounded in samples/iyi/hello.iyi, 149 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.

Step two asked what a declaration has to say. This step is about what a declaration has to be able to promise.

An artifact carrying a module's declarations only works if a type's method set is knowable from the module that declared it. In Crystal any file can reopen any class. iyi closes classes and uses traits and impls instead.

A trait names requirements. abstract def marks one an implementer has to provide.

samples/iyi/hello.iyilines 17 to 19
pub trait Greet
  abstract def greet : String
end

A type gets that trait by having an explicit impl rather than by inheriting, including, or happening to have a method with the right name.

samples/iyi/hello.iyilines 46 to 50
impl Greet for User
  def greet : String
    "Hello, #{name}!"
  end
end
samples/iyi/hello.iyilines 60 to 64
impl Greet for Robot
  def greet : String
    "BEEP #{id}"
  end
end

Two types, one trait, and they now dispatch through it together. It runs the other way too: the same sample writes impl Loud for User, and that body calls the greet the first impl provided. A type can implement as many traits as it likes, and a trait can be implemented by as many types as like it.

Where an impl is allowed to live#

R-3 is one sentence: an impl Trait for Type goes in the module that defines the trait or the module that defines the type. Nowhere else.

In hello.iyi both are in the same file, so the rule costs nothing and you would not notice it. samples/iyi/modules.iyi is where you notice. Greet comes from app/greeter and User is declared locally, so exactly two modules could host that impl and you have to pick one. A third module that imports both is refused, and that refusal is the point: it is what makes two modules unable to write the same impl and discover it at link time.

Coherence checked locally, with no global pass. That is the whole trade, and it is what an artifact needs.

Two familiar shortcuts are closed. include Greet inside a struct does not work, because a type acquires a trait by having an impl whose location R-3 can check, and include has no such rule. An import's names do not reach a trait's methods either: a trait method resolves from the receiver rather than from your scope, so no import line can bring one in or shadow it.

There is a nice side effect. Leave a requirement unmet and the error lands where you wrote the impl, not somewhere downstream where a caller trips over it. The module that declares the impl is the module that has to be right.

Binding parameters with forall#

An impl over a generic type has to say which names are parameters.

samples/iyi/generics.iyilines 22 to 26
impl Show for Box(T) forall T
  def show : String
    "Box(#{value})"
  end
end

Drop the forall T and the meaning of the impl would depend on your imports: T might be a fresh parameter, or it might be some type that happens to be in scope, and a library could flip which one by adding an export. Rust asks for the same binder for the same reason.

The names belong to the impl, and they bind positionally. Pair(A, B) declares its parameters, and an impl of it may call them X and Y, because an impl states arity rather than vocabulary. Crystal makes a reopened generic repeat the declared names, which leaks one declaration's naming into every impl of it.

The same binder shows up on a method, where it can carry a bound.

samples/iyi/hello.iyilines 105 to 107
pub def announce(item : T) : String forall T : Greet
  "-> #{item.greet}"
end

announce exists for every T. The bound is checked where T binds, so calling it with a type that has no Greet impl is rejected at that call, and the sample writes out the message you would get.

This is the shape the Kemal port needed. A handler's return type is a type parameter bounded by a trait, so the framework's promise, give me something I can turn into a body, gets checked on the line where the mistake is, by a module that has never heard of your types. README.md is unkind and accurate about the comparison: Kemal accepts the same block and serves an empty body, on every request, forever.

Limits#

A bound on an impl is a different mechanism and it is not implemented. impl Show for Box(T) forall T : Show parses and is rejected. A bound there makes the impl conditional, so Box(Int32) would implement Show only if Int32 did, which has to be checked where the type is instantiated rather than where the impl is written. The bound on announce above works precisely because nothing about it is conditional.

Two more are refused on purpose rather than for want of effort. A specialized impl, impl Show for Box(Int32), needs a rule for which impl wins that stays sound when the two live in separately compiled modules. A blanket impl, impl Show for T forall T : Show, is refused for the same reason open classes are: it lets a distant module add methods to every type in the program.

Put the impl in the wrong module#

Back to the location rule, because it is the one you will hit first and the refusal is the most useful thing in this step. Here are three modules: one owns the trait, one owns the type, and the third owns neither and writes the impl anyway. Watch which two the compiler offers you.

Then the same rule from the calling side. A type's method set is closed, so a bound on a type parameter is a question with a definite answer at the call: Int32 has no impl Greet and cannot acquire one later, so the call is refused as a missing trait rather than as a failed overload match.


Break it

an impl in a module that owns neither the trait nor the type

records/break/r3_orphan_impl.iyi17 lines
# Breaks R-3: `impl Trait for Type` may live in the module that defines the
# trait or the module that defines the type, and nowhere else. `Greet` belongs
# to `dep/greeter` and `User` belongs to `dep/user`, so this module owns
# neither and is the one place the impl cannot be written. That restriction is
# what makes a type's method set knowable from its declarations alone.
module diagnostics/r3_orphan_impl

import dep/greeter::*
import dep/user::*

impl Greet for User
  def greet : String
    "Hello, #{name}."
  end
end

puts User.new("iyi").greet
records/break/dep/greeter.iyi19 lines
# A module the break programs import. Its surface is deliberately narrow: one
# exported function, one exported trait, and one function left unexported so a
# consumer has something it is not allowed to reach.
module dep/greeter

pub trait Greet
  abstract def greet : String
end

pub def polite(name : String) : String
  "#{greeting}, #{name}."
end

# No `pub`, so this is the module's own business. It is not in the module's
# declarations, which is what makes reaching for it from another module an
# error rather than a style question (SPEC.md R-1, IV.2).
def greeting : String
  "Hello"
end
records/break/dep/user.iyi10 lines
# A second module the break programs import, so that a trait and a type can
# live in two different places and an impl can be written in a third.
module dep/user

pub struct User
  getter name : String

  def initialize(@name : String)
  end
end
$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r3_orphan_impl.iyi
Showing last frame. Use --error-trace for full trace.

In records/break/r3_orphan_impl.iyi:11:6

 11 | impl Greet for User
           ^
Error: can't implement Dep::Greeter::Greet for Dep::User::User in Diagnostics::R3OrphanImpl: an impl must live in the module that defines the trait (Dep::Greeter) or the module that defines the type (Dep::User). This is R-3, the orphan rule, and it is what lets coherence be checked without a global pass — see SPEC.md IV.4
R-3Open classes are gone. impl Trait for Type must live in the module defining the trait or the type.SPEC.md, line 40. 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.

Break it

a type that does not implement a required trait

records/break/r3_unmet_trait_bound.iyi29 lines
# Breaks R-3 from the calling side: a type's method set is closed, so a bound
# on a type parameter is a question with a definite answer at the call. `Int32`
# has no `impl Greet` and cannot acquire one later, so the call is refused as a
# missing trait rather than as a failed overload match.
module diagnostics/r3_unmet_trait_bound

pub trait Greet
  abstract def greet : String
end

pub struct User
  getter name : String

  def initialize(@name : String)
  end
end

impl Greet for User
  def greet : String
    "Hello, #{name}."
  end
end

pub def announce(item : T) : String forall T : Greet
  "-> #{item.greet}"
end

puts announce(User.new("iyi"))
puts announce(42)
$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r3_unmet_trait_bound.iyi
Showing last frame. Use --error-trace for full trace.

In records/break/r3_unmet_trait_bound.iyi:29:6

 29 | puts announce(42)
           ^-------
Error: Int32 does not implement Diagnostics::R3UnmetTraitBound::Greet, required by `T` in `announce`
R-3Open classes are gone. impl Trait for Type must live in the module defining the trait or the type.SPEC.md, line 40. 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

A type's method set is knowable from the module that declared it, so coherence is checked locally with no global pass and two modules cannot write the same impl and find out at link time. A missed requirement also lands where you wrote the impl rather than downstream at whoever called it.

Standing on

samples/iyi/hello.iyi
the trait, three impls, the orphan rule note, the rejected alternatives, and the bound on a method
samples/iyi/generics.iyi
forall on an impl, positional parameter names, and the impls iyi refuses
samples/iyi/modules.iyi
the cross module case, where the choice of host module costs something
README.md
what the Kemal port can say that Kemal cannot