From c9c19887697c1a9d3df4e03b7732ee19ace5ed8e Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:46:30 -0700 Subject: [PATCH] fix: format Symbol paths in validation errors Convert Symbol path components only for error-message formatting while preserving raw path and key identities in structured failures. AI assistance: OpenAI Codex. --- src/error.ts | 4 +- test/api/symbol-path.test.ts | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 test/api/symbol-path.test.ts diff --git a/src/error.ts b/src/error.ts index 663470df..c09811bd 100644 --- a/src/error.ts +++ b/src/error.ts @@ -37,7 +37,9 @@ export class StructError extends TypeError { const { message, explanation, ...rest } = failure const { path } = failure const msg = - path.length === 0 ? message : `At path: ${path.join('.')} -- ${message}` + path.length === 0 + ? message + : `At path: ${path.map((key) => (typeof key === 'symbol' ? key.toString() : key)).join('.')} -- ${message}` super(explanation ?? msg) if (explanation != null) this.cause = msg Object.assign(this, rest) diff --git a/test/api/symbol-path.test.ts b/test/api/symbol-path.test.ts new file mode 100644 index 00000000..cc2e5020 --- /dev/null +++ b/test/api/symbol-path.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + any, + array, + assert, + is, + map, + number, + object, + string, + StructError, + validate, +} from '../../src' + +describe('symbol paths', () => { + it('returns an error for an invalid value at a symbol map key', () => { + const key = Symbol('key') + const data = new Map([[key, 42]]) + const [error, value] = validate(data, map(any(), string())) + expect(value).toBeUndefined() + expect(error).toBeInstanceOf(StructError) + expect(error?.message).toBe( + 'At path: Symbol(key) -- Expected a string, but received: 42' + ) + expect(error?.path).toStrictEqual([key]) + expect(error?.key).toBe(key) + }) + + it('returns an error for an invalid symbol map key', () => { + const key = Symbol('key') + const [error] = validate(new Map([[key, 42]]), map(string(), number())) + expect(error).toBeInstanceOf(StructError) + expect(error?.message).toBe( + 'At path: Symbol(key) -- Expected a string, but received: Symbol(key)' + ) + expect(error?.path).toStrictEqual([key]) + }) + + it('formats symbols in nested paths without changing the path', () => { + const key = Symbol() + const data = { records: [new Map([[key, 42]])] } + const [error] = validate( + data, + object({ records: array(map(any(), string())) }) + ) + expect(error?.message).toBe( + 'At path: records.0.Symbol() -- Expected a string, but received: 42' + ) + expect(error?.path).toStrictEqual(['records', 0, key]) + }) + + it('returns false from is instead of throwing for a symbol path', () => { + expect(is(new Map([[Symbol('key'), 42]]), map(any(), string()))).toBe(false) + }) + + it('throws a StructError from assert for a symbol path', () => { + expect(() => + assert(new Map([[Symbol('key'), 42]]), map(any(), string())) + ).toThrow(StructError) + }) + + it('preserves custom error messages and the symbol path in the cause', () => { + const [error] = validate( + new Map([[Symbol('key'), 42]]), + map(any(), string()), + { message: 'Invalid map' } + ) + expect(error?.message).toBe('Invalid map') + expect(error?.cause).toBe( + 'At path: Symbol(key) -- Expected a string, but received: 42' + ) + }) + + it('retains symbol identity in all failures', () => { + const first = Symbol('key') + const second = Symbol('key') + const [error] = validate( + new Map([ + [first, 1], + [second, 2], + ]), + map(any(), string()) + ) + expect(error?.failures().map((failure) => failure.path)).toStrictEqual([ + [first], + [second], + ]) + }) + + it('continues accepting valid symbol-keyed maps', () => { + const data = new Map([[Symbol('key'), 'value']]) + expect(validate(data, map(any(), string()))).toStrictEqual([ + undefined, + data, + ]) + }) +})