Modules#

A declared module is one real source file and one real Lua module. The first declaration gives its canonical name, and export defines its public surface:

module geom.shapes

local type Coordinate = number

export record Point
    x: Coordinate
    y: Coordinate
end

export const originName: string = "origin"

export function origin(): Point
    return new Point(x = 0, y = 0)
end

The file has its own private lexical scope. It does not create a global geom, share locals with another file, or need a companion declaration file. There is no final module-value return; the compiler creates and returns a stable export table.

Canonical names#

The declared name must equal the module name derived from the file's configured include root. For example, src/geom/shapes.nupp declares module geom.shapes. The .g marker in shapes.g.nupp is not part of the name, and a final /init is erased. See Build system for where a project's include roots are configured.

Only that canonical name may be used by static imports. This prevents one file from being initialized twice under different package.loaded keys. A .d.nupp file describes an external interface and cannot declare a source module.

Module segments use luacase: lowercase words run together, such as nupp.runtime.hotreload and nupp.workers.native.

Exports and privacy#

Declarations are private unless marked export:

module data.counter

local function clamp(value: integer): integer
    return math.max(0, value)
end

export type Count = uint32

export interface Reader
    read: function(self: Reader): Count
end

export function normalize(value: integer): integer
    return clamp(value)
end

Functions, records, interfaces, structs, type aliases, and constants can be exported. An exported alias or interface exists only for checking. Records and structs also export their runtime constructor value. Exported functions must write their parameter and result types so a dependent never learns a public contract by inspecting its body.

global is not allowed inside a declared module. Use export for its public surface and local or const for private names. Exporting a nominal does not change the visibility of members inside that declaration.

Dive deeper

A module is its own public declaration. No ambient table and no companion declaration file repeats that surface, and this one constraint rejected most of the design space: any option that produced a second description of a module's surface was ruled out however cheap it was to build, because the second copy is the one that goes stale. See NEP 7 for more information.

Internal modules#

A leading @!internal restricts checked imports to the package namespace that owns the module. The owner is its first canonical segment: paint owns paint.render, paint.internal.cache, and paint.tools.builder. Another namespace, such as app, cannot statically require their internal modules or name their values or types through qualified paths (NUPP2144).

An internal segment and an underscored module basename also mark a module internal. On init.nupp or init.g.nupp, @!internal applies to the namespace and every module below it. Public modules can use and wrap their own internal modules; the implementation types of a public return value do not make the public import illegal.

This is a checked API boundary. Dynamic or shadowed Lua require remains gradual; it is not an access-control sandbox.

A module with children lives in name/init.nupp (or name/init.g.nupp for gradual source). Keeping name.nupp beside name/child.nupp is rejected during project registration. A leaf module stays in a single named file until it acquires children.

Explicit imports#

require stays the explicit, Lua-shaped import:

module app.main

const shapes = require("geom.shapes")

export function makePoint(): shapes.Point
    return new shapes.Point(x = 1, y = 2)
end

A literal call through the unshadowed builtin is a static dependency and is checked against the declared interface. A dynamic name is a boundary nothing declared: in a strict file what comes back is unknown, to be narrowed or cast before it is used, and in a gradual file it is any. A locally shadowed require is an ordinary call of a function declared to return any, whatever the file is.

Naming a member from another file#

A project file does not put its basename into every other file's scope. Reading a member of an unbound module is reported:

local answer: number = mathutil.double(21)

Bind the module first, and the diagnostic names the exact require call that makes the program valid:

local mathutil = require("mathutil")
local answer: number = mathutil.double(21)

Brace selection#

Brace selection imports several values without repetitive field reads:

const {origin, originName as label} = require("geom.shapes")

as changes the local binding name. The syntax is the same generic shallow selection accepted by local and const for records and structural tables; it is not a module-only destructuring form.

Binding patterns are shallow, and they are not allowed in function parameter declarations. Braces at a call site instead pluck named parameters from an existing value:

draw({x, y} = point, color = "blue")

See calls.md for the argument forms that brace stands for.

Type-only selection#

An erased type selection is available for declared modules:

const {
    type Point as ShapePoint,
    origin as makeOrigin,
} = require("geom.shapes")

A statement containing only type selections emits no runtime require. Selecting a record without type binds its runtime declaration value. Select its type separately with type when both are needed; the two bindings may use the same name. An erased alias must be selected with type.

Qualified module paths#

The formatter turns absolute module and type references into explicit local imports by default. Qualified paths remain valid source syntax with the resolution rules below.

A registered package root lets an unshadowed dotted path name a declared module directly:

module app.read

export function read(pointer: voidptr, count: integer): nupp.mem.span.Span<uint8>
    return nupp.mem.span.fromCarray(pointer as uint8*, count)
end

