Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion ark/schema/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { attest, contextualize } from "@ark/attest"
import {
$ark,
type ArkErrors,
ArkErrors,
configureSchema,
rootSchema,
schemaScope
Expand Down Expand Up @@ -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([
{
Expand Down Expand Up @@ -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<string, unknown>
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: ["_"] })
Comment thread
gabroberge marked this conversation as resolved.
})
Comment thread
gabroberge marked this conversation as resolved.

it("flatByPath", () => {
attest(errors.flatByPath).snap({
n: [
Expand Down
40 changes: 39 additions & 1 deletion ark/schema/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
defineProperties,
flatMorph,
stringifyPath,
type Json,
type JsonArray,
type JsonObject,
type array,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, unknown>
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 {
Expand Down
Loading