Affine types#
An affine type is a compile-time-generated view of an existing representation. It adds a move or cleanup obligation to the checker without adding a runtime wrapper.
local record Mutex
locked: boolean
end
local record LockToken
mutex: Mutex
end
local function unlock(takes held: LockToken): nil
held.mutex.locked = false
end
local type HeldLock = affine(LockToken, unlock)Cleanup identity is part of the type#
affine(LockToken, unlock) looks like a function call because it is a built-in compile-time type-generator call. It is never a runtime call:
LockTokenis a compile-time type value.unlockcontributes its const function declaration identity, not a callback stored in each value.- the result has the same runtime representation as
LockToken.
Two functions with the same signature therefore create different affine types, and aliasing a generated type does not create a new nominal identity:
local type AlsoHeld = affine(LockToken, unlock) -- the same type as HeldLockDive deeper
Angle brackets apply a declared generic type such as Box<T>, and parentheses call a compile-time type generator. Keeping those two operations visibly distinct is what lets one syntax serve both the built-in generators and user-defined comptime type functions, so a package that wants its own policy constructor writes an ordinary comptime function rather than asking for a language keyword. The cost is that affine(T, cleanup) reads like a call at a glance, and the payment for it is that nupp.types.affine and every user generator are written the same way. See type-level-computation.md for the rest of the comptime type surface.
Cleanup and transfer-only forms#
The two forms are calls to the same generator:
local type Owner<T, const cleanup: function> = affine(T, cleanup)
local type MustForward<T> = affine(T)affine(T, cleanup) carries one obligation to invoke exactly cleanup, whose type must be function(takes T): nil; it may suspend. affine(T) carries an obligation that may be moved, returned, released through an unsafe boundary, or placed into another affine value, but it has no local cleanup and therefore cannot be dropped.
Both forms erase to T. Neither evaluates T nor calls cleanup when the type is constructed, and cleanup runs only when a runtime value of the generated type is explicitly dropped or reaches automatic lexical destruction. See consumption and lexical destruction for when that happens.
Constructors can introduce the policy#
A record constructor may make the affine view the default result of new:
local record File
descriptor: integer
constructor(self, descriptor: integer): affine(File, File.destroy)
self.descriptor = descriptor
end
function read(self, count: integer): string
return nativeRead(self.descriptor, count)
end
function destroy(takes self): nil
nativeClose(self.descriptor)
end
end
do
local file = new File(nativeOpen("notes.txt"))
print(file:read(128))
end -- calls File.destroy exactly onceThe result annotation does not replace File with a wrapper. It says that this constructor introduces one File.destroy obligation on the File it already builds, so file:read(...) needs no common interface, forwarding object, or conversion. File.destroy is a normal method declaration whose function identity is used by the type and whose function value is registered for lexical destruction.
The annotation must contain exactly one result and erase to the record being constructed. Omitting it preserves ordinary GC-managed construction. Constructor overloads may state different policies: argument overload selection happens first, and the selected entry supplies its result policy. See constructors and result policies for what else a constructor result may say.
Comptime type generators#
A user-defined comptime type function can call the programmable counterpart of the direct form:
local function MakeOwner(
T: type,
const cleanup: function
): type
return nupp.types.affine(T, cleanup)
end
local type HeldAgain = MakeOwner(LockToken, unlock)nupp.types.affine builds the same types affine(...) does, including the transfer-only nupp.types.affine(T). Its cleanup argument must come from a const function parameter, so declaration identity stays static and unforgeable.
Generic capability preservation#
A generic need not know whether a value is affine. takes permits the checker to move a capability when one is present, and preserves relates that capability to the result:
For an ordinary value this is an ordinary pass-through. For an affine value its single obligation moves to the result rather than being copied, which is what lets one generic API serve affine and ordinary types without overloads or an Affine interface special case.
The relation reaches inside a type the result wraps, so a constructor that stores its argument preserves the capability into the unique T component of the result. See generic preservation for the complete set of shapes it follows and the ambiguity it refuses.
Dive deeper
Preservation is a separate relation rather than a property of the type, because it is linear flow information and not a subtyping fact. A generic record is an ordinary first-order application, and preserves supplies the conservation proof beside it, which is why none of this needs higher-kinded generics. HKT would only be relevant to an API abstracting over the container itself as a type constructor, and it would still not supply the proof that the obligation moved exactly once. See NEP 4 for more information.
FAQ#
Why track ownership when LuaJIT already has GC finalizers?#
ffi.gc attaches runtime finalization to every resource and makes the garbage collector discover and dispatch its cleanup. luajit bench/ownership.lua compares that path with explicit cleanup around the same malloc and free; on LuaJIT 2.1 for arm64, finalization costs roughly an order of magnitude more per resource. Nupp's affine policy exists only during checking, adds no per-value wrapper, finalizer registration, or tracing work, and lexical destruction performs like the equivalent explicit cleanup within measurement noise.
Cleanup timing also controls capacity. The garbage collector sees a small Lua wrapper, not the file descriptor, socket, lock, or native allocation behind it, so a program can exhaust its file-descriptor limit before enough wrappers trigger collection, or keep another task waiting on a lock whose unreachable guard has not been finalized. An affine terminal runs at the scope boundary; a finalizer remains useful only as a last-resort safety net.
Why not call cleanup manually?#
Calling close, unlock, or free directly works only when every return, raised error, and transfer follows the protocol. An affine terminal makes that protocol part of the type, so the checker rejects a forgotten obligation, a second consumption, or a use after the value moved.
Does every Nupp value use ownership?#
No. Strings, numbers, tables, and records without a nontrivial capability retain ordinary Lua behavior and need no annotation, and a record constructor stays ordinary unless its result introduces a policy. See public capability contracts for the parameters that do need a mode.
Does an affine type change the runtime representation?#
No. It adds no wrapper, cleanup field, tag, or vtable, and erases to its representation, so a C pointer remains a C pointer and a struct keeps the layout declared at the boundary.