Skip to content

Commit f3669ef

Browse files
improve error logging for complex types
1 parent ae65cba commit f3669ef

7 files changed

Lines changed: 262 additions & 23 deletions

File tree

src/errors.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,87 @@
1-
export class MonarchError extends Error {}
1+
export enum ErrorCodes {
2+
MONARCH_ERROR = "MONARCH_ERROR",
3+
PARSE_ERROR = "PARSE_ERROR",
4+
VALIDATION_ERROR = "VALIDATION_ERROR",
5+
}
26

3-
export class MonarchParseError extends MonarchError {}
7+
export class MonarchError extends Error {
8+
public code: (typeof ErrorCodes)[keyof typeof ErrorCodes];
9+
public originalError?: Error;
10+
11+
constructor(
12+
message: string,
13+
code: (typeof ErrorCodes)[keyof typeof ErrorCodes] = ErrorCodes.MONARCH_ERROR,
14+
originalError?: Error,
15+
) {
16+
super(message);
17+
this.name = this.constructor.name;
18+
this.code = code;
19+
this.originalError = originalError;
20+
21+
if (!!originalError && originalError.stack) {
22+
this.stack = `${this.stack}\nCaused by: ${originalError.stack}`;
23+
} else if (Error.captureStackTrace) {
24+
Error.captureStackTrace(this, this.constructor);
25+
}
26+
}
27+
}
28+
29+
export class MonarchParseError extends MonarchError {
30+
public fieldPath?: (string | number)[];
31+
constructor(
32+
message: string,
33+
fieldPath?: (string | number)[],
34+
originalError?: Error,
35+
) {
36+
super(message, ErrorCodes.PARSE_ERROR, originalError);
37+
this.fieldPath = normalizeFieldPath(fieldPath);
38+
}
39+
}
40+
41+
export class MonarchValidationError extends MonarchError {
42+
constructor(
43+
message: string,
44+
public fieldPath: string,
45+
originalError?: Error,
46+
) {
47+
super(
48+
formatValidationMessage(fieldPath, message),
49+
ErrorCodes.VALIDATION_ERROR,
50+
originalError,
51+
);
52+
}
53+
}
54+
55+
export function normalizeFieldPath(
56+
fieldPath?: string | (string | number)[],
57+
): (string | number)[] {
58+
if (typeof fieldPath === "string") {
59+
return fieldPath.split(".");
60+
}
61+
if (Array.isArray(fieldPath)) {
62+
return fieldPath;
63+
}
64+
return [];
65+
}
66+
67+
export function formatErrorPath(
68+
path: string | number | (string | number)[],
69+
): string {
70+
return Array.isArray(path) ? path.join(".") : String(path);
71+
}
72+
73+
export function formatValidationPath(pathSegment: {
74+
schema: string;
75+
field: string;
76+
path?: (string | number)[];
77+
}): string {
78+
const { schema, field, path = [] } = pathSegment;
79+
return formatErrorPath([schema, field, ...path]);
80+
}
81+
82+
export function formatValidationMessage(
83+
fieldPath: string,
84+
message: string,
85+
): string {
86+
return `Validation error: '${fieldPath}' ${message}`;
87+
}

src/schema/schema.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { Projection } from "../collection/types/query-options";
22
import { detectProjection } from "../collection/utils/projection";
3+
import {
4+
MonarchParseError,
5+
MonarchValidationError,
6+
formatValidationPath,
7+
} from "../errors";
38
import { objectId } from "../types/objectId";
49
import { type AnyMonarchType, MonarchType } from "../types/type";
510
import type { Pretty, WithOptionalId } from "../utils/type-helpers";
@@ -52,10 +57,24 @@ export class Schema<
5257
// parse fields
5358
const types = Schema.types(this);
5459
for (const [key, type] of Object.entries(types)) {
55-
const parser = MonarchType.parser(type);
56-
const parsed = parser(input[key as keyof InferSchemaInput<this>]);
57-
if (parsed === undefined) continue;
58-
data[key as keyof typeof data] = parsed;
60+
try {
61+
const parser = MonarchType.parser(type);
62+
const parsed = parser(input[key as keyof InferSchemaInput<this>]);
63+
if (parsed === undefined) continue;
64+
data[key as keyof typeof data] = parsed;
65+
} catch (error) {
66+
if (error instanceof MonarchParseError) {
67+
throw new MonarchValidationError(
68+
error.message,
69+
formatValidationPath({
70+
schema: this.name,
71+
field: key,
72+
path: error.fieldPath,
73+
}),
74+
);
75+
}
76+
throw error;
77+
}
5978
}
6079
return data;
6180
}

