Skip to content

Commit 49f90dc

Browse files
authored
Upgrade to from Zod v3 to Zod v4 (again) (#43)
1 parent 217dc3b commit 49f90dc

File tree

5 files changed

+88
-152
lines changed

5 files changed

+88
-152
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"locate-character": "^3.0.0",
4444
"openai": "^4.98.0",
4545
"yaml": "^2.4.5",
46-
"zod": "^3.23.8"
46+
"zod": "^4.1.8"
4747
},
4848
"devDependencies": {
4949
"@eslint/eslintrc": "^3.2.0",

pnpm-lock.yaml

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/compiler/scan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,8 @@ export class Scan {
322322
configSchema?.parse(parsedObj)
323323
} catch (err) {
324324
if (isZodError(err)) {
325-
err.errors.forEach((error) => {
326-
const { message, path } = getMostSpecificError(error)
325+
err.issues.forEach((issue) => {
326+
const { message, path } = getMostSpecificError(issue)
327327

328328
const range = findYAMLItemPosition(
329329
parsedYaml.contents as YAMLItem,

src/compiler/utils.test.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { describe, it, expect } from 'vitest'
2-
import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod'
2+
import { ZodError, core, z } from 'zod'
33
import { getMostSpecificError } from './utils'
44

5+
type ZodIssue = core.$ZodIssue
6+
57
function makeZodError(issues: ZodIssue[]): ZodError {
6-
// @ts-ignore
78
return new ZodError(issues)
89
}
910

1011
describe('getMostSpecificError', () => {
1112
it('returns the message and path for a simple error', () => {
1213
const error = makeZodError([
1314
{
14-
code: ZodIssueCode.invalid_type,
15+
code: 'invalid_type',
1516
expected: 'string',
16-
received: 'number',
17+
input: 'number',
1718
path: ['foo'],
1819
message: 'Expected string',
1920
},
@@ -26,26 +27,26 @@ describe('getMostSpecificError', () => {
2627
it('returns the most specific (deepest) error in a nested structure', () => {
2728
const unionError = makeZodError([
2829
{
29-
code: ZodIssueCode.invalid_union,
30-
unionErrors: [
31-
makeZodError([
30+
code: 'invalid_union',
31+
errors: [
32+
[
3233
{
33-
code: ZodIssueCode.invalid_type,
34+
code: 'invalid_type',
3435
expected: 'string',
35-
received: 'number',
36+
input: 'number',
3637
path: ['foo', 'bar'],
3738
message: 'Expected string',
3839
},
39-
]),
40-
makeZodError([
40+
],
41+
[
4142
{
42-
code: ZodIssueCode.invalid_type,
43+
code: 'invalid_type',
4344
expected: 'number',
44-
received: 'string',
45+
input: 'string',
4546
path: ['foo'],
4647
message: 'Expected number',
4748
},
48-
]),
49+
],
4950
],
5051
path: ['foo'],
5152
message: 'Invalid union',
@@ -59,7 +60,7 @@ describe('getMostSpecificError', () => {
5960
it('returns the error message and empty path if no issues', () => {
6061
const error = makeZodError([
6162
{
62-
code: ZodIssueCode.custom,
63+
code: 'custom',
6364
path: [],
6465
message: 'Custom error',
6566
},
@@ -72,16 +73,16 @@ describe('getMostSpecificError', () => {
7273
it('handles errors with multiple paths and picks the deepest', () => {
7374
const error = makeZodError([
7475
{
75-
code: ZodIssueCode.invalid_type,
76+
code: 'invalid_type',
7677
expected: 'string',
77-
received: 'number',
78+
input: 'number',
7879
path: ['a'],
7980
message: 'Expected string',
8081
},
8182
{
82-
code: ZodIssueCode.invalid_type,
83+
code: 'invalid_type',
8384
expected: 'number',
84-
received: 'string',
85+
input: 'string',
8586
path: ['a', 'b', 'c'],
8687
message: 'Expected number',
8788
},

src/compiler/utils.ts

Lines changed: 57 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ZodError, core } from 'zod'
12
import { TAG_NAMES } from '$promptl/constants'
23
import {
34
ChainStepTag,
@@ -9,7 +10,8 @@ import {
910
} from '$promptl/parser/interfaces'
1011
import { ContentTypeTagName, MessageRole } from '$promptl/types'
1112
import { Scalar, Node as YAMLItem, YAMLMap, YAMLSeq } from 'yaml'
12-
import { ZodError, ZodIssue, ZodIssueCode } from 'zod'
13+
14+
type ZodIssue = core.$ZodIssue
1315

1416
export function isIterable(obj: unknown): obj is Iterable<unknown> {
1517
return (obj as Iterable<unknown>)?.[Symbol.iterator] !== undefined
@@ -76,7 +78,7 @@ export function tagAttributeIsLiteral(tag: ElementTag, name: string): boolean {
7678
type YAMLItemRange = [number, number] | undefined
7779
export function findYAMLItemPosition(
7880
parent: YAMLItem,
79-
path: (string | number)[],
81+
path: PropertyKey[],
8082
): YAMLItemRange {
8183
const parentRange: YAMLItemRange = parent?.range
8284
? [parent.range[0], parent.range[1]]
@@ -101,145 +103,78 @@ export function findYAMLItemPosition(
101103
}
102104

103105
export function isZodError(error: unknown): error is ZodError {
104-
if (!(error instanceof Error)) return false
105-
106-
if (error instanceof ZodError) return true
107-
if (error.constructor.name === 'ZodError') return true
108-
if ('issues' in error && error.issues instanceof Array) return true
109-
110-
return false
106+
return error instanceof ZodError
111107
}
112108

113109
function collectAllLeafIssues(issue: ZodIssue): ZodIssue[] {
110+
if (issue.code === 'invalid_union') {
111+
issue.errors
112+
return issue.errors.flatMap((issues) =>
113+
issues.flatMap(collectAllLeafIssues),
114+
)
115+
}
116+
return [issue]
117+
}
118+
119+
function getZodIssueMessage(issue: ZodIssue): string {
114120
switch (issue.code) {
115-
case ZodIssueCode.invalid_union: {
116-
// invalid_union.issue.unionErrors is ZodError[]
117-
const unionErrs: ZodError[] = (issue as any).unionErrors ?? []
118-
return unionErrs.flatMap((nestedZodError) =>
119-
nestedZodError.issues.flatMap((nestedIssue) =>
120-
collectAllLeafIssues(nestedIssue),
121-
),
122-
)
121+
case 'invalid_type': {
122+
const attr = issue.path.at(-1)
123+
return typeof attr === 'string'
124+
? `Expected type \`${issue.expected}\` for attribute "${attr}", but received \`${issue.input}\`.`
125+
: `Expected type \`${issue.expected}\`, but received \`${issue.input}\`.`
123126
}
124127

125-
case ZodIssueCode.invalid_arguments: {
126-
// invalid_arguments.issue.argumentsError is ZodError
127-
const argsErr: ZodError | undefined = (issue as any).argumentsError
128-
if (argsErr) {
129-
return argsErr.issues.flatMap((nestedIssue) =>
130-
collectAllLeafIssues(nestedIssue),
131-
)
132-
}
133-
return [issue]
128+
case 'invalid_value': {
129+
const attr = issue.path.at(-1)
130+
const literalValues = issue.values.join(', ')
131+
return typeof attr === 'string'
132+
? `Expected literal \`${literalValues}\` for attribute "${attr}", but received \`${issue.input}\`.`
133+
: `Expected literal \`${literalValues}\`, but received \`${issue.input}\`.`
134134
}
135135

136-
case ZodIssueCode.invalid_return_type: {
137-
// invalid_return_type.issue.returnTypeError is ZodError
138-
const retErr: ZodError | undefined = (issue as any).returnTypeError
139-
if (retErr) {
140-
return retErr.issues.flatMap((nestedIssue) =>
141-
collectAllLeafIssues(nestedIssue),
142-
)
143-
}
144-
return [issue]
145-
}
136+
case 'unrecognized_keys':
137+
return `Unrecognized keys: ${issue.keys.join(', ')}.`
146138

147-
default:
148-
// Any other issue code is considered a “leaf” (no deeper nested ZodError)
149-
return [issue]
150-
}
151-
}
139+
case 'invalid_union':
140+
return `Invalid union: ${issue.message}`
152141

153-
function getZodIssueMessage(issue: ZodIssue): string {
154-
if (issue.code === ZodIssueCode.invalid_type) {
155-
const attribute = issue.path[issue.path.length - 1]
156-
if (typeof attribute === 'string') {
157-
return `Expected type \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.`
158-
}
142+
case 'too_small':
143+
return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}`
159144

160-
return `Expected type \`${issue.expected}\`, but received \`${issue.received}\`.`
161-
}
162-
if (issue.code === ZodIssueCode.invalid_literal) {
163-
const attribute = issue.path[issue.path.length - 1]
164-
if (typeof attribute === 'string') {
165-
return `Expected literal \`${issue.expected}\` for attribute "${attribute}", but received \`${issue.received}\`.`
166-
}
145+
case 'too_big':
146+
return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}`
167147

168-
return `Expected literal \`${issue.expected}\`, but received \`${issue.received}\`.`
169-
}
170-
if (issue.code === ZodIssueCode.unrecognized_keys) {
171-
return `Unrecognized keys: ${issue.keys.join(', ')}.`
172-
}
173-
if (issue.code === ZodIssueCode.invalid_union) {
174-
return `Invalid union: ${issue.unionErrors
175-
.map((err) => err.message)
176-
.join(', ')}`
177-
}
178-
if (issue.code === ZodIssueCode.invalid_union_discriminator) {
179-
return `Invalid union discriminator. Expected one of: ${issue.options.join(
180-
', ',
181-
)}.`
182-
}
183-
if (issue.code === ZodIssueCode.invalid_enum_value) {
184-
return `Invalid enum value: ${issue.received}. Expected one of: ${issue.options.join(
185-
', ',
186-
)}.`
187-
}
188-
if (issue.code === ZodIssueCode.invalid_arguments) {
189-
return `Invalid arguments: ${issue.argumentsError.issues
190-
.map((err) => err.message)
191-
.join(', ')}`
192-
}
193-
if (issue.code === ZodIssueCode.invalid_return_type) {
194-
return `Invalid return type: ${issue.returnTypeError.issues
195-
.map((err) => err.message)
196-
.join(', ')}`
197-
}
198-
if (issue.code === ZodIssueCode.invalid_date) {
199-
return `Invalid date: ${issue.message || 'Invalid date format.'}`
200-
}
201-
if (issue.code === ZodIssueCode.invalid_string) {
202-
return `Invalid string: ${issue.message || 'String does not match expected format.'}`
203-
}
204-
if (issue.code === ZodIssueCode.too_small) {
205-
return `Value is too small: ${issue.message || 'Value does not meet minimum size.'}`
206-
}
207-
if (issue.code === ZodIssueCode.too_big) {
208-
return `Value is too big: ${issue.message || 'Value exceeds maximum size.'}`
209-
}
210-
if (issue.code === ZodIssueCode.invalid_intersection_types) {
211-
return `Invalid intersection types: ${issue.message || 'Types do not match.'}`
212-
}
213-
if (issue.code === ZodIssueCode.not_multiple_of) {
214-
return `Value is not a multiple of ${issue.multipleOf}: ${issue.message || 'Value does not meet multiple of condition.'}`
215-
}
216-
if (issue.code === ZodIssueCode.not_finite) {
217-
return `Value is not finite: ${issue.message || 'Value must be a finite number.'}`
218-
}
219-
if (issue.code === ZodIssueCode.custom) {
220-
return `Custom validation error: ${issue.message || 'No additional message provided.'}`
148+
case 'not_multiple_of':
149+
return `Value is not a multiple of ${issue.divisor}: ${issue.message || 'Value does not meet multiple of condition.'}`
150+
151+
case 'custom':
152+
return `Custom validation error: ${issue.message || 'No additional message provided.'}`
153+
154+
case 'invalid_key':
155+
return `Invalid key: ${issue.message || 'Key validation failed.'}`
156+
157+
case 'invalid_format':
158+
return `Invalid format: ${issue.message || `Expected format ${issue.format}.`}`
159+
160+
case 'invalid_element':
161+
return `Invalid element: ${issue.message || 'Element validation failed.'}`
162+
163+
default:
164+
// The types are exhaustive, but we don't want to miss any new ones
165+
return 'Unknown validation error.'
221166
}
222-
// For any other issue code, return the message directly
223-
return (issue as ZodIssue).message || 'Unknown validation error.'
224167
}
225168

226169
export function getMostSpecificError(error: ZodIssue): {
227170
message: string
228-
path: (string | number)[]
171+
path: PropertyKey[]
229172
} {
230173
const allIssues = collectAllLeafIssues(error)
231-
232-
if (allIssues.length === 0) {
233-
return { message: error.message, path: [] }
234-
}
235-
236-
let mostSpecific = allIssues[0]!
237-
for (const issue of allIssues) {
238-
if (issue.path.length > mostSpecific.path.length) {
239-
mostSpecific = issue
240-
}
241-
}
242-
174+
const mostSpecific = allIssues.reduce(
175+
(acc, cur) => (cur.path.length > acc.path.length ? cur : acc),
176+
allIssues[0]!,
177+
)
243178
return {
244179
message: getZodIssueMessage(mostSpecific),
245180
path: mostSpecific.path,

0 commit comments

Comments
 (0)