nupp.derive

nupp.derive holds the three bundled derive providers and the recipe API a package uses to publish its own. @derive names a provider on a record or struct declaration. The provider decides which targets it admits and adds the closed set of checked members or data that it returns.

The bundled recipes render by appending into a string.buffer, so a lua51 target needs a text.buffer provider selected. Without one, a @derive naming a provider reports NUPP3012 on the annotation that named it. The browser backend selects one, so a page needs nothing further.

@derive(nupp.derive.Debug, nupp.derive.JSON)
local record User
    @json(name = "user_id")
    id: integer

    name: string = "anonymous"

    tags: {string} = {}
end

local user = new User()
local out = string.buffer.new()
local writer = nupp.codec.json.writer(out)
user:writeJSON(writer)
writer:close()
print(user:debug(), out:tostring())

Applying a provider is a declaration-augmentation phase, not a text macro: it cannot add imports, top-level declarations, modules, records, interfaces, or independently nameable types. The bundled providers are:

  • nupp.derive.Debug: debug(self): string and nupp.Debug conformance.
  • nupp.derive.JSON: writeJSON(writer), a static fromJSON, fieldCodec, and nupp.codec.json.JSONEncodable conformance.
  • nupp.derive.Serde: one format-neutral schema and physical binding for a record or struct, with no generated format methods.

nupp.events.Event is applied the same way and marks a record or a fixed-layout struct as an event a nupp.events source constructs and delivers.

Generated members participate in normal member lookup, generic inference, and interface checking. A written member of the same name is a compile-time conflict. Stacked @derive applications combine, but a provider cannot be requested twice. See Annotations for where @derive sits among the built-in annotations.

Debug#

debug(self): string renders a record or fixed-layout struct the way the declaration reads, so what comes back names the declaration and its fields in declaration order. The derive records a format-neutral schema and physical binding; the generic formatter prepares and caches its traversal on first use.

@derive(nupp.derive.Debug)
local record Point
    x: integer
    y: integer
end

local p = new Point(x = 3, y = -1)
print(p:debug())
Point { x = 3, y = -1 }

Strings are quoted, map keys are sorted by byte order so two runs agree, nested records render through their own debug, and a runtime table that reaches itself renders as <cycle>.

@derive(nupp.derive.Debug)
local record Tag
    name: string
end

@derive(nupp.derive.Debug)
local record Post
    title: string
    views: integer
    tags: {Tag}
    scores: {[string]: integer}
end

local post = new Post(
    title = "hello",
    views = 12,
    tags = {new Tag(name = "a"), new Tag(name = "b")},
    scores = {zeta = 1, alpha = 2}
)
print(post:debug())
Post { title = "hello", views = 12, tags = {Tag { name = "a" }, Tag { name = "b" }}, scores = {["alpha"] = 2, ["zeta"] = 1} }

Field visibility#

A @debug field annotation decides what a field contributes. redact keeps the name and replaces the value, which is what a secret wants; skip removes the field from the output entirely.

@derive(nupp.derive.Debug)
local record Credentials
    user: string
    @debug(redact = true)
    password: string
    @debug(skip = true)
    cache: any
end

local c = new Credentials(user = "ada", password = "hunter2", cache = {1, 2})
print(c:debug())
Credentials { user = "ada", password = <redacted> }

Debug and Serde on the same declaration share one schema recipe. Debug alone keeps that binding internal and does not make the declaration nupp.serde.Serializable. Code that already retains a public binding can prepare the same formatter explicitly and append without constructing the final string:

@derive(nupp.derive.Debug, nupp.derive.Serde)
local struct Vec2
    x: float
    y: float
end

local prepared = nupp.serde.prepareDebug(nupp.serde.of(Vec2))
local output = string.buffer.new()
prepared:write(new Vec2(1.25, 2.5), output)
assert(output:tostring() == "Vec2 { x = 1.25, y = 2.5 }")

Serde#

Serde derives one logical nupp.serde.Schema and one nupp.serde.Binding<T>. It applies to records and fixed-layout structs, and generates no writeJSON, fromJSON, XML, or CBOR methods. A codec prepares the binding separately and caches its format-specific data.

@derive(nupp.derive.Serde)
local record User
    id: uint32
    name: string?
end

local binding = nupp.serde.of(User)
local prepared = nupp.serde.json():prepare(binding)
local text = prepared:encode(new User(id = 7, name = "ada"))
local restored, problem = prepared:decode(text)

assert(problem == nil)
assert(restored and restored.id == 7)

The same type witness works for a struct:

@derive(nupp.derive.Serde)
local struct Vec3
    x: float
    y: float
    z: float