src/types/array.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MonarchParseError } from "../errors";
1+
import { MonarchParseError, normalizeFieldPath } from "../errors";
22
import { type AnyMonarchType, MonarchType } from "./type";
33
import type { InferTypeInput, InferTypeOutput } from "./type-helpers";
44

@@ -19,9 +19,10 @@ export class MonarchArray<T extends AnyMonarchType> extends MonarchType<
1919
parsed[index] = parser(value);
2020
} catch (error) {
2121
if (error instanceof MonarchParseError) {
22-
throw new MonarchParseError(
23-
`element at index '${index}' ${error.message}`,
24-
);
22+
throw new MonarchParseError(error.message, [
23+
index,
24+
...normalizeFieldPath(error.fieldPath),
25+
]);
2526
}
2627
throw error;
2728
}

src/types/object.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MonarchParseError } from "../errors";
1+
import { MonarchParseError, normalizeFieldPath } from "../errors";
22
import { type AnyMonarchType, MonarchType } from "./type";
33
import type {
44
InferTypeInput,
@@ -34,7 +34,10 @@ export class MonarchObject<
3434
);
3535
} catch (error) {
3636
if (error instanceof MonarchParseError) {
37-
throw new MonarchParseError(`field '${key}' ${error.message}'`);
37+
throw new MonarchParseError(error.message, [
38+
key,
39+
...normalizeFieldPath(error.fieldPath),
40+
]);
3841
}
3942
throw error;
4043
}

src/types/record.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MonarchParseError } from "../errors";
1+
import { MonarchParseError, normalizeFieldPath } from "../errors";
22
import { type AnyMonarchType, MonarchType } from "./type";
33
import type { InferTypeInput, InferTypeOutput } from "./type-helpers";
44

@@ -19,7 +19,10 @@ export class MonarchRecord<T extends AnyMonarchType> extends MonarchType<
1919
parsed[key] = parser(value);
2020
} catch (error) {
2121
if (error instanceof MonarchParseError) {
22-
throw new MonarchParseError(`field '${key}' ${error.message}'`);
22+
throw new MonarchParseError(error.message, [
23+
key,
24+
...normalizeFieldPath(error.fieldPath),
25+
]);
2326
}
2427
throw error;
2528
}

src/types/tuple.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MonarchParseError } from "../errors";
1+
import { MonarchParseError, normalizeFieldPath } from "../errors";
22
import { type AnyMonarchType, MonarchType } from "./type";
33
import type { InferTypeTupleInput, InferTypeTupleOutput } from "./type-helpers";
44

@@ -26,9 +26,10 @@ export class MonarchTuple<
2626
parsed[index] = parser(input[index]);
2727
} catch (error) {
2828
if (error instanceof MonarchParseError) {
29-
throw new MonarchParseError(
30-
`element at index '${index}' ${error.message}`,
31-
);
29+
throw new MonarchParseError(error.message, [
30+
index,
31+
...normalizeFieldPath(error.fieldPath),
32+
]);
3233
}
3334
throw error;
3435
}

