From a5884632d752babca64c5d48c1f22e553e5d08d6 Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 00:14:32 -0400 Subject: [PATCH 1/6] fix(schema): harden ArkErrors.toJSON for Standard Schema / HTTP serialization ArkErrors doubles as Standard Schema `issues`; JSON.stringify must not assume every indexed entry is an ArkError with toJSON (e.g. Nest 12 validation bodies). Add regression coverage for a plain issue-shaped entry. --- ark/schema/__tests__/errors.test.ts | 10 ++++++++++ ark/schema/shared/errors.ts | 23 ++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index 27568fc647..f1c4c2bd2c 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -189,6 +189,16 @@ contextualize(() => { ]) }) + it("serialization tolerates indexed entries without toJSON (e.g. HTTP JSON.stringify)", () => { + ;(errors as unknown as { push(...items: unknown[]): number }).push({ + message: "foreign issue", + path: ["_"] + }) + const parsed = JSON.parse(JSON.stringify(errors)) as unknown[] + attest(parsed.length).equals(2) + attest(parsed[1]).equals({ message: "foreign issue", path: ["_"] }) + }) + it("flatByPath", () => { attest(errors.flatByPath).snap({ n: [ diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 08a774e836..1ca57c9b52 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -314,8 +314,29 @@ export class ArkErrors return this } + /** + * Also Standard Schema `issues`; HTTP stacks may `JSON.stringify` this array, + * so indexed entries must not assume {@link ArkError}. + */ + private static indexedIssueToJson(issue: unknown): JsonObject { + if (issue === undefined) return { message: "undefined" } + if (issue === null) return { message: "null" } + if (typeof issue !== "object") return { message: String(issue) } + + const toJSON = (issue as { toJSON?: unknown }).toJSON + if (typeof toJSON === "function") return toJSON.call(issue) + + const { message, path } = issue as Record + if (typeof message === "string") { + if (path === undefined) return { message } + return { message, path } as JsonObject + } + + return { message: String(issue) } + } + toJSON(): JsonArray { - return [...this.map(e => e.toJSON())] + return [...this.map(ArkErrors.indexedIssueToJson)] } toString(): string { From 64af60163e0267ce5590fab90cce12948fa27d6a Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 00:43:37 -0400 Subject: [PATCH 2/6] fix(schema): tighten indexedIssueToJSON for JSON.stringify Document Standard Schema issues contract, assert toJSON returns JsonObject, only include plain-issue path when it is an array, and rename helper to indexedIssueToJSON for consistency. --- ark/schema/shared/errors.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 1ca57c9b52..462d9c6eb9 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -7,6 +7,7 @@ import { defineProperties, flatMorph, stringifyPath, + type Json, type JsonArray, type JsonObject, type array, @@ -315,28 +316,41 @@ export class ArkErrors } /** - * Also Standard Schema `issues`; HTTP stacks may `JSON.stringify` this array, - * so indexed entries must not assume {@link ArkError}. + * Serialize one indexed `issues` slot for `JSON.stringify`. + * + * Ark only appends via {@link ArkErrors.add} (`ArkError`), but this value is + * also {@link StandardSchemaV1.FailureResult.issues}, whose spec entries are + * plain {@link StandardSchemaV1.Issue} shapes (message / optional path) with + * no `toJSON` requirement. Consumers may stringify failure payloads; at + * runtime the array remains an `Array` subclass, so userland or integrations + * could theoretically append spec-shaped objects. Branching here keeps + * `toJSON` aligned with that contract without assuming every slot is + * {@link ArkError}. */ - private static indexedIssueToJson(issue: unknown): JsonObject { + private static indexedIssueToJSON(issue: unknown): JsonObject { if (issue === undefined) return { message: "undefined" } if (issue === null) return { message: "null" } if (typeof issue !== "object") return { message: String(issue) } const toJSON = (issue as { toJSON?: unknown }).toJSON - if (typeof toJSON === "function") return toJSON.call(issue) + if (typeof toJSON === "function") { + // `ArkError#toJSON` returns a `JsonObject`. Exotic `toJSON` implementations + // that return non-records are out of scope here. + return toJSON.call(issue) as JsonObject + } const { message, path } = issue as Record if (typeof message === "string") { if (path === undefined) return { message } - return { message, path } as JsonObject + if (Array.isArray(path)) return { message, path: path as Json } + return { message } } return { message: String(issue) } } toJSON(): JsonArray { - return [...this.map(ArkErrors.indexedIssueToJson)] + return [...this.map(ArkErrors.indexedIssueToJSON)] } toString(): string { From de27f2479d64174887729c245d835c1615af07a2 Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 00:43:37 -0400 Subject: [PATCH 3/6] test(schema): harden ArkErrors JSON round-trip regression test Use a fresh ArkErrors instance, document the contract test intent, and assert the real ArkError row shape on parsed[0] alongside the foreign issue entry. --- ark/schema/__tests__/errors.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index f1c4c2bd2c..655a4272c6 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -190,12 +190,23 @@ contextualize(() => { }) it("serialization tolerates indexed entries without toJSON (e.g. HTTP JSON.stringify)", () => { - ;(errors as unknown as { push(...items: unknown[]): number }).push({ + // Contract regression: ArkErrors is an Array subclass and Standard Schema + // FailureResult.issues is ReadonlyArray (plain { message, path? }). + // We do not expose a public API that appends non-ArkError rows; the cast + // simulates userland / integration slots so JSON.stringify never assumes + // e.toJSON exists on every index (regression for TypeError on missing toJSON). + const mixed = nEvenAtLeast2({ n: 1 }) as ArkErrors + (mixed as unknown as { push(...items: unknown[]): number }).push({ message: "foreign issue", path: ["_"] }) - const parsed = JSON.parse(JSON.stringify(errors)) as unknown[] + const parsed = JSON.parse(JSON.stringify(mixed)) as unknown[] attest(parsed.length).equals(2) + const first = parsed[0] as Record + attest(first.code).equals("intersection") + attest(first.path).equals(["n"]) + attest(typeof first.message).equals("string") + attest(Array.isArray(first.errors)).equals(true) attest(parsed[1]).equals({ message: "foreign issue", path: ["_"] }) }) From f830419ba4fe2561f00d9f61db34734b1a05819e Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 00:49:31 -0400 Subject: [PATCH 4/6] style(schema): format errors regression test Insert leading semicolon before parenthesized expression (Prettier / ASI). --- ark/schema/__tests__/errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index 655a4272c6..6816282d34 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -196,7 +196,7 @@ contextualize(() => { // simulates userland / integration slots so JSON.stringify never assumes // e.toJSON exists on every index (regression for TypeError on missing toJSON). const mixed = nEvenAtLeast2({ n: 1 }) as ArkErrors - (mixed as unknown as { push(...items: unknown[]): number }).push({ + ;(mixed as unknown as { push(...items: unknown[]): number }).push({ message: "foreign issue", path: ["_"] }) From dadd88da23523d0b4ef4db0af0a66e1036560c98 Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 01:20:25 -0400 Subject: [PATCH 5/6] test(errors): add tests for Array methods on ArkErrors to ensure correct behavior Introduce tests to verify that Array methods like map, filter, and slice return plain Arrays instead of ArkErrors. This ensures that callbacks returning primitives do not inadvertently create ArkErrors, maintaining the integrity of JSON serialization. --- ark/schema/__tests__/errors.test.ts | 16 +++++++++++++++- ark/schema/shared/errors.ts | 11 +++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index 6816282d34..4d9a617cd9 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -1,7 +1,7 @@ import { attest, contextualize } from "@ark/attest" import { $ark, - type ArkErrors, + ArkErrors, configureSchema, rootSchema, schemaScope @@ -149,6 +149,20 @@ contextualize(() => { const errors = nEvenAtLeast2({ n: 1 }) as ArkErrors + it("Array methods allocate plain Array (Symbol.species), not ArkErrors", () => { + const messages = errors.issues.map(e => e.message) + attest(messages instanceof Array).equals(true) + attest(messages.constructor).equals(Array) + attest(messages instanceof ArkErrors).equals(false) + + const mapped = errors.map(() => 1) + attest(mapped.constructor).equals(Array) + attest(mapped instanceof ArkErrors).equals(false) + + attest(errors.filter(() => true).constructor).equals(Array) + attest(errors.slice().constructor).equals(Array) + }) + it("serialization", () => { attest(errors.toJSON()).snap([ { diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 462d9c6eb9..f35cba545c 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -166,6 +166,17 @@ export class ArkErrors { readonly [arkKind] = "errors" + /** + * Without this, `Array.prototype.map` / `filter` / `slice` / … allocate another + * `ArkErrors` via species, so callbacks that return primitives (e.g. + * `issues.map(i => i.message)` in Standard Schema consumers such as Nest) + * would fill numeric indices with strings — then `JSON.stringify` invokes + * {@link ArkErrors.toJSON} and must not assume every slot is an {@link ArkError}. + */ + static get [Symbol.species](): ArrayConstructor { + return Array + } + protected ctx: Traversal constructor(ctx: Traversal) { From fc711bd26ee96cfa79f515424e71ceb835350fd4 Mon Sep 17 00:00:00 2001 From: gabroberge Date: Sun, 17 May 2026 02:23:08 -0400 Subject: [PATCH 6/6] refactor(errors): improve documentation and handling of JSON serialization for ArkErrors Clarify the behavior of inherited array methods and their impact on JSON serialization. Update comments to reflect that `issues` can include plain objects without a `toJSON` method, ensuring compatibility with Standard Schema. Adjust the `indexedIssueToJSON` method to emphasize the handling of mixed issue types during serialization. --- ark/schema/__tests__/errors.test.ts | 8 +++----- ark/schema/shared/errors.ts | 20 ++++++-------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index 4d9a617cd9..08a1142038 100644 --- a/ark/schema/__tests__/errors.test.ts +++ b/ark/schema/__tests__/errors.test.ts @@ -204,11 +204,9 @@ contextualize(() => { }) it("serialization tolerates indexed entries without toJSON (e.g. HTTP JSON.stringify)", () => { - // Contract regression: ArkErrors is an Array subclass and Standard Schema - // FailureResult.issues is ReadonlyArray (plain { message, path? }). - // We do not expose a public API that appends non-ArkError rows; the cast - // simulates userland / integration slots so JSON.stringify never assumes - // e.toJSON exists on every index (regression for TypeError on missing toJSON). + // Simulate an extra `issues` row shaped like Standard Schema `Issue` (`{ message, path? }`, + // no `toJSON`) — Ark has no public API for that, hence the cast — so `JSON.stringify` + // must tolerate mixed slots instead of assuming every index is an `ArkError`. const mixed = nEvenAtLeast2({ n: 1 }) as ArkErrors ;(mixed as unknown as { push(...items: unknown[]): number }).push({ message: "foreign issue", diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index f35cba545c..a09a6cfb7d 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -167,11 +167,9 @@ export class ArkErrors readonly [arkKind] = "errors" /** - * Without this, `Array.prototype.map` / `filter` / `slice` / … allocate another - * `ArkErrors` via species, so callbacks that return primitives (e.g. - * `issues.map(i => i.message)` in Standard Schema consumers such as Nest) - * would fill numeric indices with strings — then `JSON.stringify` invokes - * {@link ArkErrors.toJSON} and must not assume every slot is an {@link ArkError}. + * Inherited array methods (`map`, `filter`, `slice`, …) return a plain + * `Array`, not another `ArkErrors`, so callbacks that return primitives + * (e.g. `issues.map(i => i.message)`) cannot populate a new `ArkErrors` instance. */ static get [Symbol.species](): ArrayConstructor { return Array @@ -327,16 +325,10 @@ export class ArkErrors } /** - * Serialize one indexed `issues` slot for `JSON.stringify`. + * Serialize a single `issues[index]` for `JSON.stringify`. * - * Ark only appends via {@link ArkErrors.add} (`ArkError`), but this value is - * also {@link StandardSchemaV1.FailureResult.issues}, whose spec entries are - * plain {@link StandardSchemaV1.Issue} shapes (message / optional path) with - * no `toJSON` requirement. Consumers may stringify failure payloads; at - * runtime the array remains an `Array` subclass, so userland or integrations - * could theoretically append spec-shaped objects. Branching here keeps - * `toJSON` aligned with that contract without assuming every slot is - * {@link ArkError}. + * Usually `ArkError` from `add`, but `issues` is also Standard Schema failure + * issues: plain `{ message, path? }` values need not define `toJSON`. */ private static indexedIssueToJSON(issue: unknown): JsonObject { if (issue === undefined) return { message: "undefined" }