Skip to content

feat!: Stage-3 migration#19

Closed
Zendrex wants to merge 118 commits into
mainfrom
feat/stage3-decorators
Closed

feat!: Stage-3 migration#19
Zendrex wants to merge 118 commits into
mainfrom
feat/stage3-decorators

Conversation

@Zendrex
Copy link
Copy Markdown
Owner

@Zendrex Zendrex commented Apr 23, 2026

Summary

Cut 1.0.0-alpha.1. TypeScript experimental decorators gone, TC39 Stage-3 decorators in.

Big shift: metadata used reflect-metadata and emitDecoratorMetadata. Now annotate owns its own WeakMaps keyed by constructor; class[Symbol.metadata] only used as identity correlation channel. Removes whole class of "did you import reflect-metadata first?" footguns. Factory surface had drifted into pile of createXDecorator / createXInterceptor functions, so collapsed onto two frozen registries: decorate.{class,method,property} and intercept.{method,accessor}. One import, one mental model.

Runtime floor: Node >= 20.4 (native Symbol.metadata) or transpiler shim. TypeScript >= 5.2.

Public API

Factory registries

  • decorate.class<TMeta>(), decorate.method, decorate.property
  • intercept.method, intercept.accessor

Factory accessors

  • factory.has(target[, member]) / factory.hasOwn(target[, member])
  • factory.first(target[, member]) / factory.firstOrThrow(target[, member])
  • factory.all(target[, member]) returns frozen MetadataArray<TMeta>
  • factory.reader(target) for typed reader access
  • factory.derive<TThis, ...>(options?) builds child factory sharing parent's metadata key. Accepts Pick<DecoratorOptions, "name" | "validate" | "requireInstanceOf">. Parent validator runs before child's; requireInstanceOf replaces.

Reflector

  • Free reflect(target) returns Reflector. ScopedReflector shape unchanged.
  • Cardinality now explicit at type level: methodsScalar(), propertiesScalar(), with DecoratedMethodScalar<T> / DecoratedPropertyScalar<T> results.
  • Reflecting unregistered class throws UnregisteredClassError. applied / appliedOwn predicates stay safe, return false on that path.

Runtime

  • prepare(ctor) eagerly flushes pending instance-member metadata. Reflector and predicate accessors auto-prepare; call directly only when introspecting through non-annotate path.

Decorator type constraints

  • Member factories carry TInstance / TField / TMethod / TValue / TThis generics. decorate.property<M, [M], number>() rejects application to boolean field at compile time instead of blowing up at runtime.
  • TThis (slot 4) narrows this-shape decorator accepts. Defaults to any.

Decorator options

  • validate(meta, context) runs after compose, before commit. Throwing aborts decoration. For instance members defers until prepare(ctor) so context.target resolves to concrete class.
  • requireInstanceOf: Base is declarative sugar over validate. Rejects when target class not subclass of Base; throws InvalidDecorationTargetError with requiredBase populated.

Type helpers

  • MetadataOf<F>, ArgsOf<F>, ThisOf<F> for consumer generics.

Errors

All under AnnotateError with AnnotateErrorCode discriminant:

  • UnregisteredClassError for reflecting class with no registered metadata
  • DuplicateMetadataError for unique: true violations
  • MissingMetadataError for firstOrThrow on absent metadata
  • InvalidDecorationTargetError for requireInstanceOf rejection
  • ValidationError for validate hook rejection, original throwable on Error.cause

Breaking changes

