NEP 2: Gradual typing and the strictness floor#
Created: 2026-08-19 · Status: Implemented
Summary#
Nupp is a superset of LuaJIT's Lua: every valid LuaJIT program is already a valid Nupp program. How strictly a file is checked is decided by what it is named, not by a project setting or a pragma, and because the extension is not part of the module's name, tightening a file is a rename that changes nothing requiring it.
Goals#
- Let an existing codebase adopt Nupp without a conversion step, a configuration file, or a flag day.
- Make a file's strictness visible from the file, without opening it.
- Keep one checker and one code generator: a gradual and a strict file differ in what is reported, not in what is understood or emitted.
Non-goals#
- Soundness. The type system has deliberate holes and they are written down.
- A second dialect with different semantics.
- Inferring strictness from content.
Motivation#
Two failures stop gradual type systems from being adopted, and they pull in opposite directions.
A project-wide setting has one value, so turning it on means fixing every file at once. Nobody schedules that, so it stays off, and while it is off nothing enforces the parts already finished.
Strictness declared inside the file, by a --!strict comment or a pragma, is invisible in a directory listing, in a review diff that does not reach the top of the file, and in a code search, and it is copied by accident when a file is duplicated and dropped by accident when one is rewritten.
A file name is the one property every tool already shows, and a file is the smallest thing a person edits, reviews, and moves.
Overview and specification#
Syntax#
There is none. The declaration is the file's extension:
.lua: plain Lua, and the typed layer is refused in it..g.nupp: typed syntax with no floor beneath it..d.nupp: declares an interface something else implements..nupp: typed syntax, held to the strict floor.
Worked example#
The same file under two names is the same program, checked the same way, with and without the floor beneath it:
local function scale(point, factor)
return {x = point.x * factor, y = point.y * factor}
endlocal function scale(point: Point, factor: number): Point
return new Point {x = point.x * factor, y = point.y * factor}
endBoth are the module models, and require("models") finds either.
Floor rules#
An unknown variable is an error rather than a global read, and an exported declaration needs an annotation. Keeping the difference that small is what makes the cost of a rename predictable before making it.
Lowering#
Annotations, generics, interfaces, and affine policies erase, so the generated Lua for the strict file above is the generated Lua for the gradual one:
local function scale(point, factor)
return setmetatable({x = point.x * factor, y = point.y * factor}, Point)
endTwo constructs survive, because their runtime representation is the reason someone wrote them. A struct becomes FFI cdata with a fixed layout:
local Vec2 = ffi.metatype(ffi.typeof("struct { float x; float y; }"), Vec2_mt)A cdef declaration is the other, and it stays a runtime binding because it loads a native symbol. Nothing acquires a type registry or a runtime checker.
Annotations in .lua are refused#
An annotation written into a .lua file reports NUPP1006. The extension has already settled that the file is Lua, so the annotation would govern nothing, and a construct that silently governs nothing is worse than one that is refused.
Risks and assumptions#
- This assumes renaming is cheap, which holds because version control tracks renames and module identity is unaffected. A build system keyed on paths would break the migration story.
- A project can sit in
.g.nuppforever.nupp check --strictreports what a rename would cost, but it is a report and not a ratchet. - Four extensions is a surface to learn. The bet is that each is guessable and a reader meets at most two in an ordinary project.
- "Types erase" is a useful lie, and
structis exactly where a reader is most likely to assume erasure and be wrong about memory.
Alternatives considered#
A project-wide setting, as tsconfig's strict does. One value for a whole project makes the unit of migration the project, and puts the answer to "is this file checked?" in a file the reader does not have open.
A pragma inside the file, as Luau's --!strict does. It gets the granularity right and was the closest competitor, but the marker is invisible where files are listed, reviewed, and searched, and can be silently lost or copied.
A separate strict dialect. The superset property is why an existing codebase can start at all, and a dialect that changes what a program means loses it on the first file.
Inferring the floor from content, treating a fully annotated file as strict. It makes the floor a consequence rather than a decision, so adding one untyped local silently demotes a module and no diff shows it.
Soundness. Every hole buys compatibility with idiomatic Lua, and closing them would reject programs that are correct and common.