end

local binding: nupp.serde.Binding<Vec3> = nupp.serde.of(Vec3)

Derived fields currently admit booleans, strings, finite numbers, integers through 32 bits, optionals, arrays, string-keyed maps, and other declarations that also derive Serde. Pointer-bearing struct fields are rejected because a pointer does not describe its extent or ownership. See Schema-driven serde for dynamic schemas, profiles, extensions, and the prepared JSON path.

Event#

nupp.events.Event records an event's name and representation and asks the compiler for the declaration's initializer, so a source can construct the event into storage it already holds. It generates no members; what it adds is the nupp.events.Emittable contract that every Type<E> an event source takes is bounded by.

local events = require("nupp.events")

@derive(events.Event)
@event(name = "combat.Damage")
local record Damage
    amount: number
    source: integer
    kind: string = "physical"
end

local bus: events.MessageBus<integer> = events.newMessageBus()
bus:observe(7, Damage, |event| -> print(event.kind))
bus:emit(7, Damage, amount = 10, source = 3)

@event(name = "...") sets the name events.name(Damage) answers; the declaration's own name is the default. The name is what something outside the program pins, such as a debug protocol, which is why it is written rather than derived from a path that a refactor would move.

The derive admits a concrete record or a fixed-layout struct with one construction contract. It refuses a declaration with several constructors, a constructor that lets self escape or moves an owned parameter into a field, an affine field, and a generic owner, because none of those can run against storage that is reused: the next lease would find the reference, the moved obligation, or the shared identity already there.

JSON#

JSON generates writeJSON(writer), a static fromJSON, a fieldCodec, and nupp.codec.json.JSONEncodable conformance. Encoding writes through the checked buffer-backed writer; it does not allocate a complete result string. Record and shape fields follow declaration order and string map keys sort by byte order, so the same value always produces the same bytes. Encoded field names and literal values are cached lazily on the derived schema. If encoding fails, bytes appended before the failure remain in the buffer; reset or discard it when the surrounding operation needs atomic output.

@derive(nupp.derive.JSON, nupp.derive.Debug)
local record User
    @json(name = "user_id")
    id: integer
    name: string
end

local user = new User(id = 7, name = "ada")
local out = string.buffer.new()
local writer = nupp.codec.json.writer(out)
user:writeJSON(writer)
writer:close()
print(out:tostring())

local decoded = User.fromJSON('{"user_id": 7, "name": "ada"}')
print(decoded and decoded:debug())
{"user_id":7,"name":"ada"}
User { id = 7, name = "ada" }

Decoding errors#

fromJSON returns T?, string?, and the error names the path that failed rather than saying the document was bad:

@derive(nupp.derive.JSON)
local record User
    @json(name = "user_id")
    id: integer
    name: string
end

print(select(2, User.fromJSON('{"user_id": 7, "name": "ada", "nmae": 1}')))
print(select(2, User.fromJSON('{"user_id": "seven", "name": "ada"}')))
print(select(2, User.fromJSON('{"user_id": 1e300, "name": "ada"}')))
print(select(2, User.fromJSON('{"name": "ada"}')))
$: unknown field "nmae"
$.user_id: expected finite number
$.user_id: expected integer in range
$.user_id: required field is absent

Options#

A record decides what happens to keys it does not know.

Option Effect
@json(unknown = "reject") rejects unknown keys, and is the default
@json(unknown = "ignore") ignores unknown keys

A field decides how it appears on the wire.

Option Effect
name = "wire_name" renames the key
omit = true removes the field both ways, and requires a default
omitEmpty = true omits nil, false, empty strings and empty tables, encoding only

omitEmpty is encoding only, which is the part worth knowing: a field left out of the output is still required coming back in.

@derive(nupp.derive.JSON)
local record User
    id: integer
    @json(omitEmpty = true)
    tags: {string}
end

local user = new User(id = 7, tags = {})
local out = string.buffer.new()
local writer = nupp.codec.json.writer(out)
user:writeJSON(writer)
writer:close()
local text = out:tostring()
print(text)
print(select(2, User.fromJSON(text)))
{"id":7}
$.tags: required field is absent

Use omit with an explicit field default when a field should disappear from both directions:

@json(omit = true)
secret: string = "redacted"

Schemas#

Booleans, strings, finite numbers, exactly representable integer widths, optionals, arrays, tuples, string-keyed maps, finite shapes, and records deriving JSON are all supported. int64 and uint64 are rejected, because a JSON number cannot round-trip their full range, and the erased integer type is checked against the safe interval at run time.

