iyi

Learn · step 2 of 8

What a module writes down

If a consumer compiles against declarations, the declarations have to be complete. So an exported name carries full parameter and return types, and everything inside the module goes on inferring.

What the rule says

R-2 Everything a module exports (pub) carries full parameter and return types. Non-exported code infers.R-2 SPEC.md, line 37

Run it

This step is grounded in samples/iyi/calc.iyi, 78 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 one deleted a library and built anyway. The bill for that arrives here, and it is smaller than you would expect: if a consumer only ever reads declarations, the declarations have to say everything a consumer needs. That is two questions, and R-2 answers both. Which names are written down, and what each one has to say.

Which names#

Go back to the library from step one. One of its three definitions is not exported, and the file says what that costs.

samples/iyi/app/greeter.iyilines 17 to 24
# 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

Read greeting again: it is unreachable from another file rather than merely awkward to access, and the sample says why in the comment above it. A .iyimod artifact carries a module's exports and nothing else. So a name without pub has to be genuinely unreachable, or that artifact would not be enough to compile against. Qualifying it does not help. App::Greeter.greeting fails for the same reason import app/greeter::* does not bring it in: it is not there to be found.

Notice what just happened to step one. "Unreachable rather than absent from an import's names" and "not in the artifact" turned out to be the same sentence. Visibility in iyi is the shape of a file the compiler will later read instead of your source.

And what those names say#

The other half of the rule is about the names that are written down. Here is one from a bigger program, the scanner inside the three module calculator.

samples/iyi/calc/lexer.iyilines 28 to 62
pub def scan(source : String) : Array(Token) | BadCharacter
  tokens = [] of Token
  index = 0

  while index < source.size
    c = source[index]

    # A line ends in CRLF on Windows: `echo 2 + 3 * 4| iyi run
    # samples\iyi\calc.iyi` in cmd answered "unexpected character at 9",
    # the `\r`.
    if c == ' ' || c == '\n' || c == '\t' || c == '\r'
      index = index + 1
    elsif digit?(c)
      start = index
      while index < source.size && digit?(source[index])
        index = index + 1
      end
      tokens << Token.new("number", source[start, index - start], start)
    elsif letter?(c)
      start = index
      while index < source.size && (letter?(source[index]) || digit?(source[index]))
        index = index + 1
      end
      tokens << Token.new("name", source[start, index - start], start)
    elsif operator?(c)
      tokens << Token.new("op", source[index, 1], index)
      index = index + 1
    else
      return BadCharacter.new(index, c)
    end
  end

  tokens << Token.new("end", "", index)
  tokens
end

The signature is fully written: one parameter with its type, and a return type that is a union, because scanning can fail and the last step is about what that means. Nothing else in the declaration is left to a reader or to a compiler.

Then look inside the body. tokens, index and start are declared by being assigned, and no local anywhere in that file carries a type. That is the second sentence of the premise doing its work: inference is untouched, and it is untouched everywhere it costs a consumer nothing. Crystal's local inference, flow typing and blocks are all still here, unchanged, because they never crossed a module boundary in the first place.

So the writing is asked for exactly at the edge. That is also where a person would want it: an exported signature is the documentation a consumer reads, and iyi mod dump --declarations prints the module as its consumers see it, which is the same list of signatures with the bodies gone.

Break it three ways#

An exported def with no return type looks like an oversight the compiler could just fix, since the body is right there. It cannot: the body stays in this module, so the consumer never sees it. The compiler says which symbol it would end up asking the linker for.

Then the other two ways to get this rule wrong, recorded the same way. Reach from another module for a name that carries no pub, which is the first half of the rule refusing to be worked around. And write a body that does not honor the return type above it, which is the second half: a written type is a promise to everyone compiling against the artifact, so the body is the thing that has to give.


Break it

an exported def that does not say what it returns

records/break/r2_missing_return_type.iyi11 lines
# Breaks R-2: everything a module exports carries full parameter and return
# types, because the signature is the whole of what a consumer reads. Drop the
# return type and there is nothing to write into the artifact, so the omission
# is refused where it is written rather than inferred from the body.
module diagnostics/r2_missing_return_type

pub def total(numbers : Array(Int32))
  numbers.sum
end

puts total([1, 2, 3])
$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r2_missing_return_type.iyi
Showing last frame. Use --error-trace for full trace.

In records/break/r2_missing_return_type.iyi:7:9

 7 | pub def total(numbers : Array(Int32))
             ^----
Error: `total` is exported and does not say what it returns

R-2 asks an exported signature for full types. A consumer types a call to this from the return type alone, since the body stays in this module: without one it infers `Nil`, and the symbol it then asks the linker for is not the symbol this module emitted.

Annotate the return type, or leave the def unexported.
R-2Everything a module exports (pub) carries full parameter and return types. Non-exported code infers.SPEC.md, line 37. 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 name the module never exported

records/break/r2_not_exported.iyi11 lines
# Breaks R-2: only what a module marks `pub` is reachable from outside it, and
# `greeting` in `dep/greeter` carries no `pub`. Qualifying the call does not get
# around that. It has to be genuinely unreachable rather than merely left out
# of an import's names, because a `.iyimod` carries a module's exports and nothing
# else, so if another module could call an unmarked name that artifact would
# not be enough to compile against.
module diagnostics/r2_not_exported

import dep/greeter

puts Dep::Greeter.greeting
$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r2_not_exported.iyi
Showing last frame. Use --error-trace for full trace.

In records/break/r2_not_exported.iyi:11:19

 11 | puts Dep::Greeter.greeting
                        ^-------
Error: Dep::Greeter does not export 'greeting'. Only what a module marks `pub` is reachable from outside it — see SPEC.md R-2
R-2Everything a module exports (pub) carries full parameter and return types. Non-exported code infers.SPEC.md, line 37. 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 body that does not honour its written return type

records/break/r2_wrong_return_type.iyi10 lines
# Breaks R-2 the other way: the type is written, and the body does not honour
# it. An exported signature is a promise to every consumer that reads the
# artifact instead of the body, so the body is the thing that has to give.
module diagnostics/r2_wrong_return_type

pub def total(numbers : Array(Int32)) : Int32
  numbers.join(",")
end

puts total([1, 2, 3])
$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r2_wrong_return_type.iyi
Showing last frame. Use --error-trace for full trace.

In records/break/r2_wrong_return_type.iyi:6:41

 6 | pub def total(numbers : Array(Int32)) : Int32
                                             ^----
Error: method Diagnostics::R2WrongReturnType.total must return Int32 but it is returning String

String is not in the declared type
R-2Everything a module exports (pub) carries full parameter and return types. Non-exported code infers.SPEC.md, line 37. 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

The artifact is enough on its own: a consumer types a call from the signature, so a module can be compiled against with its source deleted, which is what step one spent. The writing is asked for at the boundary, where it is documentation anyway, and nowhere else, so a three module program like samples/iyi/calc.iyi annotates what it exports and lets every local inside a body infer.

Standing on

samples/iyi/app/greeter.iyi
the unexported def, and the comment saying why it has to be unreachable rather than merely awkward
samples/iyi/calc/lexer.iyi
an exported signature with a union return type, and a body that annotates nothing
samples/iyi/modules.iyi
what mod dump --declarations prints for a module, written into the sample's own comments
SPEC.md
IV.2, what goes into a module's exports and what is deliberately kept out