21 Modules — hidden by default
What to know first
Looking back
In chapter 16′s last case study, what did you need to look at to find out whether an imported library reaches the network?
A. You look through the heads of the ops the library exports for any that take cap net (and cap c or cap machine). This chapter covers what “export” means, and how to bring in names from other files.
The need for this chapter, and its context
By the end of this chapter
export are visible outside. You will pick up how to import another file with use … from "place", how to pass several files as one compilation unit, and how to import standard-library names. You will also see that qualifying with the module name does not reach hidden names, the design decision to have no search path, and that the order of top-level declarations does not matter.The questions this chapter answers
- Where does
use geom ., with nofrom "…", look?
21.1 One file, one module#
One source file is one module. A module writes its name on the first line, and the module name need not match the file name.
examples/ch21/geom.low
module geom .
export struct point do
x i64 .
y i64 .
end
export fn manhattan input a point . input b point . output i64 .
requires ge (field a x) -1000000 .
requires le (field a x) 1000000 .
requires ge (field b x) -1000000 .
requires le (field b x) 1000000 .
requires ge (field a y) -1000000 .
requires le (field a y) 1000000 .
requires ge (field b y) -1000000 .
requires le (field b y) 1000000 .
do
return add (abs (sub (field b x) (field a x))) (abs (sub (field b y) (field a y))) .
end
fn helper input v i64 . output i64 .
do
return v .
end
Output
$ lowentc --check geom.low
== check: ok ==
The geom module exports the point struct and the manhattan op with export. helper has no export, so it can be used only inside this module.
module geom (geom.low) module app
┌────────────────────────────────────┐
│ export struct point ─────────┼──▶ door ──▶ geom.point ✔ visible
│ export fn manhattan ─────────┼──▶ door ──▶ geom.manhattan ✔ visible
│ fn helper (no export) │ geom.helper ✘ E-VISIBILITY
└────────────────────────────────────┘
what is inside the wall leaves only through a door made by exportHidden is the default. What is visible from outside is a promise, and hiding something by mistake is easier to fix than promising something by mistake. When emitting native code, too, only exported ops become symbols callable from C; everything else is static.
21.2 Importing#
use <module> from "<place>" . imports a module from the file at that place. The place is a path relative to the file that declares it.
examples/ch21/app.low
module app .
rem run: distance 3 4
use geom from "geom.low" .
fn distance input dx i64 . input dy i64 . output i64 .
requires ge dx -1000 .
requires le dx 1000 .
requires ge dy -1000 .
requires le dy 1000 .
do
let a geom.point be make geom.point do x 0 . y 0 . end .
let b geom.point be make geom.point do x dx . y dy . end .
return geom.manhattan a b .
end
Output
$ lowentc --run distance app.low 3 4
distance(3, 4) = 7
Imported names are called qualified by the module name — geom.point, geom.manhattan. The attached dot is not an operation on a value but a path to a declared name (chapter 3).
Qualification does not reach hidden names.
examples/ch21/hidden.low
module hidden .
rem expect: E-VISIBILITY
use geom from "geom.low" .
fn peek input v i64 . output i64 .
do
return geom.helper v .
end
Output
$ lowentc --check hidden.low
hidden.low:8:0 E-VISIBILITY: this name belongs to ANOTHER module and is not `export`ed — qualifying it does not open the door. A module that cannot keep anything private is not a module, it is a prefix. Mark it `export`, or stop reaching in (RFC-0011 §6.2)
In the words of the diagnostic, “a module that cannot keep anything private is not a module, it is a prefix”.
Q. Where does use geom ., with no from "…", look?
A. Inside the same compilation unit. Passing several files, as in lowentc --check app.low geom.low, makes those files one unit. If the module is not in the unit, the compiler says with W-USE-EXTERNAL that it cannot confirm the module exists, and rejects actual uses of the name. Standard-library names (use allocs .) resolve from where the standard modules are installed.
21.3 What can be exported, and how to shorten a name#
export goes on most named declarations. Some do not take it.
| Declaration | export | Why |
|---|---|---|
fn · proc | yes | ops other modules call |
struct · enum · type · newtype | yes | the input and output types of an exported op must be exported too, or it cannot be used |
trait · actor | yes | a promise types in other modules satisfy · an actor other modules spawn |
top-level let (constant) | no — E-TOPLEVEL | constants stay inside the module; to share one, export a fn that returns the value |
test | no — E-TOPLEVEL | tests belong to whoever builds that module |
Table 21.1 — Declarations that take export
When a module name is long, or two modules share a name, shorten it with as.
examples/ch21/alias.low
module alias .
rem run: distance 3 4
rem call the imported module by the short name g
use geom from "geom.low" as g .
fn distance input dx i64 . input dy i64 . output i64 .
requires ge dx -1000 .
requires le dx 1000 .
requires ge dy -1000 .
requires le dy 1000 .
do
let a g.point be make g.point do x 0 . y 0 . end .
let b g.point be make g.point do x dx . y dy . end .
return g.manhattan a b .
end
Output
$ lowentc --run distance alias.low 3 4
distance(3, 4) = 7
After use geom from "geom.low" as g . you call g.point and g.manhattan. The alias is a name used only inside this file; the module’s real name (geom) does not change. Inside this file, though, the original name no longer stands — writing geom.point is E-USE-ALIASED. An alias is a rename, not a second name: if a module could be called by two spellings, a reader would have to check that they mean the same thing. Importing the same name twice gives E-NAME-COLLISION, and the remedy that diagnostic suggests is this alias.
Counter-example. Writing an alias and then using the original name
examples/ch21/mistake_aliasold.low
module mistake_aliasold .
rem expect: E-USE-ALIASED
use geom from "geom.low" as g .
fn origin_x output i64 .
do
rem ✘ an alias was written, yet the original name is used --- the same module would be called by two spellings
let p geom.point be make geom.point do x 7 . y 0 . end .
return field p x .
end
Output
$ lowentc --check mistake_aliasold.low
mistake_aliasold.low:9:9 E-USE-ALIASED: this unit renamed that module with `as`, so its name here is `g` — the original name no longer stands. One thing, one spelling: if both names worked, a reader would have to check that they mean the same module. Write the member under `g.`
mistake_aliasold.low:9:28 E-USE-ALIASED: this unit renamed that module with `as`, so its name here is `g` — the original name no longer stands. One thing, one spelling: if both names worked, a reader would have to check that they mean the same module. Write the member under `g.`
The moment you write as g, the module’s name in this file is g and nothing else. geom.point is refused with E-USE-ALIASED. If both spellings stood, code would get changed on one side only, and a reader would have to keep checking that the two are the same module. The one place the original name belongs is the use line.
21.4 There is no search path#
Many languages fetch a module from somewhere on a search path when you write just its name. Then what the program depends on becomes knowledge outside the source, and the same source can bring in different files on different machines. Importing in Lowent is one of three things.
- Write the place:
use geom from "geom.low" . - Pass it in the same compilation unit:
lowentc --check app.low geom.low - Use a reserved standard-library name:
use allocs .
The principle is the same when a package manifest (pkg.low) lists external dependencies. Dependencies are pinned with their content hash, and if the bytes change the build is refused (chapter 31).
A common misconception. A module’s name comes from its file name
use looks for is the module declaration inside the file. The module name of lib/alloc.low is allocs, lib/str.low is strings, and lib/vec.low is vecs. The list of modules whose names differ is the first table of the standard-library part (chapter 32).21.5 When names collide#
A module cannot declare the same name twice.
examples/ch21/dup.low
module dup .
rem expect: E-NAME-DUP
fn answer output u32 . do return 1 . end
fn answer output u32 . do return 2 . end
Output
$ lowentc --check dup.low
dup.low:5:4 E-NAME-DUP: `answer` (op) is declared twice in module `dup` — a qualifier cannot tell the two apart (both are `<module>.<name>`), so the second does not hide the first: it silently makes one of them unreachable
The second does not hide the first; rather, one of the two silently becomes unreachable, so it is rejected. When imported names collide, the call site names the module explicitly. Importing never overwrites a name that already exists (E-NAME-COLLISION).
21.6 Top-level order does not matter#
Top-level declarations in a module are independent of order. An op declared later can be called earlier, and ops can call each other.
examples/ch21/order.low
module order .
rem run: is_even 10
rem run: is_even 7
fn is_even input n u32 . output bool .
requires le n 1000 .
do
if eq n 0 . do return true . end
return is_odd (sub n 1) .
end
fn is_odd input n u32 . output bool .
requires le n 1000 .
do
if eq n 0 . do return false . end
return is_even (sub n 1) .
end
Output
$ lowentc --run is_even order.low 10
is_even(10) = 1
$ lowentc --run is_even order.low 7
is_even(7) = 0
is_even calls is_odd, declared after it, and is_odd calls is_even back. Local names inside an op body, on the other hand, must be declared before use. A file as a whole is skimmed, but a body is read top to bottom.
21.7 Common mistakes#
Counter-example. Using an imported name without its module name
examples/ch21/mistake_bare.low
module mistake_bare .
rem expect: E-VISIBILITY
use geom from "geom.low" .
fn origin_x output i64 .
do
rem ✘ an imported name is used without its module name
let a point be make point do x 0 . y 0 . end .
return field a x .
end
Output
$ lowentc --check mistake_bare.low
mistake_bare.low:9:0 E-VISIBILITY: this name is another module's export, reached BARE — a cross-module name must be QUALIFIED: write `<module>.<name>` (RFC-0011 qualified-by-default; there is no glob import)
mistake_bare.low:9:0 E-VISIBILITY: this name is another module's export, reached BARE — a cross-module name must be QUALIFIED: write `<module>.<name>` (RFC-0011 qualified-by-default; there is no glob import)
Some languages let you use imported names bare, or spill them all with import *. Then you cannot tell by reading whether point belongs to this file or to some module, and names collide quietly as imports grow. Lowent has no spilling: a name from another module is always qualified, as in geom.point. The diagnostic appears twice on the same line because the type position of let and the make position are counted separately.
Counter-example. Leaving the extension out of the from place
examples/ch21/mistake_noext.low
module mistake_noext .
rem expect: E-DEP-MISSING
rem ✘ the place is the exact file name --- include `.low`
use geom from "geom" .
fn same input v i64 . output i64 .
do
return v .
end
Output
$ lowentc --check mistake_noext.low
E-DEP-MISSING: `use geom from "geom"` — cannot read that source. A dependency the tool cannot see is a dependency nobody checked
The place is never guessed. Write "geom" and the tool looks for a file named exactly geom; if there is none, it is E-DEP-MISSING. The reason is the same as for having no search path: once the tool starts trying .low or searching other directories, what the program depends on moves outside the source again. Write the file name exactly, as in use geom from "geom.low" ..
Counter-example. Importing a standard module by its file name
examples/ch21/mistake_filename.low
module mistake_filename .
rem expect: E-IR-UNDEF
rem ✘ the file is `lib/str.low`, but the module inside it is named `strings`
use str .
fn same input a slice u8 . input b slice u8 . output bool .
do
return str.eq_str a b .
end
Output
$ lowentc --check mistake_filename.low
mistake_filename.low:5:0 W-USE-EXTERNAL: this module is not in the compilation unit — there is no module search path, so nothing here can confirm it exists. Pass the file that declares it and it WILL be checked (several .low files link into one unit)
mistake_filename.low:9:0 E-IR-UNDEF: undefined name `str.eq_str` — `str` is not a module used by this unit (`use str .`) nor an enum
The module in the file lib/str.low is named strings. use str . finds no such module, so it warns with W-USE-EXTERNAL (“cannot confirm it exists”), and the place that actually uses the name is rejected with E-IR-UNDEF. Read the warning first and check the module name.
examples/ch21/filename_fixed.low
module filename_fixed .
rem run: same [104,105] [104,105]
rem run: same [104,105] [104,111]
use strings .
fn same input a slice u8 . input b slice u8 . output bool .
do
return strings.eq_str a b .
end
Output
$ lowentc --run same filename_fixed.low [104,105] [104,105]
same([104,105], [104,105]) = 1
arg0 (written) = [104,105]
arg1 (written) = [104,105]
$ lowentc --run same filename_fixed.low [104,105] [104,111]
same([104,105], [104,111]) = 0
arg0 (written) = [104,105]
arg1 (written) = [104,111]
Counter-example. Using a hidden type in an exported op’s signature
examples/ch21/secretbox.low
module secretbox .
rem expect: W-EXPORT-HIDDEN
rem a type without `export` --- its name cannot be written outside this module
struct secret do
v u64 .
end
rem ✘ an exported op returns the hidden type --- the importing side cannot write that name
export fn make_secret output secret .
do
return make secret do v 1 . end .
end
Output
$ lowentc --check secretbox.low
secretbox.low:10:0 W-EXPORT-HIDDEN: this op is exported and its signature names a type this module keeps to itself. An importing module cannot write that type, so it has nowhere to put the value and the export cannot be used from outside (§6.10.1). Export the type too, or give the op a signature made of types the other side can name
secretbox itself translates, but the exporting side is warned with W-EXPORT-HIDDEN: this export cannot be used from outside. Without that warning the problem only shows up in the module that imports it.
examples/ch21/mistake_privtype.low
module mistake_privtype .
rem expect: E-VISIBILITY
use secretbox from "secretbox.low" .
fn peek output u64 .
do
rem ✘ the type name needed to hold the result is hidden
let s secretbox.secret be secretbox.make_secret .
return field s v .
end
Output
$ lowentc --check mistake_privtype.low
mistake_privtype.low:9:0 E-VISIBILITY: this name belongs to ANOTHER module and is not `export`ed — qualifying it does not open the door. A module that cannot keep anything private is not a module, it is a prefix. Mark it `export`, or stop reaching in (RFC-0011 §6.2)
secretbox.low:10:0 W-EXPORT-HIDDEN: this op is exported and its signature names a type this module keeps to itself. An importing module cannot write that type, so it has nowhere to put the value and the export cannot be used from outside (§6.10.1). Export the type too, or give the op a signature made of types the other side can name
make_secret is exported, but the type of its result, secret, is hidden. The importer cannot write a name to hold the result and is rejected with E-VISIBILITY. The export is unusable. Export the input and output types of an exported op as well.
Counter-example. Writing another module’s enum variant with the module name in a case
examples/ch21/sizes.low
module sizes .
rem an enum for other modules to use; its variants carry no value
export enum kind do
small .
big .
end
export fn classify input n u64 . output kind . do
if lt n 100 . do
return small .
end
return big .
end
Output
$ lowentc --check sizes.low
== check: ok ==
The sizes module exports the enum kind and classify, which returns one. If the importing side qualifies the variants with the module name, this happens.
examples/ch21/mistake_enumcase.low
module mistake_enumcase .
rem run: describe 5
rem run: describe 500
use sizes from "sizes.low" .
fn describe input n u64 . output u64 . do
let k sizes.kind be sizes.classify n .
match k do
rem ✘ a variant with the module name in front; in this edition it matches any value
case sizes.small do
return 1 .
end
case sizes.big do
return 2 .
end
end
end
Output
$ lowentc --run describe mistake_enumcase.low 5
describe(5) = 1
$ lowentc --run describe mistake_enumcase.low 500
describe(500) = 2
describe 500 is big and answers 2, and describe 5 answers 1: a qualified name is read as a variant. Until 2026-09-16 it was not — lowering read case sizes.small not as a variant but as a slot that matches any value, so the first arm took every value and 500 also answered 1. The checker narrowed qualified names to variants and lowering did not; the two layers now read the same tree. Writing only the variant name is still shorter, and the type of k decides which enum it belongs to.
examples/ch21/enumcase_fixed.low
module enumcase_fixed .
rem run: describe 5
rem run: describe 500
use sizes from "sizes.low" .
fn describe input n u64 . output u64 . do
let k sizes.kind be sizes.classify n .
match k do
rem write only the variant name; the type (sizes.kind) decides which variant it is
case small do
return 1 .
end
case big do
return 2 .
end
end
end
Output
$ lowentc --run describe enumcase_fixed.low 5
describe(5) = 1
$ lowentc --run describe enumcase_fixed.low 500
describe(500) = 2
A common misconception. Two modules must not import each other
examples/ch21/cycle_a.low
module cycle_a .
rem run: fa 3
rem run: fa 4
rem imports `cycle_b`, and `cycle_b` imports this module too
use cycle_b from "cycle_b.low" .
export fn fa input n u32 . output u32 .
requires le n 10 .
do
if eq n 0 . do return 0 . end
return cycle_b.fb (sub n 1) .
end
Output
$ lowentc --run fa cycle_a.low 3
fa(3) = 1
$ lowentc --run fa cycle_a.low 4
fa(4) = 0
cycle_a imports cycle_b and cycle_b imports cycle_a, yet it translates and runs. Unlike C headers, where only what was read first is known, names are resolved after the whole translation unit is gathered — the same principle that makes the order of top-level declarations irrelevant. Still, modules that import each other are easier to read merged into one, or with the shared part moved into a third module. The recursion depth is bounded by the contract (requires le n 10 .).
21.8 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
module geom . | this file is module geom (first line) | the module name is separate from the file name — use looks for it |
export fn manhattan … · export struct point … | make it visible outside | hidden by default — what is visible is a promise |
use geom from "geom.low" . | import from a place relative to the declaring file | no search path — dependencies are in the source |
use allocs . | import from the same unit or the standard library | otherwise W-USE-EXTERNAL |
geom.point · geom.manhattan a b | qualify imported names with the module name | no spilling |
geom.helper (a hidden name) | rejected (E-VISIBILITY) | qualifying does not open the door |
| one name declared twice · two imports with one name | rejected (E-NAME-DUP · E-NAME-COLLISION) | never decide quietly which one is reached |
| order of top-level declarations | irrelevant — they may call each other | a file is scanned; a body is read top to bottom |
use geom from "geom.low" as g . | call the imported module g in this file | untangles long or clashing names — the module’s real name stays |
export let … · export test … | refused (E-TOPLEVEL) | export a constant through a fn that returns it |
case small on another module’s enum | write only the variant name | case sizes.small matches any value in this edition |
Table 21.2 — Module syntax — shape · meaning · why it looks this way
Recap
exported is visible outside. use <module> from "<place>" . imports from a place relative to the declaring file; without a place it looks in the same compilation unit or the standard library. There is no search path. Qualification does not reach hidden names, and declaring the same name twice is rejected. Top-level order does not matter, but locals in a body are declared first.