diff --git a/src/index.ts b/src/index.ts index 2814988..6ad59f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,10 +51,30 @@ export const createDecoder = (decoder: Decode): Decoder => ({ }) export class DecoderError extends SyntaxError { - constructor (message?: string) { + path: string[] + constructor (message?: string, path: string[] = []) { super(message) this.name = 'DecoderError' + this.path = path Object.setPrototypeOf(this, new.target.prototype) + + if (this.path.length === 1) { + this.message = `${this.path[0]}: ${this.message}` + } else if (this.path.length > 1) { + this.message = `${this.path[0]}.${this.message}` + } + } +} + +const forceDecodeWithPath = (decoder: Decoder, data: unknown, pathPart: string): T => { + try { + return decoder.forceDecode(data) + } catch (e) { + if (e instanceof DecoderError) { + throw new DecoderError(e.message, [pathPart, ...e.path]) + } else { + throw e + } } } @@ -161,7 +181,7 @@ export const array = (decoder: Decoder): Decoder => createDecoder({ forceDecode: (data) => { checkDefined(data) checkArrayType(data) - return data.map(decoder.forceDecode) + return data.map((x: unknown, i) => forceDecodeWithPath(decoder, x, i.toString())) } }) @@ -178,7 +198,7 @@ export const tuple = ( } return decoders.map((decoder, index) => - decoder.forceDecode(data[index]) + forceDecodeWithPath(decoder, data[index], index.toString()) ) as any as D } }) @@ -200,7 +220,7 @@ export const record = (decoder: Decoder): Decoder> => cr checkDefined(data) checkDictType(data) return Object.fromEntries( - Object.entries(data).map(([key, value]) => [key, decoder.forceDecode(value)]) + Object.entries(data).map(([key, value]) => [key, forceDecodeWithPath(decoder, value, key)]) ) } }) @@ -227,7 +247,7 @@ const required = ( for (const key in struct) { if (data[key] === undefined) throw new DecoderError(`Object missing required property '${key}'`) - parsed[key] = struct[key].forceDecode(data[key]) + parsed[key] = forceDecodeWithPath(struct[key], data[key], key) } return parsed as ObjectType @@ -244,7 +264,7 @@ const partial = ( for (const key in struct) { if (data[key] !== undefined) { - parsed[key] = struct[key].forceDecode(data[key]) + parsed[key] = forceDecodeWithPath(struct[key], data[key], key) } } diff --git a/test/index.test.ts b/test/index.test.ts index 1d16c62..a44a3ce 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -329,3 +329,111 @@ test('Decoder.validate on invalid data returns error', () => { test('Decoder.validate on valid data returns ok', () => { expect(D.string.validate('hi')).toStrictEqual({ type: 'ok', data: 'hi' }) }) + +test('DecodeError path of tuple is correct', () => { + const result = D.tuple(D.string, D.string).validate(["h1", 2]); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("1: This is not a string: 2") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of an array is correct', () => { + const result = D.array(D.string).validate(["h1", "h2", 3.14]); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("2: This is not a string: 3.14") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of an object required key is correct', () => { + const result = D.object({required: {name: D.string}}).validate({name: 1}); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("name: This is not a string: 1") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of an object optional key is correct', () => { + const result = D.object({optional: {name: D.string}}).validate({name: 1}); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("name: This is not a string: 1") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of a record is correct', () => { + const result = D.record(D.object({required: {name: D.string}})).validate({"charles": {name: 1}}); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("charles.name: This is not a string: 1") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of a key value pair is correct', () => { + const result = D.keyValuePairs(D.object({required: {name: D.string}})).validate({"charles": {name: 1}}); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("charles.name: This is not a string: 1") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of a nested object is correct', () => { + const result = D.object({optional: {sub: D.object({required: {name: D.string}})}}).validate({sub:{name: 1}}); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("sub.name: This is not a string: 1") + } else { + fail("Expected error") + } +}) + +test('DecodeError path of a recursive type is correct', () => { + + const categories: any = { + name: 'Electronics', + subcategories: [ + { + name: 'Computers', + subcategories: [ + { name: 'Desktops', subcategories: [] }, + { name: 1, subcategories: [] } + ] + }, + { name: 'Fridges', subcategories: [] } + ] + } + + interface Category { + name: string, + subcategories: Category[] + } + + const categoryDecoder: D.Decoder = D.object({ + required: { + name: D.string, + subcategories: D.array(D.recursive(() => categoryDecoder)) + } + }) + + const categoryDecoder_: D.Decoder = D.recursive(() => + D.object({ + required: { + name: D.string, + subcategories: D.array(categoryDecoder_) + } + }) + ) + + const result = categoryDecoder_.validate(categories); + if (result.type === "error") { + expect(result.error.message).toStrictEqual("subcategories.0.subcategories.1.name: This is not a string: 1") + } else { + fail("Expected error") + } +})