tests/types.test.ts

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,12 +202,16 @@ describe("Types", () => {
202202
expect(() =>
203203
// @ts-expect-error
204204
Schema.toData(schema, { permissions: { canUpdate: "yes" } }),
205-
).toThrowError("field 'canUpdate' expected 'boolean' received 'string'");
205+
).toThrowError(
206+
"'test.permissions.canUpdate' expected 'boolean' received 'string'",
207+
);
206208
// fields are validates in the order they are registered in type
207209
expect(() =>
208210
// @ts-expect-error
209211
Schema.toData(schema, { permissions: { role: false } }),
210-
).toThrowError("field 'canUpdate' expected 'boolean' received 'undefined'");
212+
).toThrowError(
213+
"'test.permissions.canUpdate' expected 'boolean' received 'undefined'",
214+
);
211215
// unknwon fields are rejected
212216
expect(() =>
213217
Schema.toData(schema, {
@@ -239,7 +243,7 @@ describe("Types", () => {
239243
expect(() =>
240244
// @ts-expect-error
241245
Schema.toData(schema, { grades: { math: "50" } }),
242-
).toThrowError("field 'math' expected 'number' received 'string'");
246+
).toThrowError("'test.grades.math' expected 'number' received 'string'");
243247
const data = Schema.toData(schema, { grades: { math: 50 } });
244248
expect(data).toStrictEqual({ grades: { math: 50 } });
245249
});
@@ -255,7 +259,7 @@ describe("Types", () => {
255259
);
256260
// @ts-expect-error
257261
expect(() => Schema.toData(schema, { items: [] })).toThrowError(
258-
"element at index '0' expected 'number' received 'undefined'",
262+
"'test.items.0' expected 'number' received 'undefined'",
259263
);
260264
const data = Schema.toData(schema, { items: [0, "1"] });
261265
expect(data).toStrictEqual({ items: [0, "1"] });
@@ -278,7 +282,7 @@ describe("Types", () => {
278282
expect(() => Schema.toData(schema, { items: [] })).not.toThrowError();
279283
// @ts-expect-error
280284
expect(() => Schema.toData(schema, { items: [0, "1"] })).toThrowError(
281-
"element at index '1' expected 'number' received 'string'",
285+
"'test.items.1' expected 'number' received 'string'",
282286
);
283287
const data = Schema.toData(schema, { items: [0, 1] });
284288
expect(data).toStrictEqual({ items: [0, 1] });
@@ -472,6 +476,130 @@ describe("Types", () => {
472476
});
473477
});
474478

479+
describe("error handling for nested structures", () => {
480+
test("deeply nested objects", () => {
481+
const schema = createSchema("test", {
482+
user: object({
483+
profile: object({
484+
contact: object({
485+
email: string().pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
486+
phone: object({
487+
country: string().length(2),
488+
number: string().pattern(/^\d{10}$/),
489+
}),
490+
}),
491+
}),
492+
}),
493+
});
494+
495+
expect(() =>
496+
Schema.toData(schema, {
497+
user: {
498+
profile: {
499+
contact: {
500+
email: "invalid",
501+
phone: { country: "USA", number: "123" },
502+
},
503+
},
504+
},
505+
}),
506+
).toThrowError(
507+
"'test.user.profile.contact.email' string must match pattern /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/",
508+
);
509+
510+
expect(() =>
511+
Schema.toData(schema, {
512+
user: {
513+
profile: {
514+
contact: {
515+
email: "test@example.com",
516+
phone: { country: "USA", number: "123" },
517+
},
518+
},
519+
},
520+
}),
521+
).toThrowError(
522+
"'test.user.profile.contact.phone.country' string must be exactly 2 characters long",
523+
);
524+
});
525+
526+
test("array of objects", () => {
527+
const schema = createSchema("test", {
528+
users: array(
529+
object({
530+
name: string().nonEmpty(),
531+
age: number().min(0),
532+
contacts: array(
533+
object({
534+
type: literal("email", "phone"),
535+
value: string().nonEmpty(),
536+
}),
537+
),
538+
}),
539+
),
540+
});
541+
542+
expect(() =>
543+
Schema.toData(schema, {
544+
users: [
545+
{
546+
name: "John",
547+
age: -1,
548+
contacts: [{ type: "email", value: "john@example.com" }],
549+
},
550+
],
551+
}),
552+
).toThrowError(
553+
"'test.users.0.age' number must be greater than or equal to 0",
554+
);
555+
556+
expect(() =>
557+
Schema.toData(schema, {
558+
users: [
559+
{
560+
name: "John",
561+
age: 30,
562+
// @ts-expect-error
563+
contacts: [{ type: "invalid", value: "john@example.com" }],
564+
},
565+
],
566+
}),
567+
).toThrowError(
568+
"'test.users.0.contacts.0.type' unknown value 'invalid', literal may only specify known values",
569+
);
570+
});
571+
572+
test("nested tuples", () => {
573+
const schema = createSchema("test", {
574+
coordinates: array(
575+
tuple([
576+
number()
577+
.min(-90)
578+
.max(90), // latitude
579+
number()
580+
.min(-180)
581+
.max(180), // longitude
582+
array(string().nonEmpty()), // tags
583+
]),
584+
),
585+
});
586+
587+
expect(() =>
588+
Schema.toData(schema, {
589+
coordinates: [[91, 0, ["north"]]],
590+
}),
591+
).toThrowError(
592+
"'test.coordinates.0.0' number must be less than or equal to 90",
593+
);
594+
595+
expect(() =>
596+
Schema.toData(schema, {
597+
coordinates: [[0, 0, [""]]],
598+
}),
599+
).toThrowError("'test.coordinates.0.2.0' string must not be empty");
600+
});
601+
});
602+
475603
describe("date", () => {
476604
const now = new Date();
477605
const past = new Date(now.getTime() - 1000 * 60 * 60 * 24 * 2); // 2 days ago

0 commit comments

Comments
 (0)