├── .gitignore ├── .prettierignore ├── .travis.yml ├── docs ├── modules │ ├── index.md │ ├── utils.ts.md │ └── index.ts.md ├── _config.yml └── index.md ├── .editorconfig ├── ava.config.js ├── .prettierrc ├── xo.config.js ├── tsconfig.json ├── src ├── utils.ts └── index.ts ├── LICENSE ├── package.json ├── README.md └── tests ├── index.test.ts └── unions.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /target/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | target/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 14.17.3 3 | -------------------------------------------------------------------------------- /docs/modules/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Modules 3 | has_children: true 4 | permalink: /docs/modules 5 | nav_order: 2 6 | --- 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | extensions: ['ts'], 3 | files: ['tests/**/*.test.ts'], 4 | require: ['ts-node/register'], 5 | } 6 | 7 | export default config 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": false, 9 | "arrowParens": "always", 10 | "proseWrap": "always" 11 | } 12 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pmarsceill/just-the-docs 2 | 3 | search_enabled: true 4 | 5 | # Aux links for the upper right navigation 6 | aux_links: 7 | "Docs": 8 | - "//gillchristian.github.io/io-ts-reporters/" 9 | "API Reference": 10 | - "//gillchristian.github.io/io-ts-reporters/modules/" 11 | 'GitHub': 12 | - 'https://github.com/gillchristian/io-ts-reporters' 13 | -------------------------------------------------------------------------------- /xo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prettier: true, 3 | space: 2, 4 | rules: { 5 | '@typescript-eslint/prefer-readonly-parameter-types': 'off', 6 | 'unicorn/no-array-callback-reference': 'off', 7 | 'unicorn/prefer-module': 'off', 8 | // Typescript protects against the issues that this concerns 9 | 'unicorn/no-fn-reference-in-iterator': 'off', 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "noImplicitAny": true, 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "noImplicitThis": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "sourceMap": true, 12 | "outDir": "./target/", 13 | "declaration": true 14 | }, 15 | "include": ["./src/*.ts", "./tests/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /docs/modules/utils.ts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: utils.ts 3 | nav_order: 2 4 | parent: Modules 5 | --- 6 | 7 | ## utils overview 8 | 9 | Added in v1.1.0 10 | 11 | --- 12 | 13 |

Table of contents

