Skip to content

Specification §15

Module system

language-design.md §15 · 142 lines · 8 min read

The organization has two markers with distinct roles. It is the lib/bin split (Rust, Zig) and go.mod/go.work, and separating them is what keeps root detection trivial:

  • mk.mod = a module, and a module is a lib: an independent, reusable box, with its own dependencies (it lists the external deps that that module imports). It is a boundary of namespace and visibility and of dependency. Folders without mk.mod merge into the parent module’s namespace (Go style). A module is never compiled alone, only as part of a project’s tree; hence unused modules coexist at no cost, and N projects share N modules (modules are libs).
  • mk.project = the bin or composer, the top module and the compilation unit. It composes the modules it uses into a compiled tree, and declares the language version, its external deps and the tasks, the precedence and the binary production (section 22). It is the root of path detection. A project produces an artifact; a module does not.

The physical unit maps 1:1 to the logical one, and a file is at most one module (there are not several module per file), so “where does module X live?” is always answerable by the path, without opening anything. Recursively, the subfolders that do not have their own mk.mod (or mk.project) belong to the parent module, and the files of a module share the same namespace, with no use between them.

Root detection: climb from the current file up to the first mk.project. Since there is exactly one per project, it is unambiguous, in the role of go.mod.

Single-file module (module Name at the top of a file): an easy-to-transport module, a single file, that declares its own imports and external libs, with embedded checksums and versions (see “Manifest”, below). Run alone, it is the whole program; placed inside a project, it becomes a submodule without submodules of its own.

myapp/ ← mk.project (BIN: composes modules, tasks, own deps)
├── mk.project
├── mk.sum (checksums of the resolved deps, auto-generated; serves the project AND modules)
├── main.mko → module "myapp" (the top is also module/lib)
├── auth/ ← mk.mod (LIB "myapp/auth", OWN deps in its mk.mod)
│ ├── tokens.mko → module "myapp/auth"
│ └── internal/ (no mk.mod → part of "myapp/auth", same namespace)
│ └── hash.mko → module "myapp/auth"
├── store/ ← mk.mod (LIB "myapp/store", own deps)
│ └── db.mko
├── experimental/ ← mk.mod (LIB unused by the bin → coexists, NOT compiled)
│ └── draft.mko
└── tools/
└── migrate/ ← mk.project (SUBPROJECT/bin, OWN tree and deps)
└── main.mko → the tree restarts HERE

Imports are absolute from the package name, never via ../:

  • main.mko does use myapp/auth, use myapp/store (sees their pub).
  • auth/tokens.mko does use myapp/store (absolute to the sibling; never ../store); sees internal/hash without use (same module).
  • tools/migrate/main.mko: climbing finds tools/migrate/mk.project first, so the root is migrate, which does not see myapp/... (if it needs to, it depends explicitly).

The nesting rule is, then, two different cases:

  • Nested mk.mod = a lib deeper in the same project. It is a boundary of visibility and of dependency (it has its deps), not an isolated bin: it can import the parent or a sibling by absolute path (subject to pub), and it is compiled as part of the tree of the project that uses it. “No access to the parent” means no implicit access and no ../, not total isolation.
  • Nested mk.project = a real subproject or bin (own tree and deps, produces its own artifact). That is the case where the tree genuinely restarts and the parent does not reach it: vendoring, sub-tools (the equivalent of a nested go.work/go.mod).

This eliminates the ambiguity of relative imports (Python’s problem), since every path says where it comes from, and keeps root detection trivial (a single mk.project per project). The import keyword is use (qualified by the module, selective with .{}, and … as for an alias); it is in section 14.

Three levels that match the physical structure, and the default is the middle one, private-to-module, the common case of files of the same module collaborating, without a mark (Principle 1):

priv fn f() // private to the FILE: not even the other files of the module see it
fn f() // private to the MODULE (default): the module's files see it, outside they do not
pub fn f() // public: other modules see it

The unmarked one is private-to-module because intra-module collaboration is common (you split a module into files that cooperate); pub is the conscious outward exposure; priv is the rare mark to hide even from the neighbors.

Struct fields invert the default, and it is not an inconsistency, it is the same Principle 1 over something with a different “safe case”. Code wants to collaborate (default visible-in-module); data wants to protect itself (default closed). An exposed field is abdicated control (anyone reads and writes, and the invariant does not hold), so the safe thing is closed, and pub exposes individually:

decl User {
id: int // private (default): encapsulated
name: string // private
pub email: string // exposed individually to other modules
}

Methods across modules: global coherence with disambiguation

Section titled “Methods across modules: global coherence with disambiguation”