Strings must be valid UTF-8, and a cycle or excessive nesting fails with the JSON path that reached it. Decoding uses Nupp's strict SIMD-accelerated codec and preserves null with nupp.codec.json.NULL while it validates the raw value.

The JSON field codec is allocated lazily as a runtime reflection extension. Use nupp.codec.json.writeRecord, writeAs(User, value, writer), and decodeAs(User, text) when a type-witness API fits better than generated members. The allocating encodeRecord and encodeAs wrappers remain available when a complete string is specifically required. See Reflection for the witness and allocation model, and nupp.codec.json for the rest of the codec.

Package providers#

A package may export a derive provider as a comptime function. Its exact signature names the one existing interface it implements:

comptime function M.derive(info: nupp.derive.Info): nupp.derive.Result<M.Inspect>
    -- inspect info and return a closed recipe
end

In a declared module, write export comptime function derive(...) instead of qualifying the function through a module table. Exported annotation declarations may accompany the provider and remain compile-time metadata, not runtime values.

A consumer applies the resolved exported symbol, not a runtime function value:

local inspect = require("inspect")

@derive(inspect.derive)
local record Credentials
    username: string
    password: string
end

Applying the provider also claims M.Inspect. An equal written is inspect.Inspect is redundant and coalesced. Interface defaults are inherited normally and associated requirements are checked normally. A provider can fill a bodyless callable requirement or declare a new function member with a closed comptime-built signature. Generic, variadic, overloaded, and effectful provider declarations are not part of the first recipe version.

Dive deeper

Debug and JSON are ordinary exported comptime function declarations implemented in src/nupp/derive.nupp, and the compiler has no provider-name or operation switch for them. Both travel through the same sealed comptime worker, immutable Info, versioned result envelope, cache and recipe lowering a package provider uses, and their schema configuration (@debug and @json) is part of the semantic annotations visible through Info rather than a second planner.

That is also the boundary against source generation. Comptime evaluates closed value-producing programs after normal type checking, and derives run as part of declaration checking and may attach only validated member recipes. Neither becomes a way to emit arbitrary source.

Provider inputs#

Every provider on an owner receives the same immutable pre-merge Info view. It contains the owner and interface type handles, ordered stored fields with read/write handles, semantic identities, and opaque diagnostic references. It contains no tokens, locations, comments, AST, CST, mutable compiler objects, or previous provider output. nupp.derive.claims(T, I) asks whether a nominal type writes or requests contract I, which lets mutually recursive derives plan without depending on provider execution order.

Info.name names the declaration. Info.qualifiedName combines its module and declaration path, giving providers a default identity without a source filename. Packages that persist this identity should offer an explicit override: renaming a declaration or moving it to another module changes its qualified name.

A generic owner is planned once, not per instantiation. A type parameter exposes its bound, or unknown, so providers cannot specialize for future concrete arguments.

Providers run through the bounded comptime worker. Their sealed source and reachable comptime helper closure travel in the module interface; they do not remain runtime functions. A provider failure may return nupp.derive.error(message, reference, code) to point at the owner or contributing field without observing a filename or source position. The code is optional and defaults to the generic provider diagnostic NUPP2810.

Initializers#

A provider may ask for the owner's initializer with initializer = true beside methods, statics, and data. The compiler then mints the declaration's constructor body, or its field list when it declares none, as a hidden member taking the instance first: initializer(storage, ...) fills storage the caller already holds and answers it, and new allocates and calls the same body. The runtime reaches it through nupp.derive.initializer(Type). A field-list initializer applies a field default where its argument is nil, which is the one place that can, since a positional call never passes through the checker's default filling.

The compiler refuses the request on a declaration whose body could notice the reuse: several constructors, a constructor that lets self escape or moves a takes parameter into a field, an affine field, or a generic owner, each reported as NUPP2810 on the application.

Filesystem inputs#

A provider that generates a recipe from a schema or other immutable project file reads it with nupp.derive.file:

comptime function M.derive(info: nupp.derive.Info): nupp.derive.Result<M.Inspect>
    local schema = nupp.derive.file("schemas/inspect.txt")
    return nupp.derive.implement {
        methods = {
            inspect = nupp.derive.forward {
                helper = nupp.derive.helper(M, "renderSchema"),
                arguments = {nupp.derive.constant(schema)},
            },
        },
    }
end

The path must be a string literal and remain within the consumer project root. The compiler reads it before the isolated worker starts, fingerprints its bytes with the provider input, and records it in the incremental dependency graph. Changing the file invalidates only provider consumers, and watch mode observes the canonical path and refuses to patch over changed generated state without a restart. Missing files are diagnostics.

