npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zendrex/annotate

v1.0.1

Published

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

Readme

@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