Gradual typing#

Nupp is a gradual superset of LuaJIT's Lua, so every valid LuaJIT program is already a valid Nupp program. A file's extension says how strictly it is checked, which puts that decision where the file is rather than in a setting that governs everything at once.

module models

export record Point
    x: number
    y: number
end

export function scale(p: Point, k: number): Point
    return new Point(x = p.x * k, y = p.y * k)
end

Saved as .nupp, that file is held to the strict floor. Saved as .g.nupp it is the same program, checked the same way, without that floor underneath it.

File extensions#

Four extensions divide the typed layer from plain Lua, and the strict floor from the gradual one.

Extension Floor Effect
.nupp strict unknown variables and untyped exports are errors
.g.nupp gradual the same typed syntax, without that floor
.d.nupp gradual declares an interface somebody else implements
.lua gradual plain Lua syntax; recognized type comments are imported

The toolchain requires, builds, and runs a .lua file unchanged. Nupp syntax is still refused because the runtime file must remain Lua, but LuaCATS, EmmyLua, and typed LuaDoc comments are always imported as gradual checker facts:

local function double(n: integer): integer
    return n * 2
end
nupp check helpers.lua
error: NUPP1006: a type annotation is not plain Lua

Write the same contract in a Lua file without changing its runtime syntax:

---@param n integer
---@return integer
local function double(n)
    return n * 2
end

Malformed or unsupported comment types recover locally to any and report a NUPP1008 warning. There is no setting that disables comment ingestion. See LuaCATS for migration and compatibility.

A .d.nupp file is gradual because it describes an interface somebody else implements, where any is often the type the interface actually has: string.buffer's encode(v: any): string does take any Lua value, and no annotation written here changes what LuaJIT accepts.

Dive deeper

The floor lives in the file name rather than in a project setting or a pragma. A project-wide setting has one value, so the unit of migration becomes the whole project and nobody schedules that. A marker inside the file is invisible where files are listed, reviewed, and searched, and is silently copied or dropped when a file is duplicated or rewritten.

A manifest strict key is refused for the same reason, with an error naming the extensions that replaced it. A manifest key and a file name can disagree, and then the file lies to the person reading it.

See NEP 2 for more information.

Strict floor rules#

The strict floor adds two rules. An unknown variable is reported instead of typing as any, and an exported declaration without an annotation is reported too, so nothing untyped crosses a module boundary.

module models

export function double(n)
    return n * factr
end
nupp check models.nupp
error: NUPP2106: an exported function must write its public result type
error: NUPP2106: an exported function must write every public parameter type
error: NUPP2105: unknown variable "factr"

That is the whole difference. The typed syntax, the checker, and the generated Lua are identical either way, and the same file under .g.nupp reports neither. See Strict floor for where the two rules sit among everything the checker does to every file.

Renaming is the migration#

Write .g.nupp while a file is being typed and rename it to .nupp when it holds the floor. The marker is not part of the module's name, so models.g.nupp is the module models, require("models") finds it either way, and nothing that requires the file changes when it moves. See Canonical names for how a module's name is derived from its path.

Running checks#

nupp check holds each file to the floor its extension asks for, and checks the whole project when it is given no files.

nupp check src/models.nupp

--strict holds every file to the strict floor whatever it is called, which is how to see what a rename would cost before making it.

nupp check --strict

nupp build --json reports the same diagnostics alongside what the build wrote, so one call says both what failed and what landed.

Constructs that aren't erased#

A struct has a checked fixed layout. Native LuaJIT targets lower it to FFI cdata, while portable targets may select the table-backed struct provider. A C header imports as checked declarations that load native symbols. Everything else is ordinary Lua at run time: the types are gone, and what remains is what you would have written by hand.

local struct Vec2
    x: float
    y: float
end
const __nuppMt_Vec2 = {__index = {}}
const Vec2 = ffi.metatype(ffi.typeof("struct { float x; float y; }"), __nuppMt_Vec2)

See Structs for the layout rules that lowering follows.

FAQ#

Does existing Lua need conversion?#

A valid LuaJIT program remains valid Nupp source when it stays in a .lua file. Rename a file to .g.nupp only when it needs typed syntax, then move it to .nupp when the strict floor is useful. Required modules may mix these extensions in one project.

Do file extensions change module identity?#

The extension chooses the typed layer and strict floor; it does not become part of the module name. require("models") continues to resolve models.g.nupp after it becomes models.nupp. Its module models declaration does not change, and declarations remain private or exported exactly as written. See Modules for the rest of the module model.

Do types exist at runtime?#

Annotations, generics, interfaces, affine policies, and most other checked constructs erase when source lowers to Lua. A struct retains a runtime representation because its checked layout is the feature, and C declarations remain runtime bindings because they load native symbols. Ordinary typed code acquires no type registry or runtime checker.