Skip to content

Commit

Permalink
🔀 Merge errors with path information
Browse files Browse the repository at this point in the history
Errors with path
  • Loading branch information
michaljanocko committed Jun 1, 2022
2 parents 09a700b + 6e3f911 commit 84828e0
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 6 deletions.
32 changes: 26 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,30 @@ export const createDecoder = <D>(decoder: Decode<D>): Decoder<D> => ({
})

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 = <T>(decoder: Decoder<T>, 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
}
}
}

Expand Down Expand Up @@ -161,7 +181,7 @@ export const array = <D>(decoder: Decoder<D>): Decoder<D[]> => createDecoder({
forceDecode: (data) => {
checkDefined(data)
checkArrayType(data)
return data.map(decoder.forceDecode)
return data.map((x: unknown, i) => forceDecodeWithPath(decoder, x, i.toString()))
}
})

Expand All @@ -178,7 +198,7 @@ export const tuple = <D extends readonly unknown[]>(
}

return decoders.map((decoder, index) =>
decoder.forceDecode(data[index])
forceDecodeWithPath(decoder, data[index], index.toString())
) as any as D
}
})
Expand All @@ -200,7 +220,7 @@ export const record = <D>(decoder: Decoder<D>): Decoder<Record<string, D>> => 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)])
)
}
})
Expand All @@ -227,7 +247,7 @@ const required = <D extends DecoderRecord>(

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<D>
Expand All @@ -244,7 +264,7 @@ const partial = <D extends DecoderRecord>(

for (const key in struct) {
if (data[key] !== undefined) {
parsed[key] = struct[key].forceDecode(data[key])
parsed[key] = forceDecodeWithPath(struct[key], data[key], key)
}
}

Expand Down
108 changes: 108 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Category> = D.object({
required: {
name: D.string,
subcategories: D.array(D.recursive(() => categoryDecoder))
}
})

const categoryDecoder_: D.Decoder<Category> = 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")
}
})

0 comments on commit 84828e0

Please sign in to comment.