Skip to content

Zendrex/annotate

Repository files navigation

@zendrex/annotate

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/annotate

Works 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.

Overview

  • Annotate.class / method / field / accessor: typed decorators with built-in readers
  • Collision-proof symbol keys per handle; optional label for diagnostics only
  • Typed selectors: read(Class).get((instance) => instance.member) instead of string names
  • cardinality: "one" (default) or "many" for repeatable decorators
  • Annotate.intercept.*: method, accessor, and field interceptors with ctx.get(instance)
  • Low-level reflect, prepare, mintUniqueKey, mintListKey for tooling that owns keys

Prerequisites

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.

Quick start

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: "/" } }]

Examples

Builders

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(...)

Reading

Class annotations — no selector:

const Controller = Annotate.class<string>();

@Controller("users")
class Users {}

Controller.read(Users).get(); // string | undefined

Member 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);

Cardinality

Default is one value per decoration site:

const Role = Annotate.method<string>();
Role.read(Users).get((users) => users.list); // string | undefined

Repeatable 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"].

Interceptors

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".

Low-level reflection

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(...).

Design notes

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.

Package exports

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

API reference

Builders

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 shapes

Each builder returns a decorator function with .read(target).

Readers

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"

Interceptors

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).

Low-level

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?);

Errors

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

Runtime issues addressed

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";

License

MIT

About

A modern decorator and metadata system for TypeScript. Create typed decorators with built-in reflection.

Resources

License

Stars

Watchers

Forks

Contributors