import { CastableBase, ReadonlyArray, ReadonlyPath, append, conflatenateAll, defineProperties, flatMorph, stringifyPath } from "@ark/util"; import { arkKind } from "./utils.js"; export class ArkError extends CastableBase { [arkKind] = "error"; path; data; nodeConfig; input; ctx; // TS gets confused by , so internally we just use the base type for input constructor({ prefixPath, relativePath, ...input }, ctx) { super(); this.input = input; this.ctx = ctx; defineProperties(this, input); const data = ctx.data; if (input.code === "union") { input.errors = input.errors.flatMap(innerError => { // flatten union errors to avoid repeating context like "foo must be foo must be"... const flat = innerError.hasCode("union") ? innerError.errors : [innerError]; if (!prefixPath && !relativePath) return flat; return flat.map(e => e.transform(e => ({ ...e, path: conflatenateAll(prefixPath, e.path, relativePath) }))); }); } this.nodeConfig = ctx.config[this.code]; const basePath = [...(input.path ?? ctx.path)]; if (relativePath) basePath.push(...relativePath); if (prefixPath) basePath.unshift(...prefixPath); this.path = new ReadonlyPath(...basePath); this.data = "data" in input ? input.data : data; } transform(f) { return new ArkError(f({ data: this.data, path: this.path, ...this.input }), this.ctx); } hasCode(code) { return this.code === code; } get propString() { return stringifyPath(this.path); } get expected() { if (this.input.expected) return this.input.expected; const config = this.meta?.expected ?? this.nodeConfig.expected; return typeof config === "function" ? config(this.input) : config; } get actual() { if (this.input.actual) return this.input.actual; const config = this.meta?.actual ?? this.nodeConfig.actual; return typeof config === "function" ? config(this.data) : config; } get problem() { if (this.input.problem) return this.input.problem; const config = this.meta?.problem ?? this.nodeConfig.problem; return typeof config === "function" ? config(this) : config; } get message() { if (this.input.message) return this.input.message; const config = this.meta?.message ?? this.nodeConfig.message; return typeof config === "function" ? config(this) : config; } get flat() { return this.hasCode("intersection") ? [...this.errors] : [this]; } toJSON() { return { data: this.data, path: this.path, ...this.input, expected: this.expected, actual: this.actual, problem: this.problem, message: this.message }; } toString() { return this.message; } throw() { throw this; } } /** * A ReadonlyArray of `ArkError`s returned by a Type on invalid input. * * Subsequent errors added at an existing path are merged into an * ArkError intersection. */ export class ArkErrors extends ReadonlyArray { [arkKind] = "errors"; ctx; constructor(ctx) { super(); this.ctx = ctx; } /** * Errors by a pathString representing their location. */ byPath = Object.create(null); /** * {@link byPath} flattened so that each value is an array of ArkError instances at that path. * * ✅ Since "intersection" errors will be flattened to their constituent `.errors`, * they will never be directly present in this representation. */ get flatByPath() { return flatMorph(this.byPath, (k, v) => [k, v.flat]); } /** * {@link byPath} flattened so that each value is an array of problem strings at that path. */ get flatProblemsByPath() { return flatMorph(this.byPath, (k, v) => [k, v.flat.map(e => e.problem)]); } /** * All pathStrings at which errors are present mapped to the errors occuring * at that path or any nested path within it. */ byAncestorPath = Object.create(null); count = 0; mutable = this; /** * Throw a TraversalError based on these errors. */ throw() { throw this.toTraversalError(); } /** * Converts ArkErrors to TraversalError, a subclass of `Error` suitable for throwing with nice * formatting. */ toTraversalError() { return new TraversalError(this); } /** * Append an ArkError to this array, ignoring duplicates. */ add(error) { const existing = this.byPath[error.propString]; if (existing) { if (error === existing) return; // If the existing error is an error for a value constrained to "never", // then we don't want to intersect the error messages. if (existing.hasCode("union") && existing.errors.length === 0) return; // If the new error is an error for a value constrained to "never", // then we want to override any existing errors. const errorIntersection = error.hasCode("union") && error.errors.length === 0 ? error : new ArkError({ code: "intersection", errors: existing.hasCode("intersection") ? [...existing.errors, error] : [existing, error] }, this.ctx); const existingIndex = this.indexOf(existing); this.mutable[existingIndex === -1 ? this.length : existingIndex] = errorIntersection; this.byPath[error.propString] = errorIntersection; // add the original error here rather than the intersection // since the intersection is reflected by the array of errors at // this path this.addAncestorPaths(error); } else { this.byPath[error.propString] = error; this.addAncestorPaths(error); this.mutable.push(error); } this.count++; } transform(f) { const result = new ArkErrors(this.ctx); for (const e of this) result.add(f(e)); return result; } /** * Add all errors from an ArkErrors instance, ignoring duplicates and * prefixing their paths with that of the current Traversal. */ merge(errors) { for (const e of errors) { this.add(new ArkError({ ...e, path: [...this.ctx.path, ...e.path] }, this.ctx)); } } /** * @internal */ affectsPath(path) { if (this.length === 0) return false; return ( // this would occur if there is an existing error at a prefix of path // e.g. the path is ["foo", "bar"] and there is an error at ["foo"] path.stringifyAncestors().some(s => s in this.byPath) || // this would occur if there is an existing error at a suffix of path // e.g. the path is ["foo"] and there is an error at ["foo", "bar"] path.stringify() in this.byAncestorPath); } /** * A human-readable summary of all errors. */ get summary() { return this.toString(); } /** * Alias of this ArkErrors instance for StandardSchema compatibility. */ get issues() { return this; } toJSON() { return [...this.map(e => e.toJSON())]; } toString() { return this.join("\n"); } addAncestorPaths(error) { for (const propString of error.path.stringifyAncestors()) { this.byAncestorPath[propString] = append(this.byAncestorPath[propString], error); } } } export class TraversalError extends Error { name = "TraversalError"; constructor(errors) { if (errors.length === 1) super(errors.summary); else super("\n" + errors.map(error => ` • ${indent(error)}`).join("\n")); Object.defineProperty(this, "arkErrors", { value: errors, enumerable: false }); } } const indent = (error) => error.toString().split("\n").join("\n ");