14. Modules and imports
One file is one module. The module name is the file's basename (io.hk gives module io), and it is flat across the project: use NAME binds the module whose file stem is NAME wherever that file sits under the project root, and a subdirectory organises source for readers and appears in no import (src/util/helpers.hk is use helpers, as stdlib/core/io.hk is use io). The accepted cost is that two files sharing a stem anywhere in one project are an error naming both: universal qualification keys every item by its module's name, and they would collide at the merge whatever the importing files called them. A directory holding its own hanki.config.hk is another package and no part of this one, and a dotted path is one thing, a dependency's exported module (below). Imports come in two forms:
use io # qualified: reach members as io.print!, io.read_line!
open option # unqualified: Option, Some, None drop into this file's scope bare
def greet!(name: Option<string>) -> () [io]
match name
Some(n) -> io.print!(n)
None -> io.print!("hello")
end
end
hanki fmt canonicalizes each contiguous run of imports: the use statements first, then the open statements, one blank line between the two sections, no blank lines within a section, and each section sorted alphabetically by full dotted path. The sort is free to reorder opens because source order has no resolution meaning: a bare use of a name that two opens export is an error (H0306, below) and never order-resolved. A comment run between imports travels with the import that follows it, as does the run attached to the block's first import, though a run detached from the first import by a blank line remains above the block; a same-line trailing comment remains on its import. Duplicates are not deduplicated, and an import block after other items is canonicalized where it stands and never hoisted to the top of the file.
An import nothing needs is a compile error (H0625), with a machine-applicable fix deleting its line (hanki check --fix). An open is live where a bare name resolves through it, and a use where the file writes a qualified reference its binding gates. A use of a core module is therefore never needed, core being ambient-qualified (below), and the diagnostic says so. Method and trait dispatch into an import-gated module counts as use, and conn.with_tx! is enough to keep use sqlite live with no sqlite. spelled anywhere, as is a doc example. An import of a module contributing test blocks or a provide is the edge that pulls those items into the workspace, and is never reported. The trade is one-sided: the lint may leave a dead import standing, and it never deletes a live one.
use is the qualified form, and the default. It brings a module into scope under its name, and members are reached as module.Name. Use it for cross-project modules where the call-site context helps: parse.parse_command!(line), todos.Todos, geometry.Point. A qualified function or action is also a value: f = io.print! stores the action under use io, and f("hello") invokes it. The reference retains its parameter types, return type and effect row.
open is the unqualified form. It drops a module's top-level names into the importing file's scope without the module. prefix. Core stdlib modules (list, option, display, eq?, str) and small libraries that feel like part of the language are conventionally opened by user code, and they are not auto-opened. A file that wants bare Some and None must write open option; without it, only the qualified spelling (option.Some, option.None) resolves. User modules are typically left qualified.
Two opens that introduce the same bare name make that name ambiguous: a bare reference that would resolve through them is a compile error (H0306, naming both exports). open order cannot be a tiebreak, since hanki fmt sorts the open section and order would rebind the name on reformat. An open whose export collides with a module in scope is the same error and the same reasoning: use util beside an open exporting util, or the ambient core list beside an open exporting list. Adding that open would otherwise rebind every util.… already written in the file, with nothing to report at the import. Reach the opened item by its qualified name, or reach the module by dropping the open or binding it as use m as alias. A name the file itself declares is not contested: the local declaration wins, and the more-local thing never loses. Co-opening the modules remains legal, open env beside open http, which both export get!. Reach a collided name by its fully-qualified module.Name, and every name only one of them exports remains usable bare.
Both forms exist because open is convenience over a spelling that already works. That applies to effect ops too: an op has no module.op call form (§6) and is reachable through its effect's name, db.Database.exec(…) under use db, and open is not the only way to reach another module's ops. Core is ambient-qualified (below), and a qualified constructor is as legal in a pattern as in an expression, option.Some(v) -> v being a fine match arm. A constructor pattern obeys the same scope rule as any other name, and carrying arguments is no exception: Some(v) needs open option as bare None does, and one match never takes two import rules. The scrutinee's type is consulted only to choose between several in-scope variants sharing a name, sys declaring Other in four of its sums, and never to bring a name into scope, which leaves what resolves independent of how much has been inferred at that point. Hence the convention: open a module for its ops, or where its bare names are already universal (Some, None, Ok, Err), and leave the rest qualified, where the prefix tells a reader which module a name came from.
use X as n renames the module qualifier. A use may bind the module under a different file-local qualifier: use http as h, then reach its members as h.get! and h.Response. The alias is local to the file, like the import, and shadows nothing elsewhere, and the qualifier resolves as the real module would (h.Response is http.Response, and import-gating still applies to the real http). Its purpose is clash resolution, where two dotted imports would land on one qualifier (use a.model beside use b.model), and shorthand for a long name. It renames the file-local qualifier and nothing more: two modules of the same basename still collide at the merge, universal qualification keying items by module name (dotted paths, below). as is supported only on use, and open X as n is a parse error, open already bringing names in bare. as is contextual, a keyword only in this position, and it remains usable as an ordinary identifier everywhere else.
Core is ambient-qualified, and the extra tier is import-gated. The qualified spelling of a core stdlib module resolves with no import: option.Some, string.len and list.map all work with neither use nor open, and an open only adds the unqualified spelling on top. The extra tier, this section's capability and utility modules (io, fs, net, http, time, datetime, json, xml, terminal, random, env, process, path, cbor, vcs, supervisor, sqlite, log, base64, base32, hex, sha2, sh, flags, uuid, glob, parsecheck, diag, tls, url, aead, pubkey), is import-gated: a file reaches net.connect!, io.print!, or any of a module's functions, actions, effect ops, constructors and types, only after it uses or opens that module. Without the import the name is out of scope, a value reference being H0301 unknown name and a qualified type annotation (net.Conn) being H0302 unknown type, and each file declares the capability vocabulary it pulls in. Any qualified type that names nothing in scope is the same H0302, and a typo'd foo.Bar is an error in place of an accepted opaque type. The effect-row capabilities, [io], [net], [fs] and the rest, are built-in language atoms and no module members, and declaring one needs no import. io is import-gated like the rest; the "conventionally opened" note above is a style convention about open against use, and no ambient seeding. The prelude below, and the whole core tier, are ambient regardless.
A user module may not shadow a stdlib module name. use <name> for a stdlib module always binds the standard library, and a local file whose basename collides with a stdlib module, list.hk, time.hk or option.hk, can never be imported: the use resolves to the stdlib, and the local file would be dropped without a word. The workspace rejects the collision with an error in place of dropping the file; rename the local module. Adding a baked module claims a name, which costs somebody a file name. The stdlib and user code share one flat namespace, and each of the 91 baked module names is a basename no project may use, config, log, http, path, env, time, net and test among them. Adding one is therefore a source-breaking change for every project that already named a file that, and it is announced with the name it claims, on the same footing as renaming or removing one. The extra tier is where this bites, that being the tier meant to keep growing (§17). The cost is priced and not escaped: multi-segment module paths remain reserved and unimplemented (§21), and there is no local-only import form to opt out with. The claimed names are pinned in a test, and a stdlib addition cannot take one by accident.
A local binding shadows a same-named module in value position. A parameter, let or var whose name matches an in-scope module (path, list, module) shadows that module wherever a value is expected, member access included, and module.length reads the length property of the local module value and not the module stdlib module. This is ordinary lexical scoping, the innermost binding winning, the same shadowing the prelude rule below grants a file's own declarations. Without it a value reference would misdispatch to the module wherever the module happened to export a same-named member. The module is then unreachable by that name inside the binding's scope; rename the binding where you need both.
A module and a same-named type compose in member position. A stdlib file named for a builtin type, i8.hk holding impl i8 and the other type-named stdlib modules in that form, makes one name reach both a module and a type. It applies to a user file in the same form too, point.hk declaring type point, even though universal qualification keys that type point.point in place of flattening it: the resolution probes both spellings, point.origin() reaches the type's associated fn, and a name both sides supply is the same ambiguity error and no silent pick of the module's. The convention makes this rare, a PascalCase Point beside module point being two distinct spellings that raise no composition question, and a rule that chose one of two candidates without saying so would be worse than one that did not apply. A qualified i8.name call resolves against the module's exports first and, on a miss, falls through to the type's associated functions, inherent first and then static trait fns, and a module-level helper in i8.hk never hides i8.zero or i8.parse from the rest of the tree. A top-level item whose name a same-named type also supplies as an associated function is rejected at the declaration (H0623, naming the two candidates); with no error the two namespaces could disagree about what i8.name means. Rename one of them, conventionally the module-level helper, the type's surface being the public one. A local binding shadowing the name still wins over both.
The same composition applies to an effect name beside a same-named type: effect Foo and type Foo in one module, where Foo.member could mean an op or an associated function (§6). Here the probe is at the use and not at the declaration. An effect's ops and a type's associated functions are two independent surfaces that collide only member by member, the unique supplier answers, and a member both supply is H0633, again an error and no silent pick.
The prelude covers the core trait names and the container types. The exception to "nothing is auto-opened" has two parts, both lowest-priority, and a file's own same-named declaration shadows them. (1) Trait names. Eq, Ord, Add, Sub, Hash, Display, Encode and Decode are in scope unqualified everywhere with no open required, these traits' behaviour being ambient already: == and != dispatch through Eq, < > <= >= through Ord, + and - on non-numeric operands through Add and Sub, #{…} interpolation through Display, and serialization derives and .encode! / Type.decode! through Encode and Decode. Naming one in a generic bound or impl head (def sort<T: Ord>(…), impl Eq<MyType>, impl Encode<MyType>) therefore resolves to the same canonical trait and forces no open. What enters scope is those eight trait names plus Ordering and its variants Less, Equal and Greater, which makes the result of cmp matchable without open ord. Only the two serialization trait names enter from their modules: another export such as decode.max_decode_depth remains qualified or needs open decode. (2) Container types. List, Array, Map, Option and Result resolve to their stdlib types (list.List, array.Array, map.Map, option.Option, result.Result) unqualified everywhere, and a function or face that returns one, def list_dir!(…) -> List<DirEntry>, ships the real methodful type even with no open list, and any caller can use its methods. Without the prelude a bare List annotation would be a distinct, methodless head. The other built-in type names resolve unqualified as built-in types too: Never, the native resource types (File, Child, TcpStream, TcpListener, TcpReadHalf, TcpWriteHalf, UnixStream, UnixListener, UnixReadHalf, UnixWriteHalf, TlsStream, TlsListener, TlsReadHalf, TlsWriteHalf, SqliteConn) and the buffers (BytesBuilder, BytesReader, ReadCheckpoint, StringBuilder). The stdlib types outside the prelude need their module opened: open set for Set, open range for Range, open pair for Pair, open secret for Secret, and open datetime for Duration and Instant. Only the type names are ambient: the variant constructors Some, None, Ok and Err still need their module open. Write open option for bare Some. Everything else, bare stdlib functions, ops and other types, still needs an explicit open.
Universal qualification puts every top-level item kind at its module-qualified name. def, actor, struct, type including each variant constructor, trait, impl, effect and meta const all sit at option.Option, option.Some, todos.Todos, display.Display in the merged workspace. Within the defining file the bare name still works, the Rewriter splicing bare to qualified at workspace merge, and across files you need either the qualified spelling or an open declaration.
# parse.hk
use types # cross-file refs via types.Quit, types.Add, ...
open from_string # the trait `i32.parse` dispatches through
open result # bare Ok(_), Err(_) patterns
def parse_command!(line: string) -> types.Command [throws types.CommandError]
if line == "quit"
types.Quit
elif line.starts_with?("done ")
match i32.parse(line.slice_from(5))
Ok(id) -> types.Done(id)
Err(_) -> throw types.Parse(line)
end
...
Names starting with _ are not exported: an open does not splat them, and a qualified reference to one is H0622 (§12).
Dotted paths reach a dependency's other modules. use <dependency>.<module> binds <module>.hk of the package the manifest binding <dependency> names, gated by that package's exports as the bare form is (§21). The qualifier is the tail, use entsoe.model then model.Doc; as renames it like any use, and open entsoe.model drops the module's names in bare. The head must be a binding declared in the importing file's own package manifest, never a stdlib module and never a sibling file, and a path is at most two segments, a binding naming a package and a package being one level deep. Two imports in one file landing on the same qualifier is an error naming both; only an as can put them there, and removing or changing it is the fix.
Note what this does not dissolve. Universal qualification keys every item by its module's basename, and two model.hk modules in one program collide at the merge whatever the importing files call them. A library's module names are part of its API, hanki api-diff keying items by module.item, and an alias is a file-local spelling and no rename of the module.
Stdlib modules are auto-injected by the compiler: use io and open option work without an io.hk or option.hk file on disk, the embedded stdlib owning them.
Dynamic loading
A module can be loaded at runtime with module.load!(path), module.unload! and module.reload!, and the path is a Hanki source file and never a pre-built artifact: the user never manages .sos. The trait T a load is checked against comes from the binding's annotation, m: Module<Greeter> = module.load!("greeter.hk"), which is the only way to supply it. The language has no call-site type argument, and there is nothing to write between load! and its parentheses. Both tiers implement this surface, and §22 records which parts each supports today. Interface matching treats bare and documented qualified native resource names as the same type, including inside collection and function types. A host binds each export, and each handler of an actor the loaded module exposes, by its full parameter list, and the loaded module's parameter defaults play no part in the match. An export or handler that appends a parameter, defaulted or not, no longer loads against a host trait declaring fewer, and module.load! throws ModuleLoadError.
The boundary between an AOT host and an interpreted loaded module is observationally transparent. Calling into a loaded module is indistinguishable from single-tier execution save in time and memory, and through explicit opt-in reflection, the same parity bar the two tiers hold to each other, and nearly free here because both tiers are codegen strategies over one shared runtime core (hanki-rtcore: heap, reference-counted memory management, scheduler, mailbox, RtValue, the numeric algebra, exit and death). A mixed-tier differential test, an AOT host plus an interpreted module checked against the all-bytecode run, enforces it. Passing opaque non-data values, closures, futures and resource handles, across the boundary is a documented scoped limitation: these non-inspectable handles are not marshallable data. Runtime-resolved backends, a JIT shipped as a sidecar .so or shelling out to the toolchain, are disfavoured: they would turn "is the backend present?" into a per-call error every dynamic-module program must handle, and they break fully-static deployment.
Data crosses the boundary by deep-copy marshalling, for every data type: scalars, strings, the arbitrary-precision numerics and bytes through their RootKind, and every struct and sum aggregate, including the applied-type generics List, Map, Option and Result and user generics. A sum or struct heap object records its own layout, and the self-describing pointer walk reconstructs it with no type information. The bytecode tier's arguments are already RtValues and need no marshalling. Opaque non-data values, closures, futures, resource handles, and a Module<T> handle passed as an argument, are the documented scoped limitation.
A trait method that declares type parameters adds one hidden convention to that marshalling. Each handle call transports the handle, then the value arguments, then the public method-own trait dictionaries, then the complete declaration-derived identity window: one runtime type identity per type parameter the method's value parameters, return type and throws payloads name, in order of first appearance there, followed by one per effect-row parameter the method's own row or those payloads name, whether or not the body catches or throws it. Each public dictionary's callable metadata preserves its ordered typed effects, including the concrete type of every throws E payload, and an effect-name list alone is not sufficient for the boundary. It also maps each canonical signature type parameter to an alpha-invariant identity source: a slot in the callable's own identity window or a slot in the enclosing handle method's retained window. The host trait and loaded export derive and structurally compare that source map independently. Specialized typed effects and the source map derive from the same substituted effect set. Runtime dispatch consumes the map directly and does not select a type witness by source parameter name. A retained foreign closure has an immutable invocation context made of its dictionary's specialized callable metadata and the host image and tag context. A throw that leaves the loaded module reconciles through that context: the host tag resolves to a qualified type name, the payload converts under the preserved E, and the qualified name resolves to the loaded module's tag, raising the throw in the loaded tag space. The context shares the closure's lifetime. A callback stored from the module remains valid after the handle is released, and dropping the closure releases the context and the retained closure once. The boundary performs no runtime-shape probing. The host derives both groups from the trait declaration and the loaded export from its own signature, which fixes the same sequence on both sides without either reading the other's lowering, and a declared type parameter the signature never names takes no slot. A loaded def or def! export binds hidden parameters after its value arguments in the same order: its public dictionaries first, then its complete identity window. Each public dictionary slot follows the method's ordinary generic and bound declaration order; its metadata names the bound trait and the specialized arguments, and the dictionary value has that trait's callable methods in declaration order with signatures specialized for the bound subject and arguments, which gives both sides the argument, result and throw conversion and leaves the boundary never probing a runtime value's shape. A top-level def or def! the loader can bind takes its value arguments, then its public dictionaries, then its identity window, and no shape or kind witness, while every other callable in the loaded module retains the ordinary demand-filtered layout. The host and an independently compiled export compare layouts before execution: alpha-renamed source parameters are compatible, and a dictionary whose trait, arguments, callable signatures or callable type sources differ, a type entry opposite an effect-row entry, an extra body-only hidden parameter beyond the declared layout, and malformed metadata each fail before execution, naming the first disagreeing slot. Three bindings are refused with a catchable module.ModuleLoadError: an export whose lowered layout departs from the declaration-derived layout, which names the first disagreeing slot with the entry kind and name on each side; an export whose lowered function takes more hidden parameters than its value arguments, public dictionaries and identity window name, which reports the three declared counts beside the lowered arity; and a trait method whose declaration has a public dictionary or a nonempty identity window, which has no actor binding because which of a loaded actor's handlers the host will bind is unknown when the module is lowered and an actor-backed Module binding rejects any nonempty public dictionary or identity window. The AOT tier transports the same layout through hanki_module_call, which receives n_args, n_dicts and n_witnesses beside one flat array holding the value arguments, then the dictionary operands, then the witness cells. A dictionary closure crossing from the AOT host is retained as a foreign closure owned by the embedded heap, a callback stored from it survives the release of the module handle, and the heap's GC or drop performs one release. Bytecode and AOT use the same convention.
An effectful loaded method behaves identically on both tiers, with one exception. On the bytecode tier it performs io, fs and net work and actor sends, its yields serviced by the host scheduler as inline code's are. The AOT-embedded loader services the same OS-seam host-calls (io, fs, net, env, process, clock reads, random draws) through the same intrinsic table, and a mixed-tier differential test pins an io.print!-ing loaded method's output and exit code across the two. The exception is a loaded method's actor sends, which the AOT-embedded loader does not yet service. It is the one place a loaded method is not tier-neutral, and the reason §22 lists actor operations on that loader as outstanding.
A loaded module may declare the actor it exposes. Where the host trait T's ! methods match a loaded actor's on handlers by name and signature, module.load!(path) spawns that actor and the returned Module<T> is its reference: m.method!(args) is a blocking send, the actor is live and stateful between load! and unload!, and unload! stops it. Each dispatch resolves the actor's handlers against its own loaded module, which is what module.reload! swaps over.
A per-actor migrate(old: T) hook drives state migration, and the reload is actor-aware: the in-flight handler finishes under the old code, every subsequent message runs under the new, and a layout change triggers migration, or, with no hook to run, a clean StateMigrationRequired.
A throw escaping a loaded method is reconciled into the invoking image's throw-type space by type name on both tiers, the per-module tags not agreeing across the boundary, and re-raised at the call site, and a loaded def! export's throws E surfaces at that caller's matching catch e: E. The AOT-embedded loader recovers the caller's tag from the image-scoped throw-type table registered at startup. The thrown value's instantiated runtime identity crosses the boundary with it; a catch arm at the call site matches the full instantiated error type (§7). A throw crossing an actor reply is the runtime-materialised actor.SendFailed, which has the full instantiated identity of its declared type on both tiers, and a matching catch e: actor.SendFailed arm selects it. A handler's own uncaught throw never crosses as a typed error; it takes the actor down, and the sender observes SendFailed::Died (§15). A loaded actor handler that throws instead goes down like any actor, its throws E not crossing the send (§15), and the caller observes the death as actor.SendFailed::Died. The error type must be shared or structurally identical across the two modules. The thrown value's variant tags are type-local and therefore agree already, and they are not remapped, which makes a divergent redefinition that reorders the type's variants the documented unsupported case.
Ownership across a loaded boundary
A handle call is call-scoped. The runtime copies the arguments and the result by the message-send rules (§15), and after the call neither side retains a pointer into the other's memory.
A transported runtime identity is an immutable, image-independent value. Each boundary copy owns a reference-counted complete type or effect-row identity, never a borrowed caller heap cell and never an image-local ordinal. Equality between two identities is structural, and survives a reload, an unload of the logical handle, and a rebind.
A value that names code and escapes its call owns a generation lease. A callback stored from a loaded module owns the callable and its captured environment, the specialized signature and effect row, the identity window captured with it, its throw-tag context, and the code generation it was created from. A reload re-points future handle calls at the recompiled code, and a callback created before the reload continues to invoke the generation it captured. An unload tombstones the handle and does not revoke a value already transferred.
An actor envelope owns its visible arguments, public dictionaries, identity windows, callback capsules and their generation leases from enqueue through dispatch or cancellation. The runtime releases an envelope once, on dispatch, on cancellation, on actor death, on unload, or on reload. A generation is reclaimable once every handle, actor, callback and queued envelope that references it has released its lease.
The declared identity window is transported on both tiers. The lifetime rules above are staged. The native dlopen lane of hanki build --aot --shared is held to the same contract, and its runtime-checked operand dispatch, complete layout validation, generation leases and catchable released-handle state are staged as well; reaching them raises abi_revision. Until then a successful load retains one process-lifetime reference to the image, and unload! invalidates the logical handle without reclaiming the mapping. Correctness does not depend on that retention. The design record is docs/design/shared-module-abi.md.