Providers have no general host I/O, so network resources, environment variables, clocks, mutable tables, and hidden filesystem reads cannot silently enter a cache or a hot-reload guarantee.

Closed forwarding recipes#

nupp.derive.implement returns instance methods and static functions. A bare Forward fills an interface requirement and inherits its signature. A nupp.derive.member supplies a function type built with nupp.types and its parameter names, allowing a provider to add a member that is not declared by the result interface.

return nupp.derive.implement {
    methods = {
        inspect = nupp.derive.forward {
            helper = nupp.derive.helper(M, "renderRecord"),
            arguments = {
                nupp.derive.constant(names),
                nupp.derive.array(values),
            },
        },
    },
}

Both forms lower through forward.v1, which names one ordinary runtime helper and supplies a closed argument list:

  • receiver() passes the generated method receiver.
  • argument(name) passes a named interface method parameter.
  • entry() passes the derived type's private runtime schema entry.
  • field(fieldInfo) directly reads one admitted stored field.
  • constant(value) embeds a bounded quotable value.
  • array(arguments) constructs a fresh array from argument recipes.

There are no nested calls, operators, branches, assignments, loops, arbitrary member accesses, or source fragments in a forwarding recipe. Table-shaped constants and arrays are fresh for each call, so mutation by one invocation cannot affect the next.

The first version refuses overloaded requirements, interface defaults, properties, setters, and metamethods. Those require separate versioned recipe capabilities rather than silently widening forward.v1.

Runtime helpers#

Runtime behavior stays in ordinary exported Nupp functions. Helpers are type checked at their declarations, and the generated call is checked again against the interface-owned argument and result packs, ownership, effects, and suspension contract. forward.v1 refuses generic runtime helpers; a later recipe version can admit them once symbolic helper identity and caching are specified. A helper module becomes an ordinary runtime dependency of the consumer even when the comptime provider itself would otherwise erase.

Dive deeper

Keeping behavior in the language makes arbitrary runtime control flow, optimization, effects, diagnostics, and future generic helpers available without turning them into a macro IR. A macro IR would have to grow its own version of each of those, and every one would then be a second implementation to keep agreeing with the first.

The generated wrapper is a semantic node the compiler may inline or sink when ordinary optimization proves that safe. Such optimization is not part of the provider contract, so a recipe cannot depend on it happening.

Generates checked members on a declaration while that declaration is checked.

@derive(Provider, ...) names providers on a record or struct. Each one reads an immutable semantic view of the declaration and returns a closed recipe; the compiler generates members from that recipe and checks them the way it checks written ones. This is a declaration-augmentation phase rather than a text macro. A provider adds no imports, top-level declarations, modules, or independently nameable types, and it observes no tokens, source positions, comments, or another provider's output.

Three providers ship with the compiler and cross the same boundary a package provider does: Debug, JSON, and Serde.

Derive the shipped providers#

@derive(nupp.derive.Debug, nupp.derive.JSON)
local record Settings
    host: string = "localhost"
    port: integer = 8080
end

local settings = new Settings()
assert(settings:debug() == 'Settings { host = "localhost", port = 8080 }')

Write a package provider#

A provider is an exported, nongeneric comptime function whose signature names the one interface it implements. Info describes the owner, implement returns the recipe, and a consumer applies the resolved export rather than a runtime function value. Applying it also claims that interface.

local M = {}

interface M.Named
    named: function(self): string
end

function M.nameValue(value: string): string
    return "name=" .. value
end

comptime function M.derive(info: nupp.derive.Info): nupp.derive.Result<M.Named>
    local field = info.fields[1]
    if not field or field.name ~= "name" then
        return nupp.derive.error("Named needs a first field named name", info.reference)
    end

    return nupp.derive.implement{
        methods = {
            named = nupp.derive.forward{
                helper = nupp.derive.helper(M, "nameValue"),
                arguments = {nupp.derive.field(field)},
            },
        },
    }
end

@derive(M.derive)
record M.User
    name: string
end

local user = new M.User(name = "ada")
assert(user:named() == "name=ada")

return M

What a recipe may say#

The interface owns every generated signature, so a recipe chooses only which runtime function stands behind a requirement and which values reach it. forward names one ordinary exported Nupp function and a closed argument list built from receiver, argument, field, constant, and array. There are no nested calls, operators, branches, loops, assignments, or provider-chosen signatures, so behavior stays in ordinary type-checked Nupp rather than in a macro IR, and the generated call is checked against the helper's declaration the way any other call is.

forward.v1 fills only bodyless, unoverloaded callable requirements, and cannot replace an interface default. A provider that refuses a declaration returns error rather than raising.