The compiler resolves the longest registered module prefix. The remaining segments must be exported members; a miss is diagnosed rather than falling back to any. The facility is generic, so dependency roots such as tecs.world.query.each(...) work the same way as nupp. See nupp.mem.span for the module the example names.

A lexical binding wins:

local tecs = makeTestDouble()
tecs.world.query -- ordinary field access on the test double

Language intrinsics such as nupp.pin, nupp.borrow, nupp.sizeof, and nupp.types keep their compiler meaning and are reserved against module or export collisions.

Qualified access is lazy at the module boundary, not at every field access. Each selected module becomes one hidden direct import in the containing Lua chunk, and repeated source accesses reuse it:

local __nuppModule = require("nupp.mem.span")
return __nuppModule.fromCarray(pointer, count)

There is no per-call loader, proxy, metatable guard, or injected helper. A module removed with dead code is not selected; a live reference loads once when its containing module initializes, and Lua's require cache owns reuse. Use an explicit dynamic require when runtime-first-use loading is genuinely needed. A qualified type path creates no runtime import when it is used only as a type.

Grouped checking and cycles#

The compiler derives the static dependency graph and checks mutually dependent modules as a group. This is generic project behavior, not a special standard library mode and not a source keyword. Files keep separate lexical scopes, generated chunks, caches, and Lua module identities.

Before checking bodies, the group publishes written exported type and function signatures. Mutually referring exported types and functions therefore keep their real types instead of degrading to any:

module a
const b = require("b")

export function fromA(value: integer): integer
    return b.fromB(value)
end
module b
const a = require("a")

export function fromB(value: integer): integer
    return value + 1
end

export function throughA(value: integer): integer
    return a.fromA(value)
end

Runtime initialization is still eager. Each module publishes its stable export table and hoisted function closures before loading dependencies. That makes the cycle above safe: neither function is called until both modules finish loading.

A cycle is not safe when top-level evaluation immediately reads or calls an export that the other module has not initialized. Moving that work behind an exported function breaks the temporal dependency. Grouped checking makes names available early; it does not invent results for cyclic top-level computation.

An initialization failure clears the partial package.loaded entry and rethrows the original error rather than leaving a half-loaded module cached.

Incremental checking#

The project index first extracts parser-only headers: canonical name, raw exports, written signatures, locations, and dependencies. The checker then elaborates those headers into typed interfaces. Recursive requests inside an active group see the already-published interface rather than an any placeholder.

Changing a private function body rechecks that module without changing its public interface. Changing an exported signature invalidates dependents. Each module remains its own cache and build output even when several interfaces are checked together.

Migrating a table-shaped module#

Existing Lua-shaped modules keep working. A file that builds a table and returns it needs no change:

local shapes = {}

record shapes.Point
    x: number
    y: number
end

function shapes.origin(): shapes.Point
    return new shapes.Point(x = 0, y = 0)
end

return shapes

Such a file does not gain declared-module cycle behavior or qualified namespace access. Qualified paths resolve only declared modules, so migration stays explicit and cannot accidentally reinterpret an arbitrary Lua table as a package tree.

export = value adopts a declared identity without an internal rewrite:

module geom.shapes

local shapes = {}

record shapes.Point
    x: number
    y: number
end

function shapes.origin(): shapes.Point
    return new shapes.Point(x = 0, y = 0)
end

export = shapes

The value is evaluated once and becomes the module value itself, preserving table identity, mutable fields, and callable metatables. It adds no loader, proxy, field copy, or wrapper to exported calls, and it keeps ordinary Lua initialization, which makes it gradual across an active cycle.

The two forms do not mix for anything that carries a runtime value. A module either exports its declarations, and the compiler builds the table, or it names its value, and that value is the module; an export record, export function or export const beside export = has no table of the compiler's to land on, and reports NUPP2143. Write it as a member of the value instead, the way record shapes.Point does above. An export interface or export type is erased and stays legal beside one, because it contributes a type and no value.

Prefer individual export declarations in new code. Their compiler-created stable table and written interfaces participate fully in grouped cycle checking, which the migration form does not.

Tooling#

Formatting, semantic highlighting, definitions, rename, and completion understand module, export, brace selection, and qualified paths. Completion on a registered namespace lists child modules and exports. Definition on an export reaches its declaration, and an exact module segment reaches the module declaration.

FAQ#

Does a declared module load with plain require?#

Yes. The compiler returns a stable export table under the module's canonical name, so Lua code and other Nupp files load it the same way. See Explicit imports for the checked form.

Can two modules require each other?#

Yes, as long as neither reads the other's exports while its own top level is still running. See Grouped checking and cycles for what the checker publishes early and what stays eager at runtime.

Does a qualified path load its module on first field access?#

No. Each selected module becomes one hidden import that loads when the containing chunk initializes. See Qualified module paths for the code that generates.