14 | 15 | - [utils](#utils) 16 | - [takeUntil](#takeuntil) 17 | 18 | --- 19 | 20 | # utils 21 | 22 | ## takeUntil 23 | 24 | **Signature** 25 | 26 | ```ts 27 | export declare const takeUntil: ( 28 | predicate: Predicate, 29 | ) => (as: readonly A[]) => readonly A[] 30 | ``` 31 | 32 | Added in v1.1.0 33 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @since 1.1.0 3 | */ 4 | import {Predicate} from 'fp-ts/function' 5 | 6 | /** 7 | * @since 1.1.0 8 | */ 9 | /* eslint-disable @typescript-eslint/array-type */ 10 | export const takeUntil = 11 | (predicate: Predicate) => 12 | (as: ReadonlyArray): ReadonlyArray => { 13 | const init = [] 14 | 15 | // eslint-disable-next-line unicorn/no-for-loop 16 | for (let i = 0; i < as.length; i++) { 17 | init[i] = as[i] 18 | if (predicate(as[i])) { 19 | return init 20 | } 21 | } 22 | 23 | return init 24 | } 25 | /* eslint-enable @typescript-eslint/array-type */ 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Oliver Joseph Ash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | nav_order: 1 4 | --- 5 | 6 | # io-ts-reporters 7 | 8 | [Error reporters](https://github.com/gcanti/io-ts#error-reporters) for 9 | [io-ts](https://github.com/gcanti/io-ts). 10 | 11 | Currently this package only includes one reporter. The output is an array of 12 | strings in the format of: 13 | 14 | ``` 15 | Expecting ${expectedType} at ${path} but instead got: ${expectedType} 16 | ``` 17 | 18 | And for union types: 19 | 20 | ``` 21 | Expecting one of: 22 | ${unionType1} 23 | ${unionType2} 24 | ${...} 25 | ${unionTypeN} 26 | at ${path} but instead got: ${actualValue} 27 | ``` 28 | 29 | ## Example 30 | 31 | ```ts 32 | import * as t from 'io-ts' 33 | import reporter from 'io-ts-reporters' 34 | 35 | const User = t.interface({name: t.string}) 36 | 37 | // When decoding fails, the errors are reported 38 | reporter.report(User.decode({nam: 'Jane'})) 39 | //=> ['Expecting string at name but instead got: undefined'] 40 | 41 | // Nothing gets reported on success 42 | reporter.report(User.decode({name: 'Jane'})) 43 | //=> [] 44 | ``` 45 | 46 | To only format the validation errors in case the validation failed (ie. 47 | `mapLeft`) use `formatValidationErrors` instead. 48 | 49 | ```ts 50 | import * as t from 'io-ts' 51 | import {formatValidationErrors} from 'io-ts-reporters' 52 | import * as E from 'fp-ts/Either' 53 | import {pipe} from 'fp-ts/pipeable' 54 | 55 | const User = t.interface({name: t.string}) 56 | 57 | const result = User.decode({nam: 'Jane'}) // Either 58 | 59 | E.mapLeft(formatValidationErrors)(result) // Either 60 | ``` 61 | 62 | For more examples see [the tests](./tests/index.test.ts). 63 | 64 | ## Credits 65 | 66 | This library was created by [OliverJAsh](https://github.com/OliverJAsh). 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "io-ts-reporters", 3 | "version": "2.0.1", 4 | "description": "Formatting of io-ts validation errors", 5 | "main": "./target/src/index.js", 6 | "typings": "./target/src/index.d.ts", 7 | "sideEffects": false, 8 | "scripts": { 9 | "fmt": "prettier --write '**/*.{json,md,ts,js}'", 10 | "fmt:check": "prettier --check '**/*.{json,md,ts,js}'", 11 | "docs": "yarn docs-ts", 12 | "lint": "npm run typecheck && npm run lint:only", 13 | "lint:only": "xo", 14 | "typecheck": "tsc --noEmit", 15 | "compile": "rm -rf ./target/* && tsc", 16 | "test": "npm run fmt:check && npm run lint && npm run test:unit && yarn docs", 17 | "test:unit": "ava", 18 | "prepublishOnly": "npm run compile && npm run lint" 19 | }, 20 | "files": [ 21 | "target/src", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/gillchristian/io-ts-reporters.git" 27 | }, 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/gillchristian/io-ts-reporters/issues" 31 | }, 32 | "homepage": "https://github.com/gillchristian/io-ts-reporters", 33 | "dependencies": { 34 | "@scarf/scarf": "^1.1.1" 35 | }, 36 | "peerDependencies": { 37 | "fp-ts": "^2.10.5", 38 | "io-ts": "^2.2.16" 39 | }, 40 | "devDependencies": { 41 | "@types/tape": "^4.2.34", 42 | "@typescript-eslint/eslint-plugin": "^4.28.3", 43 | "@typescript-eslint/parser": "^4.28.3", 44 | "ava": "^3.8.2", 45 | "docs-ts": "^0.6.10", 46 | "eslint-config-xo-typescript": "^0.43.0", 47 | "fp-ts": "^2.10.5", 48 | "io-ts": "^2.2.16", 49 | "io-ts-types": "^0.5.16", 50 | "prettier": "^2.3.2", 51 | "ts-node": "^10.1.0", 52 | "typescript": "^4.3.5", 53 | "xo": "^0.41.0" 54 | }, 55 | "tags": [ 56 | "typescript", 57 | "runtime", 58 | "decoder", 59 | "encoder", 60 | "schema" 61 | ], 62 | "keywords": [ 63 | "typescript", 64 | "runtime", 65 | "decoder", 66 | "encoder", 67 | "schema" 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # io-ts-reporters 2 | 3 | [Error reporters](https://github.com/gcanti/io-ts#error-reporters) for 4 | [io-ts](https://github.com/gcanti/io-ts). 5 | 6 | ![scarf-downloads](https://scarf.sh/package/installs-badge/376fab85-f2aa-4e85-9225-3be51d9534fc) 7 | 8 | Currently this package only includes one reporter. The output is an array of 9 | strings in the format of: 10 | 11 | ``` 12 | Expecting ${expectedType} at ${path} but instead got: ${actualValue} 13 | ``` 14 | 15 | And for union types: 16 | 17 | ``` 18 | Expecting one of: 19 | ${unionType1} 20 | ${unionType2} 21 | ${...} 22 | ${unionTypeN} 23 | at ${path} but instead got: ${actualValue} 24 | ``` 25 | 26 | ## Installation 27 | 28 | ```bash 29 | yarn add io-ts-reporters 30 | ``` 31 | 32 | ## Example 33 | 34 | ```ts 35 | import * as t from 'io-ts' 36 | import reporter from 'io-ts-reporters' 37 | 38 | const User = t.interface({name: t.string}) 39 | 40 | // When decoding fails, the errors are reported 41 | reporter.report(User.decode({nam: 'Jane'})) 42 | //=> ['Expecting string at name but instead got: undefined'] 43 | 44 | // Nothing gets reported on success 45 | reporter.report(User.decode({name: 'Jane'})) 46 | //=> [] 47 | ``` 48 | 49 | To only format the validation errors in case the validation failed (ie. 50 | `mapLeft`) use `formatValidationErrors` instead. 51 | 52 | ```ts 53 | import * as t from 'io-ts' 54 | import {formatValidationErrors} from 'io-ts-reporters' 55 | import * as E from 'fp-ts/Either' 56 | import {pipe} from 'fp-ts/pipeable' 57 | 58 | const User = t.interface({name: t.string}) 59 | 60 | const result = User.decode({nam: 'Jane'}) // Either 61 | 62 | E.mapLeft(formatValidationErrors)(result) // Either 63 | ``` 64 | 65 | For more examples see [the tests](./tests/index.test.ts). 66 | 67 | ## TypeScript compatibility 68 | 69 | | io-ts-reporters version | required typescript version | 70 | | ----------------------- | --------------------------- | 71 | | 1.0.0 | 3.5+ | 72 | | <= 0.0.21 | 2.7+ | 73 | 74 | ## Testing 75 | 76 | ```bash 77 | yarn 78 | yarn run test 79 | ``` 80 | 81 | [io-ts]: https://github.com/gcanti/io-ts#error-reporters 82 | 83 | ## Credits 84 | 85 | This library was created by [OliverJAsh](https://github.com/OliverJAsh). 86 | -------------------------------------------------------------------------------- /docs/modules/index.ts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: index.ts 3 | nav_order: 1 4 | parent: Modules 5 | --- 6 | 7 | ## index overview 8 | 9 | An 10 | [io-ts Reporter](https://gcanti.github.io/io-ts/modules/Reporter.ts.html#reporter-interface). 11 | 12 | **Example** 13 | 14 | ```ts 15 | import * as t from 'io-ts' 16 | import Reporter from 'io-ts-reporters' 17 | 18 | const User = t.interface({name: t.string}) 19 | 20 | assert.deepEqual(Reporter.report(User.decode({nam: 'Jane'})), [ 21 | 'Expecting string at name but instead got: undefined', 22 | ]) 23 | assert.deepEqual(Reporter.report(User.decode({name: 'Jane'})), []) 24 | ``` 25 | 26 | Added in v1.2.0 27 | 28 | --- 29 | 30 |

Table of contents

31 | 32 | - [deprecated](#deprecated) 33 | - [~~reporter~~](#reporter) 34 | - [formatters](#formatters) 35 | - [ReporterOptions (interface)](#reporteroptions-interface) 36 | - [formatValidationError](#formatvalidationerror) 37 | - [formatValidationErrors](#formatvalidationerrors) 38 | - [internals](#internals) 39 | - [TYPE_MAX_LEN](#type_max_len) 40 | 41 | --- 42 | 43 | # deprecated 44 | 45 | ## ~~reporter~~ 46 | 47 | Deprecated, use the default export instead. 48 | 49 | **Signature** 50 | 51 | ```ts 52 | export declare const reporter: ( 53 | validation: E.Either, 54 | options?: ReporterOptions | undefined, 55 | ) => string[] 56 | ``` 57 | 58 | Added in v1.0.0 59 | 60 | # formatters 61 | 62 | ## ReporterOptions (interface) 63 | 64 | **Signature** 65 | 66 | ```ts 67 | export interface ReporterOptions { 68 | truncateLongTypes?: boolean 69 | } 70 | ``` 71 | 72 | Added in v1.2.2 73 | 74 | ## formatValidationError 75 | 76 | Format a single validation error. 77 | 78 | **Signature** 79 | 80 | ```ts 81 | export declare const formatValidationError: ( 82 | error: t.ValidationError, 83 | options?: ReporterOptions | undefined, 84 | ) => O.Option 85 | ``` 86 | 87 | Added in v1.0.0 88 | 89 | ## formatValidationErrors 90 | 91 | Format validation errors (`t.Errors`). 92 | 93 | **Signature** 94 | 95 | ```ts 96 | export declare const formatValidationErrors: ( 97 | errors: t.Errors, 98 | options?: ReporterOptions | undefined, 99 | ) => string[] 100 | ``` 101 | 102 | **Example** 103 | 104 | ```ts 105 | import * as E from 'fp-ts/Either' 106 | import * as t from 'io-ts' 107 | import {formatValidationErrors} from 'io-ts-reporters' 108 | 109 | const result = t.string.decode(123) 110 | 111 | assert.deepEqual( 112 | E.mapLeft(formatValidationErrors)(result), 113 | E.left(['Expecting string but instead got: 123']), 114 | ) 115 | ``` 116 | 117 | Added in v1.2.0 118 | 119 | # internals 120 | 121 | ## TYPE_MAX_LEN 122 | 123 | **Signature** 124 | 125 | ```ts 126 | export declare const TYPE_MAX_LEN: 160 127 | ``` 128 | 129 | Added in v1.2.1 130 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as iots from 'io-ts' 3 | import {withMessage} from 'io-ts-types/lib/withMessage' 4 | 5 | import Reporter, {TYPE_MAX_LEN} from '../src' 6 | 7 | test('reports an empty array when the result doesn’t contain errors', (t) => { 8 | const PrimitiveType = iots.string 9 | const result = PrimitiveType.decode('foo') 10 | 11 | t.deepEqual(Reporter.report(result), []) 12 | }) 13 | 14 | test('formats a top-level primitve type correctly', (t) => { 15 | const PrimitiveType = iots.string 16 | const result = PrimitiveType.decode(42) 17 | 18 | t.deepEqual(Reporter.report(result), ['Expecting string but instead got: 42']) 19 | }) 20 | 21 | test('formats array items', (t) => { 22 | const NumberGroups = iots.array(iots.array(iots.number)) 23 | const result = NumberGroups.decode({}) 24 | 25 | t.deepEqual(Reporter.report(result), [ 26 | 'Expecting Array> but instead got: {}', 27 | ]) 28 | }) 29 | 30 | test('formats nested array item mismatches correctly', (t) => { 31 | const NumberGroups = iots.array(iots.array(iots.number)) 32 | const result = NumberGroups.decode([[{}]]) 33 | 34 | t.deepEqual(Reporter.report(result), [ 35 | 'Expecting number at 0.0 but instead got: {}', 36 | ]) 37 | }) 38 | 39 | test('formats branded types correctly', (t) => { 40 | interface PositiveBrand { 41 | readonly Positive: unique symbol 42 | } 43 | 44 | const Positive = iots.brand( 45 | iots.number, 46 | (n): n is iots.Branded => n >= 0, 47 | 'Positive', 48 | ) 49 | 50 | t.deepEqual(Reporter.report(Positive.decode(-1)), [ 51 | 'Expecting Positive but instead got: -1', 52 | ]) 53 | 54 | const PatronizingPositive = withMessage( 55 | Positive, 56 | (_i) => "Don't be so negative!", 57 | ) 58 | 59 | t.deepEqual(Reporter.report(PatronizingPositive.decode(-1)), [ 60 | "Expecting Positive but instead got: -1 (Don't be so negative!)", 61 | ]) 62 | }) 63 | 64 | test('truncates really long types', (t) => { 65 | const longType = iots.type({ 66 | '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890': 67 | iots.number, 68 | }) 69 | const messages = Reporter.report(longType.decode(null)) 70 | t.is(messages.length, 1) 71 | t.regex( 72 | messages[0], 73 | new RegExp( 74 | `^Expecting .{${TYPE_MAX_LEN - 3}}\\.{3} but instead got: null$`, 75 | ), 76 | 'Should be truncated', 77 | ) 78 | }) 79 | 80 | test('doesn’t truncate really long types when truncating is disabled', (t) => { 81 | const longTypeName = 82 | '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890' 83 | const longType = iots.type({ 84 | [longTypeName]: iots.number, 85 | }) 86 | const messages = Reporter.report(longType.decode(null), { 87 | truncateLongTypes: false, 88 | }) 89 | t.is(messages.length, 1) 90 | t.is( 91 | messages[0], 92 | `Expecting { ${longTypeName}: number } but instead got: null`, 93 | 'Should not be truncated', 94 | ) 95 | }) 96 | -------------------------------------------------------------------------------- /tests/unions.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as iots from 'io-ts' 3 | 4 | import Reporter, {TYPE_MAX_LEN} from '../src' 5 | 6 | test('formats keyof unions as "regular" types', (t) => { 7 | const WithKeyOf = iots.interface({ 8 | oneOf: iots.keyof({a: null, b: null, c: null}), 9 | }) 10 | 11 | t.deepEqual(Reporter.report(WithKeyOf.decode({oneOf: ''})), [ 12 | 'Expecting "a" | "b" | "c" at oneOf but instead got: ""', 13 | ]) 14 | }) 15 | 16 | test('union of string literals (no key)', (t) => { 17 | t.deepEqual(Reporter.report(Gender.decode('male')), [ 18 | [ 19 | 'Expecting one of:', 20 | ' "Male"', 21 | ' "Female"', 22 | ' "Other"', 23 | 'but instead got: "male"', 24 | ].join('\n'), 25 | ]) 26 | }) 27 | 28 | test('union of interfaces', (t) => { 29 | const UnionOfInterfaces = iots.union([ 30 | iots.interface({key: iots.string}), 31 | iots.interface({code: iots.number}), 32 | ]) 33 | const WithUnion = iots.interface({data: UnionOfInterfaces}) 34 | 35 | t.deepEqual(Reporter.report(WithUnion.decode({})), [ 36 | [ 37 | 'Expecting one of:', 38 | ' { key: string }', 39 | ' { code: number }', 40 | 'at data but instead got: undefined', 41 | ].join('\n'), 42 | ]) 43 | 44 | t.deepEqual(Reporter.report(WithUnion.decode({data: ''})), [ 45 | [ 46 | 'Expecting one of:', 47 | ' { key: string }', 48 | ' { code: number }', 49 | 'at data but instead got: ""', 50 | ].join('\n'), 51 | ]) 52 | 53 | t.deepEqual(Reporter.report(WithUnion.decode({data: {}})), [ 54 | [ 55 | 'Expecting one of:', 56 | ' { key: string }', 57 | ' { code: number }', 58 | 'at data but instead got: {}', 59 | ].join('\n'), 60 | ]) 61 | 62 | t.deepEqual(Reporter.report(WithUnion.decode({data: {code: '123'}})), [ 63 | [ 64 | 'Expecting one of:', 65 | ' { key: string }', 66 | ' { code: number }', 67 | 'at data but instead got: {"code":"123"}', 68 | ].join('\n'), 69 | ]) 70 | }) 71 | 72 | const Gender = iots.union([ 73 | iots.literal('Male'), 74 | iots.literal('Female'), 75 | iots.literal('Other'), 76 | ]) 77 | 78 | test('string union when provided undefined', (t) => { 79 | const Person = iots.interface({name: iots.string, gender: Gender}) 80 | 81 | t.deepEqual(Reporter.report(Person.decode({name: 'Jane'})), [ 82 | [ 83 | 'Expecting one of:', 84 | ' "Male"', 85 | ' "Female"', 86 | ' "Other"', 87 | 'at gender but instead got: undefined', 88 | ].join('\n'), 89 | ]) 90 | }) 91 | 92 | test('string union when provided another string', (t) => { 93 | const Person = iots.interface({name: iots.string, gender: Gender}) 94 | 95 | t.deepEqual( 96 | Reporter.report(Person.decode({name: 'Jane', gender: 'female'})), 97 | [ 98 | [ 99 | 'Expecting one of:', 100 | ' "Male"', 101 | ' "Female"', 102 | ' "Other"', 103 | 'at gender but instead got: "female"', 104 | ].join('\n'), 105 | ], 106 | ) 107 | 108 | t.deepEqual(Reporter.report(Person.decode({name: 'Jane'})), [ 109 | [ 110 | 'Expecting one of:', 111 | ' "Male"', 112 | ' "Female"', 113 | ' "Other"', 114 | 'at gender but instead got: undefined', 115 | ].join('\n'), 116 | ]) 117 | }) 118 | 119 | test('string union deeply nested', (t) => { 120 | const Person = iots.interface({ 121 | name: iots.string, 122 | children: iots.array(iots.interface({gender: Gender})), 123 | }) 124 | 125 | t.deepEqual( 126 | Reporter.report( 127 | Person.decode({ 128 | name: 'Jane', 129 | children: [{}, {gender: 'Whatever'}], 130 | }), 131 | ), 132 | [ 133 | [ 134 | 'Expecting one of:', 135 | ' "Male"', 136 | ' "Female"', 137 | ' "Other"', 138 | 'at children.0.gender but instead got: undefined', 139 | ].join('\n'), 140 | [ 141 | 'Expecting one of:', 142 | ' "Male"', 143 | ' "Female"', 144 | ' "Other"', 145 | 'at children.1.gender but instead got: "Whatever"', 146 | ].join('\n'), 147 | ], 148 | ) 149 | }) 150 | 151 | test('truncates really long unions', (t) => { 152 | const longUnion = iots.union([ 153 | iots.type({ 154 | '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890': 155 | iots.string, 156 | }), 157 | iots.type({ 158 | '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890': 159 | iots.number, 160 | }), 161 | ]) 162 | const messages = Reporter.report(longUnion.decode(null)) 163 | t.is(messages.length, 1) 164 | t.regex( 165 | messages[0], 166 | new RegExp( 167 | `^Expecting one of:\n( *.{${ 168 | TYPE_MAX_LEN - 3 169 | }}\\.{3}\n){2} *but instead got: null$`, 170 | ), 171 | 'Should be truncated', 172 | ) 173 | }) 174 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An [io-ts Reporter](https://gcanti.github.io/io-ts/modules/Reporter.ts.html#reporter-interface). 3 | * 4 | * @example 5 | * 6 | * import * as t from 'io-ts'; 7 | * import Reporter from 'io-ts-reporters'; 8 | * 9 | * const User = t.interface({ name: t.string }); 10 | * 11 | * assert.deepEqual( 12 | * Reporter.report(User.decode({ nam: 'Jane' })), 13 | * ['Expecting string at name but instead got: undefined'], 14 | * ) 15 | * assert.deepEqual( Reporter.report(User.decode({ name: 'Jane' })), []) 16 | * 17 | * @since 1.2.0 18 | */ 19 | import * as A from 'fp-ts/Array' 20 | import * as E from 'fp-ts/Either' 21 | import * as NEA from 'fp-ts/NonEmptyArray' 22 | import * as O from 'fp-ts/Option' 23 | import * as R from 'fp-ts/Record' 24 | import {pipe} from 'fp-ts/pipeable' 25 | import * as t from 'io-ts' 26 | import {Reporter} from 'io-ts/lib/Reporter' 27 | 28 | import {takeUntil} from './utils' 29 | 30 | const isUnionType = ({type}: t.ContextEntry) => type instanceof t.UnionType 31 | 32 | const jsToString = (value: t.mixed) => 33 | value === undefined ? 'undefined' : JSON.stringify(value) 34 | 35 | const keyPath = (ctx: t.Context) => 36 | // The context entry with an empty key is the original 37 | // type ("default context"), not a type error. 38 | ctx 39 | .map((c) => c.key) 40 | .filter(Boolean) 41 | .join('.') 42 | 43 | // The actual error is last in context 44 | const getErrorFromCtx = (validation: t.ValidationError) => 45 | // https://github.com/gcanti/fp-ts/pull/544/files 46 | A.last(validation.context as t.ContextEntry[]) 47 | 48 | const getValidationContext = (validation: t.ValidationError) => 49 | // https://github.com/gcanti/fp-ts/pull/544/files 50 | validation.context as t.ContextEntry[] 51 | 52 | /** 53 | * @category internals 54 | * @since 1.2.1 55 | */ 56 | export const TYPE_MAX_LEN = 160 // Two lines of 80-col text 57 | const truncateType = (type: string, options: ReporterOptions = {}): string => { 58 | const {truncateLongTypes = true} = options 59 | 60 | if (truncateLongTypes && type.length > TYPE_MAX_LEN) { 61 | return `${type.slice(0, TYPE_MAX_LEN - 3)}...` 62 | } 63 | 64 | return type 65 | } 66 | 67 | const errorMessageSimple = ( 68 | expectedType: string, 69 | path: string, 70 | error: t.ValidationError, 71 | options?: ReporterOptions, 72 | ) => 73 | // https://github.com/elm-lang/core/blob/18c9e84e975ed22649888bfad15d1efdb0128ab2/src/Native/Json.js#L199 74 | [ 75 | `Expecting ${truncateType(expectedType, options)}`, 76 | path === '' ? '' : `at ${path}`, 77 | `but instead got: ${jsToString(error.value)}`, 78 | error.message ? `(${error.message})` : '', 79 | ] 80 | .filter(Boolean) 81 | .join(' ') 82 | 83 | const errorMessageUnion = ( 84 | expectedTypes: string[], 85 | path: string, 86 | value: unknown, 87 | options?: ReporterOptions, 88 | ) => 89 | // https://github.com/elm-lang/core/blob/18c9e84e975ed22649888bfad15d1efdb0128ab2/src/Native/Json.js#L199 90 | [ 91 | 'Expecting one of:\n', 92 | expectedTypes 93 | .map((type) => ` ${truncateType(type, options)}`) 94 | .join('\n'), 95 | path === '' ? '\n' : `\nat ${path} `, 96 | `but instead got: ${jsToString(value)}`, 97 | ] 98 | .filter(Boolean) 99 | .join('') 100 | 101 | // Find the union type in the list of ContextEntry 102 | // The next ContextEntry should be the type of this branch of the union 103 | const findExpectedType = (ctx: t.ContextEntry[]) => 104 | pipe( 105 | ctx, 106 | A.findIndex(isUnionType), 107 | O.chain((n) => A.lookup(n + 1, ctx)), 108 | ) 109 | 110 | const formatValidationErrorOfUnion = ( 111 | path: string, 112 | errors: NEA.NonEmptyArray, 113 | options?: ReporterOptions, 114 | ) => { 115 | const expectedTypes = pipe( 116 | errors, 117 | A.map(getValidationContext), 118 | A.map(findExpectedType), 119 | A.compact, 120 | ) 121 | 122 | const value = pipe( 123 | expectedTypes, 124 | A.head, 125 | O.map((v) => v.actual), 126 | O.getOrElse((): unknown => undefined), 127 | ) 128 | 129 | const expected = expectedTypes.map(({type}) => type.name) 130 | 131 | return expected.length > 0 132 | ? O.some(errorMessageUnion(expected, path, value, options)) 133 | : O.none 134 | } 135 | 136 | const formatValidationCommonError = ( 137 | path: string, 138 | error: t.ValidationError, 139 | options?: ReporterOptions, 140 | ) => 141 | pipe( 142 | error, 143 | getErrorFromCtx, 144 | O.map((errorContext) => 145 | errorMessageSimple(errorContext.type.name, path, error, options), 146 | ), 147 | ) 148 | 149 | const groupByKey = NEA.groupBy((error: t.ValidationError) => 150 | pipe(error.context, takeUntil(isUnionType), keyPath), 151 | ) 152 | 153 | const format = ( 154 | path: string, 155 | errors: NEA.NonEmptyArray, 156 | options?: ReporterOptions, 157 | ) => 158 | NEA.tail(errors).length > 0 159 | ? formatValidationErrorOfUnion(path, errors, options) 160 | : formatValidationCommonError(path, NEA.head(errors), options) 161 | 162 | /** 163 | * Format a single validation error. 164 | * 165 | * @category formatters 166 | * @since 1.0.0 167 | */ 168 | export const formatValidationError = ( 169 | error: t.ValidationError, 170 | options?: ReporterOptions, 171 | ) => formatValidationCommonError(keyPath(error.context), error, options) 172 | 173 | /** 174 | * Format validation errors (`t.Errors`). 175 | * 176 | * @example 177 | * import * as E from 'fp-ts/Either' 178 | * import * as t from 'io-ts' 179 | * import { formatValidationErrors } from 'io-ts-reporters' 180 | * 181 | * const result = t.string.decode(123) 182 | * 183 | * assert.deepEqual( 184 | * E.mapLeft(formatValidationErrors)(result), 185 | * E.left(['Expecting string but instead got: 123']) 186 | * ) 187 | * 188 | * @category formatters 189 | * @since 1.2.0 190 | */ 191 | export const formatValidationErrors = ( 192 | errors: t.Errors, 193 | options?: ReporterOptions, 194 | ) => 195 | pipe( 196 | errors, 197 | groupByKey, 198 | R.mapWithIndex((path, errors) => format(path, errors, options)), 199 | R.compact, 200 | R.toArray, 201 | A.map(([_key, error]) => error), 202 | ) 203 | 204 | /** 205 | * @category formatters 206 | * @since 1.2.2 207 | */ 208 | export interface ReporterOptions { 209 | truncateLongTypes?: boolean 210 | } 211 | 212 | /** 213 | * Deprecated, use the default export instead. 214 | * 215 | * @category deprecated 216 | * @deprecated 217 | * @since 1.0.0 218 | */ 219 | export const reporter = ( 220 | validation: t.Validation, 221 | options?: ReporterOptions, 222 | ) => 223 | pipe( 224 | validation, 225 | E.mapLeft((errors) => formatValidationErrors(errors, options)), 226 | E.fold( 227 | (errors) => errors, 228 | () => [], 229 | ), 230 | ) 231 | 232 | interface PrettyReporter extends Reporter { 233 | report: ( 234 | validation: t.Validation, 235 | options?: ReporterOptions, 236 | ) => string[] 237 | } 238 | 239 | const prettyReporter: PrettyReporter = {report: reporter} 240 | export default prettyReporter 241 | --------------------------------------------------------------------------------