🔒 fix: resolve the metadata symbol into a binding, not a per-use lookup

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 07:20:41 +00:00
parent 297d3bfe77
commit 305be7a16f
2 changed files with 27 additions and 12 deletions
+5 -2
View File
@@ -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"
],
+22 -10
View File
@@ -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<symbol, DecoratorMetadata | undefined>)[METADATA_KEY];
return (metadata as Record<symbol, ClassModel> | undefined)?.[MODEL] ?? {};
}
@@ -168,7 +180,7 @@ export function defineRule<T>(
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<symbol, DecoratorMetadata | undefined>;
holder[METADATA_KEY] ??= Object.create(null) as DecoratorMetadata;
addConstraint(holder[METADATA_KEY]!, property, constraint, options);
}