From 305be7a16ffef2507ad7b742d8c4dbd065ae1616 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:20:41 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20fix:=20resolve=20the=20metadata?= =?UTF-8?q?=20symbol=20into=20a=20binding,=20not=20a=20per-use=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #4 flagged that the Symbol.metadata polyfill is a module-level side effect while package.json declares "sideEffects": false, so a bundler is permitted to drop the module. Checking it narrowed the concern and corrected half of it. The decorator transforms are not exposed: esbuild's helper is __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : Symbol.for("Symbol." + name) which already falls back. The exposure was in this library's own read path, which used `Symbol.metadata` directly. Had the symbol been absent, `clazz[undefined]` would read a property literally named "undefined", modelOf() would return an empty model, and every object would validate clean — silent success, the worst failure mode a validation library can have. The key is now resolved once into METADATA_KEY, with the same Symbol.for fallback the transforms use, and all reads and writes go through it. The global assignment stays for consumer emit that reads Symbol.metadata directly, and package.json now lists metadata.js under sideEffects so bundlers keep it. 193 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK --- package.json | 7 +++++-- src/metadata.ts | 32 ++++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 54e4ee3..622a86b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cereale", "version": "2.0.0", - "description": "Strongly typed JSON mapping and validation for TypeScript classes — validated domain objects, not validated data", + "description": "Strongly typed JSON mapping and validation for TypeScript classes \u2014 validated domain objects, not validated data", "type": "module", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -13,7 +13,10 @@ "require": "./dist/cjs/index.js" } }, - "sideEffects": false, + "sideEffects": [ + "./dist/esm/metadata.js", + "./dist/cjs/metadata.js" + ], "files": [ "dist" ], diff --git a/src/metadata.ts b/src/metadata.ts index 8305909..d3f73b8 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,11 +1,23 @@ import type { ClassConstructor } from './interfaces.js'; -// TypeScript's standard-decorator emit reads `Symbol.metadata`. Node does not define it yet, -// so it is installed here, before any decorated class in the consuming application is -// evaluated. `Symbol.for` keeps it identical across duplicate copies of the library, which -// the dual ESM/CJS build can otherwise produce. -((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= - Symbol.for('Symbol.metadata'); +/** + * The key decorator metadata is stored under. + * + * Resolved into a binding rather than read as `Symbol.metadata` at each use. If the well-known + * symbol is absent, `Symbol.metadata` evaluates to `undefined` and `clazz[undefined]` quietly + * reads a property literally named "undefined" — `modelOf` would return an empty model and + * every object would validate clean. Silent success is the worst failure mode a validation + * library can have, so the fallback is baked into the value the code actually uses. + * + * `Symbol.for` matches what the decorator transforms emit (esbuild's `__knownSymbol` uses the + * same fallback), and keeps the key identical across duplicate copies of the library, which + * the dual ESM/CJS build can otherwise produce. + */ +const METADATA_KEY: symbol = (Symbol as { metadata?: symbol }).metadata ?? Symbol.for('Symbol.metadata'); + +// Also installed globally, because a consumer's own compiler emit may read `Symbol.metadata` +// directly. package.json marks this module as having side effects so it survives bundling. +((Symbol as { metadata?: symbol }).metadata as symbol | undefined) ??= METADATA_KEY; export interface ValidationArguments { value: any; @@ -139,7 +151,7 @@ export function addConstraint( /** Reads the model declared on a class. Returns an empty model for undecorated classes. */ export function modelOf(clazz: unknown): ClassModel { if (typeof clazz !== 'function') return {}; - const metadata = (clazz as { [Symbol.metadata]?: DecoratorMetadata })[Symbol.metadata]; + const metadata = (clazz as unknown as Record)[METADATA_KEY]; return (metadata as Record | undefined)?.[MODEL] ?? {}; } @@ -168,7 +180,7 @@ export function defineRule( constraint: ValidationConstraint, options?: ValidationOptions ): void { - const holder = clazz as unknown as { [Symbol.metadata]?: DecoratorMetadata }; - holder[Symbol.metadata] ??= Object.create(null) as DecoratorMetadata; - addConstraint(holder[Symbol.metadata]!, property, constraint, options); + const holder = clazz as unknown as Record; + holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata; + addConstraint(holder[METADATA_KEY]!, property, constraint, options); }