diff --git a/.changeset/vast-terms-fix.md b/.changeset/vast-terms-fix.md new file mode 100644 index 0000000..aecea29 --- /dev/null +++ b/.changeset/vast-terms-fix.md @@ -0,0 +1,5 @@ +--- +"monarch-orm": patch +--- + +Add path to error message diff --git a/src/errors.ts b/src/errors.ts index 7bb9bc1..ba4ea7d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,4 +6,41 @@ export class MonarchError extends Error {} /** * Schema parsing and validation error. */ -export class MonarchParseError extends MonarchError {} +export class MonarchParseError extends MonarchError { + private path: (string | number)[]; + private cause?: MonarchParseError; + + constructor(message: string); + constructor(cause: { path: string | number; error: MonarchParseError }); + constructor( + error: + | string + | { + path: string | number; + error: MonarchParseError; + }, + ) { + let message: string; + let path: (string | number)[] = []; + let cause: MonarchParseError | undefined; + + if (typeof error === "string") { + message = error; + } else { + cause = error.error.cause ?? error.error; + path = [error.path, ...error.error.path]; + + const pathString = path.reduce((acc, p, i) => { + if (typeof p === "number") { + return `${acc}[${p}]`; + } + return i === 0 ? p : `${acc}.${p}`; + }, ""); + message = `${pathString}: ${cause.message}`; + } + + super(message); + this.path = path; + this.cause = cause; + } +} diff --git a/src/schema/schema.ts b/src/schema/schema.ts index bae4067..cb516f4 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -1,5 +1,6 @@ import type { Projection } from "../collection/types/query-options"; import { detectProjection } from "../collection/utils/projection"; +import { MonarchParseError } from "../errors"; import { objectId } from "../types/objectId"; import { type AnyMonarchType, MonarchType } from "../types/type"; import type { Pretty, WithOptionalId } from "../utils/type-helpers"; @@ -114,10 +115,17 @@ export class Schema< // parse fields const types = Schema.types(schema); for (const [key, type] of Object.entries(types)) { - const parser = MonarchType.parser(type as AnyMonarchType); - const parsed = parser(input[key as keyof InferSchemaInput]); - if (parsed === undefined) continue; - data[key as keyof typeof data] = parsed; + try { + const parser = MonarchType.parser(type as AnyMonarchType); + const parsed = parser(input[key as keyof InferSchemaInput]); + if (parsed === undefined) continue; + data[key as keyof typeof data] = parsed; + } catch (error) { + if (error instanceof MonarchParseError) { + throw new MonarchParseError({ path: key, error }); + } + throw error; + } } return data; } diff --git a/src/types/array.ts b/src/types/array.ts index bfe223f..bcdb8e3 100644 --- a/src/types/array.ts +++ b/src/types/array.ts @@ -29,7 +29,7 @@ export class MonarchArray extends MonarchType> extends Mon parsed[key as keyof typeof parsed] = parser(input[key as keyof typeof input] as InferTypeInput); } catch (error) { if (error instanceof MonarchParseError) { - throw new MonarchParseError(`field '${key}' ${error.message}'`); + throw new MonarchParseError({ path: key, error }); } throw error; } diff --git a/src/types/objectId.ts b/src/types/objectId.ts index d67b741..bd77929 100644 --- a/src/types/objectId.ts +++ b/src/types/objectId.ts @@ -16,7 +16,7 @@ export class MonarchObjectId extends MonarchType { constructor() { super((input) => { if (ObjectId.isValid(input)) return new ObjectId(input); - throw new MonarchParseError(`expected valid ObjectId received '${typeof input}' ${input}`); + throw new MonarchParseError(`expected 'ObjectId' received '${typeof input}' ${input}`); }); } } diff --git a/src/types/record.ts b/src/types/record.ts index 77abdc3..1d340f2 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -27,7 +27,7 @@ export class MonarchRecord extends MonarchType< parsed[key] = parser(value); } catch (error) { if (error instanceof MonarchParseError) { - throw new MonarchParseError(`field '${key}' ${error.message}'`); + throw new MonarchParseError({ path: key, error }); } throw error; } diff --git a/src/types/tuple.ts b/src/types/tuple.ts index 1f00d8b..3a96cc8 100644 --- a/src/types/tuple.ts +++ b/src/types/tuple.ts @@ -23,7 +23,9 @@ export class MonarchTuple exten super((input) => { if (Array.isArray(input)) { if (input.length !== types.length) { - throw new MonarchParseError(`expected array with ${types.length} elements received ${input.length} elements`); + throw new MonarchParseError( + `expected 'array' with ${types.length} elements received ${input.length} elements`, + ); } const parsed = [] as InferTypeTupleOutput; for (const [index, type] of types.entries()) { @@ -32,7 +34,7 @@ export class MonarchTuple exten parsed[index] = parser(input[index]); } catch (error) { if (error instanceof MonarchParseError) { - throw new MonarchParseError(`element at index '${index}' ${error.message}`); + throw new MonarchParseError({ path: index, error }); } throw error; } diff --git a/src/types/union.ts b/src/types/union.ts index deffb90..7c57996 100644 --- a/src/types/union.ts +++ b/src/types/union.ts @@ -38,7 +38,7 @@ export class MonarchUnion exten throw error; } } - throw new MonarchParseError(`expected one of union variants but received '${typeof input}'`); + throw new MonarchParseError(`expected 'union' variant received '${typeof input}'`); }); } } diff --git a/tests/query/insert-find.test.ts b/tests/query/insert-find.test.ts index 73dac97..844118e 100644 --- a/tests/query/insert-find.test.ts +++ b/tests/query/insert-find.test.ts @@ -75,7 +75,7 @@ describe("Insert and Find Operations", async () => { it("rejects invalid ObjectId string", async () => { await expect(async () => { await collections.users.insertOne({ _id: "not_an_object_id", ...mockUsers[0] }); - }).rejects.toThrowError("expected valid ObjectId received"); + }).rejects.toThrowError("expected 'ObjectId'"); }); it("inserts empty document with default values", async () => { diff --git a/tests/types/array.test.ts b/tests/types/array.test.ts index abee365..05fb04d 100644 --- a/tests/types/array.test.ts +++ b/tests/types/array.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { Schema, createSchema } from "../../src"; import { array, number, string } from "../../src/types"; -describe("array()", () => { +describe("array", () => { test("validates array type", () => { const schema = createSchema("test", { items: array(number()), @@ -14,7 +14,7 @@ describe("array()", () => { expect(() => Schema.encode(schema, { items: [] })).not.toThrowError(); // @ts-expect-error expect(() => Schema.encode(schema, { items: [0, "1"] })).toThrowError( - "element at index '1' expected 'number' received 'string'", + "items[1]: expected 'number' received 'string'", ); const data = Schema.encode(schema, { items: [0, 1] }); expect(data).toStrictEqual({ items: [0, 1] }); diff --git a/tests/types/binary.test.ts b/tests/types/binary.test.ts index 76876d0..b470ef3 100644 --- a/tests/types/binary.test.ts +++ b/tests/types/binary.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { binary } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("binary()", () => { +describe("binary", () => { test("validates Binary type", () => { const schema = createSchema("test", { data: binary(), diff --git a/tests/types/boolean.test.ts b/tests/types/boolean.test.ts index f61f99a..5e4ddce 100644 --- a/tests/types/boolean.test.ts +++ b/tests/types/boolean.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { Schema, createSchema } from "../../src"; import { boolean } from "../../src/types"; -describe("boolean()", () => { +describe("boolean", () => { test("validates boolean type", () => { const schema = createSchema("test", { isActive: boolean(), diff --git a/tests/types/decimal128.test.ts b/tests/types/decimal128.test.ts index 70d50f0..9bc91f5 100644 --- a/tests/types/decimal128.test.ts +++ b/tests/types/decimal128.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { decimal128 } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("decimal128()", () => { +describe("decimal128", () => { test("validates Decimal128 type", () => { const schema = createSchema("test", { value: decimal128(), diff --git a/tests/types/long.test.ts b/tests/types/long.test.ts index da2101a..8278126 100644 --- a/tests/types/long.test.ts +++ b/tests/types/long.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { long } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("long()", () => { +describe("long", () => { test("validates Long type", () => { const schema = createSchema("test", { value: long(), diff --git a/tests/types/object.test.ts b/tests/types/object.test.ts index fa0023a..39ba9f5 100644 --- a/tests/types/object.test.ts +++ b/tests/types/object.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { Schema, createSchema } from "../../src"; -import { boolean, literal, object } from "../../src/types"; +import { array, boolean, literal, number, object, string } from "../../src/types"; describe("object", () => { test("object", () => { @@ -17,12 +17,12 @@ describe("object", () => { expect(() => // @ts-expect-error Schema.encode(schema, { permissions: { canUpdate: "yes" } }), - ).toThrowError("field 'canUpdate' expected 'boolean' received 'string'"); + ).toThrowError("permissions.canUpdate: expected 'boolean' received 'string'"); // fields are validates in the order they are registered in type expect(() => // @ts-expect-error Schema.encode(schema, { permissions: { role: false } }), - ).toThrowError("field 'canUpdate' expected 'boolean' received 'undefined'"); + ).toThrowError("permissions.canUpdate: expected 'boolean' received 'undefined'"); // unknwon fields are rejected expect(() => Schema.encode(schema, { @@ -37,4 +37,40 @@ describe("object", () => { permissions: { canUpdate: true, canDelete: false, role: "moderator" }, }); }); + + test("nested object", () => { + const schema = createSchema("test", { + user: object({ + name: string(), + profile: object({ + age: number(), + tags: array(string()), + }), + }), + }); + + // nested object field error + expect(() => + Schema.encode(schema, { + // @ts-expect-error + user: { name: "John", profile: { age: "thirty", tags: [] } }, + }), + ).toThrowError("user.profile.age: expected 'number' received 'string'"); + + // nested array element error + expect(() => + Schema.encode(schema, { + // @ts-expect-error + user: { name: "John", profile: { age: 30, tags: ["valid", 123] } }, + }), + ).toThrowError("user.profile.tags[1]: expected 'string' received 'number'"); + + // valid data + const data = Schema.encode(schema, { + user: { name: "John", profile: { age: 30, tags: ["developer", "typescript"] } }, + }); + expect(data).toStrictEqual({ + user: { name: "John", profile: { age: 30, tags: ["developer", "typescript"] } }, + }); + }); }); diff --git a/tests/types/objectid.test.ts b/tests/types/objectid.test.ts index d44cd94..b037313 100644 --- a/tests/types/objectid.test.ts +++ b/tests/types/objectid.test.ts @@ -4,7 +4,7 @@ import { createDatabase, createSchema, Schema } from "../../src"; import { objectId } from "../../src/types"; import { createMockDatabase } from "../mock"; -describe("objectId()", () => { +describe("objectId", () => { test("validates ObjectId type", () => { const schema = createSchema("test", { id: objectId(), @@ -32,9 +32,9 @@ describe("objectId()", () => { id: objectId(), }); - expect(() => Schema.encode(schema, { id: "invalid" })).toThrowError("expected valid ObjectId"); + expect(() => Schema.encode(schema, { id: "invalid" })).toThrowError("expected 'ObjectId'"); // @ts-expect-error - expect(() => Schema.encode(schema, { id: {} })).toThrowError("expected valid ObjectId"); + expect(() => Schema.encode(schema, { id: {} })).toThrowError("expected 'ObjectId'"); }); test("works with nullable and optional", () => { diff --git a/tests/types/record.test.ts b/tests/types/record.test.ts index 6a93c4f..9c7caf6 100644 --- a/tests/types/record.test.ts +++ b/tests/types/record.test.ts @@ -15,7 +15,7 @@ describe("record", () => { expect(() => // @ts-expect-error Schema.encode(schema, { grades: { math: "50" } }), - ).toThrowError("field 'math' expected 'number' received 'string'"); + ).toThrowError("grades.math: expected 'number' received 'string'"); const data = Schema.encode(schema, { grades: { math: 50 } }); expect(data).toStrictEqual({ grades: { math: 50 } }); }); diff --git a/tests/types/tuple.test.ts b/tests/types/tuple.test.ts index 81557ca..95debc5 100644 --- a/tests/types/tuple.test.ts +++ b/tests/types/tuple.test.ts @@ -12,13 +12,17 @@ describe("tuple", () => { expect(() => Schema.encode(schema, {})).toThrowError("expected 'array' received 'undefined'"); // @ts-expect-error expect(() => Schema.encode(schema, { items: [] })).toThrowError( - "expected array with 2 elements received 0 elements", + "expected 'array' with 2 elements received 0 elements", ); const data = Schema.encode(schema, { items: [0, "1"] }); expect(data).toStrictEqual({ items: [0, "1"] }); // @ts-expect-error + expect(() => Schema.encode(schema, { items: [0, 1] })).toThrowError( + "items[1]: expected 'string' received 'number'", + ); + // @ts-expect-error expect(() => Schema.encode(schema, { items: [1, "1", 2] })).toThrowError( - "expected array with 2 elements received 3 elements", + "expected 'array' with 2 elements received 3 elements", ); }); }); diff --git a/tests/types/type.test.ts b/tests/types/type.test.ts index 2169d9f..2fb7362 100644 --- a/tests/types/type.test.ts +++ b/tests/types/type.test.ts @@ -6,7 +6,7 @@ import { pipe, type } from "../../src/types"; const simpleString = () => type((input) => input); const simpleNumber = () => type((input) => input); -describe("type()", () => { +describe("type", () => { it("validates and transforms input", () => { const schema = createSchema("users", { age: simpleNumber()