astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
125 lines (124 loc) • 6.08 kB
JavaScript
export const errorMap = (issue) => {
const baseErrorPath = flattenErrorPath(issue.path ?? []);
if (issue.code === "invalid_union") {
// Optimization: Combine type and literal errors for keys that are common across ALL union types
// Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will
// raise a single error when `key` does not match:
// > Did not match union.
// > key: Expected `'tutorial' | 'blog'`, received 'foo'
let typeOrLiteralErrByPath = new Map();
for (const unionError of issue.errors.flat()) {
if (unionError.code === "invalid_type") {
const flattenedErrorPath = flattenErrorPath(unionError.path);
if (typeOrLiteralErrByPath.has(flattenedErrorPath)) {
typeOrLiteralErrByPath.get(flattenedErrorPath).expected.push(unionError.expected);
} else {
typeOrLiteralErrByPath.set(flattenedErrorPath, {
code: unionError.code,
received: unionError.received,
expected: [unionError.expected],
message: unionError.message
});
}
}
}
const messages = [prefix(baseErrorPath, "Did not match union.")];
const details = [...typeOrLiteralErrByPath.entries()].filter(([, error]) => error.expected.length === issue.errors.flat().length).map(([key, error]) => key === baseErrorPath ? `> ${getTypeOrLiteralMsg(error)}` : `> ${prefix(key, getTypeOrLiteralMsg(error))}`);
if (details.length === 0) {
// For discriminated unions, show the expected discriminator options
if ("discriminator" in issue && issue.discriminator && "options" in issue) {
const options = issue.options;
if (Array.isArray(options)) {
details.push(`> Expected \`${issue.discriminator}\` to be ${options.map((o) => `\`${stringify(o)}\``).join(" | ")}`);
details.push("> Received `" + stringify(issue.input) + "`");
}
}
}
if (details.length === 0) {
const expectedShapes = [];
for (const unionErrors of issue.errors) {
const expectedShape = [];
for (const _issue of unionErrors) {
// If the issue is a nested union error, show the associated error message instead of the
// base error message.
if (_issue.code === "invalid_union") {
return errorMap(_issue);
}
const relativePath = flattenErrorPath(_issue.path).replace(baseErrorPath, "").replace(leadingPeriod, "");
if (_issue.code === "custom" && _issue.message && _issue.message.includes("security.csp")) {
expectedShape.push(_issue.message);
} else if ("expected" in _issue && typeof _issue.expected === "string") {
expectedShape.push(relativePath ? `${relativePath}: ${_issue.expected}` : _issue.expected);
} else if ("values" in _issue) {
expectedShape.push(..._issue.values.filter((v) => typeof v === "string").map((v) => `"${v}"`));
} else if (relativePath) {
expectedShape.push(relativePath);
}
}
if (expectedShape.length === 1 && !expectedShape[0]?.includes(":")) {
// In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`.
expectedShapes.push(expectedShape.join(""));
} else if (expectedShape.length > 0) {
expectedShapes.push(`{ ${expectedShape.join("; ")} }`);
}
}
if (expectedShapes.length) {
details.push("> Expected type `" + expectedShapes.join(" | ") + "`");
details.push("> Received `" + stringify(issue.input) + "`");
}
}
return { message: messages.concat(details).join("\n") };
} else if (issue.code === "invalid_key") {
// Zod 4.4.0+: Record key validation failures are now structured `invalid_key` issues
// with nested `issues` array containing the actual validation errors.
const keyIssues = issue.issues;
if (Array.isArray(keyIssues) && keyIssues.length > 0) {
const firstIssue = keyIssues[0];
const msg = firstIssue.message || "Invalid key in record";
return { message: prefix(baseErrorPath, msg) };
}
return { message: prefix(baseErrorPath, "Invalid key in record") };
} else if (issue.code === "invalid_element") {
// Zod 4.4.0+: Map/Set element validation failures are now structured `invalid_element` issues.
const elementIssues = issue.issues;
if (Array.isArray(elementIssues) && elementIssues.length > 0) {
const firstIssue = elementIssues[0];
const msg = firstIssue.message || "Invalid element";
return { message: prefix(baseErrorPath, msg) };
}
return { message: prefix(baseErrorPath, "Invalid element") };
} else if (issue.code === "invalid_type") {
return { message: prefix(baseErrorPath, getTypeOrLiteralMsg({
code: issue.code,
received: typeof issue.input,
expected: [issue.expected],
message: issue.message
})) };
} else if (issue.message) {
return { message: prefix(baseErrorPath, issue.message) };
}
};
const getTypeOrLiteralMsg = (error) => {
// received could be `undefined` or the string `'undefined'`
if (typeof error.received === "undefined" || error.received === "undefined") return error.message ?? "Required";
const expectedDeduped = new Set(error.expected);
switch (error.code) {
case "invalid_type": return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(error.received)}\``;
case "invalid_literal": return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify(error.received)}\``;
}
};
const prefix = (key, msg) => key.length ? `**${key}**: ${msg}` : msg;
const unionExpectedVals = (expectedVals) => [...expectedVals].map((expectedVal) => stringify(expectedVal)).join(" | ");
const flattenErrorPath = (errorPath) => errorPath.join(".");
/** `JSON.stringify()` a value with spaces around object/array entries. */
const stringify = (val) => {
// `JSON.stringify` returns `undefined` for `undefined`/functions/symbols. This can happen for
// nested union issues where `issue.input` is not populated, so fall back to `String()`.
const json = JSON.stringify(val, null, 1);
if (json === undefined) {
return String(val);
}
return json.split(newlinePlusWhitespace).join(" ");
};
const newlinePlusWhitespace = /\n\s*/;
const leadingPeriod = /^\./;