fix: bind instance and ~standard to the class they are read from - #2
fix: bind instance and ~standard to the class they are read from#2btravers wants to merge 2 commits into
Conversation
The lazy accessors overwrote themselves with a data property on the
receiver, which a plain `class Y extends X {}` then inherited: reading
`X.instance` first left `Y.instance` resolving to `X`'s schema, so
`Y.instance.parse(...)` silently built an `X`. Memoise per receiver in a
module-level WeakMap and keep the accessors in place instead, so identity
stays stable per class and read order no longer decides the outcome.
c24af18 to
4c0189c
Compare
|
Superseded. Entity subclassing is being prohibited rather than made correct (see the follow-up PR), so the case this fixed — With second-level subclassing blocked at construction, the original self-overwriting getter on The subclass-identity tests here go too, for the same reason. The genuinely useful finding from this branch — that |
The bug
instanceand~standardwere lazy accessors that overwrote themselveswith a plain data property on the receiver the first time they ran. That made
identity stable, but it is first-read-wins per receiver, and a data property
defined on a class is inherited by any plain subclass that has no
Entity(...)call of its own:No error, no warning — silently the wrong class. The flaw was documented in a
comment in
instance.ts; it reproduced exactly as written.The fix
Keep the accessor in place permanently and memoise the built schema per
receiver in a module-level
WeakMapkeyed by the class object. Every readre-enters the getter with
thisbound to the class actually read and getsthat class's own entry, so:
X.instance === X.instance— built once, stable identity (the existing"built once and reused" test still passes unchanged)
Sub.instance !== Org.instance, andSub.instance.parse(raw) instanceof Subis
trueregardless of read order~standardno longer caches separately: it reads throughthis.instance, sothe two can never disagree about which class they decode to, and zod hangs
~standardoff the schema at construction, which makes it stable for free.Both properties stay non-enumerable (absent from
Object.keysand spread) andconfigurable.
Why an accessor at all is unchanged and still documented: the consuming class
does not exist when the entity builder runs, so a plain value would close over
the base constructor and
X.instance.parse(...)would build a base instance,failing
instanceof X. The getter readsthisfrom the access site.Also
instance.tsnow describes the WeakMap mechanism andrecords why the self-overwrite was abandoned.
instance.spec.ts: both read orders, distinct-and-stableinstance/~standardper class, a two-level chain, and a check that nothingis stamped onto the class as an own or enumerable property. Three of them
fail against the old implementation.
standing type-level caveat (a static property cannot repolymorphize per
subclass — the comment on
EntityStatic["instance"]intypes.tsisunaffected by this fix).
Gate
pnpm format,lint,typecheck(incl..test-d.ts),test(66 passed),knip,build— all pass.