|
1 | | -/** |
2 | | - * This is a modified version of Astro's error map. source: |
3 | | - * https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts |
4 | | - */ |
5 | | -import type { z } from 'zod/v3'; |
| 1 | +import type { $ZodErrorMap } from 'zod/v4/core'; |
6 | 2 |
|
7 | | -interface TypeOrLiteralErrByPathEntry { |
| 3 | +type TypeOrLiteralErrByPathEntry = { |
8 | 4 | code: 'invalid_type' | 'invalid_literal'; |
9 | 5 | received: unknown; |
10 | 6 | expected: unknown[]; |
11 | | -} |
| 7 | + message: string | undefined; |
| 8 | +}; |
12 | 9 |
|
13 | | -export const errorMap: z.ZodErrorMap = (baseError, ctx) => { |
14 | | - const baseErrorPath = flattenErrorPath(baseError.path); |
15 | | - if (baseError.code === 'invalid_union') { |
| 10 | +export const errorMap: $ZodErrorMap = (issue) => { |
| 11 | + const baseErrorPath = flattenErrorPath(issue.path ?? []); |
| 12 | + if (issue.code === 'invalid_union') { |
16 | 13 | // Optimization: Combine type and literal errors for keys that are common across ALL union types |
17 | 14 | // Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will |
18 | 15 | // raise a single error when `key` does not match: |
19 | 16 | // > Did not match union. |
20 | 17 | // > key: Expected `'tutorial' | 'blog'`, received 'foo' |
21 | | - const typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>(); |
22 | | - for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) { |
23 | | - if (unionError.code === 'invalid_type' || unionError.code === 'invalid_literal') { |
| 18 | + let typeOrLiteralErrByPath = new Map<string, TypeOrLiteralErrByPathEntry>(); |
| 19 | + for (const unionError of issue.errors.flat()) { |
| 20 | + if (unionError.code === 'invalid_type') { |
24 | 21 | const flattenedErrorPath = flattenErrorPath(unionError.path); |
25 | | - const typeOrLiteralErr = typeOrLiteralErrByPath.get(flattenedErrorPath); |
26 | | - if (typeOrLiteralErr) { |
27 | | - typeOrLiteralErr.expected.push(unionError.expected); |
| 22 | + if (typeOrLiteralErrByPath.has(flattenedErrorPath)) { |
| 23 | + typeOrLiteralErrByPath.get(flattenedErrorPath)!.expected.push(unionError.expected); |
28 | 24 | } else { |
29 | 25 | typeOrLiteralErrByPath.set(flattenedErrorPath, { |
30 | 26 | code: unionError.code, |
31 | 27 | received: (unionError as any).received, |
32 | 28 | expected: [unionError.expected], |
| 29 | + message: unionError.message, |
33 | 30 | }); |
34 | 31 | } |
35 | 32 | } |
36 | 33 | } |
37 | | - const messages: string[] = [ |
38 | | - prefix( |
39 | | - baseErrorPath, |
40 | | - typeOrLiteralErrByPath.size ? 'Did not match union:' : 'Did not match union.', |
41 | | - ), |
42 | | - ]; |
| 34 | + const messages: string[] = [prefix(baseErrorPath, 'Did not match union.')]; |
| 35 | + const details: string[] = [...typeOrLiteralErrByPath.entries()] |
| 36 | + // If type or literal error isn't common to ALL union types, |
| 37 | + // filter it out. Can lead to confusing noise. |
| 38 | + .filter(([, error]) => error.expected.length === issue.errors.flat().length) |
| 39 | + .map(([key, error]) => |
| 40 | + key === baseErrorPath |
| 41 | + ? // Avoid printing the key again if it's a base error |
| 42 | + `> ${getTypeOrLiteralMsg(error)}` |
| 43 | + : `> ${prefix(key, getTypeOrLiteralMsg(error))}`, |
| 44 | + ); |
| 45 | + |
| 46 | + if (details.length === 0) { |
| 47 | + const expectedShapes: string[] = []; |
| 48 | + for (const unionErrors of issue.errors) { |
| 49 | + const expectedShape: string[] = []; |
| 50 | + for (const _issue of unionErrors) { |
| 51 | + // If the issue is a nested union error, show the associated error message instead of the |
| 52 | + // base error message. |
| 53 | + if (_issue.code === 'invalid_union') { |
| 54 | + return errorMap(_issue as any); |
| 55 | + } |
| 56 | + const relativePath = flattenErrorPath(_issue.path) |
| 57 | + .replace(baseErrorPath, '') |
| 58 | + .replace(leadingPeriod, ''); |
| 59 | + if ('expected' in _issue && typeof _issue.expected === 'string') { |
| 60 | + expectedShape.push( |
| 61 | + relativePath ? `${relativePath}: ${_issue.expected}` : _issue.expected, |
| 62 | + ); |
| 63 | + } else if ('values' in _issue) { |
| 64 | + expectedShape.push( |
| 65 | + ..._issue.values.filter((v) => typeof v === 'string').map((v) => `"${v}"`), |
| 66 | + ); |
| 67 | + } else if (relativePath) { |
| 68 | + expectedShape.push(relativePath); |
| 69 | + } |
| 70 | + } |
| 71 | + if (expectedShape.length === 1 && !expectedShape[0]?.includes(':')) { |
| 72 | + // In this case the expected shape is not an object, but probably a literal type, e.g. `['string']`. |
| 73 | + expectedShapes.push(expectedShape.join('')); |
| 74 | + } else if (expectedShape.length > 0) { |
| 75 | + expectedShapes.push(`{ ${expectedShape.join('; ')} }`); |
| 76 | + } |
| 77 | + } |
| 78 | + if (expectedShapes.length) { |
| 79 | + details.push('> Expected type `' + expectedShapes.join(' | ') + '`'); |
| 80 | + details.push('> Received `' + stringify(issue.input) + '`'); |
| 81 | + } |
| 82 | + } |
| 83 | + |
43 | 84 | return { |
44 | | - message: messages |
45 | | - .concat( |
46 | | - [...typeOrLiteralErrByPath.entries()] |
47 | | - // If type or literal error isn't common to ALL union types, |
48 | | - // filter it out. Can lead to confusing noise. |
49 | | - .filter(([, error]) => error.expected.length === baseError.unionErrors.length) |
50 | | - .map(([key, error]) => |
51 | | - // Avoid printing the key again if it's a base error |
52 | | - key === baseErrorPath |
53 | | - ? `> ${getTypeOrLiteralMsg(error)}` |
54 | | - : `> ${prefix(key, getTypeOrLiteralMsg(error))}`, |
55 | | - ), |
56 | | - ) |
57 | | - .join('\n'), |
| 85 | + message: messages.concat(details).join('\n'), |
58 | 86 | }; |
59 | | - } |
60 | | - if (baseError.code === 'invalid_literal' || baseError.code === 'invalid_type') { |
| 87 | + } else if (issue.code === 'invalid_type') { |
61 | 88 | return { |
62 | 89 | message: prefix( |
63 | 90 | baseErrorPath, |
64 | 91 | getTypeOrLiteralMsg({ |
65 | | - code: baseError.code, |
66 | | - received: (baseError as any).received, |
67 | | - expected: [baseError.expected], |
| 92 | + code: issue.code, |
| 93 | + received: typeof issue.input, |
| 94 | + expected: [issue.expected], |
| 95 | + message: issue.message, |
68 | 96 | }), |
69 | 97 | ), |
70 | 98 | }; |
71 | | - } else if (baseError.message) { |
72 | | - return { message: prefix(baseErrorPath, baseError.message) }; |
73 | | - } else { |
74 | | - return { message: prefix(baseErrorPath, ctx.defaultError) }; |
| 99 | + } else if (issue.message) { |
| 100 | + return { message: prefix(baseErrorPath, issue.message) }; |
75 | 101 | } |
76 | 102 | }; |
77 | 103 |
|
78 | 104 | const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => { |
79 | | - if (error.received === 'undefined') return 'Required'; |
| 105 | + // received could be `undefined` or the string `'undefined'` |
| 106 | + if (typeof error.received === 'undefined' || error.received === 'undefined') |
| 107 | + return error.message ?? 'Required'; |
80 | 108 | const expectedDeduped = new Set(error.expected); |
81 | 109 | switch (error.code) { |
82 | 110 | case 'invalid_type': |
83 | | - return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify( |
| 111 | + return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify( |
84 | 112 | error.received, |
85 | | - )}`; |
| 113 | + )}\``; |
86 | 114 | case 'invalid_literal': |
87 | | - return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify( |
| 115 | + return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received \`${stringify( |
88 | 116 | error.received, |
89 | | - )}`; |
| 117 | + )}\``; |
90 | 118 | } |
91 | 119 | }; |
92 | 120 |
|
93 | 121 | const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg); |
94 | 122 |
|
95 | 123 | const unionExpectedVals = (expectedVals: Set<unknown>) => |
96 | | - [...expectedVals] |
97 | | - .map((expectedVal, idx) => { |
98 | | - if (idx === 0) return JSON.stringify(expectedVal); |
99 | | - const sep = ' | '; |
100 | | - return `${sep}${JSON.stringify(expectedVal)}`; |
101 | | - }) |
102 | | - .join(''); |
| 124 | + [...expectedVals].map((expectedVal) => stringify(expectedVal)).join(' | '); |
| 125 | + |
| 126 | +const flattenErrorPath = (errorPath: (string | number | symbol)[]) => errorPath.join('.'); |
103 | 127 |
|
104 | | -const flattenErrorPath = (errorPath: Array<string | number>) => errorPath.join('.'); |
| 128 | +/** `JSON.stringify()` a value with spaces around object/array entries. */ |
| 129 | +const stringify = (val: unknown) => |
| 130 | + JSON.stringify(val, null, 1).split(newlinePlusWhitespace).join(' '); |
| 131 | +const newlinePlusWhitespace = /\n\s*/; |
| 132 | +const leadingPeriod = /^\./; |
0 commit comments