Typed Stage-3 decorators with scoped metadata reads. Zero dependencies, no reflect-metadata.
Annotate gives decorator authors one handle that creates the decorator and reads metadata back with the right type.
bun add @zendrex/annotateWorks with npm, pnpm, and yarn. Any toolchain that emits Stage-3 decorators can use it on Node, Bun, and modern browsers. Ships ESM, CJS, and TypeScript declarations.
Annotate.class/method/field/accessor: typed decorators with built-in readers- Collision-proof symbol keys per handle; optional
labelfor diagnostics only - Typed selectors:
read(Class).get((instance) => instance.member)instead of string names cardinality: "one"(default) or"many"for repeatable decoratorsAnnotate.intercept.*: method, accessor, and field interceptors withctx.get(instance)- Low-level
reflect,prepare,mintUniqueKey,mintListKeyfor tooling that owns keys
| Approach | Typical use | Requirements |
|---|---|---|
Runtime Symbol.metadata |
Engines that already expose Symbol.metadata |
TypeScript 5.2+, experimentalDecorators: false, Stage-3 decorator transform |
| Shim | Node, Bun, older browsers, embedded runtimes, or any engine without Symbol.metadata |
Import @zendrex/annotate/shim once before decorated classes load |
TypeScript must use Stage-3 decorators (experimentalDecorators: false). Legacy TypeScript decorators and parameter decorators are not supported.
import { Annotate } from "@zendrex/annotate";
const Route = Annotate.method((method: "GET" | "POST", path: string) => ({ method, path }));
class Users {
@Route("GET", "/")
list() {}
}
Route.read(Users).get((users) => users.list);
// { method: "GET", path: "/" }
Route.read(Users).methods();
// [{ kind: "method", name: "list", static: false, metadata: { method: "GET", path: "/" } }]Direct metadata:
const Controller = Annotate.class<string>();
const Column = Annotate.field<{ type: "text" | "int" }>();Argument mapper:
const Route = Annotate.method((method: "GET" | "POST", path: string) => ({ method, path }));Options (label, validation, host restriction):
class ControllerBase {}
const Route = Annotate.method({
label: "Route",
args: (method: "GET" | "POST", path: string) => ({ method, path }),
validate: (route) => {
if (!route.path.startsWith("/")) {
throw new Error("path must start with /");
}
},
requires: ControllerBase,
});Available builders:
Annotate.class(...)
Annotate.method(...)
Annotate.field(...)
Annotate.accessor(...)Class annotations — no selector:
const Controller = Annotate.class<string>();
@Controller("users")
class Users {}
Controller.read(Users).get(); // string | undefinedMember annotations — typed selectors:
Route.read(Users).get((users) => users.list);
Route.read(Users).static.get((ctor) => ctor.health);Selectors must synchronously read exactly one public member. Calling a method, reading none, or reading multiple members throws InvalidSelectorError.
Collection helpers return inherited metadata most-derived-first:
Route.read(Users).entries();
Route.read(Users).methods();
Column.read(Users).fields();
Watched.read(Users).accessors();Entries include kind, name, static, and metadata:
const staticRoutes = Route.read(Users)
.methods()
.filter((route) => route.static);Default is one value per decoration site:
const Role = Annotate.method<string>();
Role.read(Users).get((users) => users.list); // string | undefinedRepeatable decorators:
const Tag = Annotate.method<string>({ cardinality: "many" });
class Api {
@Tag("public")
@Tag("internal")
list() {}
}
Tag.read(Api).get((api) => api.list); // readonly string[]Stage-3 decorators apply inner-first, so the example above reads ["internal", "public"].
Under Annotate.intercept:
const Log = Annotate.intercept.method<string>({
label: "Log",
wrap: (original, ctx) =>
function (this: object, ...args: unknown[]) {
console.log(ctx.name, ctx.get(this));
return original.apply(this, args as never);
},
});Hooks:
Annotate.intercept.method({ wrap: (original, ctx) => original });
Annotate.intercept.accessor({ get: (original, ctx) => original, set: (original, ctx) => original });
Annotate.intercept.field({ init: (initial, ctx) => initial });ctx.get(instance) reads this interceptor's metadata on the current member. Return type follows cardinality: T | undefined for "one", readonly T[] for "many".
The high-level API does not expose metadata keys. For tooling that deliberately manages keys:
import { mintListKey, reflect } from "@zendrex/annotate";
const Tags = mintListKey<string>("tags");
reflect(Users).methods(Tags);Also exported: prepare, createScopedReflector, and mintUniqueKey. Most applications should prefer Annotate.*.read(...).
Keys. Each Annotate.* handle mints an internal symbol key. String label values are diagnostics only, not identity.
Storage. Metadata uses TC39 Symbol.metadata (Stage-3). The shim aligns Symbol.for("Symbol.metadata") with the transformer on engines without native support.
Readers. read(target) prepares the class, walks the prototype chain, and returns scoped readers. Member reads use proxy selectors so names stay type-checked.
Cardinality. "one" maps to a unique key; "many" maps to a list key. Duplicate one-cardinality decoration on the same site throws DuplicateMetadataError.
Validation. Optional validate hooks receive mapped metadata and a ValidateContext. Class and static-member checks run during decoration initialization; instance-member checks run when metadata is prepared, usually on first read or first instance creation. Failures become ValidationError. requires rejects hosts that do not extend the given base class.
| Import | Provides |
|---|---|
@zendrex/annotate |
Annotate, errors, reflect, prepare, createScopedReflector, key minting, public types |
@zendrex/annotate/shim |
Side-effect install of Symbol.metadata on older runtimes |
Annotate.class<TMeta>();
Annotate.class<TMeta>({ cardinality: "many" });
Annotate.class((...args) => meta);
Annotate.class({ label, args, validate, requires, cardinality });
Annotate.method / Annotate.field / Annotate.accessor — same shapesEach builder returns a decorator function with .read(target).
Class:
Controller.read(Users).get(); // TMeta | undefined (or readonly TMeta[] if many)
Controller.read(Users).entries(); // ClassAnnotationEntry[]Member:
Route.read(Users).get((users) => users.list);
Route.read(Users).static.get((ctor) => ctor.health);
Route.read(Users).entries();
Route.read(Users).methods(); // kind === "method"
Column.read(Users).fields(); // kind === "field"
Watched.read(Users).accessors(); // kind === "accessor"Annotate.intercept.method({ label, wrap, validate, requires, cardinality });
Annotate.intercept.accessor({ label, get, set, validate, requires, cardinality });
Annotate.intercept.field({ label, init, validate, requires, cardinality });PublicInterceptorContext: kind, name, static, get(instance).
prepare(ctor);
reflect(target).class(key);
reflect(target).methods(key);
reflect(target).properties(key);
reflect(target).all(key);
createScopedReflector(ctor, key);
mintUniqueKey<T>(label?);
mintListKey<T>(label?);All extend AnnotateError with a stable code:
| Class | Code | When |
|---|---|---|
DuplicateMetadataError |
"duplicate" |
one-cardinality annotation applied twice to the same site |
InvalidDecorationTargetError |
"invalidTarget" |
requires rejects the host class |
InvalidSelectorError |
"invalidSelector" |
selector does not read exactly one public member |
MissingMetadataError |
"missing" |
low-level throw-on-missing read found nothing |
UnregisteredClassError |
"unregistered" |
low-level reflect() query sees no registered metadata |
UnregisteredMetadataKeyError |
"unregisteredKey" |
low-level store receives an unregistered symbol key |
ValidationError |
"validation" |
a validate hook threw |
Annotate does not patch the engine. It stores metadata in annotate-owned structures and controls when decorations are flushed so reads stay correct on imperfect Stage-3 implementations.
| Issue | Typical runtimes | What Annotate does |
|---|---|---|
Symbol.metadata missing |
Node, Bun, older browsers, some embedded engines | Import @zendrex/annotate/shim once so the transformer and runtime agree on Symbol.for("Symbol.metadata"). Native Symbol.metadata is left untouched when already present. |
| Instance members register only after an instance exists | All Stage-3 engines | Instance-member decorations are queued and flushed on first prepare(ctor), first instance creation (via addInitializer), or first read that materializes the class. Call prepare(Users) to eager-flush without constructing. |
Shared instance addInitializer callbacks |
Bun 1.3.13 (and similar) | Bun can reuse one initializer across classes so only the last registration runs. Annotate’s initializer only calls prepare(this.constructor), so whichever callback runs drains the correct class’s pending metadata. |
| Skipped field value-replacement initializers | Bun 1.3 (var _init transformer bug) |
When several fields in one class use Annotate.intercept.field, Bun may skip per-field initializer closures. Field interceptors keep a per-class index and can re-apply every field hook from the instance in one pass so each field still gets its own metadata. |
Regression tests for the Bun cases live under tests/integration/cross-class-isolation.test.ts and tests/integration/bun-multi-field.test.ts.
Import the shim once before decorated classes load on runtimes without native Symbol.metadata:
import "@zendrex/annotate/shim";MIT