Upgrading — these bite:

  1. experimentalDecorators / emitDecoratorMetadata must be false.
  2. reflect-metadata peer gone. Drop from deps.
  3. createParameterDecorator and parameter reflector slice gone. Stage-3 has no parameter primitive; nothing to wrap.
  4. createPropertyInterceptor now intercept.accessor, requires accessor keyword (or explicit get / set members). Plain fields still carry metadata via decorate.property, just can't be intercepted.
  5. InterceptorContext drops descriptor and owner. Stage-3 context carries name / static / kind directly.
  6. Reflecting unregistered class throws UnregisteredClassError instead of returning empty view. Most callers want this; silent-empty-view hid bugs.
  7. Instance-member metadata registers lazily on first instantiation. Class decorator, static decorated member, accessor, prepare(ctor), or any reflector / predicate read forces materialization.
  8. ensureProperty workaround gone. Non-annotate introspection ("x" in Ctor.prototype, Object.keys(instance)) sees different results than before. Use factory.has(ctor, name) or reflect(ctor).properties() to enumerate decorated members.
  9. Deep imports from ./factories/* no longer supported. Use decorate / intercept.

Internal cleanup worth flagging

  • metadata store was one big file. Split into class store, member store, ctor correlation, deferred queue.
  • Errors sit under AnnotateError with code discriminant; consumers switch on code instead of instanceof-ing five classes.
  • Reflector rewritten on WeakMap reads with own-bag chain walk for declaring-class resolution. Shared prototype-chain walker, no duplication across three places.
  • materialize() now prepare(); reflector Singular variants now Scalar. Both old names ambiguous.
  • Symbol.metadata shim exposed as side-effect import at @zendrex/annotate/shim for older runtimes.
  • Added scripts/check-helper-imports.ts as build guard. Helper-package code leaking into dist/ was recurring problem; CI catches now.

Test plan

  • bun run check-types clean
  • bun test full suite green
  • Type-constraint assertions (tests/unit/factories/type-constraints.test-d.ts)
  • Integration: reflectInstance against every Stage-3 decorator kind
  • Integration: materialization + subclass regression coverage
  • Integration: interceptor decoration-order independence
  • Build guard: no helper-package leaks in dist/
  • Smoke-test bun run build output against downstream consumer before promoting alpha

Try It Out

Test out the latest build of this PR as it's being developed by checking the comment below.
#19 (comment)

Zendrex added 30 commits April 23, 2026 03:00
Implements registerCtor, resolveCtorFromMetadata, getCorrelationFor,
queueDeferred, hasPendingFor, and flushFor to wire up the pending
decoration buffer and bidirectional ctor↔correlation WeakMaps (C5).
Remove DecoratedConstructorParameter, DecoratedMethodParameter, DecoratedParameter,
the "constructor-parameter"/"method-parameter" discriminators, and the parameters()
method on ScopedReflector. Downstream callsites will be updated in Phase L.
Rewrites createClassDecorator to use Stage-3 ClassDecoratorContext, adds TInstance
generic constraint, and wires registerCtor/flushFor for Symbol.metadata correlation.

Adds v0.x bridge stubs to metadata/store and factories/shared so legacy factory
files remain importable while awaiting Stage-3 rewrites. Fixes reflector.ts to use
collectClassMeta/getMemberMeta instead of removed reflect-metadata helpers.
Removes broken store re-exports from index.ts and drops reflect import until the
reflector is fully migrated.

Type errors: 100 → 47. Tests: 5 pass, 1 skip (reflect L2 gated).
…requisite)

Combines plan Task L1 (drop parameters() from ScopedReflector) and L2 Step 3
(rewrite reflector.ts to use new store APIs + auto-materialize +
UnregisteredClassError). Done ahead of Phase G-K because Bun's runtime module
evaluation fails on reflector.ts's broken imports of removed store names,
which blocks factory test runtime-load.

L2 Step 1/2/4/5 (reflector.test.ts rewrite and its dedicated commit) deferred
until after G1-K1 complete, per plan ordering for the test-level validation.

EA-6 applied: isMethodLike classifies static accessors as method-like.
Parent-declared static members are inherited by child classes via the
constructor prototype chain. `isMethodLike` and `isStaticMember` previously
checked only the immediate constructor, misclassifying inherited statics as
instance properties and reporting `static: false`. Mirror the existing
instance-side chain walk for statics.
EA-3 mandates `any` (not `unknown`) for ClassMethodDecoratorContext's This
generic to accept typed `this:` on methods without rejection by the
lib.es2023.decorators.d.ts constraint.
…13 transpiler bug

Bun 1.3.13 emits a shared `_init` variable per module for classes with
decorated fields; the later class's initializer overwrites the earlier
class's, causing A's @column initializer to never fire correctly.
Store-level sibling isolation is already covered by store.test.ts.
Zendrex added 26 commits April 25, 2026 17:44
Removed comment that merely restated what the conditional expression does,
per project style guide: avoid narration comments when code is self-documenting.
Kept Symbol descriptions in factories per linter (useSymbolDescription rule)
and ecosystem debugging value—descriptions are not noise.
Replace unclear single-letter identifier with descriptive name
for better readability and to match project style guidelines.
…efined

Changes visit parameter return type from `unknown` to `boolean | undefined` to
enforce compile-time clarity. This prevents accidental returns of arbitrary
truthy values and makes the walk-termination contract explicit. All callers
already comply with the stricter signature.
Two-overload helper for idiomatic get-or-create patterns on Map and WeakMap.
Replaces 8 inline upsert sites across three metadata stores:
- class-meta-store (2 sites)
- member-meta-store (5 sites)
- metadata-deferred-queue (1 site)

Removes nesting and improves readability; single factory call on miss.
Both class-meta-store and member-meta-store repeated the same three
prototype-chain walk shapes (any non-empty link, first list value,
collect-all). Hoist them into store-walk.ts so each call site reads as
intent rather than as a hand-rolled walk.

Six sites collapsed:
- class-meta-store: collectClassMeta, hasAnyClassMeta,
  firstClassMetaForKey, hasAnyClassMetaForKey
- member-meta-store: collectMemberMeta, firstMemberMetaForKey,
  hasAnyMemberMeta, hasAnyMemberMetaForKey

class-meta-store drops 107 -> 79 lines; member-meta-store drops 159 ->
132 lines. The remaining walkPrototypeChain uses in member-meta-store
(getMemberStatic, collectMemberNames) consume Map keys/values rather
than lists and stay specialized.
- I1: add once-per-file cast safety comment in class-meta-store and
  member-meta-store explaining the unknown[] → T[] cast is safe because
  callers parameterize reads with the type they put in
- I2: reword firstOnChain JSDoc lead to unambiguously say "first element
  of the first non-empty list" (not "first value produced by walking")
- M3: note subclass-first walk order in chainHasNonEmpty JSDoc, matching
  the other two helpers

M1 was already satisfied — all three store-walk helpers carry explicit
return types in the existing signatures.
Replace `MemberBucket = Map<symbol, Map<name, unknown[]>>` with
`Map<symbol, Map<name, MemberEntry>>`, where `MemberEntry` carries the
member's append-only `values` plus the `static` flag captured at first
append. Drop the standalone `memberStaticStore` WeakMap and its chain
walk in `getMemberStatic`.

`getMemberStatic` now requires a `key` parameter; the only caller is
the reflector, which already has the key in scope when iterating
member names. The old name-only WeakMap had a latent silent-overwrite
bug (two factories decorating the same name with different keys would
clobber each other's static flag) — the per-key entry shape avoids it.

Behavior is unchanged for public APIs. Removes one WeakMap and one
chain walk per probe on the reflector hot path. Pairs with T2.3 which
will expose `snapshotMembers` built on the new entry shape.
`values` is mutated via `entry.values.push(meta)` in `appendMemberMeta`.
Keeping `readonly` there was misleading; `static` remains readonly as it
is truly invariant for a given (key, name) pair.
Add snapshotMembers(ctor, key) which walks the prototype chain once and
returns a Map<name, MemberEntry> with chain-merged values (subclass-first)
and the most-derived static flag.

Reflector.collectMembers now consumes the snapshot, replacing
collectMemberNames + per-name getMemberStatic + per-name collectMemberMeta.
Per collectMembers call this drops chain walks from 1+2N to 1; per all()
call from 2+4N to 2.

Behavior parity verified: same iteration order (chain walk), same merge
order (subclass-first concat), same static semantics (most-derived
defining link), same cardinality-aware metadata shape.
Both `ensureClassRegistered` (factories) and `ReflectorImpl.ensureRegistered`
walked the prototype chain twice — once via `hasAnyClassMeta`, once via
`hasAnyMemberMeta`. Replace with a single `hasAnyMeta` that walks the chain
once and probes both stores per link, short-circuiting on first hit.

Each store now exposes a tiny `hasOwnAny*Meta(ctor)` probe (own-only, no
walk) so the combined helper can stay in its own module without leaking
store internals. The chain-walking `hasAnyClassMeta` / `hasAnyMemberMeta`
are removed — the two refactor sites were their only callers.
Reader helpers call prepare on every first/has/all access. Once a ctor's
deferred metadata has been drained, mark it via a WeakSet sentinel so
subsequent prepare(ctor) calls collapse to a single WeakSet lookup
instead of WeakMap reads + flushFor. queueDeferred invalidates the
sentinel for the registered ctor (if any) so fresh deferred work always
re-flushes. The chain-walk fallback intentionally does not mark, since
its early-stop semantics may leave deeper ancestors pending.
Assert isFullyPrepared(A) === true after first prepare(A) to directly
prove the sentinel is written, making the short-circuit test non-vacuous.

Add two chain-walk tests: one asserts prepare(B) with pending ancestor
does NOT mark B fully prepared; the other proves a subsequent prepare(B)
still drains newly queued ancestor work.
Per-call `new ReflectorImpl(ctor)` defeats the per-instance methodLikeCache
and re-runs ensureRegistered (prepare + hasAnyMeta) on every reflect()
call. Cache impls in a module-level WeakMap keyed by the resolved ctor so
both short-circuits stay warm across calls.
Add two cache-correctness tests: (1) prove methodLikeCache is shared
across reflect() calls via prototype mutation and spy-on-descriptor
approaches; (2) verify cached ReflectorImpl with registered=false
retries ensureRegistered after late decoration, confirming the
UnregisteredClassError → success path works end-to-end.
Add formatSlot(target, memberName?) to reflector/class-name to produce
the canonical "Class" or "Class.member" slot string used in error
messages. Refactor DuplicateMetadataError, MissingMetadataError,
InvalidDecorationTargetError, and ValidationError to share it instead
of each rebuilding the slot inline. Quoting and " on " prefix stay at
the call sites so pinned message formats are preserved.
Replace tautological targetDisplayName comparison with literal "<anonymous>"
assertion so regressions in either function are independently caught.

Add MissingMetadataError test for a symbol memberName, asserting the
stringified form (Symbol(my-key)) appears in the error message.
Introduce defineAnnotateError(spec) so every concrete error declares its
name, code, message format, and AnnotateError context fields in one
declarative block. Subclasses with positional constructors or extra
fields (DuplicateMetadataError, UnregisteredMetadataKeyError,
InvalidDecorationTargetError) keep a thin wrapper; the rest become
const + interface aliases over the factory output.

Public API of every subclass is preserved: same constructor signatures,
same instance fields (including InvalidDecorationTargetError.requiredBase),
same .name strings, and unchanged instanceof chains. Add focused identity
tests in tests/unit/errors.test.ts covering DuplicateMetadataError plus a
cross-subclass invariant for ctor.name vs instance.name.
- AnnotateErrorContext now Pick<AnnotateErrorOptions, ...> (no drift)
- Factory super() call uses spread (...spec.toContext(args))
- Rename `extract` -> `toContext` for clarity
- MissingMetadataError, UnregisteredClassError, ValidationError as empty
  class extensions to restore nominal type narrowing
…ntKey helper

Introduces a non-exported mintKey<TKey> that handles symbol creation and registry
write; mintUniqueKey and mintListKey become typed one-liner wrappers, eliminating
the duplicated body and consolidating the cast justification.
Share the validate→append→correlate→flush protocol between class-level and
static-member decorations behind a single internal helper, so future decorator
kinds inherit the same commit pipeline instead of duplicating it.
…ator doc

Add focused unit tests for commitDecoration asserting validators run before
append and that registerCtor/flushFor execute (proven via flushed deferred
member meta). Also updates the createClassDecorator JSDoc to reference
commitDecoration instead of the now-internal registerCtor + flushFor calls.
…ity fork

Expose `mintMetadataKey<T, C>(cardinality, description?)` from the
cardinality registry so generic builders can pass cardinality through
as a type parameter rather than forking on `mintUniqueKey` vs
`mintListKey`. The 5 factory files now mint with a single helper,
each shrinking by one import.

`mintUniqueKey`/`mintListKey` remain exported for end-user code where
the cardinality-specific name reads more clearly at call sites.
…dundant cardinality generic

Replace the single `mintMetadataKey<T, C extends Cardinality>` declaration with
two overloads that narrow the return type from the literal argument alone
(`"unique"` → UniqueMetadataKey<T>, `"list"` → ListMetadataKey<T>), removing
the need for a second generic at every call site. Update all 10 factory call
sites (class, method, method-interceptor, property, accessor-interceptor —
unique and list variants each) and the cardinality-registry test to use the
single-generic form. Add one assertion confirming overload narrowing compiles
without a cast.
- Extract `requireCardinality` and `assertNotDuplicate` into
  `append-guards.ts` so `appendClassMeta`/`appendMemberMeta` share one
  copy of the registry-lookup + duplicate-check pair.
- Add `readValues<T>` in `store-walk.ts` as the single trust boundary
  for the `unknown[] -> readonly T[]` brand cast.
- Harden `getOrCreate` to use `has`/`set` so callers may legitimately
  store `undefined`.
- Move correlation->ctor resolution out of `prepared-sentinel`:
  rename `invalidatePreparedFor(correlation)` to
  `invalidatePrepared(ctor)`; `queueDeferred` resolves the ctor itself.
- Make `registerCtor` fail loudly when a correlation/ctor is already
  bound to a different partner (was silently first-wins).
- Loosen `walkPrototypeChain` visit return to `boolean | void`.
- Tighten JSDoc across the metadata module; fold runtime type assertions
  into `.test-d.ts` files and drop redundant duplicate test files.
- Make `DecoratorOptions.compose` conditionally required: when `TArgs`
  widens beyond `[TMeta]`, callers must supply a mapper, removing the
  unreachable `args[0] as TMeta` runtime fallback.
- Drop `TNewMethod`/`TNewValue` generics from `DecoratedMethodFactory`
  and `DecoratedAccessorFactory` `derive`; the original method/value
  shape is fixed by the parent factory.
- Add `CardinalityOf<F>` helper alongside `MetadataOf` / `ArgsOf` /
  `ThisOf`, propagating `TCard` through `FactoryGenerics`.
- Export `AnyClass` from `factories/types` and reuse it in
  `class-decorator` instead of inlining the structural shape.
- Extract `prepareFactoryShell` in `factories/shared` to deduplicate
  `{ composeFn, label, validators }` setup across all four
  `build*Factory` entries; add `compose` overloads for the identity
  tuple vs configured-mapper paths.
- Polish doc comments across `errors`, `factories/*`, `metadata/types`,
  `reflector/types`, and `index` for tightness and accuracy; tighten
  `Deferred.key` to `MetadataKey`.
- README: clarify that instance-member validation/`requireInstanceOf`
  fire on first `new` (or first reflective read), not at decoration
  time, and update the `Tag` list-cardinality example to reflect
  Stage-3 inner-first ordering.
- Consolidate `factories/types.test-d.ts`, `factories/types-list.test-d.ts`,
  `metadata/types.test-d.ts`, and `reflector/types.test-d.ts` into a
  single `tests/unit/types.test-d.ts`; refresh `MemberEntry`/`Deferred`
  shapes used in store unit tests.
@Zendrex Zendrex closed this Apr 26, 2026
@Zendrex Zendrex changed the title feat!: Stage-3 decorators (v1.0.0-alpha.1) feat!: Stage-3 migration Apr 26, 2026
@Zendrex
Copy link
Copy Markdown
Owner Author

Zendrex commented Apr 26, 2026

This PR got out of hand as I continued to refine it. Final shape is avail at #20

@Zendrex Zendrex deleted the feat/stage3-decorators branch May 19, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant