Skip to content

Commit 76a053e

Browse files
committed
fix(spec): keep defineForm's option-value refusal an Error with a stack
The refusal was rebuilt with `new z.ZodError(...)`, which in zod v4 classic is a trait object with no `Error` parent and no `stack`: an uncaught module-load throw printed `ZodError { name, message: [Getter/Setter] }` and hid the issues and the remedy. It is now a `z.ZodRealError` built from the remedied issue copies, with its trace captured at the `defineForm` call (zod builds every ZodRealError with `Error.stackTraceLimit = 0` and captures a trace only in `parse`, so the parse's own `safeParse` error carries no frame either). The refusal pin now asserts an `Error`, a string `stack`, and a frame in the calling module; a new case reads the printed wall (the stack's head) for the grammar message and the remedy. Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74ea5db commit 76a053e

2 files changed

Lines changed: 42 additions & 2 deletions

File tree

‎packages/spec/src/ui/form-option-enum-derive.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,20 @@ function formWithRow(schemaId: string, row: Record<string, unknown>) {
108108
});
109109
}
110110

111-
/** The ZodError a build threw, or a loud failure when it built. */
111+
/** This file's own name — the module every `defineForm(...)` call below is made from. */
112+
const THIS_MODULE = 'form-option-enum-derive.test.ts';
113+
114+
/**
115+
* The ZodError a build threw, or a loud failure when it built.
116+
*
117+
* The refusal must be an `Error` with a stack, or an uncaught module-load
118+
* throw prints `ZodError { name, message: [Getter/Setter] }` and nothing else:
119+
* `new z.ZodError(...)` is a zod trait object, not an `Error`, and
120+
* `toBeInstanceOf(z.ZodError)` passes on it all the same. A string `stack` is
121+
* not enough either — zod builds `parsed.error` and a bare
122+
* `new z.ZodRealError(...)` with no frame at all — so the stack must also name
123+
* the module that called `defineForm`.
124+
*/
112125
function refusal(build: () => unknown): z.ZodError {
113126
let thrown: unknown;
114127
try {
@@ -117,6 +130,9 @@ function refusal(build: () => unknown): z.ZodError {
117130
thrown = e;
118131
}
119132
expect(thrown, 'expected defineForm to REFUSE at module load').toBeInstanceOf(z.ZodError);
133+
expect(thrown).toBeInstanceOf(Error);
134+
expect(typeof (thrown as Error).stack).toBe('string');
135+
expect((thrown as Error).stack, 'the stack names no frame in the module that called defineForm').toContain(THIS_MODULE);
120136
return thrown as z.ZodError;
121137
}
122138

@@ -201,6 +217,19 @@ describe('defineForm: the refusal of an unspellable option value names the deriv
201217
expect(statesBlanketOmitRule('When this row edits a spec enum, omit `options`: …')).toBe(true);
202218
});
203219

220+
it('the wall an uncaught throw prints (the stack) carries the grammar message and the remedy', () => {
221+
// Node prints an uncaught `Error` by its `stack`, whose first line is
222+
// `name: message`, and a ZodError's message is its issue list as JSON.
223+
const stack = refusal(formWithOption('new-tab')).stack ?? '';
224+
const firstFrame = stack.search(/\n\s+at /);
225+
const head = firstFrame < 0 ? stack : stack.slice(0, firstFrame);
226+
expect(head.startsWith('ZodError: '), head.slice(0, 80)).toBe(true);
227+
// Today's grammar message, read live off the object face and JSON-escaped as the message prints it.
228+
expect(head).toContain(JSON.stringify(objectFaceMessage('new-tab')).slice(1, -1));
229+
expect(namesDerivePath(head), head).toBe(true);
230+
expect(scopesDeriveToUnspellable(head), head).toBe(true);
231+
});
232+
204233
it('a nested row (composite `fields`) gets the same remedy', () => {
205234
const thrown = refusal(() => defineForm({
206235
schemaId: 'action',

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6369,7 +6369,18 @@ export function defineForm(
63696369
data: { provider: 'schema', schemaId },
63706370
});
63716371
if (parsed.success) return parsed.data;
6372-
throw new z.ZodError(withOptionValueDeriveRemedy(parsed.error.issues));
6372+
// The refusal stays an `Error` with a stack, so an uncaught module-load throw
6373+
// prints its issues and the remedy, with the author's `defineForm(...)` call
6374+
// as the first frame. Two traps, both measured on zod 4.6.1: `new z.ZodError`
6375+
// builds a plain object (no `Error` parent, no `stack`), which node prints as
6376+
// `ZodError { name, message: [Getter/Setter] }`; and zod builds every
6377+
// `ZodRealError` with `Error.stackTraceLimit = 0`, capturing a trace only in
6378+
// `parse`, so `parsed.error` or a bare `new z.ZodRealError` carries no frame.
6379+
// The error is built from issues that already carry the remedy, so its
6380+
// lazily computed `message` holds it whenever it is first read.
6381+
const refusal = new z.ZodRealError(withOptionValueDeriveRemedy(parsed.error.issues));
6382+
z.core.util.captureStackTrace(refusal, defineForm);
6383+
throw refusal;
63736384
}
63746385

63756386
/**

0 commit comments

Comments
 (0)