├── .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 | 
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 |
--------------------------------------------------------------------------------