Packages and source layout
A module is one file, and it is the unit of encapsulation (Modules). A package is a directory of modules, and it is the unit of distribution — the thing that has a name and a version, that depends on other things, and that a compiler is pointed at. Every module belongs to exactly one package.
The two units nest: a module chooses
which of its declarations other modules may see; a package chooses which of its modules
other packages may see. A
package boundary is also where a module's name changes: what a package calls Model,
everything outside it calls Todo.Model.
What a package is
A package is a directory containing a zelkova.toml manifest. The manifest names the
package and describes it; a directory without one is not a package, and the compiler will
refuse to compile a raw collection of source files. Beside the manifest are the two source roots,
src/ and tests/.
todo/
zelkova.toml
zelkova.lock
src/
App.zel module App
Model.zel module Model
Model/
Internal.zel module Model.Internal
tests/
ModelTest.zel module ModelTest
zelkova.lock is written beside the manifest by the toolchain and records the exact
versions a build was resolved to. It is not written by hand and says nothing the manifest
does not already permit; the toolchain appendix
is where it is described.
Source roots
A package has two fixed source roots: src/ and tests/.
src/ holds the package's modules — what the package is, and the only thing it ships.
tests/ holds modules that exercise them, compiled when this package's own tests are run
and at no other time; a package that depends on this one never reads a file under tests/.
Tests describes the second root in full. Everything below is true of both.
Every file under a root whose name ends in .zel is a module of the package, found by
walking that root recursively. That is true whether or not the package keeps the module
private and whether or not anything imports it: an unreferenced module still has to parse
and still has to type-check, so a package cannot carry a broken file by leaving it
unmentioned.
A module's name is its path under its root, with each directory separator written as a .
and the .zel dropped — the rule stated in full under
Modules. That is the module's name within its package,
which is what its neighbours import it by; how a package's modules are named from outside is
Imports across a package boundary, below. Every
directory under a root is a segment of a module name, and must be spelled like one: an
uppercase-initial identifier. src/js/Basics.zel is not a legal file, because js.Basics is
not a legal module name.
The two roots share one set of names. src/Model.zel and tests/Model.zel are both Model,
which is a module name declared twice in one package, and that is an error — the same error
as declaring it twice under one root. A test module is imported by its name like any other.
Known gap: neither half is checked, and the path-derived name is computed and then
discarded (docs/tickets/lang-6.md). A file may declare any module
name regardless of where it sits, so a lowercase directory is accepted today.
A file whose name does not end in .zel is not a module and is not read. That is what
allows a module foreign facade's companion files to sit next to it in the same
directory, which is where Foreign interoperability requires them to be:
src/
Core/
Basics.zel module foreign Core.Basics
Basics.mjs the JavaScript behind it
Basics.wasm the WebAssembly component behind it
The manifest
zelkova.toml is a TOML document with six fields, of which five are
required:
name = "todo"
version = "0.4.1"
main = "App"
private-modules = ["Model.Internal"]
[dependencies.acme-widgets]
version = "^1.2.0"
git = "https://github.com/acme/widgets"
# unwrapped: its Decode module is on every other line in this package
[dependencies.acme-json]
version = "^0.4.1"
git = "https://github.com/acme/json"
wrapped = false
[test-dependencies.acme-expect]
version = "^2.0.0"
git = "https://github.com/acme/expect"
The format is TOML because a manifest is read and edited by hand far more often than it is generated. It takes comments, so why a dependency is pinned or unwrapped can be written beside the pin rather than in a commit message nobody will find; and a dependency's fields read as one line each instead of as nested punctuation.
name is one identifier: ASCII lowercase letters, digits and hyphens. It begins with a
letter, and every hyphen is followed by a letter. It is not a dotted name and has no author
segment. A package name and a module name are never confusable — one is lowercase-with-hyphens,
the other uppercase-with-dots — and the shape of a package name is what makes the namespace it
derives unambiguous.
version is exactly three non-negative integers separated by dots. There are no
pre-release suffixes and no build metadata.
main is optional and names the module holding a program's entry point; see
Programs.
private-modules lists the modules other packages may not import. It is required, and
it may be empty — a package that keeps nothing private writes private-modules = [], so that
a reader can tell a wholly public package from a forgotten field. Every entry must name a
module the package actually holds.
dependencies is a table mapping package names to what is wanted of each; see
Dependencies. It is required, and a package with none writes the header
[dependencies] with nothing under it.
test-dependencies is the same table for packages the tests need and the package itself
does not; see Tests. It is required and may likewise be empty.
What a package exposes
Every module of a package under src/ is importable from outside it, except the ones
private-modules names. Those are package-internal: importable from other modules of the
same package and from nowhere else.
From outside the package, a package-internal module is indistinguishable from one the package does not hold: the name reaches nothing, and the error says so in those terms.
A module foreign facade is never importable from outside, whatever the manifest says,
because its guarantees are about a companion file that ships with the package that declares
it — Foreign interoperability is where that rule and its reasoning live.
Listing a facade in private-modules changes nothing; the declaration itself is what makes
it internal.
A package offers foreign-backed values to its dependents by wrapping them: an ordinary
module imports the facade and re-declares what it offers under the types it really handles.
That module is public like any other, and it is where a signature the compiler cannot check
becomes one it can. zelkova-core is the worked example — Basics is an ordinary module,
and every value in it a companion computes reaches its dependents through a declaration
there rather than out of the facade.
module foreign Core.Widget exposing (measure)
unsafe measure : Int -> IntDependencies
A package may only use another package it has declared. dependencies maps a package name to
a table, which says both what is wanted of that package and where it comes from:
[dependencies.acme-widgets]
version = "^1.2.0"
git = "https://github.com/acme/widgets"
[dependencies.acme-json]
version = ">=0.4.1, <0.6.0"
git = "https://github.com/acme/json"
[dependencies.acme-parser]
path = "../acme-parser"
The version constraint
^x.y.z is the ordinary form. It admits any version at or above x.y.z that does not change
the leftmost non-zero position — so ^1.2.0 allows 1.9.3 and not 2.0.0, ^0.3.1 allows
0.3.9 and not 0.4.0, and ^0.0.4 allows nothing but 0.0.4. The leftmost non-zero
position is where breaking changes go, and below 1.0.0 there is no major position for them
to go in. =x.y.z admits that version alone. A two-sided range admits everything between its
bounds, the lower inclusive and the upper exclusive.
Where a dependency comes from
Every entry names a source, and names exactly one. There is no index anywhere that a bare
name could be looked up in, and so no version constraint alone is enough to find a package:
the manifest is where a package says where its dependencies are, and reading one file is
enough to know. git is a repository URL; path is a directory, relative to the manifest
that names it, holding a package being developed alongside this one.
Naming a published version and naming its commit are one act, because publishing a version
is tagging a commit with v followed by that version — 1.2.4 is published as v1.2.4.
So a git entry asks for a version and stops there: the constraint is what finds the commit,
by way of the tag, and nothing else in the entry restates in a repository's vocabulary what
the constraint has already said.
[dependencies.acme-json]
version = "^0.4.1"
git = "https://github.com/acme/json" # resolves through the tag v0.4.3
Work that has not been published has no version to ask for, and an entry reaches it by
writing rev in place of version: one commit, named in full.
[dependencies.acme-widgets]
git = "https://github.com/acme/widgets"
rev = "a3f9c1d84b2e57906fbbb0a4c1d2e3f4a5b6c7d8" # size-rework, 2026-08-30
A commit is the only pin there is, and a branch name is not one. A version constraint and a commit are both things the manifest states and a reader can check; a branch is a name someone else owns, and what it points at changes with nothing in this package changing — a dependency that moves without a diff is one nobody reviewed. Following a branch is something done between edits of the manifest, by resolving a tip and writing down the commit it was, and where that commit came from is a comment beside it.
A path names a directory, and there is nothing left for a version constraint to choose
among, so an entry naming one carries no version either:
[dependencies.acme-parser]
path = "../acme-parser"
So an entry says what it wants exactly once: a git source carries a version constraint or a
rev, and a path carries neither. Two of them in one entry would be two answers to a
question that has one, and the second is either agreeing with the first or contradicting it.
Which of those the manifest is doing cannot be read off the entry, so neither is a state worth
being able to write.
Saying it once is not saying less. Whichever form is written, a package arrives declaring a
version in its own manifest, and that version is what it brings to the rest of the build:
every constraint anyone else placed on that package still has to hold. What an entry gives up
is a constraint on its own choice, which had already been made by the time the constraint
could be read.
The key is the package's name and the fetched package's own name field must equal it.
Otherwise a manifest could call a package anything, and the namespace a dependent derives from
that key would name modules the package itself has never heard of. Writing the name rather
than only the source is also what keeps the manifest readable on its own: AcmeWidgets.Size
is traceable to an entry by reading, with nothing fetched.
Two entries anywhere in a build that give one name two different sources is an error, and so is one name given a source and, elsewhere, another. A name is a package's identity for the whole build, not a label local to the manifest that writes it, and two packages under one name would collide in exactly the way at most one version already forbids.
How a source is fetched, where the fetched copy is kept, and what a build does when the network is unavailable are matters for the toolchain rather than the language, and the toolchain appendix describes them.
Not implemented: only a path source is obtained. A git entry is read and validated, and
then reported as a source the compiler cannot fetch, so a build that has one compiles nothing
(docs/tickets/lang-61.md).
wrapped
An entry may also carry wrapped, which is true unless written, and which decides
how that dependency's modules are named in this package.
[dependencies.acme-parser]
path = "../acme-parser"
wrapped = false
One version of each
Two rules bind the resolved set of packages a build is made from. The package graph is acyclic — a package may not depend on itself, directly or through a chain — for the same reason the module graph is (Modules): there is no order in which the members of a cycle could be compiled. A test-dependency's dependency on the package it tests is not a cycle. And at most one version of a package is in a build. Two versions of one package would present the same namespace to the same importers.
Beyond satisfying every constraint, which version a resolver picks is not a question the
language answers. What it picked is recorded in zelkova.lock, generated beside the
manifest: the manifest says what is acceptable and the lock file says what was chosen, so a
build is reproducible without the manifest having to be rewritten to pin it. Its contents are
in the toolchain appendix.
Not implemented: nothing writes or reads a zelkova.lock, and no version constraint is
checked. A path source carries no constraint to check and is the only source obtained today,
so every package in a build is at the version its own manifest declares
(docs/tickets/lang-61.md).
Only direct dependencies are usable
Only a package listed in dependencies is importable within this package.
So the packages a module may draw on are exactly the ones written in the manifest
beside it, and a reader never has to walk a dependency chain to find out where a module could
have come from. It also means upgrading a dependency cannot change what any import in your own
package resolves to.
zelkova-core is a dependency of every package
Every module behaves as though it began with a fixed list of imports, drawn from Basics,
Maybe, Result, List, Task, Char, String and Tuple — the list is
in the Modules chapter. Those modules belong to
zelkova-core, which is a dependency of every package and is not written in dependencies.
Its version is the compiler's.
It is seen unwrapped, in every package. So Basics is Basics and List is List everywhere in the language. The names of
core's public modules are therefore taken in every package — a module of your own called
List would be a second module answering to one name.
The eight default imports' names are reserved for
zelkova-core outright: no other package may declare a module under one of them, whether or
not zelkova-core is among its dependencies and whether or not zelkova-core has published
that module. Declaring one is rejected when the build is resolved, before any module of the
package is compiled.
Not implemented: the compiler carries no copy of zelkova-core, so a package that needs
one writes it in dependencies like any other, and core's other module names are taken only
in a package that does (docs/tickets/lang-62.md) — the eight default
imports' names are the exception, reserved in every package regardless. The rest of the rule
holds: a package of that name is seen unwrapped whatever the entry naming it says.
Imports across a package boundary
An import names a module, never a package. Which modules a name can reach is decided by the
manifest — the package's own modules, and the public modules of its direct dependencies — and
a package's modules are named from outside through that package's namespace.
The namespace
A package's namespace is derived from its name: split it at the hyphens, uppercase the first
letter of each piece, and join. acme-widgets is AcmeWidgets, todo is Todo.
A dependent names one of its modules by writing the namespace, a .,
and the module's name within its package.
acme-widgets/
src/
Size.zel module Size, and AcmeWidgets.Size to a dependent
Style/
Dark.zel module Style.Dark, and AcmeWidgets.Style.Dark to a dependent
Nothing has to be checked for two packages to keep out of each other's way. A package name is unique in a build, so distinct package names give distinct namespaces, and no two wrapped modules can be reached by one name.
The namespace is not a directory and does not appear under src/. Nor does a package ever
write its own: inside acme-widgets, Size is Size, and AcmeWidgets.Size names nothing.
A module's name within its package is the one thing its file path decides, and the namespace
is added at the boundary by whoever crosses it.
module App exposing (start)
import AcmeWidgets.Size
start : AcmeWidgets.Size.Size
start = AcmeWidgets.Size.smallThe prefix an import brings into scope is the module's name as written, here all of
AcmeWidgets.Size. as shortens it, exactly as it does for a neighbour, and is how a file
that leans on one module of a dependency keeps its uses short:
module App exposing (start)
import AcmeWidgets.Size as Size
start : Size.Size
start = Size.smallWithin one package nothing is prefixed, because no boundary is crossed:
module Model exposing (Task, empty)
type Task
= Task
empty : Task
empty = Taskmodule App exposing (start)
import Model
start : Model.Task
start = Model.emptyA name that is neither a module of the package nor a public module of a dependency is an error:
module App exposing (start)
import Elsewhere
type Task
= Task
start : Task
start = TaskUnwrapping a dependency
A package that finds the prefix costs more than it is worth may drop it, per dependency,
by writing wrapped = false in that dependency's entry. That package's modules are then
named by their own names in every file of the depending package: Size, not
AcmeWidgets.Size.
[dependencies.acme-widgets]
version = "^1.2.0"
git = "https://github.com/acme/widgets"
wrapped = false
Unwrapping is a property of the pair (this package, that dependency) and not of the dependency itself. It is written by the package doing the importing because that is where the cost of the prefix is paid: a package used once in a file and a package used on every other line are the same package, and only its user knows which it is. Two packages depending on one package may spell it differently, and neither choice is visible to anyone else. A package cannot decide how it is spelled by its dependents, and does not need to.
A module has exactly one spelling in any file. Wrapped, Size names nothing; unwrapped,
AcmeWidgets.Size names nothing. Two ways to write one import would put two prefixes on one
module, which is the thing one module, one import exists
to prevent.
What a package boundary cannot rename
Unwrapping and as produce spellings, and a spelling is a convenience the file or the
package granting it keeps to itself. What a module offers to whoever imports it is written in
names — AcmeWidgets.Size, always — so a type does not change identity by being mentioned in
a file that spells it short, and two packages that spell one dependency differently still
agree about every type in it.
Three things follow.
A package cannot rename a module it exposes. A public module's name is its path, so
src/Model/Internal.zel is Todo.Model.Internal to everyone outside todo and cannot be
presented as Todo.Parser. Moving the file is the only way to change the name, and it is a
breaking change for the same reason renaming an exposed function is.
A package cannot re-export a module of a dependency as one of its own. todo may depend
on acme-widgets and use it throughout, but AcmeWidgets.Size is a module of acme-widgets
under every spelling and there is no Todo.Size to be had. A package's public surface is its
own modules and nothing else, which is what makes the namespace prefix reliable: the package a
module comes from is readable from the name.
A signature that names a dependency's type asks the consumer for that dependency. Values
flow freely — a consumer can call the function, hold the result and pass it on, knowing nothing
about where its type was declared. Writing that type down is what needs the dependency, and a
consumer adds it to its own manifest, at which point both packages mean the same type: one
version of acme-widgets is in the build, so AcmeWidgets.Size.Size is one type wherever it
is written.
-- todo exposes: resize : Task -> AcmeWidgets.Size.Size
-- in a package depending on todo alone:
import Todo.Model
bigger = Todo.Model.resize task -- fine
f : Todo.Model.Task -> AcmeWidgets.Size.Size
^^^^^^^^^^^^^^^^^^^^
-- error: there is no module named `AcmeWidgets.Size`
-- `acme-widgets` is not a dependency of this package
That cost falls on whoever exposes the type, and it is the design pressure it looks like: a type in a public signature is part of the package's interface, and a package that puts a dependency's type there has made that dependency part of what it asks of its users. Wrapping the type in one of its own is how a package chooses not to.
Two modules under one name is an error
Two wrapped dependencies cannot collide, whatever they contain, and that is what the namespace
is for. Unwrapping is what puts names in the same space: an unwrapped dependency's modules sit
beside the depending package's own modules, beside zelkova-core's, and beside any other
unwrapped dependency's.
Two modules answering to one name in one package is an error. It is reported when the build is resolved, before any module of that package is compiled, and it names both modules and the packages they come from:
error: two modules are named `Size` in package `todo`
acme-widgets 1.2.0, imported unwrapped
fmonniot-ui 3.0.1, imported unwrapped
It is not reported at an import line, and not only in the files that would have been
ambiguous. The manifest is what created the ambiguity and the manifest is what has to change —
by wrapping one of the two, or by renaming the package's own module — so a build that has no
coherent answer for a name is stopped before any file is read for one.
Known gap: a collision that involves a module of a test-dependency, whether with one of the
package's own modules or with a module of a plain dependency, should be reported with the
others, before any module of the package is compiled. It is reported only after the package's
src/ has been checked and every test-dependency compiled, and not at all when either fails:
the compiler learns which modules a test-dependency exposes by compiling it, and a
test-dependency may depend on the package, so it is compiled after that package's src/
(docs/tickets/bug-42.md).
Tests
tests/ is the package's second source root. A module under it is an ordinary module, with
one difference: it is compiled when this package's own tests are run, and never otherwise.
A package that depends on this one does not compile it, does not resolve what it needs, and
cannot observe that it exists.
A test module may import any module of its own package, the private ones included. A package whose internals could only be tested through its public surface would be pushed into exposing them. It may also import the public modules of the package's dependencies and of its test-dependencies.
Nothing may import a test module. A module under src/ cannot, which is what keeps tests/
out of what the package ships.
What a test is
A test is a value a module under tests/ exposes whose type is Test. Every such value is
a test, and nothing else in the module is one: a test module may declare and expose helpers, and
a helper whose type is not Test is never run.
Test is a type zelkova-test declares and exposes without its constructors, and the rule
above names that one. It reaches a test module through
test-dependencies and nothing else, so a module under src/ cannot
name it at all; a test module writes import Test.
A runner has the type to go on and nothing else: no name is fixed and no manifest field lists
them. A test is exposed even though nothing imports a test module, because a runner reads the
module's interface and the exposing clause decides what is in one.
What a Test holds — a name, a check, a group of other tests — belongs to zelkova-test and to
any library built on it. How a runner is invoked and what it reports is
the toolchain's.
test-dependencies
test-dependencies maps package names to entries of exactly the shape dependencies takes.
It is required and may be empty.
A package listed there is available to tests/ and to nothing else.
A package name appears in at most one of the two maps. Anything already in
dependencies is usable from tests/ without being written twice.
The rest of the resolution rules are unchanged and apply to the union of the two maps. At most
one version of each package is in the build, only direct dependencies are usable, and
zelkova.lock records what was chosen for both.
The graph is acyclic too, with the package's src/ and tests/ counted as two nodes. So a
test-dependency may depend on the package being tested: that dependency is the package's
src/, the test-dependency is compiled against it, and the package's tests/ is compiled
against both. A chain that returns to the package through dependencies alone is still a
cycle. This holds for the package whose tests are being run and no other, since no other
package's test-dependencies are resolved.
Programs
A package whose manifest has no main field is a library: it is something other packages
depend on, and it does not run.
main names one module, by its name within the package, which must be a module under src/
and must expose a value called main. Naming it in the manifest rather than fixing it by
convention means the entry point is findable in the same file as everything else about the
package, and a module can be a program's entry point without its name having to say so.
main must have type Task (). A
Task describes work, and running the program is
running it: the runtime is handed the value
main names and performs what it describes.
module App exposing (main)
main : Task ()
main =
Task.succeed ()The () is the whole of what a program produces: nothing is waiting on a result, so a main
of any other type would name a value nobody reads.
It also decides what a program does about a boundary failure. Every primitive effect carries a
Result Failure in its payload and Task ()
carries no payload, so a program reaching main has said what to do with each of them.
A package can be both. main and private-modules are independent, so a program may also be
depended on as a library, and the module holding main may be one of the private ones.
Not implemented: the block above does not compile under the spec harness, which has no
Task among its interfaces.