ADR-331: Attributes Are Values of Ordinary Structs, Written With @ Before a Declaration, Marked by @meta::Attribute, and Read as Members; a Struct Field May Have a Default

Status: Accepted Date: October 7, 2026 (#496) Answers: #496, how a program says more about a field, a type or a function than its type and documentation - a name on the wire, an example, a constraint, a header a parameter comes from Related: ADR-330 (a type as an argument, members of a type); ADR-329 (what an API description is built from); ADR-318 (a default is a build-time value); ADR-304 (reflection, no macros); ADR-251 (the ledger); Part I 4.1-4.2, 5.1; Part II 10.3

1. Question

Where does a program write what a reader of its types needs beyond the types - and how is it checked, defined, read, and changed without breaking those who use it?

A field created_at that JSON calls createdAt, an example value for an API description, a parameter read from a header: nothing in the language says these. /// is prose the compiler reads nothing from (Part I), and a reflected field answers its name, value, type and documentation only (ADR-330).

2. How others do it

  Mechanism Defined by Where it may stand Repeated
Java annotations, @JsonProperty("x") @interface @Target @Repeatable
Kotlin annotations annotation class @Target(AnnotationTarget.FIELD) @Repeatable
C# attributes, [JsonPropertyName("x")] a class deriving Attribute [AttributeUsage(…)] AllowMultiple
PHP 8 attributes, #[Name('x')] a class marked #[Attribute(TARGET_PROPERTY)] in that same mark IS_REPEATABLE
Rust attributes read by derive macros the macro the macro decides the macro decides
Go struct tags, json:"x" nobody; text fields only -
Python Field(alias="x") as a default ordinary values fields -

From memory, not checked against a version. What transfers: PHP’s one mark that says both this is an attribute and where; a constructor-like call with positional and named arguments; repetition allowed by the definition.

3. Options, weighed

Option Why it lost
Directives in /// (Go’s tags, swag) text nobody checks; Part I keeps documentation prose
A trait impl naming fields as text (Zig’s declarations) a misspelled field name is not checked, and the fact stands away from the field
Any pub struct is an attribute one placed where nobody reads it, or before the wrong kind of declaration, is a mistake nobody sees
A trait meta::Annotation with a constant list of places an impl block for one line
A declaration of its own, annotation Name on field { … } a new kind of item that is almost a struct
The mark in the prelude, @AnnotationTarget(field) the prelude holds what a program cannot do without; meta can grow without breaking a name
Annotation, Target in the name written and read often; Attribute is shorter and says enough
Inferring an enum variant from the parameter type everywhere a rule for the whole language, under which a name may be a variant or a binding
The struct literal’s braces at the use, @json::Name { value: "x" } long for the common one-field case
Defaults only for attribute structs every pub struct has the same problem: a field added breaks every literal elsewhere

4. Decision

D1 — An attribute is a value of an ordinary struct, written with @ before a declaration.

struct User {
    /// When the user was created.
    @json::Name("createdAt")
    @openapi::Example("2024-01-01T00:00:00Z")
    created_at: time::Instant,
}

It stands after a declaration’s /// lines and before the declaration. Its arguments are build-time values, as a comptime holds (Part II 10.2). Nothing of it reaches the running program but what a build-time reading makes of it.

D2 — A struct is an attribute when @meta::Attribute(…) marks it, naming where it may stand.

@meta::Attribute(field)
pub struct Name { pub value: String }

The places are field, struct, enum, variant, fn, parameter, trait and impl. @ with a struct not so marked, or before a declaration its mark does not name, is NK1235. meta::Attribute is the one attribute std declares for this; meta stays a module of its own, so it can grow without taking a name from a program.

D3 — In an attribute’s arguments, a variant is written by its name alone.

field, not meta::Target::Field: the compiler reads the variant off the parameter’s enum. Only there; everywhere else a variant is written whole.

D4 — An attribute’s arguments follow Part I 5.1: fields without a default by position, fields with one by name after ;.

@meta::Attribute(field)
pub struct Name {
    pub value: String,
    pub case: Case = Case::Camel,
}

@json::Name("createdAt")
@json::Name("created_at"; case: snake)

Positional arguments fill the fields without a default, in declaration order; a field with a default is an option. A field added with a default breaks no use; a field added without one, or a field renamed or moved among those without, does. The form is an attribute’s only; a struct literal keeps its braces and names.

D5 — A field of any struct may have a default.

pub struct Page {
    pub size: i64 = 50,
    pub cursor: String?,
}
let p = Page { cursor: null }       // size is 50

A default is a build-time value (ADR-318), evaluated once where the struct is declared. A literal may leave such a field out; one that leaves out a field without a default is NK1234. A reflected field answers .default, its default as a build-time T? - null where it has none.

D6 — An attribute is read as a member: field.attribute(X), T::attribute(X).

let wire = field.attribute(json::Name)?.value ?? field.name

X is a type (ADR-330 D1), so a misspelled one is NK1135, and the result is an X? with typed fields. A reflected variant answers .attribute(X) as well. An attribute on a function, a parameter, a trait or an impl is checked and carried; reading it waits for reflection over those, which is not decided.

D7 — An attribute stands once, unless its mark says repeatable: true.

@meta::Attribute(field; repeatable: true) allows it several times, and field.attributes(X) reads them as a list in written order. A second one without it is NK1236.

D8 — Attributes and defaults travel in the ledger.

A type’s attributes, its fields’ and its variants’, and every field’s default, are written to the ledger as build-time values cross (ADR-251), so a package that reads them sees those of a dependency’s types. /// stays out of it.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.