Reflection#

Reflection asks what a declared type means without making an instance carry the answer. Record.reflect() returns at runtime, and nupp.reflect(T) returns while the program is compiled.

local record User
    id: integer
    name: string = "anonymous"
end

local info = User.reflect()
print(info.name, info.fields[1].name)

The two forms are separate:

Where it runs Entry point Result
Comptime nupp.reflect(T) an immutable semantic graph used to generate or validate code
Runtime Record.reflect() one cached immutable descriptor for that record

Both describe declarations, not object layout. For the target-specific layout of a struct, use nupp.sizeof, nupp.alignof, nupp.offsetof, and soa.layoutof. See Layout reflection for what those report.

Runtime reflection#

Record and struct declarations have type Type<T>, distinct from their instances. A Type<Interface> accepts declarations whose instances implement that interface, so registries can retain heterogeneous declaration witnesses without accepting ordinary values. A concrete Type<Record> or Type<Struct> still identifies only that declaration.

Call Record.reflect() when runtime code needs the declaration. It returns the same descriptor on every call. The descriptor is read-only and exposes the record's kind, name, fields, annotations, and fingerprint, plus type, the original Type<Record>.

local record User
    id: integer
    name: string = "anonymous"
end

local info = User.reflect()
assert(info.type == User)
assert(info == User.reflect())
assert(info.fields[1].name == "id")

The compiler emits descriptor data when a declaration witness is used for reflection, a binding, or a derive that requests it. Merely constructing an instance does not request the descriptor.

Extensions#

info:extension(key) is the cache boundary for work derived from a descriptor. nupp.reflect.extensionKey creates a typed key with a provider; the first call builds it against that descriptor, and every later call with the same key returns what was built. Key identity is assigned a dense process-local slot rather than a name, so two extensions that happen to describe the same thing stay separate without making object keys the host's cache representation. Slot numbers are not persistent identifiers.

The state of a build in progress is kept as well as the finished one. An extension whose build reflects its way back to the descriptor it is being built for is reported as a recursive initialization instead of recurring until the stack runs out, and a build that failed once is reported the same way every time rather than retried.

Applications may define their own typed extension keys. Serialization also caches schema extensions lazily, keyed by the selected binding, codec, protocol policy, and direction. See Serialization bindings for how external models own those choices.

Type witnesses#

Every record and struct has a visible nominal type value: its declaration name is a Type<T>, distinct from an instance. A record witness remains the record's ordinary runtime table, while a struct witness is its LuaJIT ctype. Constructors, static members, and method dispatch keep their runtime behavior. See Names hold their table for how the two stand apart in the type system.

local record User
    id: integer
    name: string
end

local witness: Type<User> = User
local user: User = new User(id = 7, name = "ada")

assert(witness == User)
assert(User ~= user)

An API that accepts a declared record or struct takes Type<T>. A record name is never a metatable<T>, which remains for an explicit table passed to Lua's metatable functions.

Dive deeper

A caller supplies the declaration and nothing else. Passing a separate type witness beside every value would thread it through every generic that forwards the call, so a signature with nothing to do with reflection would acquire a parameter because something three layers down wanted one. Taking the declaration itself keeps the witness where the API needs it and out of every signature between here and there.

Serialization bindings#

A type witness supplies neutral field access and construction. A binding chooses how that declaration participates in a data model, and a codec chooses the wire representation:

local serde = require("nupp.serde")
local json = require("nupp.serde.json")
local record User
    id: uint32
    name: string
end
local binding = serde.binding(User)
local text = json.encode(binding, new User(id = 7, name = "Ada"))
assert(json.decode(binding, text).name == "Ada")

A declaration may have several bindings. Protocol names, field mappings, and conversion rules belong to the selected binding or codec policy, rather than to a universal representation attached to the type. See Serialization bindings for mappings and runtime models.

Comptime reflection#

nupp.reflect(T) resolves a type in type position and hands comptime an immutable, target-independent descriptor. It is for generators and validators, and it adds no runtime descriptor to the program.

local record Position
    x: number
    y: number
end

const PositionCodec: nupp.reflect.FieldCodec<Position> = comptime do
    return nupp.reflect.fieldCodec(nupp.reflect(Position))
end

local function encode(position: Position): {[string]: any}
    return PositionCodec:encode(position)
end

Descriptor graph#

The descriptor is an acyclic indexed graph, which is what keeps a recursive type finite: root selects a node in types, and every edge is an index into that same array. It covers nominal records, interfaces, and structs; shapes, fields, and indexers; function signatures and packs; generic arguments; unions and intersections; ownership wrappers; and arrays, pointers, and C types. The root's kind, name, fields, annotations, and fingerprint are available directly as well.

Comptime code may read descriptor members, use #, and traverse arrays with deterministic ipairs or pairs. Views keep their identity for equality, reject mutation, and cannot escape as runtime tables.

Annotations and fingerprints#

Checked typed annotations travel with the descriptor as an ordered annotations array, and an @ref argument is an edge into the same graph. Annotation names, arguments, values, and referenced types all contribute to the fingerprint, which is computed from the canonical semantic graph rather than from the checker's process-local type identities.

A generated result is invalidated when an annotation changes as well as when a field type changes. Changing @debug(redact = true), for example, changes the descriptor fingerprint and invalidates results derived from it.

Field codecs#

nupp.reflect.fieldCodec is a materialization boundary. It produces a nupp.reflect.FieldCodec<R> for the same nominal record R and copies exactly that record's declared present fields with rawget. Encoding and decoding are shallow; decoding selects the same fields from a keyed table and restores the record's runtime identity. Its compatibility fingerprint is t: followed by those field names in declaration order, and the declared codec type must name the same record. Reflection of a runtime value, an unresolved type, or a non-record codec input is refused. See Opaque results materialize at a declaration for the rule that governs where the result may land.

FAQ#

Does reflection add metadata to every record?#

No. A record gets a runtime blueprint only when the program calls reflect() on it or derives something that needs one, and the descriptor is built on the first call rather than at declaration. See Runtime reflection for what the registry costs.

Should a generator reflect at comptime or at runtime?#

Reflect at comptime when the result is code or a validation that can be decided while compiling, and at runtime when the program has a value in hand and needs its declaration. Comptime descriptors are target-independent and leave nothing behind; runtime descriptors are cached per record and carry the extension cache.

Can reflection report a struct's memory layout?#

No. Reflection reads declared meaning, so a field's offset is not one of its results. Use nupp.sizeof, nupp.alignof, nupp.offsetof, and soa.layoutof, which report against the build's layoutTarget.