From f68bb076393b0c489e658f915f9d401ee1acb645 Mon Sep 17 00:00:00 2001 From: Brett Zamir Date: Mon, 31 Aug 2026 12:34:42 -0700 Subject: [PATCH] feat: add ability to iterate symbol keys; fixes #6 --- CHANGES.md | 16 ++ README.md | 86 +++++++++- package.json | 2 +- test/test.js | 304 ++++++++++++++++++++++++++++++++++- tsconfig-build.json | 1 - tsconfig.json | 1 - typeson.js | 374 +++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 751 insertions(+), 33 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index bfd59db..4fa20fb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,21 @@ # typeson CHANGES +## 10.3.0 + +- feat: preserve Symbol-keyed properties (issue #6). A type may set + `iterateSymbols: true` on its state object, or the `Typeson` constructor + may be given `iterateSymbols: true`, to iterate enumerable own + Symbol-keyed properties. `Symbol.for()` and well-known Symbols round-trip + automatically; local `Symbol()`s round-trip when both ends are constructed + with a matching `symbols` name map. Metadata is stored under a separate + `$symbolKeys` map (keyed by owner keypath) so `$types` and cyclic + references keep working; unrevivable Symbol keys are skipped unless + `throwOnUnregisteredSymbol` is set. Each `$symbolKeys` entry reserves an + optional `descriptor` object (`enumerable`/`writable`/`configurable`), + honored on revival, so property attributes can be added later without a + breaking format change. +- feat: add `endIterateSymbols` encapsulate-observer event. + ## 10.2.0 - feat: add an optional `ownOnly` argument to `getByKeyPath()` and `setAtKeyPath()` diff --git a/README.md b/README.md index 42fb23b..e06450a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,29 @@ const objs = [ ]; ``` +With Symbol iteration enabled (the `iterateSymbols` option or state-object +property), Symbol-keyed properties are collected under a `$symbolKeys` map +keyed by the owner's keypath, and typed values within it are still tracked +in `$types`: + +```js +// new Typeson({symbols: {S: mySymbol}, iterateSymbols: true}) +// .stringify({a: 1, [mySymbol]: new Date(0), [Symbol.for('g')]: 2}); +// {"a":1, +// "$symbolKeys":{"":[ +// {"key":{"registered":"S"},"value":0}, +// {"key":{"for":"g"},"value":2}]}, +// "$types":{"$symbolKeys.''.0.value":"Date"}} +``` + +Each `$symbolKeys` entry is `{key, value}`, where `key` is one of +`{registered: name}`, `{for: globalKey}` or `{wellKnown: name}`. An optional +`descriptor` object (`{enumerable?, writable?, configurable?}`) is reserved +for future use: encapsulation does not emit it today (only enumerable own +Symbol keys are iterated, and they revive as plain data properties), but +`revive()` will apply one if present, so the attribute set can grow without a +breaking format change. + Or a cyclic array: ```js @@ -252,7 +275,10 @@ Creates an instance of Typeson, on which you may configure additional types to s encapsulateObserver?: function, // Default no-op encapsulateError?: function, // Optional ignore/substitute handler sync?: true, // Don't force a promise response regardless of type - throwOnBadSyncType?: true // Default to throw when mismatch with `TypesonPromise` obtained for sync request or not returned for async + throwOnBadSyncType?: true, // Default to throw when mismatch with `TypesonPromise` obtained for sync request or not returned for async + symbols?: {[name: string]: symbol}, // Local Symbols to preserve as keys + iterateSymbols?: boolean, // Iterate Symbol-keyed properties of every object + throwOnUnregisteredSymbol?: boolean // Throw rather than skip an unrevivable Symbol key } ``` @@ -260,6 +286,43 @@ Creates an instance of Typeson, on which you may configure additional types to s Whether or not to support cyclic references. Defaults to `true` unless explicitly set to `false`. If this property is `false`, the parsing algorithm becomes a little faster and in case a single object occurs on multiple properties, it will be duplicated in the output (as `JSON.stringify()` would do). If this property is `true`, several instances of same object will only occur once in the generated JSON and other references will just contain a pointer to the single reference. +###### `symbols`: object + +A map of names to local `Symbol()` instances. `JSON.stringify()` drops +Symbol-keyed properties; when symbol iteration is enabled (see `iterateSymbols` +below and the `iterateSymbols` state-object property), Typeson records them +separately under a `$symbolKeys` property on the root and restores them on `revive()`. + +Global registry Symbols (`Symbol.for()`) and well-known Symbols (`Symbol.iterator`, +`Symbol.toStringTag`, etc.) have a portable identity and are handled +automatically. A plain `Symbol()` does not, so to round-trip such a key both the +serializing and the reviving `Typeson` must be constructed with the same +`symbols` map: + +```js +const mySymbol = Symbol('my symbol'); +const typeson = new Typeson({ + symbols: {MySymbol: mySymbol}, + iterateSymbols: true +}); +const back = typeson.parse(typeson.stringify({[mySymbol]: 123})); +console.log(back[mySymbol]); // 123 +``` + +###### `iterateSymbols`: boolean + +When `true`, enumerable own Symbol-keyed properties of *every* object are +iterated and preserved (see `symbols` above). Defaults to `false`. For modular +control, a type may instead set `iterateSymbols: true` on its state object (see +the `test`/`replace` `stateObj`) so that only its own instances are affected. + +###### `throwOnUnregisteredSymbol`: boolean + +By default a Symbol-keyed property whose Symbol has no portable identity (not a +`Symbol.for()` value, not well-known, and not present in `symbols`) is silently +skipped, just as functions and Symbol *values* are. Set this to `true` to throw a +`TypeError` instead. + ###### `encapsulateObserver`: object (see description) For encapsulations/stringifications, this callback will be executed as objects are iterated and types are detected. An observer might be used to build an interface based on the original object taking advantage of serialized values (the `replaced` property) passed to the observer along the way, even potentially without concern to the actual encapsulated result. @@ -287,7 +350,8 @@ The following properties are also present in particular cases: - `endIterateIn` - Will be `true` if finishing iteration of `in` properties. - `endIterateOwn` - Will be `true` if finishing iteration of "own" properties. - `endIterateUnsetNumeric` - Will be `true` if finishing iteration of unset numeric properties. -- `end` - Convenience property that will be `true` if `endIterateIn`, `endIterateOwn`, or `endIterateUnsetNumeric` is `true`. +- `endIterateSymbols` - Will be `true` if finishing iteration of Symbol-keyed properties. +- `end` - Convenience property that will be `true` if `endIterateIn`, `endIterateOwn`, `endIterateUnsetNumeric`, or `endIterateSymbols` is `true`. ###### `encapsulateError`: callback (see description) @@ -494,7 +558,7 @@ A class (constructor function) that would use default test, encapsulation and re - `revive`: Uses `Object.create()` to revive the correct type and copies all properties into it. - `testPlainObjects`: `false`: Tests non-plain objects only. -###### `test` (obj : any, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean}) : boolean +###### `test` (obj : any, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean, iterateSymbols: boolean}) : boolean Function that tests whether an instance is of your type and returns a truthy value if it is. @@ -524,7 +588,19 @@ converted to `null` by a `stringify` call). Thus encapsulators have the ability to set `iterateUnsetNumeric: true` on their state object, but note that doing so will add a performance cost. -###### `replace` (obj: YourType, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean}) : Object +Enumerable own Symbol-keyed properties are also skipped by default (as +`JSON.stringify()` skips them). An encapsulator may set +`iterateSymbols: true` on its state object to have them preserved for its +own instances (the constructor's `iterateSymbols` option does the same for +every object). Such properties are written to a `$symbolKeys` map on the +root rather than into the keypath-based `$types` map, and restored on +`revive()`. Only Symbols with a portable identity are kept — `Symbol.for()` +values, well-known Symbols such as `Symbol.toStringTag`, and local +`Symbol()`s supplied to both ends via the constructor's `symbols` option; +any other Symbol key is skipped (or throws, with +`throwOnUnregisteredSymbol`). + +###### `replace` (obj: YourType, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean, iterateSymbols: boolean}) : Object Function that maps your instance to a JSON-serializable object. Can also be called an `encapsulator`. For the `stateObj`, see `tester`. In a property context (for arrays @@ -535,7 +611,7 @@ See the `tester` for a discussion of the `stateObj`. Note that replacement results will themselves be recursed for state changes and type detection. -###### `replaceAsync` (obj: YourType, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean}) : `TypesonPromise` +###### `replaceAsync` (obj: YourType, stateObj : {ownKeys: boolean, iterateIn: ('array'|'object'), iterateUnsetNumeric: boolean, iterateSymbols: boolean}) : `TypesonPromise` Expected to return a `TypesonPromise` which resolves to the replaced value. See `replace`. diff --git a/package.json b/package.json index 30c6f94..d6e3d0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typeson", - "version": "10.2.0", + "version": "10.3.0", "description": "Preserves types over JSON, BSON or socket.io", "main": "./dist/typeson-commonjs2.min.cjs", "browser": "./dist/typeson.umd.js", diff --git a/test/test.js b/test/test.js index a0189d9..f49fa51 100644 --- a/test/test.js +++ b/test/test.js @@ -1034,7 +1034,7 @@ describe('Typeson', function () { } }; - const typeson = new Typeson().register([map]); + const typeson = new Typeson({iterateSymbols: true}).register([map]); const a = { // eslint-disable-next-line camelcase -- Testing @@ -1051,11 +1051,303 @@ describe('Typeson', function () { 'Revives `Map` on class with Symbol.toStringTag' ); - // Todo: Need to implement Symbol iteration - // assert( - // back[Symbol.toStringTag] === 'a', - // 'Revives `Symbol.toStringTag`' - // ); + assert( + back[Symbol.toStringTag] === 'a', + 'Revives `Symbol.toStringTag`' + ); + }); + + describe('Symbol keys', () => { + /** + * @type {import('../typeson.js').TypeSpecSet} + */ + const DateType = { + Date: { + test (x) { return x instanceof Date; }, + replace (d) { return d.getTime(); }, + revive (n) { return new Date(n); } + } + }; + + it('does not iterate Symbol keys by default', () => { + const sym = Symbol('s'); + const tson = new Typeson(); + const back = tson.parseSync(tson.stringifySync({[sym]: 1, a: 2})); + assert( + !Object.getOwnPropertySymbols(back).includes(sym), + 'Symbol key dropped without opt-in' + ); + assert(back.a === 2, 'Ordinary keys unaffected'); + }); + + it( + 'round-trips registered, global and well-known Symbol keys ' + + 'via the `iterateSymbols` option', + () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + + const input = { + plain: 1, + [local]: 'a', + [Symbol.for('global')]: 'b', + [Symbol.toStringTag]: 'Tag' + }; + const back = tson.parseSync(tson.stringifySync(input)); + + assert(back.plain === 1, 'Ordinary key preserved'); + assert(back[local] === 'a', 'Registered local Symbol key'); + assert( + back[Symbol.for('global')] === 'b', + '`Symbol.for` key revived to the same global Symbol' + ); + assert( + back[Symbol.toStringTag] === 'Tag', + 'Well-known Symbol key revived by name' + ); + } + ); + + it('can be enabled per type via `stateObj.iterateSymbols`', () => { + const local = Symbol('local'); + const tson = new Typeson({symbols: {Local: local}}).register({ + withSymbols: { + testPlainObjects: true, + test (x, stateObj) { + if (Object.getOwnPropertySymbols(x).length) { + stateObj.iterateSymbols = true; + return true; + } + return false; + }, + replace: (x) => ({...x}), + revive: (x) => x + } + }); + + const other = {noSymbols: true}; + const back = tson.parseSync(tson.stringifySync({ + nested: {[local]: 'x'}, other + })); + assert( + back.nested[local] === 'x', + 'Symbol key on the object the type matched' + ); + assert( + !Object.getOwnPropertySymbols(back.other).length, + 'Objects not matched by the type are left alone' + ); + }); + + it('records a typed Symbol value in `$types` and revives it', () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }).register(DateType); + + const tson1 = tson.stringifySync({[local]: new Date(0)}); + const parsed = JSON.parse(tson1); + assert( + parsed.$types["$symbolKeys.''.0.value"] === 'Date', + 'Symbol value keypath is namespaced under `$symbolKeys`' + ); + + const back = tson.parseSync(tson1); + assert( + back[local] instanceof Date && back[local].getTime() === 0, + 'Typed Symbol value revived to an instance' + ); + }); + + it('drops a Symbol key whose value is `undefined`', () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + const back = tson.parseSync(tson.stringifySync({ + [local]: undefined, kept: 1 + })); + assert( + !Object.getOwnPropertySymbols(back).includes(local), + 'Symbol key with `undefined` value is dropped, like a ' + + 'plain key' + ); + assert(back.kept === 1, 'Other keys preserved'); + }); + + it('preserves a cyclic reference held under a Symbol key', () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + + /** @type {{name: string, [key: symbol]: unknown}} */ + const obj = {name: 'root'}; + obj[local] = obj; + const back = tson.parseSync(tson.stringifySync(obj)); + assert(back[local] === back, 'Cyclic Symbol value points at root'); + assert(back.name === 'root', 'Ordinary key preserved'); + }); + + it( + 'skips a Symbol with no portable identity, or throws when ' + + '`throwOnUnregisteredSymbol` is set', + () => { + const orphan = Symbol('orphan'); + const lenient = new Typeson({iterateSymbols: true}); + const back = lenient.parseSync( + lenient.stringifySync({[orphan]: 1, keep: 2}) + ); + assert( + !Object.getOwnPropertySymbols(back).includes(orphan), + 'Unregistered Symbol key skipped' + ); + assert(back.keep === 2, 'Other keys preserved'); + + const strict = new Typeson({ + iterateSymbols: true, throwOnUnregisteredSymbol: true + }); + assert.throws( + () => strict.stringifySync({[orphan]: 1}), + TypeError + ); + } + ); + + it('round-trips Symbol keys asynchronously', async () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }).register({ + Date: { + test: (x) => x instanceof Date, + replaceAsync: (d) => new TypesonPromise((resolve) => { + resolve(d.getTime()); + }), + reviveAsync: (n) => new TypesonPromise((resolve) => { + resolve(new Date(n)); + }) + } + }); + + const back = await tson.parseAsync( + await tson.stringifyAsync({q: 1, [local]: new Date(7)}) + ); + assert(back.q === 1, 'Ordinary key preserved'); + assert( + back[local] instanceof Date && back[local].getTime() === 7, + 'Async typed Symbol value revived' + ); + }); + + it('round-trips Symbol keys on a non-plain root (array)', () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + + const arr = [1, 2]; + Object.defineProperty(arr, local, { + configurable: true, enumerable: true, value: 'x', writable: true + }); + const back = tson.parseSync(tson.stringifySync(arr)); + assert(back[local] === 'x', 'Symbol key on the array preserved'); + assert(Array.isArray(back) && back[0] === 1 && back[1] === 2, + 'Array data preserved'); + }); + + it('emits an `endIterateSymbols` observer event', () => { + const local = Symbol('local'); + let seen = false; + const tson = new Typeson({ + symbols: {Local: local}, + iterateSymbols: true, + encapsulateObserver (o) { + if (o.endIterateSymbols) { + seen = true; + } + } + }); + tson.encapsulateSync({[local]: 1}); + assert(seen, '`endIterateSymbols` was observed'); + }); + + it('throws on revival when a registered Symbol is unknown', () => { + const local = Symbol('local'); + const writer = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + const tson = writer.stringifySync({[local]: 1}); + + // A reader that never registered the Symbol under that name + const reader = new Typeson({iterateSymbols: true}); + assert.throws( + () => reader.parseSync(tson), + /Unregistered symbol: Local/u + ); + }); + + it('rejects a non-symbol value in the `symbols` option', () => { + assert.throws( + // @ts-expect-error Intentional bad `symbols` entry for test + () => new Typeson({symbols: {Bad: 123}}), + TypeError + ); + }); + + it('throws on a crafted unknown well-known Symbol identity', () => { + const reader = new Typeson({iterateSymbols: true}); + assert.throws( + () => reader.parseSync(JSON.stringify({ + a: 1, + $symbolKeys: { + '': [{key: {wellKnown: 'nope'}, value: 1}] + } + })), + /Unknown well-known Symbol: nope/u + ); + }); + + it('honors an explicit descriptor in `$symbolKeys` metadata', () => { + // `descriptor` is a reserved extension point; encapsulation does + // not emit it yet, but revival must respect one if present. + const reader = new Typeson({iterateSymbols: true}); + const back = reader.parseSync(JSON.stringify({ + a: 1, + $symbolKeys: { + '': [{ + key: {for: 'ext'}, + value: 2, + descriptor: {enumerable: false} + }] + } + })); + const sym = Symbol.for('ext'); + assert(back[sym] === 2, 'Value assigned under the Symbol key'); + assert( + !Object.prototype.propertyIsEnumerable.call(back, sym), + '`descriptor.enumerable: false` was honored' + ); + }); + + it('round-trips an object with a literal `$symbolKeys` key', () => { + const local = Symbol('local'); + const tson = new Typeson({ + symbols: {Local: local}, iterateSymbols: true + }); + + const back = tson.parseSync(tson.stringifySync({ + $symbolKeys: 'literal', [local]: 'sym', z: 9 + })); + assert( + back.$symbolKeys === 'literal', + 'Literal `$symbolKeys` string key survives' + ); + assert(back[local] === 'sym', 'Symbol key still revived'); + assert(back.z === 9, 'Other keys preserved'); + }); }); it('should throw upon attempt to parse type that is not registered', () => { diff --git a/tsconfig-build.json b/tsconfig-build.json index 20b6711..126acaf 100644 --- a/tsconfig-build.json +++ b/tsconfig-build.json @@ -7,7 +7,6 @@ "checkJs": true, "noEmit": false, "noImplicitAny": true, - "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, diff --git a/tsconfig.json b/tsconfig.json index 15dada4..c71161c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,6 @@ "checkJs": true, "noEmit": true, "noImplicitAny": true, - "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, "strict": true, diff --git a/typeson.js b/typeson.js index 5a51c78..403f97a 100644 --- a/typeson.js +++ b/typeson.js @@ -14,6 +14,8 @@ * @property {boolean} [replaced] * @property {"object"|"array"} [iterateIn] * @property {boolean} [iterateUnsetNumeric] + * @property {boolean} [iterateSymbols] Iterate enumerable own Symbol-keyed + * properties (see also the `iterateSymbols` option) * @property {boolean} [addLength] * @property {boolean} [ownKeys] */ @@ -127,6 +129,87 @@ function setOwnEnumerable (obj, key, value) { }); } +// Names of the well-known symbols (`Symbol.iterator`, `Symbol.toStringTag`, +// etc.); these have a portable identity across realms via their name. +const wellKnownSymbolNames = /** @type {const} */ ([ + 'asyncIterator', 'hasInstance', 'isConcatSpreadable', 'iterator', + 'match', 'matchAll', 'replace', 'search', 'species', 'split', + 'toPrimitive', 'toStringTag', 'unscopables' +]); + +/** + * @typedef {{for: string}|{wellKnown: string}|{registered: string}} + * SymbolKeyIdentity + */ + +/** + * Optional property-descriptor bits for a Symbol-keyed entry in the + * `$symbolKeys` map. Currently unused by encapsulation (only enumerable + * own Symbol keys are iterated and they are revived as plain data + * properties); reserved so that attributes such as `enumerable` can be + * added later without a breaking format change. When absent, an entry is + * revived as a configurable, writable, enumerable data property. + * @typedef {{ + * enumerable?: boolean, + * writable?: boolean, + * configurable?: boolean + * }} SymbolKeyDescriptor + */ + +/** + * Describe a Symbol in a way that can be serialized and later revived to a + * Symbol with the same identity. Returns `null` for a plain `Symbol()` that + * was not registered (it has no portable identity). + * @param {symbol} sym + * @param {Map} symbolsByValue + * @returns {SymbolKeyIdentity|null} + */ +function resolveSymbolIdentity (sym, symbolsByValue) { + const globalKey = Symbol.keyFor(sym); + if (globalKey !== undefined) { + return {for: globalKey}; + } + const wellKnown = wellKnownSymbolNames.find((name) => { + return Symbol[name] === sym; + }); + if (wellKnown) { + return {wellKnown}; + } + const registered = symbolsByValue.get(sym); + if (registered !== undefined) { + return {registered}; + } + return null; +} + +/** + * Reverse of {@link resolveSymbolIdentity}. + * @param {SymbolKeyIdentity} identity + * @param {{[name: string]: symbol}} symbolsByName + * @throws {Error} If a registered identity names an unknown Symbol + * @returns {symbol} + */ +function resolveSymbolFromIdentity (identity, symbolsByName) { + if ('for' in identity) { + return Symbol.for(identity.for); + } + if ('wellKnown' in identity) { + const name = wellKnownSymbolNames.find((nm) => { + return nm === identity.wellKnown; + }); + if (!name) { + throw new Error( + 'Unknown well-known Symbol: ' + identity.wellKnown + ); + } + return Symbol[name]; + } + if (!hasOwn(symbolsByName, identity.registered)) { + throw new Error('Unregistered symbol: ' + identity.registered); + } + return symbolsByName[identity.registered]; +} + /** * @typedef {object} PlainObjectType * @property {string} keypath @@ -191,6 +274,12 @@ function nestedPathsFirst (a, b) { * @property {boolean} [end] */ +/** + * @typedef {object} EndIterateSymbolsEvent + * @property {boolean} [endIterateSymbols] + * @property {boolean} [end] + */ + /** * @typedef {object} TypeDetectedEvent * @property {boolean} [typeDetected] @@ -215,7 +304,7 @@ function nestedPathsFirst (a, b) { /** * @typedef {KeyPathEvent & EndIterateInEvent & EndIterateOwnEvent & - * EndIterateUnsetNumericEvent & + * EndIterateUnsetNumericEvent & EndIterateSymbolsEvent & * TypeDetectedEvent & ReplacingEvent & {} & { * replaced?: any * } & { @@ -260,7 +349,7 @@ function nestedPathsFirst (a, b) { /** * @callback Observer * @param {KeyPathEvent|EndIterateInEvent|EndIterateOwnEvent| - * EndIterateUnsetNumericEvent| + * EndIterateUnsetNumericEvent|EndIterateSymbolsEvent| * TypeDetectedEvent|ReplacingEvent} [event] * @returns {void} */ @@ -279,6 +368,15 @@ function nestedPathsFirst (a, b) { * `encapsulateAync`, `reviveSync`, `reviveAsync` * @property {number|boolean} [fallback] `true` sets to 0. Default is * positive infinity. Used within `register` + * @property {{[name: string]: symbol}} [symbols] Map of names to local + * `Symbol()` instances, so that Symbol-keyed properties using them can be + * revived with the same identity. Both ends must supply the same map. + * @property {boolean} [iterateSymbols] Iterate enumerable own Symbol-keyed + * properties of every object (a type may instead set `iterateSymbols` on + * its state object to enable this only for its own instances). + * @property {boolean} [throwOnUnregisteredSymbol] Throw (rather than + * silently skip) when a Symbol-keyed property uses a Symbol with no + * portable identity (not global, not well-known, and not in `symbols`). * @property {EncapsulateObserver} [encapsulateObserver] * @property {EncapsulateErrorHandler} [encapsulateError] */ @@ -324,6 +422,31 @@ class Typeson { /** @type {TypeSpecSet} */ this.types = {}; + + // Symbol-key identity maps, populated from the `symbols` option. + // `symbolsByName` is used on revival to look a Symbol up by the + // name it was registered under; `symbolsByValue` is used on + // encapsulation to find the name for an encountered Symbol. + + /** @type {{[name: string]: symbol}} */ + this.symbolsByName = Object.create(null); + + /** @type {Map} */ + this.symbolsByValue = new Map(); + + const symbolsOption = options?.symbols; + if (symbolsOption) { + Object.entries(symbolsOption).forEach(([name, sym]) => { + if (typeof sym !== 'symbol') { + throw new TypeError( + 'Non-symbol value supplied for `symbols` entry: ' + + name + ); + } + this.symbolsByName[name] = sym; + this.symbolsByValue.set(sym, name); + }); + } } /** @@ -518,6 +641,20 @@ class Typeson { * }} */ const types = {}, + /** + * Symbol-keyed properties encountered while `iterateSymbols` is + * in effect, keyed by the (raw) keypath of the owning object; + * each entry pairs a portable Symbol description with the + * encapsulated value. Attached to the result as `$symbolKeys`. + * @type {{ + * [ownerKeypath: string]: { + * key: SymbolKeyIdentity, + * value?: unknown, + * descriptor?: SymbolKeyDescriptor + * }[] + * }} + */ + symbolKeys = {}, /** @type {object[]} */ refObjs = [], // For checking cyclic references /** @type {string[]} */ @@ -546,29 +683,35 @@ class Typeson { } return getJSONType(_ret); } - if (typeNames.length) { - if (opts.returnTypeNames) { - return [...new Set(typeNames)]; - } + if (opts.returnTypeNames) { + return typeNames.length ? [...new Set(typeNames)] : false; + } + + const hasSymbolKeys = Object.keys(symbolKeys).length > 0; + + // An object that already carries its own `$types` or + // `$symbolKeys` string key must be wrapped in `$` to avoid + // ambiguity with our metadata (checked before we attach ours). + const ambiguous = isObject(_ret) && + (hasOwn(_ret, '$types') || hasOwn(_ret, '$symbolKeys')); + if (hasSymbolKeys || typeNames.length) { // Special if array (or a primitive) was serialized // because JSON would ignore custom `$types` prop on it - if (!_ret || !isPlainObject(_ret) || - // Also need to handle if this is an object with its - // own `$types` property (to avoid ambiguity) - hasOwn(_ret, '$types') - ) { + if (ambiguous || !_ret || !isPlainObject(_ret)) { + // The `$types` companion may be empty here when only + // Symbol keys were found; revival tolerates that. _ret = {$: _ret, $types: {$: types}}; - } else { + } else if (typeNames.length) { _ret.$types = types; } + if (hasSymbolKeys) { + _ret.$symbolKeys = symbolKeys; + } // No special types - } else if (isObject(_ret) && hasOwn(_ret, '$types')) { + } else if (ambiguous) { _ret = {$: _ret, $types: true}; } - if (opts.returnTypeNames) { - return false; - } return _ret; }; @@ -709,7 +852,7 @@ class Typeson { // `@type {Observer}` here as doesn't see param is optional /** * @param {KeyPathEvent|EndIterateInEvent|EndIterateOwnEvent| - * EndIterateUnsetNumericEvent| + * EndIterateUnsetNumericEvent|EndIterateSymbolsEvent| * TypeDetectedEvent|ReplacingEvent} [_obj] * @returns {void} */ @@ -987,6 +1130,89 @@ class Typeson { if (runObserver) { runObserver({endIterateOwn: true, end: true}); } + + // Iterate enumerable own Symbol-keyed properties when a type + // (via its state object) or the `iterateSymbols` option + // asks for it. Each such property is recorded in the + // separate `symbolKeys` map (keyed by the owner's keypath) + // with a portable description of the Symbol, and its value + // is encapsulated at a real keypath under `$symbolKeys` so + // `$types` and cyclic-reference handling apply unchanged. + if (_stateObj.iterateSymbols || opts.iterateSymbols) { + Object.getOwnPropertySymbols(value).filter((sym) => { + // With regular object keys, we don't iterate + // enumerable properties (e.g., by + // `Object.getOwnPropertyNames`) by default, so, + // for parity, we don't do so for symbols either. + // However, we make space for a `descriptor` if + // this is desired in the future. If we wanted one + // for regular keys, we'd need to add the likes of + // a `$descriptors` property keyed by keypath, + // parallel to $types + return Object.prototype.propertyIsEnumerable.call( + value, sym + ); + }).forEach((sym) => { + const identity = resolveSymbolIdentity( + sym, this.symbolsByValue + ); + if (!identity) { + if (opts.throwOnUnregisteredSymbol) { + throw new TypeError( + 'Cannot serialize a Symbol-keyed ' + + 'property whose Symbol has no portable ' + + 'identity (not global, not well-known, ' + + 'and not in the `symbols` option): ' + + String(sym) + ); + } + return; + } + const list = symbolKeys[keypath] ??= []; + /** + * @type {{ + * key: SymbolKeyIdentity, + * value?: unknown, + * descriptor?: SymbolKeyDescriptor + * }} + */ + const entry = {key: identity}; + list.push(entry); + const idx = list.length - 1; + const kp = `$symbolKeys.${ + escapeKeyPathComponent(keypath) + }.${String(idx)}.value`; + const symbolValueHolder = {value: value[sym]}; + const ownKeysObj = {ownKeys: true}; + _adaptBuiltinStateObjectProperties( + _stateObj, + ownKeysObj, + () => { + const encapsulatedValue = getEncapsulatedValue( + kp, symbolValueHolder, 'value' + ); + const val = encapsulatedValue && + encapsulatedValue.value; + if (hasConstructorOf(val, TypesonPromise)) { + promisesData.push([ + kp, val, Boolean(_cyclic), _stateObj, + entry, 'value', _stateObj.type + ]); + } else if ( + encapsulatedValue && ( + val !== undefined || + 'substitute' in encapsulatedValue + ) + ) { + setOwnEnumerable(entry, 'value', val); + } + } + ); + }); + if (runObserver) { + runObserver({endIterateSymbols: true, end: true}); + } + } } // Iterate array for non-own numeric properties (we can't // replace the prior loop though as it iterates non-integer @@ -1216,9 +1442,23 @@ class Typeson { return finishRevival(obj.$); } - // No type info added. Revival not needed. + // Symbol-keyed properties are recorded separately by `encapsulate` + // (see the `$symbolKeys` map); when present, revival is needed + // even if there is no `$types` map at all. When the result was + // wrapped in `$`, the metadata rode on the wrapper (captured here + // before any unwrap). `symbolMetaInTree` tracks whether the + // metadata sits on the object `_revive` walks, in which case its + // revived copy (with nested types/cyclic refs resolved) is used. + const symbolKeysMeta = obj.$symbolKeys; + let symbolMetaInTree = symbolKeysMeta !== undefined; + + // No type info added. Revival not needed (unless Symbol keys were + // recorded, in which case we still need to re-home them). if (!types || typeof types !== 'object' || Array.isArray(types)) { - return finishRevival(obj); + if (!symbolKeysMeta) { + return finishRevival(obj); + } + types = {}; } /** @@ -1242,6 +1482,18 @@ class Typeson { obj = obj.$; types = types.$; ignore$Types = false; + // The `$symbolKeys` metadata rode on the wrapper. Move it onto + // the object `_revive` will actually walk so that its + // `$symbolKeys...` keypaths resolve as normal — but never + // clobber a genuine own `$symbolKeys` property (the reason + // this object was wrapped in the first place). + symbolMetaInTree = false; + if (symbolKeysMeta !== undefined && isObject(obj) && + !hasOwn(obj, '$symbolKeys') + ) { + obj.$symbolKeys = symbolKeysMeta; + symbolMetaInTree = true; + } } /** @@ -1540,6 +1792,73 @@ class Typeson { return hasConstructorOf(retrn, Undefined) ? undefined : retrn; } + /** + * Re-home Symbol-keyed properties (recorded by `encapsulate` in the + * `$symbolKeys` map) onto their owning objects once the rest of the + * tree, including any nested types and cyclic references, has been + * revived; then drop the `$symbolKeys` scaffolding from `result`. + * Mutates `result` in place. + * @param {any} result + * @returns {void} + */ + const reHomeSymbolKeys = (result) => { + /* c8 ignore next 3 -- Defensive; `result` is an object here */ + if (!isObject(result)) { + return; + } + // When the metadata was walked as part of the tree, use its + // revived copy (nested types and cyclic references resolved) + // and remove the scaffolding afterwards; otherwise fall back + // to the raw metadata (plain Symbol values only). + const meta = symbolMetaInTree + ? result.$symbolKeys + : symbolKeysMeta; + /* c8 ignore next 3 -- Defensive; only a typed root could drop it */ + if (!meta) { + return; + } + Object.entries(meta).forEach(([ownerKeypath, entries]) => { + const owner = getByKeyPath(result, ownerKeypath, true); + /* c8 ignore next 3 -- Defensive; owner keypath resolves */ + if (!isObject(owner)) { + return; + } + /** + * @type {{ + * key: SymbolKeyIdentity, + * value?: unknown, + * descriptor?: SymbolKeyDescriptor + * }[]} + */ + (entries).forEach((entry) => { + if (!hasOwn(entry, 'value')) { + return; + } + const sym = resolveSymbolFromIdentity( + entry.key, this.symbolsByName + ); + const value = checkUndefined(entry.value); + if (entry.descriptor) { + // Reserved extension point: honor an explicit + // descriptor (e.g. a future non-enumerable Symbol + // key) over the default data-property assignment. + Object.defineProperty(owner, sym, { + configurable: true, + enumerable: true, + writable: true, + ...entry.descriptor, + value + }); + } else { + owner[sym] = value; + } + }); + }); + if (symbolMetaInTree) { + delete result.$symbolKeys; + } + }; + const possibleTypesonPromise = revivePlainObjects(); let ret; if (hasConstructorOf(possibleTypesonPromise, TypesonPromise)) { @@ -1564,6 +1883,23 @@ class Typeson { } } + if (symbolKeysMeta) { + if (isThenable(ret)) { + ret = ret.then( + /** + * @param {unknown} r + * @returns {unknown} + */ + (r) => { + reHomeSymbolKeys(r); + return r; + } + ); + } else { + reHomeSymbolKeys(ret); + } + } + return isThenable(ret) ? sync && opts.throwOnBadSyncType ? (() => {