Providers run inside the bounded comptime worker, once per owner rather than once per generic instantiation, and a recipe is memoized on the Info fingerprint. claims asks whether a type already writes or requests an interface, which lets mutually recursive derives plan without depending on evaluation order.

Module contents

Types

TypeKindDescription
ArgumentrecordOne value a generated call passes.
DebugOptionsinterfaceThe checked shape of @debug, written on a field.
EntryrecordOne derived type, as the registry holds it.
EventOptionsinterfaceThe checked shape of @event, written on a declaration deriving nupp.events.Event.
FieldrecordOne written stored field in the immutable derive input projection.
ForwardrecordOne requirement's implementation, from forward, to be placed in implement under the name of the requirement it fills.
InforecordThe immutable semantic view passed to a comptime function provider.
JSONContractinterface
JSONOptionsinterfaceSemantic configuration visible to providers as Info annotations.
MemberrecordA generated member with a comptime-built function signature and forward recipe.
ProviderrecordA comptime-only provider symbol accepted by @derive.
ReferencerecordAn opaque reference which a provider may attach to a diagnostic.
ResultrecordWhat a provider returns: the recipe implementing interface I.
RuntimeHelperrecordAn ordinary exported Nupp function a generated member calls, from helper.

Functions

FunctionKindDescription
argumentfunctionPasses one parameter of the generated method, under the name the interface requirement declares for it.
arrayfunctionBuilds a fresh array from argument recipes, once per call.
claimsfunctionAsks whether a type writes or requests an interface.
constantfunctionEmbeds a bounded quotable value.
Debugcomptime functionGenerates debug(self): string and nupp.Debug conformance for a record or struct.
debugfunctionRenders a derived record the way @derive(Debug) promises.
fieldCodecfunctionThe field codec a derived type's fieldCodec static answers.
fromJSONfunctionDecodes a JSON document into a derived record.
installfunctionFills a program's derive namespace in and wires JSON to reflection.
JSONcomptime functionGenerates writeJSON(writer), a static fromJSON(text): T?, string?, a fieldCodec, and nupp.codec.json.JSONEncodable...
Serdecomptime functionDerives one format-neutral schema and physical binding blueprint.
writeJSONfunctionWrites a derived record as one JSON value.
entryfunctionPasses the record's registered derive entry.
errorfunctionRefuses the declaration with a diagnostic instead of returning a recipe.
fieldfunctionReads one stored field of the receiver directly.
filefunctionReads one immutable filesystem input admitted to this provider invocation.
forwardfunctionImplements one requirement by calling one runtime helper.
helperfunctionNames an ordinary exported Nupp function to forward to.
implementfunctionReturns forwarding implementations.
memberfunctionDeclares a generated member's function signature and forwarding recipe.
receiverfunctionPasses the generated method's receiver.

Types#

Argumentrecord#

record Argument
end

One value a generated call passes.

Built by receiver, entry, argument, field, constant, or array. The compiler owns it: a provider may hold one and place it in an argument list, and nothing else.

DebugOptionsinterface#

interface DebugOptions
    skip: boolean?
    redact: boolean?
end

The checked shape of @debug, written on a field.

Fields

skip#
skip: boolean?

Removes the field from the rendered output entirely.

redact#
redact: boolean?

Keeps the field's name and renders its value as <redacted>.

Entryrecord#

record derive.Entry
    key: string?
    mt: any
    schema: {
        data: {[string]: any},
        [string]: any
    }

    codec: nupp.reflect.FieldCodec<any>
    decoder: any
end

One derived type, as the registry holds it.

Fields

key#
key: string?

The recipe fingerprint the entry is filed under.

mt#
mt: any

The type's runtime table, which is its instances' metatable.

schema#
schema: {
    data: {[string]: any},
    [string]: any
}

The recipe's data, plus the JSON field list and unknown-field policy that jsonCodec folds onto it the first time the type is encoded.

codec#
decoder#
decoder: any

The JSON decoder, present once the codec has been built.

EventOptionsinterface#

interface EventOptions
    name: string?
end

The checked shape of @event, written on a declaration deriving nupp.events.Event.

Fields

name#
name: string?

The name the event registers under. The declaration's own name when omitted; written when the name is an external surface something pins.

Fieldrecord#

record Field
    readonly name: string
    readonly readable: boolean
    readonly writable: boolean
    readonly readType: any?
    readonly writeType: any?
    readonly hasDefault: boolean
    readonly defaultValue: any
    readonly annotations: {nupp.reflect.Annotation}
    readonly reference: Reference
end

One written stored field in the immutable derive input projection.

