From 117c918275ea4e18cc71b26a7450860da15c58b9 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Wed, 8 Dec 2021 18:09:57 +0000 Subject: [PATCH 1/2] WIP --- examples/io-ts/nice-reporter.ts | 61 ++++++++++++++++++++++++ examples/io-ts/niceReporter.test.ts | 74 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 examples/io-ts/nice-reporter.ts create mode 100644 examples/io-ts/niceReporter.test.ts diff --git a/examples/io-ts/nice-reporter.ts b/examples/io-ts/nice-reporter.ts new file mode 100644 index 0000000..b933c4f --- /dev/null +++ b/examples/io-ts/nice-reporter.ts @@ -0,0 +1,61 @@ +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, '') + +// we want the longest key, that isn't a number ((???)) +const sortByLongestKey = (entries: t.Context): t.Context => + [...entries].sort( + (a, b) => + destroyNumbers(b.key).length - destroyNumbers(a.key).length + ) + +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 ${ + 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}: ` : '' +} diff --git a/examples/io-ts/niceReporter.test.ts b/examples/io-ts/niceReporter.test.ts new file mode 100644 index 0000000..c64b422 --- /dev/null +++ b/examples/io-ts/niceReporter.test.ts @@ -0,0 +1,74 @@ +import { niceReporter } from './nice-reporter' +import * as t from 'io-ts' + +describe('NiceReporter', () => { + it('Basic failures', () => { + const result = t.number.decode('dog') + expect(niceReporter(result)).toEqual(['Expected number, got dog']) + }) + + 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 dog', + ]) + }) + + 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 dog', + ]) + }) + + 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', + ]) + }) +}) From 02889f0a251406cb87294aac725f928c6d07cf3f Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Wed, 5 Jan 2022 18:00:13 +0000 Subject: [PATCH 2/2] Nice --- examples/io-ts/niceReporter.test.ts | 53 +++++++++++++++++-- .../{nice-reporter.ts => niceReporter.ts} | 13 ++--- 2 files changed, 52 insertions(+), 14 deletions(-) rename examples/io-ts/{nice-reporter.ts => niceReporter.ts} (81%) diff --git a/examples/io-ts/niceReporter.test.ts b/examples/io-ts/niceReporter.test.ts index c64b422..5c734b6 100644 --- a/examples/io-ts/niceReporter.test.ts +++ b/examples/io-ts/niceReporter.test.ts @@ -1,10 +1,12 @@ -import { niceReporter } from './nice-reporter' +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 dog']) + expect(niceReporter(result)).toEqual([ + 'Expected number, got string', + ]) }) it('Enum failure', () => { @@ -16,7 +18,7 @@ describe('NiceReporter', () => { const result = trafficLight.decode('dog') expect(niceReporter(result)).toEqual([ - 'Expected ("red" | "yellow" | "green"), got dog', + 'Expected ("red" | "yellow" | "green"), got string', ]) }) @@ -28,7 +30,7 @@ describe('NiceReporter', () => { const result = trafficLight.decode('dog') expect(niceReporter(result)).toEqual([ - 'Expected TrafficLight, got dog', + 'Expected TrafficLight, got string', ]) }) @@ -71,4 +73,47 @@ describe('NiceReporter', () => { '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/nice-reporter.ts b/examples/io-ts/niceReporter.ts similarity index 81% rename from examples/io-ts/nice-reporter.ts rename to examples/io-ts/niceReporter.ts index b933c4f..009c510 100644 --- a/examples/io-ts/nice-reporter.ts +++ b/examples/io-ts/niceReporter.ts @@ -17,13 +17,6 @@ const labelForCodec = (codec: t.Decoder) => codec.name const destroyNumbers = (str: string) => str.replace(/[0-9]/g, '') -// we want the longest key, that isn't a number ((???)) -const sortByLongestKey = (entries: t.Context): t.Context => - [...entries].sort( - (a, b) => - destroyNumbers(b.key).length - destroyNumbers(a.key).length - ) - const renderContext = (context: t.Context): string => { return ( contextString(context) + @@ -47,9 +40,9 @@ const chooseContextEntry = (context: t.Context): t.ContextEntry => { } const renderContextItem = (contextEntry: t.ContextEntry): string => { - return `Expected ${labelForCodec(contextEntry.type)}, got ${ - contextEntry.actual - }` + return `Expected ${labelForCodec( + contextEntry.type + )}, got ${typeof contextEntry.actual}` } const contextString = (context: t.Context): string => {