Learn · step 1 of 8
Delete the library, build again
A module is the unit of compilation, and a consumer compiles against what its imports declare rather than against what they do. Build once keeping each module's artifact, delete the library's source, and build again.
What the rule says
R-1 A module is the unit of compilation. import forms a DAG. Compiling a module reads only its dependencies' export metadata, never their bodies.R-1 SPEC.md, line 36Run it
This step is grounded in samples/iyi/modules.iyi, 124 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.
This is the step the language exists for. Everything else in iyi, the pub
signatures, the traits, the orphan rule, is here to make it hold.
Two files are enough to show it, so start with the one being imported. Every iyi
file opens by naming its compilation unit, and that name is absolute. It tells
you where the file sits: a module called app/greeter lives at
app/greeter.iyi, resolved from the project root and never relative to whoever
imports it. No manifest, no include path, no search order.
# A module other files can import. See `../modules.iyi`.
#
# R-1: the module path is the compilation unit's name, and it is absolute —
# resolved from the project root, never relative to whoever imports it. That
# is what makes `app/greeter` mean the same module in every file that writes
# it. The file lives at `app/greeter.iyi` because the path says so.
module app/greeter
pub trait Greet
abstract def greet : String
end
pub def polite(name : String) : String
"#{greeting}, #{name}."
end
# R-2: not exported, so this is the module's own business. `modules.iyi` can
# neither `import app/greeter::{greeting}` nor reach it by any other route —
# `.iyimod` carries a module's exports and nothing else, so a name left
# unmarked has to be unreachable or that metadata would not be enough to
# compile against (SPEC.md IV.2).
def greeting : String
"Hello"
end
# Also exported, and deliberately named the same as something in
# `app/formal` — see how `modules.iyi` has to deal with that.
pub def title : String
"friend"
endpolite and title carry pub and greeting does not, which is the next
step's subject. What matters here is the other half of that file: the signatures,
and not the bodies underneath them.
Now build a consumer of it once, keeping each module's artifact. Then delete the library and build again.
$ iyi build --emit-iyimod mods -o hello main.iyi $ rm -r app # the library, gone $ iyi build --use-iyimod mods -o hello main.iyi $ ./hello Hello, world.
Look at the rm -r app line, then at what the program printed. The library's
source is gone. The build works anyway, and the binary it produced runs.
Nothing was cached. The second build never opens polite's body, because there
is no body on the disk to open. It reads mods/app/greeter.iyimod, which carries
the declarations it type checks against and the machine code it links. README.md
puts it in one sentence in the paragraph under that block: a module compiles
against what its imports say, not against what they do.
That is R-1, and this is what it buys. Everything after this step is the bill.
Try it on the samples#
The repository does not ask you to take this on faith, and the sample carries the procedure in its own comments.
# Compiling this file without `app/greeter.iyi`
# ---------------------------------------------------------------------------
#
# R-1 says a module that imports another reads its `.iyimod` and never opens
# its source. That is not a claim about a future release:
#
# ./bin/crystal build --no-codegen --emit-iyimod /tmp/mods samples/iyi/modules.iyi
# ./bin/crystal mod dump --declarations /tmp/mods/app/greeter.iyimod
# mv samples/iyi/app /tmp/app-elsewhere
# ./bin/crystal build --no-codegen --use-iyimod /tmp/mods samples/iyi/modules.iyi # compiles
# mv /tmp/app-elsewhere samples/iyi/app
#
# The second command prints what the third one compiles against: `app/greeter`
# with `pub def polite(name : String) : String`, `pub trait Greet` with its
# requirement, and no bodies at all. `greeting` is not in it — it carries no
# `pub`, and R-2b's "unreachable rather than absent from an import's names"
# is the same statement as "not in the artifact".The middle command is the one to run first. mod dump --declarations prints
exactly what the artifact only build gets to see: app/greeter with
pub def polite(name : String) : String, pub trait Greet with its requirement,
and no bodies at all. greeting is missing from it, because it never carried
pub.
The same move on a framework#
Two files is a demonstration. A web framework is a test.
$ iyi build --emit-iyimod mods samples/iyi/webapp.iyi # writes kemal/router.iyimod, ... $ rm -rf samples/iyi/kemal # the library's source, gone $ iyi build --use-iyimod mods samples/iyi/webapp.iyi # builds, links, runs, same output
samples/iyi/webapp.iyi is a port of Kemal onto iyi's rules, and its router
lives in samples/iyi/kemal. That whole directory goes away between the two
builds, and the program still builds, links, runs, and prints the same output.
The roundtrip gate#
bench/samples_roundtrip.sh is where the claim stops being prose. For every
sample that imports a module it builds with --emit-iyimod, runs the binary, and
keeps what it printed. Then it deletes app, std, boot and kemal, so a
build that wants a module's source has nothing to fall back on. Then it builds
each sample again with --use-iyimod, runs that second binary, and diffs the two
outputs. Any sample that fails to build either way, or prints something
different, exits the script non zero. README.md says CI runs it on every push.
Look at what the diff compares: that both programs ran, and printed the same thing. Merely checking that both builds succeeded would only prove the front end swallowed the artifacts. The script's own header is blunt about why the whole test: the only way to be sure a consumer never reads a dependency's source is to take the source away and see whether the build still produces a program that behaves.
What that boundary saves in seconds belongs to a machine rather than to a language, so this page will not quote you a number. README.md publishes them with the machine attached and the command that measures them.
Acyclic imports#
The rule that import forms a DAG is enforced long before any of that, and it
has to be: a module initializes after every module it imports, so two modules
that wait for each other have no order to be initialized in and there is no
build to have.
Write one anyway and the compiler declines, draws the cycle it found, and names the move that fixes it.
Break it
two modules that import each other
# Breaks R-1: a module is the unit of compilation and `import` forms a DAG, so
# the graph has to be acyclic for anything else to hold. A cycle has no
# initialisation order, since a module initialises after every module it
# imports and here each would have to follow the other, and the acyclicity is
# also what makes at most one module able to define any given impl. `dep/loop_a`
# and `dep/loop_b` import each other.
module diagnostics/r1_import_cycle
import dep/loop_a::*
puts a# Half of a deliberate import cycle. See `../r1_import_cycle.iyi`.
module dep/loop_a
import dep/loop_b
pub def a : String
"a"
end# The other half of the cycle. `dep/loop_a` imports this module and this
# module imports `dep/loop_a`, which is the edge that closes it.
module dep/loop_b
import dep/loop_a
pub def b : String
"b"
end$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r1_import_cycle.iyi Showing last frame. Use --error-trace for full trace. In records/break/dep/loop_b.iyi:5:1 5 | import dep/loop_a ^ Error: import cycle dep/loop_a -> dep/loop_b -> dep/loop_a `import` forms a DAG (R-1). A cycle has no initialisation order — a module initialises after every module it imports (SPEC.md III.5 rule 1), and here each would have to go after the other — and it is what makes at most one module able to define any given impl (SPEC.md IV.4). Move what the modules in the cycle share into a module they both import.
import forms a DAG. Compiling a module reads only its dependencies' export metadata, never their bodies.SPEC.md, line 36. 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 change inside a module cannot reach past that module's exported signatures, so what has to be rebuilt is bounded by the import graph instead of by the size of your program. Whole program compilers cannot offer that boundary, and Crystal is a whole program compiler. bench/samples_roundtrip.sh deletes app, std, boot and kemal and rebuilds every sample from artifacts on every push, so the boundary is a gate rather than a claim.
Standing on
README.md- the two console sequences, quoted with their lines on each block, and the paragraph under the first one
samples/iyi/app/greeter.iyi- the library the two builds import, shown whole
samples/iyi/modules.iyi- the same procedure written into the sample's own comments, shown here
bench/samples_roundtrip.sh- the check, what it deletes, and what it compares