From 9a957eb1d9f7554b1679788b31098db6b093a17e Mon Sep 17 00:00:00 2001 From: Phu Si On Date: Fri, 27 Mar 2026 16:24:02 +0200 Subject: [PATCH] feat: add deserializeComplexTypes option for Date round-tripping Add a new deserializeComplexTypes option that preserves Date objects through JSON serialization and deserialization. When enabled: - Date objects are tagged during serialization using a wrapper format - Tagged objects are restored to Date instances during deserialization - Works with nested objects, arrays, dot-notation, and encryption - Collision-safe: only matches objects with exactly the two tag keys The reviver strictly validates the tag format (exact key count + known type) to prevent false positives. The option is ignored when custom serialize or deserialize functions are provided. Closes sindresorhus/electron-store#18 --- readme.md | 28 +++++ source/index.ts | 45 +++++++ source/types.ts | 31 +++++ test/complex-types.ts | 277 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 test/complex-types.ts diff --git a/readme.md b/readme.md index d1a36d5..430fdec 100644 --- a/readme.md +++ b/readme.md @@ -420,6 +420,34 @@ You would usually not need this, but it could be useful if you use a custom `cwd > [!NOTE] > Setting restrictive permissions can cause problems if different users need to read the file. A common problem is a user running your tool with and without `sudo` and then not being able to access the config the second time. +#### deserializeComplexTypes + +Type: `boolean`\ +Default: `false` + +Preserve non-JSON types like `Date` through serialization and deserialization. + +When enabled, `Date` objects are tagged during serialization so they can be restored as proper `Date` instances when read back. This allows round-tripping of `Date` values. + +```js +import Conf from 'conf'; + +const config = new Conf({ + projectName: 'foo', + deserializeComplexTypes: true +}); + +config.set('timestamp', new Date()); +console.log(config.get('timestamp') instanceof Date); +//=> true + +config.set('nested', {createdAt: new Date()}); +console.log(config.get('nested').createdAt instanceof Date); +//=> true +``` + +This option is ignored when custom `serialize` or `deserialize` functions are provided. + ### Instance You can use [dot-notation](https://github.com/sindresorhus/dot-prop) in a `key` to access nested properties. diff --git a/source/index.ts b/source/index.ts index 9f4fc83..660c648 100644 --- a/source/index.ts +++ b/source/index.ts @@ -70,6 +70,45 @@ const checkValueType = (key: string, value: unknown): void => { const INTERNAL_KEY = '__internal__'; const MIGRATION_KEY = `${INTERNAL_KEY}.migrations.version`; +const COMPLEX_TYPE_TAG = '$$type'; +const COMPLEX_VALUE_TAG = '$$value'; + +/** +JSON replacer that tags `Date` objects for round-tripping. +Uses `this` binding from `JSON.stringify` to access the original value +(before `.toJSON()` converts it to a string). +*/ +function complexTypeReplacer(this: Record, key: string, value: unknown): unknown { + const rawValue = this[key]; + if (rawValue instanceof Date) { + return {[COMPLEX_TYPE_TAG]: 'Date', [COMPLEX_VALUE_TAG]: rawValue.toISOString()}; + } + + return value; +} + +/** +JSON reviver that restores tagged objects back to their original types. +Only matches objects with exactly `$$type` and `$$value` keys. +*/ +function complexTypeReviver(_key: string, value: unknown): unknown { + if ( + value !== null + && typeof value === 'object' + && !Array.isArray(value) + && COMPLEX_TYPE_TAG in value + && COMPLEX_VALUE_TAG in value + && Object.keys(value).length === 2 + ) { + const tagged = value as Record; + if (tagged[COMPLEX_TYPE_TAG] === 'Date' && typeof tagged[COMPLEX_VALUE_TAG] === 'string') { + return new Date(tagged[COMPLEX_VALUE_TAG] as string); + } + } + + return value; +} + export default class Conf = Record> implements Iterable<[keyof T, T[keyof T]]> { readonly path: string; readonly events: EventTarget; @@ -845,12 +884,18 @@ export default class Conf = Record>): void { + const hasCustomSerialization = Boolean(options.serialize ?? options.deserialize); + if (options.serialize) { this._serialize = options.serialize; + } else if (options.deserializeComplexTypes && !hasCustomSerialization) { + this._serialize = value => JSON.stringify(value, complexTypeReplacer, '\t'); } if (options.deserialize) { this._deserialize = options.deserialize; + } else if (options.deserializeComplexTypes && !hasCustomSerialization) { + this._deserialize = value => JSON.parse(value, complexTypeReviver); } } diff --git a/source/types.ts b/source/types.ts index bf97428..1a3f86c 100644 --- a/source/types.ts +++ b/source/types.ts @@ -304,6 +304,37 @@ export type Options> = { @default 0o666 */ readonly configFileMode?: number; + + /** + Preserve non-JSON types like `Date` through serialization and deserialization. + + When enabled, `Date` objects are tagged during serialization so they can be restored as proper `Date` instances when read back. This allows round-tripping of `Date` values. + + Uses a tagged wrapper format in the JSON: `{"$$type": "Date", "$$value": "2024-01-01T00:00:00.000Z"}`. + + This option is ignored when custom `serialize` or `deserialize` functions are provided. + + @default false + + @example + ``` + import Conf from 'conf'; + + const config = new Conf({ + projectName: 'foo', + deserializeComplexTypes: true + }); + + config.set('timestamp', new Date()); + config.get('timestamp') instanceof Date; + //=> true + + config.set('nested', {createdAt: new Date()}); + config.get('nested').createdAt instanceof Date; + //=> true + ``` + */ + readonly deserializeComplexTypes?: boolean; }; export type Migrations> = Record) => void>; diff --git a/test/complex-types.ts b/test/complex-types.ts new file mode 100644 index 0000000..f5687e6 --- /dev/null +++ b/test/complex-types.ts @@ -0,0 +1,277 @@ +import fs from 'node:fs'; +import {describe, it, beforeEach, afterEach} from 'node:test'; +import assert from 'node:assert/strict'; +import Conf from '../source/index.js'; +import { + createTempDirectory, + trackConf, + resetTrackedConfs, + runRegisteredCleanups, +} from './_utilities.js'; + +afterEach(() => { + resetTrackedConfs(); + runRegisteredCleanups(); +}); + +describe('deserializeComplexTypes', () => { + let config: Conf; + + beforeEach(() => { + config = trackConf(new Conf({cwd: createTempDirectory(), deserializeComplexTypes: true})); + }); + + it('round-trips a Date value', () => { + const date = new Date('2024-06-15T12:30:00.000Z'); + config.set('timestamp', date); + const result = config.get('timestamp'); + assert.ok(result instanceof Date); + assert.strictEqual(result.toISOString(), date.toISOString()); + }); + + it('round-trips a Date value with dot-notation key', () => { + const date = new Date('2024-01-01T00:00:00.000Z'); + config.set('nested.createdAt', date); + const result = config.get('nested.createdAt'); + assert.ok(result instanceof Date); + assert.strictEqual(result.toISOString(), date.toISOString()); + }); + + it('round-trips Date values inside a nested object', () => { + const now = new Date(); + config.set('user', { + name: 'test', + createdAt: now, + profile: { + updatedAt: now, + }, + }); + + const user = config.get('user') as Record; + assert.ok(user.createdAt instanceof Date); + assert.strictEqual((user.createdAt as Date).toISOString(), now.toISOString()); + + const profile = user.profile as Record; + assert.ok(profile.updatedAt instanceof Date); + assert.strictEqual((profile.updatedAt as Date).toISOString(), now.toISOString()); + }); + + it('round-trips Date values inside arrays', () => { + const dates = [ + new Date('2024-01-01T00:00:00.000Z'), + new Date('2024-06-15T00:00:00.000Z'), + new Date('2024-12-31T00:00:00.000Z'), + ]; + config.set('dates', dates); + const result = config.get('dates') as Date[]; + assert.strictEqual(result.length, 3); + for (const [index, date] of result.entries()) { + assert.ok(date instanceof Date, `dates[${index}] should be a Date`); + assert.strictEqual(date.toISOString(), dates[index].toISOString()); + } + }); + + it('round-trips mixed arrays containing Date and non-Date values', () => { + const date = new Date('2024-03-15T10:00:00.000Z'); + config.set('mixed', ['hello', date, 42, null, true]); + const result = config.get('mixed') as unknown[]; + assert.strictEqual(result[0], 'hello'); + assert.ok(result[1] instanceof Date); + assert.strictEqual((result[1] as Date).toISOString(), date.toISOString()); + assert.strictEqual(result[2], 42); + assert.strictEqual(result[3], null); + assert.strictEqual(result[4], true); + }); + + it('does not convert non-Date values', () => { + config.set('str', 'hello'); + config.set('num', 42); + config.set('bool', true); + config.set('nil', null); + config.set('obj', {a: 1, b: 'two'}); + config.set('arr', [1, 2, 3]); + + assert.strictEqual(config.get('str'), 'hello'); + assert.strictEqual(config.get('num'), 42); + assert.strictEqual(config.get('bool'), true); + assert.strictEqual(config.get('nil'), null); + assert.deepStrictEqual(config.get('obj'), {a: 1, b: 'two'}); + assert.deepStrictEqual(config.get('arr'), [1, 2, 3]); + }); + + it('does not falsely revive objects with $$type but extra keys', () => { + const value = {$$type: 'Date', $$value: '2024-01-01T00:00:00.000Z', extra: true}; + config.set('notADate', value); + const result = config.get('notADate') as Record; + assert.ok(!(result instanceof Date)); + assert.strictEqual(result.$$type, 'Date'); + assert.strictEqual(result.extra, true); + }); + + it('does not falsely revive objects with only $$type', () => { + const value = {$$type: 'Date'}; + config.set('partial', value); + const result = config.get('partial') as Record; + assert.ok(!(result instanceof Date)); + assert.strictEqual(result.$$type, 'Date'); + }); + + it('does not falsely revive objects with unknown $$type', () => { + const value = {$$type: 'RegExp', $$value: '.*'}; + config.set('unknownType', value); + const result = config.get('unknownType') as Record; + assert.ok(!(result instanceof Date)); + assert.strictEqual(result.$$type, 'RegExp'); + assert.strictEqual(result.$$value, '.*'); + }); + + it('persists tagged format in the JSON file', () => { + const date = new Date('2024-06-15T12:00:00.000Z'); + config.set('myDate', date); + + const raw = fs.readFileSync(config.path, 'utf8'); + const parsed = JSON.parse(raw); + assert.deepStrictEqual(parsed.myDate, { + $$type: 'Date', + $$value: '2024-06-15T12:00:00.000Z', + }); + }); + + it('works with .store getter', () => { + const date1 = new Date('2024-01-01T00:00:00.000Z'); + const date2 = new Date('2024-12-31T00:00:00.000Z'); + config.set('start', date1); + config.set('end', date2); + config.set('name', 'test'); + + const store = config.store; + assert.ok(store.start instanceof Date); + assert.ok(store.end instanceof Date); + assert.strictEqual(store.name, 'test'); + }); + + it('works with .store setter', () => { + const date = new Date('2024-06-15T00:00:00.000Z'); + config.store = { + timestamp: date, + label: 'hello', + } as any; + + assert.ok(config.get('timestamp') instanceof Date); + assert.strictEqual(config.get('label'), 'hello'); + }); + + it('handles Date.now() timestamps', () => { + const date = new Date(); + config.set('now', date); + const result = config.get('now'); + assert.ok(result instanceof Date); + assert.strictEqual((result as Date).getTime(), date.getTime()); + }); + + it('handles invalid Date gracefully', () => { + const invalidDate = new Date('not-a-date'); + config.set('invalid', invalidDate); + const result = config.get('invalid'); + assert.ok(result instanceof Date); + assert.ok(Number.isNaN((result as Date).getTime())); + }); + + it('works with encryption', () => { + const encConfig = trackConf(new Conf({ + cwd: createTempDirectory(), + deserializeComplexTypes: true, + encryptionKey: 'secret123', + })); + + const date = new Date('2024-06-15T12:00:00.000Z'); + encConfig.set('secure', date); + const result = encConfig.get('secure'); + assert.ok(result instanceof Date); + assert.strictEqual((result as Date).toISOString(), date.toISOString()); + }); + + it('deeply nested objects with multiple Date fields', () => { + const data = { + level1: { + level2: { + level3: { + created: new Date('2024-01-01T00:00:00.000Z'), + modified: new Date('2024-06-15T00:00:00.000Z'), + }, + items: [ + {date: new Date('2024-03-01T00:00:00.000Z'), value: 'a'}, + {date: new Date('2024-04-01T00:00:00.000Z'), value: 'b'}, + ], + }, + }, + }; + + config.set('deep', data); + const result = config.get('deep') as typeof data; + assert.ok(result.level1.level2.level3.created instanceof Date); + assert.ok(result.level1.level2.level3.modified instanceof Date); + assert.ok(result.level1.level2.items[0].date instanceof Date); + assert.ok(result.level1.level2.items[1].date instanceof Date); + assert.strictEqual(result.level1.level2.items[0].value, 'a'); + }); +}); + +describe('deserializeComplexTypes disabled (default)', () => { + it('does not restore Date objects when option is not set', () => { + const config = trackConf(new Conf({cwd: createTempDirectory()})); + const date = new Date('2024-06-15T12:00:00.000Z'); + config.set('timestamp', date); + + // Without the option, Date is stored as ISO string by JSON.stringify + const result = config.get('timestamp'); + assert.strictEqual(typeof result, 'string'); + assert.strictEqual(result, date.toISOString()); + }); + + it('ignores deserializeComplexTypes when custom serialize is provided', () => { + const config = trackConf(new Conf({ + cwd: createTempDirectory(), + deserializeComplexTypes: true, + serialize: value => JSON.stringify(value), + })); + + const date = new Date('2024-06-15T12:00:00.000Z'); + config.set('timestamp', date); + + // Custom serialize overrides: both replacer and reviver are disabled + const result = config.get('timestamp'); + assert.strictEqual(typeof result, 'string'); + }); + + it('ignores deserializeComplexTypes when custom deserialize is provided', () => { + const config = trackConf(new Conf({ + cwd: createTempDirectory(), + deserializeComplexTypes: true, + deserialize: value => JSON.parse(value), + })); + + const date = new Date('2024-06-15T12:00:00.000Z'); + config.set('timestamp', date); + + // Custom deserialize overrides: both replacer and reviver are disabled + const result = config.get('timestamp'); + assert.strictEqual(typeof result, 'string'); + }); +}); + +describe('deserializeComplexTypes with accessPropertiesByDotNotation: false', () => { + it('round-trips Date values without dot-notation', () => { + const config = trackConf(new Conf({ + cwd: createTempDirectory(), + deserializeComplexTypes: true, + accessPropertiesByDotNotation: false, + })); + + const date = new Date('2024-06-15T12:00:00.000Z'); + config.set('timestamp', date); + const result = config.get('timestamp'); + assert.ok(result instanceof Date); + assert.strictEqual(result.toISOString(), date.toISOString()); + }); +});