Makoto lets a module define a method (UFCS free function fn (p: other.Point) norm()) on a type owned by another module — an “orphan” method in the Rust/Haskell sense. This is deliberate: it lets you write different adapters or bindings for the same third-party library W, which is more powerful than a strict orphan rule. The three scope modifiers apply unchanged: priv fn is file-local, an unmarked fn is module-local, and pub fn exports the method across modules — a pub orphan method is visible to importers exactly like a pub method on the module’s own type (it is not a silent no-op).

Coherence is global, resolved by visibility rather than by an ownership rule:

  • If only one definition of Point.norm is in scope, the call p.norm() resolves to it directly.
  • If two visible modules A and B both define pub fn (p: X.Point) norm(), an unqualified p.norm() is an ambiguity error, resolved by qualifying the call (A.norm(p) vs B.norm(p)) or by selecting one at the import (use norm from A). The same Point therefore never silently takes on two different norm semantics depending on which module you are reading from.

Defining the same method in modules whose scopes never meet is allowed and is the feature (independent adapters); the conflict only exists — and is only reported — where both are actually visible at once.

A module is a lib (a unit of reusable code, with its own deps); a project is a bin (composes modules into a tree and produces the artifact); a package is the unit of distribution, a module (lib) published with a name and version. Deps live in both manifests: each mk.mod lists the deps of that module, and the mk.project lists the deps of the bin plus the composition, the tasks and the precedence (section 22). The build resolves the union by MVS (minimal version selection, Go style: reproducible, with no solver) and locks it in mk.sum.

// mk.mod: the lib "auth" declares what IT imports
module auth
deps {
github.com/acme/jwt 1.4.0
}

The module <name> line is authoritative: it is the name other code imports (use .../auth), regardless of the directory name. The directory name is only a fallback used when a module has no manifest declaring its name; a declared name always wins, and directory and declared name are allowed to differ.

// mk.project, the bin: own deps + composition/tasks (detailed in section 22)
package myapp
makoto 1.2
deps {
github.com/acme/http 2.3.1
github.com/foo/json 0.9.0
}

External dependencies enter via Git, as in Go (mk get <repo>), with versioning and pinning; resolution is MVS over the union of the deps of all the modules of the compiled tree.

Where each use comes from is decided by the prefix, with no ambiguity between stdlib, external and local:

  • use json: the bare form is stdlib (comes with the compiler; no version nor checksum, does not cross the network).
  • use myapp/auth: the package-name prefix is local (same project).
  • use github.com/acme/http: the URL form is external (it has to be in the deps).

Version vs. checksum: different things. The version is which code you want (2.3.1 is a pointer to a Git tag, and tags can be rewritten, repos recreated, downloads intercepted). The checksum is the proof that it is exactly that code (sha256:… only matches those bytes). A version is intent, edited by hand; a checksum is a verifiable lock, generated by the machine, for a reproducible build and against supply-chain. It is the go.mod/go.sum, Cargo.lock pair. The checksum exists only for external deps (the boundary through which untrusted code enters); stdlib and local do not have one.

Where the checksum lives differs by mode, but it always exists:

  • Project and module: version by hand (in the bin’s mk.project, in each lib’s mk.mod), checksum auto-generated in the mk.sum, which serves both (locks the resolved deps of the tree). Separate files because a project and a module are already folders.
  • Single-file: version and checksum embedded in the header, on the same line, because the file is made to travel (chat, gist, USB stick), and it is precisely there that the lock matters most (the registry’s “0.9.0” may not be what ran at the source):
module quickscript
deps {
github.com/foo/json 0.9.0 sha256:a1b2c3… // version + lock together, embedded
}

The workspace building-block already exists: since modules are shareable libs, N projects in a monorepo reference N common modules without ceremony. Only the explicit multi-bin orchestration (a go.work that lists several sibling-projects as a single target) is deferred: Go only added it years later, and single-project plus subproject-mk.project plus shared-module-libs already cover the common case.

use is resolved at compile time, and a module can be a comptime value, enabling conditional use for compile-time selection (choosing a backend by architecture, for example):

const backend := use(if target.arch == x86 { return "backend_x86" } else { return "backend_wasm" })

And since a module is imported source code (not a binary with frozen types), the comptime of an imported module runs in your compilation, together with yours: List[int] imported from collections is monomorphized in your build, with your int. There is no “generic that does not cross a module”: everything is source recompiled together, at the use site (Zig model).

Circular imports are forbidden (ABA is a compile error), since a module cycle is guaranteed pain of initialization and reasoning.

Without cycles, the dependency graph is always orderable, and the initialization order is its topological one: if A imports B, B initializes first (A depends on B). Between independent siblings (neither imports the other) the order is undefined, but irrelevant, because top-level initialization is const (comptime, no effect) or pure, and nothing observes the difference. A startup side effect (open a file, register a handler) does not live in a hidden global var, but rather in an explicit boot process (section 8), with a declared order. This gives predictable initialization without an init-ordering system.