fix(schema): avoid JSON.stringify crashes on ArkErrors issues slots#1619
fix(schema): avoid JSON.stringify crashes on ArkErrors issues slots#1619gabroberge wants to merge 6 commits into
Conversation
…lization 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.
There was a problem hiding this comment.
The fix itself is correctly implemented and the branching logic is sound, but the motivating scenario relies on bypassing ArkErrors's ReadonlyArray typing via an as unknown as { push } cast — there is no public path through ArkType that lands a non-ArkError in this array. Before adding a permanent defensive serializer to a hot, public surface, it would help to see a real-world reproduction that produces the TypeError without forcing the cast (e.g. a Standard Schema consumer that legitimately ends up with heterogeneous issues from ArkType output).
TL;DR — Hardens ArkErrors#toJSON so JSON.stringify no longer throws TypeError: e.toJSON is not a function if the array contains entries that don't implement toJSON, and adds a regression test.
Key changes
- Defensive indexed-issue serializer —
ArkErrors#toJSONnow routes each entry through a new privateindexedIssueToJsonhelper that branches on the entry's shape instead of assuming every element is anArkError. - Regression test — pushes a plain
{ message, path }object into anArkErrorsinstance and assertsJSON.parse(JSON.stringify(errors))round-trips both entries.
Summary | 2 files | 1 commit | base: main ← fix/arkerrors-tojson-heterogeneous-issues
Tolerating non-ArkError entries in toJSON
Before:
toJSONmapped each entry withe.toJSON(), throwing if any entry lacked the method.
After: Each entry is routed throughindexedIssueToJson, which handlesundefined/null/ non-objects / objects withtoJSON/{ message, path }shapes / fallbackString(issue).
The branch order is sensible and preserves existing ArkError behavior via toJSON.call(issue). The crash described in the PR body, however, requires (errors as unknown as { push }).push(...) — ArkType's own code only inserts via add(error: ArkError), so a real reproduction path from Standard Schema usage would strengthen the case for keeping this defensive code long-term.
ark/schema/shared/errors.ts · ark/schema/__tests__/errors.test.ts
Claude Opus | 𝕏
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.
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.
Insert leading semicolon before parenthesized expression (Prettier / ASI).
…ect 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.
…ation 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.

Summary
ArkErrors[Symbol.species]toArraysoArray.prototype.map/filter/slice/ … allocate a plainArray, not anotherArkErrors. This fixes the case whereissues.map(issue => issue.message)(or similar) could produce anArkErrorswhose numeric indices held strings (callback results), which then reachedJSON.stringifyand triggeredTypeError: e.toJSON is not a functioninsideArkErrors.prototype.toJSON.ArkErrors#toJSON: each indexed entry is serialized viaindexedIssueToJSON, which tolerates primitives and plain{ message, path? }shapes (defensive complement at the JSON boundary).Symbol.species/map→Array, plus round-tripJSON.stringifycoverage including a simulated non-ArkErrorslot for the defensive path.How the problem shows up
schema["~standard"].validatereturns anArkErrorsinstance;issuesis the same value (anArraysubclass).[Symbol.species],issues.map(…)can allocate anotherArkErrors, filling indices0..n-1with callback return values (e.g. strings fromissue.message), notArkErrorinstances.JSON.stringify.ArkErrorsdefinestoJSON, so serialization invokesArkErrors.prototype.toJSONeven when numeric slots are no longerArkErrorobjects.this.map(e => e.toJSON())assumes everyeis anArkError→TypeError: e.toJSON is not a function, often surfacing as a server error while building the response body instead of a stable client error JSON payload.How this change fixes it
static get [Symbol.species](): ArrayConstructor { return Array }— array methods that create a new array now yield a normalArray, soissues.map(issue => issue.message)returns a plain array of strings (or similar), not a newArkErrorswith primitives at numeric indices.ArkErrors#toJSONmaps each slot withindexedIssueToJSON:undefined/null→{ message: "undefined" | "null" }{ message: String(issue) }toJSON→toJSON.call(issue) as JsonObjectmessage:{ message }, or{ message, path }only whenpathisundefinedor an array (typed asJson){ message: String(issue) }map→ species** path no longer crashes during **JSON.stringify**, and **toJSON** stays a small safety net for heterogeneous **issues` at the HTTP/JSON edge.Note on the defensive
toJSONpathThe
indexedIssueToJSONhelper is intentionally broader than strictArkError-only output: it keepsJSON.stringifyfrom throwing on primitives or plain issue-shaped objects at an index.[Symbol.species]addresses the main failure mode (array methods materializing anotherArkErrorsfull of strings). In principle, maintainers could drop the defensive branch later and revert toe.toJSON()-only serialization if the project prefers a stricter surface and accepts that only “well-formed”ArkErrorscontents are supported at that boundary—at the cost of less resilience to oddissuesshapes from outside callers.