Fields arrive in declaration order. A generated member never reaches one by name; pass this value to field to read it.

Fields

name#
name: string

The name as written.

readable#
readable: boolean

Whether the field can be read, and so whether field admits it.

writable#
writable: boolean

Whether the field can be written.

readType#
readType: any?

A transported handle for the read type, or nil for a write-only field.

writeType#
writeType: any?

A transported handle for the write type, or nil for a read-only field.

hasDefault#
hasDefault: boolean

Whether omission during construction supplies a declaration-owned default.

defaultValue#
defaultValue: any

The source-free constant default value. Read only when hasDefault is true.

annotations#
annotations: {nupp.reflect.Annotation}

The field's typed annotations in source order, @json and @debug among them.

reference#
reference: Reference

Points this provider's error at this field.

Forwardrecord#

record Forward
end

One requirement's implementation, from forward, to be placed in implement under the name of the requirement it fills.

Inforecord#

record Info
    readonly schema: integer
    readonly kind: string
    readonly name: string
    readonly qualifiedName: string
    readonly visibility: string
    readonly providerIdentity: string
    readonly ownerIdentity: string
    readonly ownerType: any
    readonly interfaceType: any
    readonly fields: {Field}
    readonly annotations: {nupp.reflect.Annotation}
    readonly hasConstructor: boolean
    readonly reference: Reference
    readonly fingerprint: string
end

The immutable semantic view passed to a comptime function provider.

One Info describes one owner for one provider, and is built before any provider's output is merged, so two providers on the same declaration see the same thing whichever order they run in. A generic owner is projected once rather than per instantiation, and a type parameter exposes its bound, or unknown, so a provider cannot specialize for arguments a later instantiation supplies.

Fields

schema#
schema: integer

The projection's schema version, currently 1.

kind#
kind: string

What the owner was declared as: record, struct, or interface.

name#
name: string

The owner's declared name.

qualifiedName#
qualifiedName: string

The declaration's module-qualified name, independent of filesystem paths.

visibility#
visibility: string

How the declaration is bound: local, module, global, or nested.

providerIdentity#
providerIdentity: string

The stable identity of the provider being run.

ownerIdentity#
ownerIdentity: string

The stable identity of the owning declaration.

ownerType#
ownerType: any

A transported handle for the owner's type, accepted by claims.

interfaceType#
interfaceType: any

A transported handle for the one interface this provider implements.

fields#

The written stored fields, in declaration order.

annotations#
annotations: {nupp.reflect.Annotation}

The owner's own typed annotations, in source order.

hasConstructor#
hasConstructor: boolean

Whether the declaration writes a constructor.

reference#
reference: Reference

Points this provider's error at the declaration as a whole.

fingerprint#
fingerprint: string

A digest of everything above. Two owners with one fingerprint share one evaluation, so a provider must decide from Info and nothing else.

JSONContractinterface#

interface derive.JSONContract is JSONEncodable
end

JSONOptionsinterface#

interface JSONOptions
    unknown: ("reject" | "ignore")?
    name: string?
    omit: boolean?
    omitEmpty: boolean?
end

Semantic configuration visible to providers as Info annotations.

Each of these is the checked shape of one field annotation. A provider reads what was written from Field.annotations or Info.annotations; the shipped providers read the same projection rather than consulting a second planner. The checked shape of @json, written on a record or on one of its fields.

Fields

unknown#
unknown: ("reject" | "ignore")?

What decoding does with a key the record does not declare. Written on the record, and reject when omitted.

name#
name: string?

The key this field encodes to and decodes from.

omit#
omit: boolean?

Removes the field from both directions. Requires a default.

omitEmpty#
omitEmpty: boolean?

Omits nil, false, empty strings, and empty tables when encoding. Encoding only: a field left out of the output is still required coming back in.

Memberrecord#

record Member
end

A generated member with a comptime-built function signature and forward recipe. Use this when the provider declares a member that is not already an interface requirement.

Providerrecord#

record Provider
end

A comptime-only provider symbol accepted by @derive.

A shipped provider is one of the three values below; a package provider is the comptime function a module exports. Neither survives into the program.

Referencerecord#

record Reference
end

An opaque reference which a provider may attach to a diagnostic.

Obtained from Info.reference for the declaration or Field.reference for one field, and accepted only by error. It carries no filename, line, or column: a provider says which part of the declaration is at fault and the compiler decides where to point.

Resultrecord#

record Result<I>
end

What a provider returns: the recipe implementing interface I.

I is the interface the provider implements, or any when every generated member supplies its own signature. Writing an interface in the return type is how @derive knows which contract applying the provider claims. Produce one with implement, or refuse the declaration with error. The compiler owns it and there is nothing on it to read: what it carries crosses the comptime boundary as a versioned recipe the compiler validates.

