diff --git a/examples/io-ts/niceReporter.test.ts b/examples/io-ts/niceReporter.test.ts new file mode 100644 index 0000000..5c734b6 --- /dev/null +++ b/examples/io-ts/niceReporter.test.ts @@ -0,0 +1,119 @@ +import { niceReporter } from './niceReporter' +import * as t from 'io-ts' + +describe('NiceReporter', () => { + it('Basic failures', () => { + const result = t.number.decode('dog') + expect(niceReporter(result)).toEqual([ + 'Expected number, got string', + ]) + }) + + it('Enum failure', () => { + const trafficLight = t.union([ + t.literal('red'), + t.literal('yellow'), + t.literal('green'), + ]) + + const result = trafficLight.decode('dog') + expect(niceReporter(result)).toEqual([ + 'Expected ("red" | "yellow" | "green"), got string', + ]) + }) + + it('Enum failure uses name', () => { + const trafficLight = t.union( + [t.literal('red'), t.literal('yellow'), t.literal('green')], + 'TrafficLight' + ) + + const result = trafficLight.decode('dog') + expect(niceReporter(result)).toEqual([ + 'Expected TrafficLight, got string', + ]) + }) + + it('Missing field in type', () => { + const userRecord = t.type({ name: t.string }) + + const result = userRecord.decode({}) + expect(niceReporter(result)).toEqual([ + 'name: Expected string, got undefined', + ]) + }) + + it('Multiple missing fields in type', () => { + const userRecord = t.type({ name: t.string, age: t.number }) + + const result = userRecord.decode({}) + expect(niceReporter(result)).toEqual([ + 'name: Expected string, got undefined', + 'age: Expected number, got undefined', + ]) + }) + + it('One missing field in type', () => { + const userRecord = t.type({ name: t.string, age: t.number }) + + const result = userRecord.decode({ age: 123 }) + expect(niceReporter(result)).toEqual([ + 'name: Expected string, got undefined', + ]) + }) + + it('Error in nested type', () => { + const userRecord = t.type({ + name: t.string, + pets: t.type({ dog: t.boolean }), + }) + + const result = userRecord.decode({ name: 'Dog', pets: {} }) + expect(niceReporter(result)).toEqual([ + 'pets.dog: Expected boolean, got undefined', + ]) + }) + + it('Multiple items missing in a record', () => { + const userRecord = t.type({ + name: t.string, + pets: t.type({ dog: t.boolean }), + }) + + const result = userRecord.decode({}) + expect(niceReporter(result)).toEqual([ + 'name: Expected string, got undefined', + 'pets: Expected { dog: boolean }, got undefined', + ]) + }) + + const maybe = (prop: t.Type) => + t.union([ + t.type({ type: t.literal('Just'), value: prop }), + t.type({ type: t.literal('Nothing') }), + ]) + + it('Cannot match union type', () => { + const userRecord = t.type({ + maybeThing: maybe(t.string), + }) + + const result = userRecord.decode({ maybeThing: 123 }) + expect(niceReporter(result)).toEqual([ + 'maybeThing: Expected ({ type: "Just", value: string } | { type: "Nothing" }), got number', + ]) + }) + + it('Partial match on union type', () => { + const userRecord = t.type({ + maybeThing: maybe(t.string), + }) + + const result = userRecord.decode({ + maybeThing: { type: 'Just', value: 123 }, + }) + expect(niceReporter(result)).toEqual([ + 'maybeThing.value: Expected string, got number', + ]) + }) +}) diff --git a/examples/io-ts/niceReporter.ts b/examples/io-ts/niceReporter.ts new file mode 100644 index 0000000..009c510 --- /dev/null +++ b/examples/io-ts/niceReporter.ts @@ -0,0 +1,54 @@ +import * as t from 'io-ts' +import * as E from 'fp-ts/Either' + +const getUniq = (array: A[]): A[] => [...new Set(array)] + +export const niceReporter = ( + result: E.Either +): string[] | A => { + if (E.isRight(result)) { + return result.right + } + + return getUniq(result.left.map((err) => renderContext(err.context))) +} + +const labelForCodec = (codec: t.Decoder) => codec.name + +const destroyNumbers = (str: string) => str.replace(/[0-9]/g, '') + +const renderContext = (context: t.Context): string => { + return ( + contextString(context) + + renderContextItem(chooseContextEntry(context)) + ) +} + +const last = (arr: readonly A[], fromEnd: number) => + arr[arr.length - fromEnd] + +// for t.type we want the last item +// but for t.union we want the second last item because the specific one is +// boring +const chooseContextEntry = (context: t.Context): t.ContextEntry => { + const sndLast = last(context, 2) + + if (sndLast && (sndLast.type as any)._tag === 'UnionType') { + return sndLast + } + return last(context, 1) +} + +const renderContextItem = (contextEntry: t.ContextEntry): string => { + return `Expected ${labelForCodec( + contextEntry.type + )}, got ${typeof contextEntry.actual}` +} + +const contextString = (context: t.Context): string => { + const ctx = context + .map((contextEntry) => contextEntry.key) + .filter((a) => destroyNumbers(a).length > 0) + .join('.') + return ctx.length > 0 ? `${ctx}: ` : '' +}