Skip to content

Commit dd66081

Browse files
authored
Adjust error message format in response (#2665)
Switching to zod core's `toDotPath()` in `getMessageFromError()`. I'm not using `z.prettifyError()` because its prettiness to me is questionable due to: - icons/symbols - new lines - offsets In my opinion such formatting should be delegated to UI. For that I'm planning #2663
1 parent 3121496 commit dd66081

8 files changed

Lines changed: 44 additions & 18 deletions

File tree

express-zod-api/src/common-helpers.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { z } from "zod/v4";
44
import type { $ZodTransform, $ZodType } from "zod/v4/core";
55
import { CommonConfig, InputSource, InputSources } from "./config-type";
66
import { contentTypes } from "./content-type";
7-
import { OutputValidationError } from "./errors";
87
import { AuxMethod, Method } from "./method";
98

109
/** @desc this type does not allow props assignment, but it works for reading them when merged with another interface */
@@ -72,15 +71,12 @@ export const ensureError = (subject: unknown): Error =>
7271
export const getMessageFromError = (error: Error): string => {
7372
if (error instanceof z.ZodError) {
7473
return error.issues
75-
.map(({ path, message }) =>
76-
(path.length ? [path.join("/")] : []).concat(message).join(": "),
77-
)
74+
.map(({ path, message }) => {
75+
const prefix = path.length ? `${z.core.toDotPath(path)}: ` : "";
76+
return `${prefix}${message}`;
77+
})
7878
.join("; ");
7979
}
80-
if (error instanceof OutputValidationError) {
81-
const hasFirstField = error.cause.issues[0]?.path.length > 0;
82-
return `output${hasFirstField ? "/" : ": "}${error.message}`;
83-
}
8480
return error.message;
8581
};
8682

express-zod-api/src/errors.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ export class OutputValidationError extends IOSchemaError {
5555
public override name = "OutputValidationError";
5656

5757
constructor(public override readonly cause: z.ZodError) {
58-
super(getMessageFromError(cause), { cause });
58+
const prefixedPath = new z.ZodError(
59+
cause.issues.map(({ path, ...rest }) => ({
60+
...rest,
61+
path: ["output", ...path],
62+
})),
63+
);
64+
super(getMessageFromError(prefixedPath), { cause });
5965
}
6066
}
6167

express-zod-api/tests/__snapshots__/common-helpers.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ exports[`Common Helpers > defaultInputSources > should be declared in a certain
2626
}
2727
`;
2828

29-
exports[`Common Helpers > getMessageFromError() > should compile a string from ZodError 1`] = `"user/id: expected number, got string; user/name: expected string, got number"`;
29+
exports[`Common Helpers > getMessageFromError() > should compile a string from ZodError 1`] = `"user.id: expected number, got string; user.name: expected string, got number"`;
3030

3131
exports[`Common Helpers > getMessageFromError() > should handle empty path in ZodIssue 1`] = `"Top level refinement issue"`;
3232

express-zod-api/tests/__snapshots__/endpoint.spec.ts.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ exports[`Endpoint > #handleResult > Should handle errors within ResultHandler 1`
1414
]
1515
`;
1616

17+
exports[`Endpoint > #parseOutput > Should throw on output validation failure 1`] = `
18+
{
19+
"error": {
20+
"message": "output.email: Invalid email address",
21+
},
22+
"status": "error",
23+
}
24+
`;
25+
1726
exports[`Endpoint > .getResponses() > should return the negative responses (readonly) 1`] = `
1827
[
1928
{

express-zod-api/tests/__snapshots__/result-helpers.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ NotFoundError({
3131
})
3232
`;
3333

34-
exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: Invalid input: expected string, received number 1`] = `
34+
exports[`Result helpers > ensureHttpError() > should handle OutputValidationError: output: Invalid input: expected string, received number 1`] = `
3535
InternalServerError({
3636
"cause": ZodError({
3737
"issues": [

express-zod-api/tests/__snapshots__/system.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ exports[`App in production mode > Validation > Should fail on handler output typ
180180
},
181181
],
182182
}),
183-
"message": "output/anything: Too small: expected number to be >0",
183+
"message": "output.anything: Too small: expected number to be >0",
184184
}),
185185
"payload": {
186186
"key": "123",

express-zod-api/tests/endpoint.spec.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,7 @@ describe("Endpoint", () => {
140140
});
141141
const { responseMock } = await testEndpoint({ endpoint });
142142
expect(responseMock._getStatusCode()).toBe(500);
143-
expect(responseMock._getJSONData()).toEqual({
144-
status: "error",
145-
error: { message: "output/email: Invalid email address" },
146-
});
143+
expect(responseMock._getJSONData()).toMatchSnapshot();
147144
});
148145

149146
test("Should throw on output parsing non-Zod error", async () => {

express-zod-api/tests/errors.spec.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import {
1010
} from "../src/errors";
1111

1212
describe("Errors", () => {
13+
const zodError = new z.ZodError([
14+
{
15+
code: "invalid_type",
16+
path: ["test"],
17+
message: "expected string, received number",
18+
expected: "string",
19+
input: 123,
20+
},
21+
]);
22+
1323
describe("RoutingError", () => {
1424
const error = new RoutingError("test", "get", "/v1/test");
1525

@@ -83,7 +93,6 @@ describe("Errors", () => {
8393
});
8494

8595
describe("OutputValidationError", () => {
86-
const zodError = new z.ZodError([]);
8796
const error = new OutputValidationError(zodError);
8897

8998
test("should be an instance of IOSchemaError and Error", () => {
@@ -95,13 +104,18 @@ describe("Errors", () => {
95104
expect(error.name).toBe("OutputValidationError");
96105
});
97106

107+
test("the message should be formatted and contain prefixed path", () => {
108+
expect(error.message).toBe(
109+
"output.test: expected string, received number",
110+
);
111+
});
112+
98113
test("should have .cause property matching the one used for constructing", () => {
99114
expect(error.cause).toEqual(zodError);
100115
});
101116
});
102117

103118
describe("InputValidationError", () => {
104-
const zodError = new z.ZodError([]);
105119
const error = new InputValidationError(zodError);
106120

107121
test("should be an instance of IOSchemaError and Error", () => {
@@ -113,6 +127,10 @@ describe("Errors", () => {
113127
expect(error.name).toBe("InputValidationError");
114128
});
115129

130+
test("the message should be formatted", () => {
131+
expect(error.message).toBe("test: expected string, received number");
132+
});
133+
116134
test("should have .cause property matching the one used for constructing", () => {
117135
expect(error.cause).toEqual(zodError);
118136
});

0 commit comments

Comments
 (0)