Type parameters

NameDescription
I

the interface this provider implements, or any

RuntimeHelperrecord#

record RuntimeHelper
end

An ordinary exported Nupp function a generated member calls, from helper.

The module owning it becomes a runtime dependency of the consumer, even when the comptime provider itself erases.

Functions#

argumentfunction#

local argument: function(name: string): Argument

Passes one parameter of the generated method, under the name the interface requirement declares for it.

Arguments

NameTypeDescription
namestring

the parameter's declared name

Returns

TypeDescription
Argument

the argument recipe

arrayfunction#

local array: function(arguments: {Argument}): Argument

Builds a fresh array from argument recipes, once per call.

Arguments

NameTypeDescription
arguments{Argument}

the element recipes, in order

Returns

TypeDescription
Argument

the argument recipe

claimsfunction#

local claims: function(subject: any, interface: any): boolean

Asks whether a type writes or requests an interface.

The answer does not depend on whether that type's own providers have run, so mutually recursive derives can plan against one another.

Arguments

NameTypeDescription
subjectany

a type handle, such as a Field.readType

interfaceany

a type handle, such as Info.interfaceType

Returns

TypeDescription
boolean

whether subject claims interface

constantfunction#

local constant: function(value: any): Argument

Embeds a bounded quotable value.

A table-shaped constant is rebuilt for every call, so mutation by one invocation cannot reach the next.

Arguments

NameTypeDescription
valueany

the value to embed

Returns

TypeDescription
Argument

the argument recipe

derive.Debugcomptime function#

comptime function derive.Debug(info: nupp.derive.Info): nupp.derive.Result<nupp.Debug>

Generates debug(self): string and nupp.Debug conformance for a record or struct. The method lazily prepares a schema-driven declaration-order formatter and shares its schema recipe with Serde when both are derived.

@debug(redact = true) keeps a field's name and hides its value, which is what a secret wants; @debug(skip = true) removes the field from the output entirely.

@derive(nupp.derive.Debug)
local record Credentials
    user: string
    @debug(redact = true)
    password: string
end

local credentials = new Credentials(user = "ada", password = "hunter2")
assert(credentials:debug() == 'Credentials { user = "ada", password = <redacted> }')

Arguments

NameTypeDescription
infonupp.derive.Info

Returns

TypeDescription
nupp.derive.Result<nupp.Debug>

derive.debugfunction#

function derive.debug(value: any, entry: derive.Entry): string

Renders a derived record the way @derive(Debug) promises.

Nothing calls this by hand. @derive(Debug) writes the debug member that does, with the type's own registry entry already bound.

Arguments

NameTypeDescription
valueany

the record to render

entryderive.Entry

the type's registry entry

Returns

TypeDescription
string

the rendered text

derive.fieldCodecfunction#

function derive.fieldCodec(entry: derive.Entry): nupp.reflect.FieldCodec<any>

The field codec a derived type's fieldCodec static answers.

Built on first use and memoized as a reflection extension, so every route to it answers the same codec.

Arguments

NameTypeDescription
entryderive.Entry

the type's registry entry

Returns

TypeDescription
nupp.reflect.FieldCodec<any>

the codec, built if this is the first ask

derive.fromJSONfunction#

function derive.fromJSON<T is table>(text: string, entry: derive.Entry): T?, string?

Decodes a JSON document into a derived record.

Type parameters

NameDescription
T

Arguments

NameTypeDescription
textstring

the document to decode

entryderive.Entry

the type's registry entry

Returns

TypeDescription
T?

the record, or nil when the document did not fit it

string?

what was wrong with the document, when something was

derive.installfunction#

function derive.install(namespace: any): nil

Fills a program's derive namespace in and wires JSON to reflection.

The second half is conditional because reflection is its own feature. A program that derives only Debug has no registry of descriptors, and the JSON codec is memoized as a reflection extension, so what it does not get is JSON.

Arguments

NameTypeDescription
namespaceany

the program's nupp table

Returns

TypeDescription
nil

derive.JSONcomptime function#

comptime function derive.JSON(info: nupp.derive.Info): nupp.derive.Result<JSONEncodable>

Generates writeJSON(writer), a static fromJSON(text): T?, string?, a fieldCodec, and nupp.codec.json.JSONEncodable conformance.

Encoding is deterministic: fields follow declaration order and string map keys sort by byte order, so one value always produces the same bytes. A decode error names the path that failed rather than saying the document was bad.

