diff --git a/ark/schema/__tests__/errors.test.ts b/ark/schema/__tests__/errors.test.ts index 27568fc647..08a1142038 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([ { @@ -189,6 +203,25 @@ contextualize(() => { ]) }) + it("serialization tolerates indexed entries without toJSON (e.g. HTTP JSON.stringify)", () => { + // 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", + path: ["_"] + }) + 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: ["_"] }) + }) + it("flatByPath", () => { attest(errors.flatByPath).snap({ n: [ diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 08a774e836..a09a6cfb7d 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, @@ -165,6 +166,15 @@ export class ArkErrors { readonly [arkKind] = "errors" + /** + * 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 + } + protected ctx: Traversal constructor(ctx: Traversal) { @@ -314,8 +324,36 @@ export class ArkErrors return this } + /** + * Serialize a single `issues[index]` for `JSON.stringify`. + * + * 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" } + 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") { + // `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 } + if (Array.isArray(path)) return { message, path: path as Json } + return { message } + } + + return { message: String(issue) } + } + toJSON(): JsonArray { - return [...this.map(e => e.toJSON())] + return [...this.map(ArkErrors.indexedIssueToJSON)] } toString(): string {