iyi

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 36

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

samples/iyi/app/greeter.iyi30 lines
# 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"
end

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

quoted from README.md, lines 623 to 627
$ 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.

samples/iyi/modules.iyilines 103 to 119
# 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.

quoted from README.md, lines 197 to 199
$ 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

records/break/r1_import_cycle.iyi11 lines
# 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
records/break/dep/loop_a.iyi8 lines
# 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
records/break/dep/loop_b.iyi9 lines
# 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.
R-1A module is the unit of compilation. 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