@derive(nupp.derive.JSON)
local record User
    @json(name = "user_id")
    id: integer
    name: string
end

local user = new User(id = 7, name = "ada")
local out = string.buffer.new()
local writer = nupp.codec.json.writer(out)
user:writeJSON(writer)
writer:close()
assert(out:tostring() == '{"user_id":7,"name":"ada"}')

local decoded, failure = User.fromJSON('{"name": "ada"}')
assert(decoded == nil and failure == '$.user_id: required field is absent')

Arguments

NameTypeDescription
infonupp.derive.Info

Returns

TypeDescription
nupp.derive.Result<JSONEncodable>

derive.Serdecomptime function#

comptime function derive.Serde(info: nupp.derive.Info): nupp.derive.Result<Serializable>

Derives one format-neutral schema and physical binding blueprint.

Unlike JSON, this provider admits both records and fixed-layout structs and generates no per-format traversal methods.

Arguments

NameTypeDescription
infonupp.derive.Info

Returns

TypeDescription
nupp.derive.Result<Serializable>

derive.writeJSONfunction#

function derive.writeJSON<T is table>(value: T, entry: derive.Entry, exclusive out: Writer): nil

Writes a derived record as one JSON value.

The field list, the renaming and the omission rules all come from the recipe @derive(JSON) folded, so the document is what the declaration asked for rather than what the runtime table happens to hold.

Type parameters

NameDescription
T

Arguments

NameTypeDescription
valueT

the record to encode

entryderive.Entry

the type's registry entry

exclusive outWriter

the checked destination

Returns

TypeDescription
nil

entryfunction#

local entry: function(): Argument

Passes the record's registered derive entry.

Returns

TypeDescription
Argument

the argument recipe

errorfunction#

local error: function(message: string, reference: Reference?, code: string?): any

Refuses the declaration with a diagnostic instead of returning a recipe.

The reference decides what the diagnostic points at: Info.reference for the declaration, or the Field.reference of the field that made the recipe impossible. Returning this is how a provider fails; raising is a provider fault.

Arguments

NameTypeDescription
messagestring

what the consumer is told

referenceReference?

which part of the declaration is at fault

codestring?

the diagnostic code, defaulting to NUPP2810

Returns

TypeDescription
any

the refusal, as this provider's Result<I>

fieldfunction#

local field: function(field: Field): Argument

Reads one stored field of the receiver directly. The field must be readable.

Arguments

NameTypeDescription
fieldField

one of Info.fields

Returns

TypeDescription
Argument

the argument recipe

filefunction#

local file: function(path: string): string

Reads one immutable filesystem input admitted to this provider invocation.

The path must be a string literal in the provider's checked source. It resolves from the consumer project root, becomes an incremental query dependency, and is included in the provider recipe fingerprint. Watch mode therefore observes the file and rechecks only modules that consume the provider.

Arguments

NameTypeDescription
pathstring

project-relative path written as a literal

Returns

TypeDescription
string

the file's bytes

forwardfunction#

local forward: function(specification: any): Forward

Implements one requirement by calling one runtime helper.

helper names the function and arguments is the closed list it receives, in order. The interface owns the resulting signature, and the generated call is checked against the helper's declaration for types, ownership, effects, and its suspension contract.

Arguments

NameTypeDescription
specificationany

a table with helper and arguments

Returns

TypeDescription
Forward

the implementation, to place in implement

helperfunction#

local helper: function(module: any, name: string): RuntimeHelper

Names an ordinary exported Nupp function to forward to.

Runtime behavior stays in the language: helpers are checked at their own declarations, and a generic helper is refused by forward.v1.

Arguments

NameTypeDescription
moduleany

the module value holding the function

namestring

the name it is exported under

Returns

TypeDescription
RuntimeHelper

the helper handle

implementfunction#

local implement: function(specification: any): any

Returns forwarding implementations. methods fills instance members and statics fills static members.

Both are keyed by member name. A Forward fills a bodyless, unoverloaded callable requirement on the provider's interface; a Member supplies a new member's function signature and forwarding recipe. Generating a member that another provider on the same owner already generates is refused.

Arguments

NameTypeDescription
specificationany

a table with optional methods and statics maps

Returns

TypeDescription
any

the recipe, as this provider's Result<I>

memberfunction#

local member: function(specification: any): Member

Declares a generated member's function signature and forwarding recipe.

a forward recipe

Arguments

NameTypeDescription
specificationany

a table with signature, optional parameter names, and

Returns

TypeDescription
Member

the member recipe, to place in implement

receiverfunction#

local receiver: function(): Argument

Passes the generated method's receiver.

Returns

TypeDescription
Argument

the argument recipe