Skip to content
Noodle
InstallLearnPlayground
GitHub

Modules and packages

Each .nl file defines one source module. Each source directory is one package. These two boundaries are separate: a package contains modules, but it does not add another source-level namespace by itself.

The filename price_tools.nl derives the module name PriceTools. A module header, when present, must match that derived name:

module PriceTools
export func subtotal(price : Int, quantity : Int) -> Int do
price * quantity
end
func apply_internal_fee(value : Int) -> Int do
value + 1
end

Top-level declarations are private by default. export adds a declaration to the module’s public signature. Another module may call PriceTools.subtotal, but cannot name apply_internal_fee.

A source file without a module header can still be checked or selected as a program entry, but no other source module can reference it. This is why the first-program tutorial could omit a header safely. The header is optional syntax, not a second way to choose a module’s name: when present, it must still match the name derived from the filename. A leading underscore is not part of that derived name: _helpers.nl derives Helpers, so a matching module Helpers header keeps the file package-internal.

Noodle has no import declarations. A visible module is used through qualified names. In main.nl:

module Main
func main() -> Unit do
total = PriceTools.subtotal(12, 3);
Debug.trace(total);
end

Both files live directly in the same package directory:

shop/
main.nl
price_tools.nl

Check the package and run the selected entry file:

Terminal window
noodle check shop
noodle run shop/main.nl

The entry file must declare exactly one non-generic main with no parameters and a Unit body result. main does not need to be exported.

Noodle also has a source-part mechanism for physically splitting one module across several files. A part is not another module: it shares the primary file’s module header, private namespace, checking state, and public signature. Declarations in different parts can therefore refer to one another as if they had been written in the same source file. A common use is moving a module’s tests into one or more *.tests.part files while keeping the tests close to the private implementation they exercise.

The primary .nl file selects parts with a top-level include directive. The part filename is a basename, not a path, and its companion file is tied to the primary file:

compiler/frontend/parser.nl
compiler/frontend/parser.nl.cursor.part
compiler/frontend/parser.nl.types.part

The corresponding directives are written in parser.nl:

module Parser
include "cursor.part"
include "types.part"

For a module whose implementation and tests are split separately, the primary file can include both kinds of part explicitly:

compiler/typing/function_bodies.nl:
include "functions.part"
include "functions.tests.part"

The compiler accepts an optional module header and include directives only in the primary file. A .part file contains complete top-level declarations; it cannot declare another module or include another part. Part names use the lowercase, dot-separated .part form. The position of each include and the order of the directives determine where its declarations enter the module; the compiler does not infer parts from directory order or filename sorting. Only explicitly included parts participate in the module.

Parts retain their own file identity. Diagnostics and autofilled source locations point to the .part file where the declaration or expression was written, rather than pretending that all text came from the primary file. Parts do not create separate modules, .nli signatures, JavaScript module artifacts, package entries, or .extern.mjs companions; one primary module has one artifact and one external companion boundary.

Treat .part as a last resort, not as the normal way to organize a package. It is useful for quickly splitting tests out of implementation code, for generated parser/compiler sources, or for a genuinely shared private scope where extracting a module would create an artificial interface or a module cycle. A test part is still part of the production module: it does not become an independently importable test module or receive a separate signature.

For a reusable subsystem that will be maintained over time, prefer several ordinary modules with explicit export boundaries, and keep tests in the module or package layout that best reflects those boundaries. That refactoring makes ownership, dependencies, and test scope visible to readers instead of preserving one very large shared scope.

Only .nl files directly inside the package directory belong to that package. A child directory is a separate package; it does not inherit the parent’s manifest or gain privileged access to the parent.

A package whose directory name starts with _ (such as app/_tools) is package-internal: only the package rooted at its immediate parent directory (app) may declare it as a dependency and import its modules. A sibling package, an unrelated package, a grandparent package, or a transitive consumer that reaches it directly gets an internal-package-dependency error. The check uses canonical package directories, so .. path components and symlinks cannot bypass it. This is separate from package-internal modules (_helpers.nl): the module rule restricts which file inside a package may import one file, while the package rule restricts which package may depend on a whole child package.

Without nlpkg.json, a package has no direct dependencies except the default standard library. Add a manifest only when package-level configuration is needed. A manifest’s dependencies names the packages whose modules the current package may reference:

app/nlpkg.json
{
"dependencies": ["../domain"]
}

A dependency’s modules are then used under their ordinary qualified names, exactly like modules of the same package. Here app declares domain as a dependency and calls the Order module defined there:

app/
nlpkg.json
main.nl
domain/
order.nl
domain/order.nl
module Order
export datatype Order
Order{unit_price: Int, quantity: Int}
end
export func subtotal(order : Order) -> Int do
order.unit_price * order.quantity
end
app/main.nl
module Main
func main() -> Unit do
order = Order.make(?{unit_price: 120, quantity: 3});
Debug.trace(Order.subtotal(order));
end

The same qualified-name syntax works whether the module lives in the current package or in a declared dependency; the manifest is what makes the other package’s modules source-visible.

Dependency paths are resolved relative to the manifest’s package directory. They must begin with ./ or ../ (or be the exact special name stdlib). Only direct dependencies become source-visible; dependencies are not re-exported transitively.

A dependency may declare a source namespace in its own manifest:

{
"namespace": "Acme.Tools"
}

Consumers then use the full path, such as Acme.Tools.PriceTools.subtotal(12, 3). Namespaces affect source references, not the package’s physical identity.

noodle build, run, and test write derived files under .nlc/. Source modules remain separate modular ESM artifacts. .nlc is compiler-owned output: it does not define modules, and handwritten .nli files are not authoritative source interfaces.

Only the generated JavaScript modules are guaranteed as consumable build output. Every other file and directory under .nlc/ is compiler-internal state; tools must not depend on its format, name, or layout remaining stable.

You can now build useful ordinary programs. Continue to Advanced topics when you need reusable abstractions or explicit effect contracts, or open the Standard library to learn collections and common APIs.