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 40Run 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.
pub trait Greet
abstract def greet : String
endA type gets that trait by having an explicit impl rather than by inheriting, including, or happening to have a method with the right name.
impl Greet for User
def greet : String
"Hello, #{name}!"
end
endimpl Greet for Robot
def greet : String
"BEEP #{id}"
end
endTwo 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.
impl Show for Box(T) forall T
def show : String
"Box(#{value})"
end
endDrop 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.
pub def announce(item : T) : String forall T : Greet
"-> #{item.greet}"
endannounce 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
# 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# 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# 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
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
# 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`
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