├── .changeset ├── README.md └── config.json ├── .commitlintrc.cjs ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── MessageBuilder.test.ts ├── MessageBuilder.ts ├── ValidationError.test.ts ├── ValidationError.ts ├── config.ts ├── errorMap.ts ├── fromError.test.ts ├── fromError.ts ├── fromZodError.test.ts ├── fromZodError.ts ├── fromZodIssue.test.ts ├── fromZodIssue.ts ├── index.ts ├── isValidationError.test.ts ├── isValidationError.ts ├── isValidationErrorLike.test.ts ├── isValidationErrorLike.ts ├── isZodErrorLike.test.ts ├── isZodErrorLike.ts ├── toValidationError.ts └── utils │ ├── NonEmptyArray.ts │ ├── joinPath.test.ts │ └── joinPath.ts ├── package-lock.json ├── package.json ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "main", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier", 7 | "plugin:prettier/recommended", 8 | "plugin:import/recommended", 9 | "plugin:import/typescript" 10 | ], 11 | "plugins": [ 12 | "prettier", 13 | "@typescript-eslint", 14 | "import" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "rules": { 18 | "id-length": [ 19 | "warn", 20 | { 21 | "min": 2, 22 | "exceptions": [ 23 | "_" 24 | ] 25 | } 26 | ], 27 | "prettier/prettier": [ 28 | "error", 29 | {}, 30 | { 31 | "usePrettierrc": true 32 | } 33 | ], 34 | "@typescript-eslint/ban-ts-ignore": "off", 35 | "@typescript-eslint/ban-ts-comment": "off", 36 | "@typescript-eslint/consistent-type-imports": [ 37 | "error", 38 | { 39 | "prefer": "type-imports", 40 | "fixStyle": "separate-type-imports" 41 | } 42 | ], 43 | "no-console": [ 44 | "warn" 45 | ], 46 | "import/extensions": [ 47 | "error", 48 | "ignorePackages" 49 | ], 50 | "import/order": [ 51 | "error", 52 | { 53 | "groups": [ 54 | "builtin", 55 | "external", 56 | "internal", 57 | "parent", 58 | "sibling", 59 | "index", 60 | "object", 61 | "type" 62 | ] 63 | } 64 | ], 65 | "import/newline-after-import": [ 66 | "error", 67 | { 68 | "count": 1 69 | } 70 | ] 71 | }, 72 | "env": { 73 | "node": true 74 | }, 75 | "overrides": [ 76 | { 77 | "files": [ 78 | "*.test.ts", 79 | "*.spec.ts", 80 | "**/__tests__/**" 81 | ], 82 | "env": { 83 | "jest": true 84 | } 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | time: '06:00' 8 | timezone: 'Europe/London' 9 | versioning-strategy: 'increase-if-necessary' 10 | ignore: 11 | # for all deps 12 | - dependency-name: "*" 13 | # ...ignore major updates 14 | update-types: ["version-update:semver-major"] 15 | commit-message: 16 | prefix: 'chore' 17 | labels: 18 | - 'dependencies' 19 | open-pull-requests-limit: 3 20 | pull-request-branch-name: 21 | separator: '-' 22 | reviewers: 23 | - 'jmike' 24 | - 'nikoskalogridis' 25 | target-branch: 'main' 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review] 6 | 7 | jobs: 8 | block-autosquash: 9 | # bypass the action if the PR is a draft 10 | if: github.event.pull_request.draft == false 11 | name: block-autosquash 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Block autosquash commits 15 | uses: xt0rted/block-autosquash-commits-action@v2.0.0 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | build: 20 | # bypass the action if the PR is a draft 21 | if: github.event.pull_request.draft == false 22 | name: build 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version-file: '.nvmrc' 31 | 32 | - uses: actions/cache@v4 33 | id: npm-cache 34 | with: 35 | path: '**/node_modules' 36 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json', '.nvmrc') }} 37 | 38 | - uses: actions/cache@v4 39 | id: dist 40 | with: 41 | path: '**/dist' 42 | key: ${{ runner.os }}-dist-${{ github.sha }} 43 | 44 | - name: Install dependencies 45 | if: steps.npm-cache.outputs.cache-hit != 'true' 46 | run: npm ci --ignore-scripts 47 | 48 | - name: Build dist files 49 | run: npm run build 50 | 51 | test-unit: 52 | name: test:unit 53 | needs: build 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - uses: actions/setup-node@v4 60 | with: 61 | node-version-file: '.nvmrc' 62 | 63 | - uses: actions/cache@v4 64 | id: npm-cache 65 | with: 66 | path: '**/node_modules' 67 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json', '.nvmrc') }} 68 | 69 | - uses: actions/cache@v4 70 | with: 71 | path: '**/dist' 72 | key: ${{ runner.os }}-dist-${{ github.sha }} 73 | 74 | - name: Run tests 75 | run: npm run test 76 | 77 | test-deno: 78 | runs-on: ubuntu-latest 79 | strategy: 80 | matrix: 81 | deno: ['v1.x'] 82 | name: test:deno(${{ matrix.deno }}) 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - uses: denoland/setup-deno@v1 87 | with: 88 | deno-version: ${{ matrix.deno }} 89 | - run: deno --version 90 | - run: deno run ./index.ts 91 | working-directory: ./lib 92 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | # This makes action fetch all Git history so that Changesets can generate changelogs with the correct commits 16 | fetch-depth: 0 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Create Release Pull Request or Publish to npm 27 | uses: changesets/action@v1 28 | with: 29 | publish: npm run release 30 | commit: 'chore: version packages' 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.CHANGESET_RELEASE_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # production 5 | dist 6 | build 7 | 8 | # misc 9 | .DS_Store 10 | .env 11 | 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # other 17 | coverage 18 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "semi": true, 6 | "arrowParens": "always", 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @causaly/zod-validation-error 2 | 3 | ## 3.4.1 4 | 5 | ### Patch Changes 6 | 7 | - 94d5f3b: Bump zod to v.3.24.4 in package.json as dev + peer dependency 8 | 9 | ## 3.4.0 10 | 11 | ### Minor Changes 12 | 13 | - 3a7928c: Customize error messages using a MessageBuilder. 14 | 15 | ## 3.3.1 16 | 17 | ### Patch Changes 18 | 19 | - 42bc4fe: Test Version Packages fix 20 | 21 | ## 3.3.0 22 | 23 | ### Minor Changes 24 | 25 | - 66f5b5d: Match `ZodError` via heuristics instead of relying on `instanceof`. 26 | 27 | _Why?_ Because we want to ensure that zod-validation-error works smoothly even when multiple versions of zod have been installed in the same project. 28 | 29 | ## 3.2.0 30 | 31 | ### Minor Changes 32 | 33 | - 6b4e8a0: Introduce `fromError` API which is a less strict version of `fromZodError` 34 | - 35a28c6: Add runtime check in `fromZodError` and throw dev-friendly `TypeError` suggesting usage of `fromError` instead 35 | 36 | ## 3.1.0 37 | 38 | ### Minor Changes 39 | 40 | - 3f5e391: Better error messages for zod.function() types 41 | 42 | ## 3.0.3 43 | 44 | ### Patch Changes 45 | 46 | - 2f1ef27: Bundle code as a single index.js (cjs) or index.mjs (esm) file. Restore exports configuration in package.json. 47 | 48 | ## 3.0.2 49 | 50 | ### Patch Changes 51 | 52 | - 24b773c: Revert package.json exports causing dependant projects to fail 53 | 54 | ## 3.0.1 55 | 56 | ### Patch Changes 57 | 58 | - 3382fbc: 1. Fix issue with ErrorOptions not being found in earlier to es2022 typescript configs. 2. Add exports definition to package.json to help bundlers (e.g. rollup) identify the right module to use. 59 | 60 | ## 3.0.0 61 | 62 | ### Major Changes 63 | 64 | - deb4639: BREAKING CHANGE: Refactor `ValidationError` to accept `ErrorOptions` as second parameter. 65 | 66 | What changed? 67 | 68 | Previously, `ValidationError` accepted `Array` as 2nd parameter. Now, it accepts `ErrorOptions` which contains a `cause` property. If `cause` is a `ZodError` then it will extract the attached issues and expose them over `error.details`. 69 | 70 | Why? 71 | 72 | This change allows us to use `ValidationError` like a native JavaScript [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Error). For example, we can now do: 73 | 74 | ```typescript 75 | import { ValidationError } from 'zod-validation-error'; 76 | 77 | try { 78 | // attempt to do something that might throw an error 79 | } catch (err) { 80 | throw new ValidationError('Something went deeply wrong', { cause: err }); 81 | } 82 | ``` 83 | 84 | How can you update your code? 85 | 86 | If you are using `ValidationError` directly, then you need to update your code to pass `ErrorOptions` as a 2nd parameter. 87 | 88 | ```typescript 89 | import { ValidationError } from 'zod-validation-error'; 90 | 91 | // before 92 | const err = new ValidationError('Something went wrong', zodError.issues); 93 | 94 | // after 95 | const err = new ValidationError('Something went wrong', { cause: zodError }); 96 | ``` 97 | 98 | If you were never using `ValidationError` directly, then you don't need to do anything. 99 | 100 | ## 2.1.0 101 | 102 | ### Minor Changes 103 | 104 | - b084ad5: Add `includePath` option to allow users take control on whether to include the erroneous property name in their error messages. 105 | 106 | ## 2.0.0 107 | 108 | ### Major Changes 109 | 110 | - b199ca1: Update `toValidationError()` to return only `ValidationError` instances 111 | 112 | This change only affects users of `toValidationError()`. The method was previously returning `Error | ValidationError` and now returns only `ValidationError`. 113 | 114 | ## 1.5.0 115 | 116 | ### Minor Changes 117 | 118 | - 82b7739: Expose errorMap property to use with zod.setErrorMap() method 119 | 120 | ## 1.4.0 121 | 122 | ### Minor Changes 123 | 124 | - 8893d16: Expose fromZodIssue method 125 | 126 | ## 1.3.1 127 | 128 | ### Patch Changes 129 | 130 | - 218da5f: fix: casing typo of how zod namespace was referenced 131 | 132 | ## 1.3.0 133 | 134 | ### Minor Changes 135 | 136 | - 8ccae09: Added exports of types for parameters of fromZodError function 137 | 138 | ## 1.2.1 139 | 140 | ### Patch Changes 141 | 142 | - 449477d: Switch to using npm instead of yarn. Update node requirement to v.16+ 143 | 144 | ## 1.2.0 145 | 146 | ### Minor Changes 147 | 148 | - f3aa0b2: Better handling for single-item paths 149 | 150 | Given a validation error at array position 1 the error output would read `Error X at "[1]"`. After this change, the error output reads `Error X at index 1`. 151 | 152 | Likewise, previously a validation error at property "_" would yield `Error X at "["_"]"`. Now it yields`Error X at "\*"` which reads much better. 153 | 154 | ## 1.1.0 155 | 156 | ### Minor Changes 157 | 158 | - b693f52: Handle unicode and special-character identifiers 159 | 160 | ## 1.0.1 161 | 162 | ### Patch Changes 163 | 164 | - b868741: Fix broken links in API docs 165 | 166 | ## 1.0.0 167 | 168 | ### Major Changes 169 | 170 | - 90b2f83: Update ZodValidationError to behave more like a native Error constructor. Make options argument optional. Add name property and define toString() method. 171 | 172 | ## 0.3.2 173 | 174 | ### Patch Changes 175 | 176 | - a2e5322: Ensure union errors do not output duplicate messages 177 | 178 | ## 0.3.1 179 | 180 | ### Patch Changes 181 | 182 | - 9c4c4ec: Make union errors more detailed 183 | 184 | ## 0.3.0 185 | 186 | ### Minor Changes 187 | 188 | - 59ad8df: Expose isValidationErrorLike type-guard 189 | 190 | ## 0.2.2 191 | 192 | ### Patch Changes 193 | 194 | - fa81c9b: Drop SWC; Fix ESM export 195 | 196 | ## 0.2.1 197 | 198 | ### Patch Changes 199 | 200 | - 7f420d1: Update build and npm badges on README.md 201 | 202 | ## 0.2.0 203 | 204 | ### Minor Changes 205 | 206 | - fde2f50: update dependency in package json so the user does not have to manually install it, will be installed on package install. 207 | 208 | ## 0.1.1 209 | 210 | ### Patch Changes 211 | 212 | - 67336ac: Enable automatic release to npm 213 | 214 | ## 0.1.0 215 | 216 | ### Minor Changes 217 | 218 | - fcda684: Initial functionality 219 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright 2022 Causaly, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zod-validation-error 2 | 3 | Wrap zod validation errors in user-friendly readable messages. 4 | 5 | [![Build Status](https://github.com/causaly/zod-validation-error/actions/workflows/ci.yml/badge.svg)](https://github.com/causaly/zod-validation-error/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/zod-validation-error.svg?color=0c0)](https://www.npmjs.com/package/zod-validation-error) 6 | 7 | #### Features 8 | 9 | - User-friendly readable messages, configurable via options; 10 | - Maintain original issues under `error.details`; 11 | - Extensive tests. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | npm install zod-validation-error 17 | ``` 18 | 19 | #### Requirements 20 | 21 | - Node.js v.18+ 22 | - TypeScript v.4.5+ 23 | 24 | ## Quick start 25 | 26 | ```typescript 27 | import { z as zod } from 'zod'; 28 | import { fromError } from 'zod-validation-error'; 29 | 30 | // create zod schema 31 | const zodSchema = zod.object({ 32 | id: zod.number().int().positive(), 33 | email: zod.string().email(), 34 | }); 35 | 36 | // parse some invalid value 37 | try { 38 | zodSchema.parse({ 39 | id: 1, 40 | email: 'foobar', // note: invalid email 41 | }); 42 | } catch (err) { 43 | const validationError = fromError(err); 44 | // the error is now readable by the user 45 | // you may print it to console 46 | console.log(validationError.toString()); 47 | // or return it as an actual error 48 | return validationError; 49 | } 50 | ``` 51 | 52 | ## Motivation 53 | 54 | Zod errors are difficult to consume for the end-user. This library wraps Zod validation errors in user-friendly readable messages that can be exposed to the outer world, while maintaining the original errors in an array for _dev_ use. 55 | 56 | ### Example 57 | 58 | #### Input (from Zod) 59 | 60 | ```json 61 | [ 62 | { 63 | "code": "too_small", 64 | "inclusive": false, 65 | "message": "Number must be greater than 0", 66 | "minimum": 0, 67 | "path": ["id"], 68 | "type": "number" 69 | }, 70 | { 71 | "code": "invalid_string", 72 | "message": "Invalid email", 73 | "path": ["email"], 74 | "validation": "email" 75 | } 76 | ] 77 | ``` 78 | 79 | #### Output 80 | 81 | ``` 82 | Validation error: Number must be greater than 0 at "id"; Invalid email at "email" 83 | ``` 84 | 85 | ## API 86 | 87 | - [ValidationError(message[, options])](#validationerror) 88 | - [createMessageBuilder(props)](#createMessageBuilder) 89 | - [errorMap](#errormap) 90 | - [isValidationError(error)](#isvalidationerror) 91 | - [isValidationErrorLike(error)](#isvalidationerrorlike) 92 | - [isZodErrorLike(error)](#iszoderrorlike) 93 | - [fromError(error[, options])](#fromerror) 94 | - [fromZodIssue(zodIssue[, options])](#fromzodissue) 95 | - [fromZodError(zodError[, options])](#fromzoderror) 96 | - [toValidationError([options]) => (error) => ValidationError](#tovalidationerror) 97 | 98 | ### ValidationError 99 | 100 | Main `ValidationError` class, extending native JavaScript `Error`. 101 | 102 | #### Arguments 103 | 104 | - `message` - _string_; error message (required) 105 | - `options` - _ErrorOptions_; error options as per [JavaScript definition](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/Error#options) (optional) 106 | - `options.cause` - _any_; can be used to hold the original zod error (optional) 107 | 108 | #### Example 1: construct new ValidationError with `message` 109 | 110 | ```typescript 111 | const { ValidationError } = require('zod-validation-error'); 112 | 113 | const error = new ValidationError('foobar'); 114 | console.log(error instanceof Error); // prints true 115 | ``` 116 | 117 | #### Example 2: construct new ValidationError with `message` and `options.cause` 118 | 119 | ```typescript 120 | import { z as zod } from 'zod'; 121 | const { ValidationError } = require('zod-validation-error'); 122 | 123 | const error = new ValidationError('foobar', { 124 | cause: new zod.ZodError([ 125 | { 126 | code: 'invalid_string', 127 | message: 'Invalid email', 128 | path: ['email'], 129 | validation: 'email', 130 | }, 131 | ]), 132 | }); 133 | 134 | console.log(error.details); // prints issues from zod error 135 | ``` 136 | 137 | ### createMessageBuilder 138 | 139 | Creates zod-validation-error's default `MessageBuilder`, which is used to produce user-friendly error messages. 140 | 141 | Meant to be passed as an option to [fromError](#fromerror), [fromZodIssue](#fromzodissue), [fromZodError](#fromzoderror) or [toValidationError](#tovalidationerror). 142 | 143 | You may read more on the concept of the `MessageBuilder` further [below](#MessageBuilder). 144 | 145 | #### Arguments 146 | 147 | - `props` - _Object_; formatting options (optional) 148 | - `maxIssuesInMessage` - _number_; max issues to include in user-friendly message (optional, defaults to 99) 149 | - `issueSeparator` - _string_; used to concatenate issues in user-friendly message (optional, defaults to ";") 150 | - `unionSeparator` - _string_; used to concatenate union-issues in user-friendly message (optional, defaults to ", or") 151 | - `prefix` - _string_ or _null_; prefix to use in user-friendly message (optional, defaults to "Validation error"). Pass `null` to disable prefix completely. 152 | - `prefixSeparator` - _string_; used to concatenate prefix with rest of the user-friendly message (optional, defaults to ": "). Not used when `prefix` is `null`. 153 | - `includePath` - _boolean_; used to provide control on whether to include the erroneous property name suffix or not (optional, defaults to `true`). 154 | 155 | #### Example 156 | 157 | ```typescript 158 | import { createMessageBuilder } from 'zod-validation-error'; 159 | 160 | const messageBuilder = createMessageBuilder({ 161 | includePath: false, 162 | maxIssuesInMessage: 3 163 | }); 164 | ``` 165 | 166 | ### errorMap 167 | 168 | A custom error map to use with zod's `setErrorMap` method and get user-friendly messages automatically. 169 | 170 | #### Example 171 | 172 | ```typescript 173 | import { z as zod } from 'zod'; 174 | import { errorMap } from 'zod-validation-error'; 175 | 176 | zod.setErrorMap(errorMap); 177 | ``` 178 | 179 | ### isValidationError 180 | 181 | A [type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) utility function, based on `instanceof` comparison. 182 | 183 | #### Arguments 184 | 185 | - `error` - error instance (required) 186 | 187 | #### Example 188 | 189 | ```typescript 190 | import { z as zod } from 'zod'; 191 | import { ValidationError, isValidationError } from 'zod-validation-error'; 192 | 193 | const err = new ValidationError('foobar'); 194 | isValidationError(err); // returns true 195 | 196 | const invalidErr = new Error('foobar'); 197 | isValidationError(err); // returns false 198 | ``` 199 | 200 | ### isValidationErrorLike 201 | 202 | A [type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) utility function, based on _heuristics_ comparison. 203 | 204 | _Why do we need heuristics since we can use a simple `instanceof` comparison?_ Because of multi-version inconsistencies. For instance, it's possible that a dependency is using an older `zod-validation-error` version internally. In such case, the `instanceof` comparison will yield invalid results because module deduplication does not apply at npm/yarn level and the prototype is different. 205 | 206 | tl;dr if you are uncertain then it is preferable to use `isValidationErrorLike` instead of `isValidationError`. 207 | 208 | #### Arguments 209 | 210 | - `error` - error instance (required) 211 | 212 | #### Example 213 | 214 | ```typescript 215 | import { ValidationError, isValidationErrorLike } from 'zod-validation-error'; 216 | 217 | const err = new ValidationError('foobar'); 218 | isValidationErrorLike(err); // returns true 219 | 220 | const invalidErr = new Error('foobar'); 221 | isValidationErrorLike(err); // returns false 222 | ``` 223 | 224 | ### isZodErrorLike 225 | 226 | A [type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) utility function, based on _heuristics_ comparison. 227 | 228 | _Why do we need heuristics since we can use a simple `instanceof` comparison?_ Because of multi-version inconsistencies. For instance, it's possible that a dependency is using an older `zod` version internally. In such case, the `instanceof` comparison will yield invalid results because module deduplication does not apply at npm/yarn level and the prototype is different. 229 | 230 | #### Arguments 231 | 232 | - `error` - error instance (required) 233 | 234 | #### Example 235 | 236 | ```typescript 237 | import { z as zod } from 'zod'; 238 | import { ValidationError, isZodErrorLike } from 'zod-validation-error'; 239 | 240 | const zodValidationErr = new ValidationError('foobar'); 241 | isZodErrorLike(zodValidationErr); // returns false 242 | 243 | const genericErr = new Error('foobar'); 244 | isZodErrorLike(genericErr); // returns false 245 | 246 | const zodErr = new zod.ZodError([ 247 | { 248 | code: zod.ZodIssueCode.custom, 249 | path: [], 250 | message: 'foobar', 251 | fatal: true, 252 | }, 253 | ]); 254 | isZodErrorLike(zodErr); // returns true 255 | ``` 256 | 257 | ### fromError 258 | 259 | Converts an error to `ValidationError`. 260 | 261 | _What is the difference between `fromError` and `fromZodError`?_ The `fromError` function is a less strict version of `fromZodError`. It can accept an unknown error and attempt to convert it to a `ValidationError`. 262 | 263 | #### Arguments 264 | 265 | - `error` - _unknown_; an error (required) 266 | - `options` - _Object_; formatting options (optional) 267 | - `messageBuilder` - _MessageBuilder_; a function that accepts an array of `zod.ZodIssue` objects and returns a user-friendly error message in the form of a `string` (optional). 268 | 269 | #### Notes 270 | 271 | Alternatively, you may pass the following `options` instead of a `messageBuilder`. 272 | 273 | - `options` - _Object_; formatting options (optional) 274 | - `maxIssuesInMessage` - _number_; max issues to include in user-friendly message (optional, defaults to 99) 275 | - `issueSeparator` - _string_; used to concatenate issues in user-friendly message (optional, defaults to ";") 276 | - `unionSeparator` - _string_; used to concatenate union-issues in user-friendly message (optional, defaults to ", or") 277 | - `prefix` - _string_ or _null_; prefix to use in user-friendly message (optional, defaults to "Validation error"). Pass `null` to disable prefix completely. 278 | - `prefixSeparator` - _string_; used to concatenate prefix with rest of the user-friendly message (optional, defaults to ": "). Not used when `prefix` is `null`. 279 | - `includePath` - _boolean_; used to provide control on whether to include the erroneous property name suffix or not (optional, defaults to `true`). 280 | 281 | They will be passed as arguments to the [createMessageBuilder](#createMessageBuilder) function. The only reason they exist is to provide backwards-compatibility with older versions of `zod-validation-error`. They should however be considered deprecated and may be removed in the future. 282 | 283 | ### fromZodIssue 284 | 285 | Converts a single zod issue to `ValidationError`. 286 | 287 | #### Arguments 288 | 289 | - `zodIssue` - _zod.ZodIssue_; a ZodIssue instance (required) 290 | - `options` - _Object_; formatting options (optional) 291 | - `messageBuilder` - _MessageBuilder_; a function that accepts an array of `zod.ZodIssue` objects and returns a user-friendly error message in the form of a `string` (optional). 292 | 293 | #### Notes 294 | 295 | Alternatively, you may pass the following `options` instead of a `messageBuilder`. 296 | 297 | - `options` - _Object_; formatting options (optional) 298 | - `issueSeparator` - _string_; used to concatenate issues in user-friendly message (optional, defaults to ";") 299 | - `unionSeparator` - _string_; used to concatenate union-issues in user-friendly message (optional, defaults to ", or") 300 | - `prefix` - _string_ or _null_; prefix to use in user-friendly message (optional, defaults to "Validation error"). Pass `null` to disable prefix completely. 301 | - `prefixSeparator` - _string_; used to concatenate prefix with rest of the user-friendly message (optional, defaults to ": "). Not used when `prefix` is `null`. 302 | - `includePath` - _boolean_; used to provide control on whether to include the erroneous property name suffix or not (optional, defaults to `true`). 303 | 304 | They will be passed as arguments to the [createMessageBuilder](#createMessageBuilder) function. The only reason they exist is to provide backwards-compatibility with older versions of `zod-validation-error`. They should however be considered deprecated and may be removed in the future. 305 | 306 | ### fromZodError 307 | 308 | Converts zod error to `ValidationError`. 309 | 310 | _Why is the difference between `ZodError` and `ZodIssue`?_ A `ZodError` is a collection of 1 or more `ZodIssue` instances. It's what you get when you call `zodSchema.parse()`. 311 | 312 | #### Arguments 313 | 314 | - `zodError` - _zod.ZodError_; a ZodError instance (required) 315 | - `options` - _Object_; formatting options (optional) 316 | - `messageBuilder` - _MessageBuilder_; a function that accepts an array of `zod.ZodIssue` objects and returns a user-friendly error message in the form of a `string` (optional). 317 | 318 | #### Notes 319 | 320 | Alternatively, you may pass the following `options` instead of a `messageBuilder`. 321 | 322 | - `options` - _Object_; formatting options (optional) 323 | user-friendly message (optional, defaults to 99) 324 | - `issueSeparator` - _string_; used to concatenate issues in user-friendly message (optional, defaults to ";") 325 | - `unionSeparator` - _string_; used to concatenate union-issues in user-friendly message (optional, defaults to ", or") 326 | - `prefix` - _string_ or _null_; prefix to use in user-friendly message (optional, defaults to "Validation error"). Pass `null` to disable prefix completely. 327 | - `prefixSeparator` - _string_; used to concatenate prefix with rest of the user-friendly message (optional, defaults to ": "). Not used when `prefix` is `null`. 328 | - `includePath` - _boolean_; used to provide control on whether to include the erroneous property name suffix or not (optional, defaults to `true`). 329 | 330 | They will be passed as arguments to the [createMessageBuilder](#createMessageBuilder) function. The only reason they exist is to provide backwards-compatibility with older versions of `zod-validation-error`. They should however be considered deprecated and may be removed in the future. 331 | 332 | ### toValidationError 333 | 334 | A curried version of `fromZodError` meant to be used for FP (Functional Programming). Note it first takes the options object if needed and returns a function that converts the `zodError` to a `ValidationError` object 335 | 336 | ```js 337 | toValidationError(options) => (zodError) => ValidationError 338 | ``` 339 | 340 | #### Example using fp-ts 341 | 342 | ```typescript 343 | import * as Either from 'fp-ts/Either'; 344 | import { z as zod } from 'zod'; 345 | import { toValidationError, ValidationError } from 'zod-validation-error'; 346 | 347 | // create zod schema 348 | const zodSchema = zod 349 | .object({ 350 | id: zod.number().int().positive(), 351 | email: zod.string().email(), 352 | }) 353 | .brand<'User'>(); 354 | 355 | export type User = zod.infer; 356 | 357 | export function parse( 358 | value: zod.input 359 | ): Either.Either { 360 | return Either.tryCatch(() => schema.parse(value), toValidationError()); 361 | } 362 | ``` 363 | 364 | ## MessageBuilder 365 | 366 | `zod-validation-error` can be configured with a custom `MessageBuilder` function in order to produce case-specific error messages. 367 | 368 | #### Example 369 | 370 | For instance, one may want to print `invalid_string` errors to the console in red color. 371 | 372 | ```typescript 373 | import { z as zod } from 'zod'; 374 | import { type MessageBuilder, fromError } from 'zod-validation-error'; 375 | import chalk from 'chalk'; 376 | 377 | // create custom MessageBuilder 378 | const myMessageBuilder: MessageBuilder = (issues) => { 379 | return issues 380 | // format error message 381 | .map((issue) => { 382 | if (issue.code === zod.ZodIssueCode.invalid_string) { 383 | return chalk.red(issue.message); 384 | } 385 | 386 | return issue.message; 387 | }) 388 | // join as string with new-line character 389 | .join('\n'); 390 | } 391 | 392 | // create zod schema 393 | const zodSchema = zod.object({ 394 | id: zod.number().int().positive(), 395 | email: zod.string().email(), 396 | }); 397 | 398 | // parse some invalid value 399 | try { 400 | zodSchema.parse({ 401 | id: 1, 402 | email: 'foobar', // note: invalid email value 403 | }); 404 | } catch (err) { 405 | const validationError = fromError(err, { 406 | messageBuilder: myMessageBuilder 407 | }); 408 | // the error is now displayed with red letters 409 | console.log(validationError.toString()); 410 | } 411 | 412 | ``` 413 | 414 | ## FAQ 415 | 416 | ### How to distinguish between errors 417 | 418 | Use the `isValidationErrorLike` type guard. 419 | 420 | #### Example 421 | 422 | Scenario: Distinguish between `ValidationError` VS generic `Error` in order to respond with 400 VS 500 HTTP status code respectively. 423 | 424 | ```typescript 425 | import * as Either from 'fp-ts/Either'; 426 | import { z as zod } from 'zod'; 427 | import { isValidationErrorLike } from 'zod-validation-error'; 428 | 429 | try { 430 | func(); // throws Error - or - ValidationError 431 | } catch (err) { 432 | if (isValidationErrorLike(err)) { 433 | return 400; // Bad Data (this is a client error) 434 | } 435 | 436 | return 500; // Server Error 437 | } 438 | ``` 439 | 440 | ### How to use `ValidationError` outside `zod` 441 | 442 | It's possible to implement custom validation logic outside `zod` and throw a `ValidationError`. 443 | 444 | #### Example 1: passing custom message 445 | 446 | ```typescript 447 | import { ValidationError } from 'zod-validation-error'; 448 | import { Buffer } from 'node:buffer'; 449 | 450 | function parseBuffer(buf: unknown): Buffer { 451 | if (!Buffer.isBuffer(buf)) { 452 | throw new ValidationError('Invalid argument; expected buffer'); 453 | } 454 | 455 | return buf; 456 | } 457 | ``` 458 | 459 | #### Example 2: passing custom message and original error as cause 460 | 461 | ```typescript 462 | import { ValidationError } from 'zod-validation-error'; 463 | 464 | try { 465 | // do something that throws an error 466 | } catch (err) { 467 | throw new ValidationError('Something went deeply wrong', { cause: err }); 468 | } 469 | ``` 470 | 471 | ### How to use `ValidationError` with custom "error map" 472 | 473 | Zod supports customizing error messages by providing a custom "error map". You may combine this with `zod-validation-error` to produce user-friendly messages. 474 | 475 | #### Example 1: produce user-friendly error messages using the `errorMap` property 476 | 477 | If all you need is to produce user-friendly error messages you may use the `errorMap` property. 478 | 479 | ```typescript 480 | import { z as zod } from 'zod'; 481 | import { errorMap } from 'zod-validation-error'; 482 | 483 | zod.setErrorMap(errorMap); 484 | ``` 485 | 486 | #### Example 2: extra customization using `fromZodIssue` 487 | 488 | If you need to customize some error code, you may use the `fromZodIssue` function. 489 | 490 | ```typescript 491 | import { z as zod } from 'zod'; 492 | import { fromZodIssue } from 'zod-validation-error'; 493 | 494 | const customErrorMap: zod.ZodErrorMap = (issue, ctx) => { 495 | switch (issue.code) { 496 | case ZodIssueCode.invalid_type: { 497 | return { 498 | message: 499 | 'Custom error message of your preference for invalid_type errors', 500 | }; 501 | } 502 | default: { 503 | const validationError = fromZodIssue({ 504 | ...issue, 505 | // fallback to the default error message 506 | // when issue does not have a message 507 | message: issue.message ?? ctx.defaultError, 508 | }); 509 | 510 | return { 511 | message: validationError.message, 512 | }; 513 | } 514 | } 515 | }; 516 | 517 | zod.setErrorMap(customErrorMap); 518 | ``` 519 | 520 | ### How to use `zod-validation-error` with `react-hook-form`? 521 | 522 | ```typescript 523 | import { useForm } from 'react-hook-form'; 524 | import { zodResolver } from '@hookform/resolvers/zod'; 525 | import { errorMap } from 'zod-validation-error'; 526 | 527 | useForm({ 528 | resolver: zodResolver(schema, { errorMap }), 529 | }); 530 | ``` 531 | 532 | ### Does `zod-validation-error` support CommonJS 533 | 534 | Yes, `zod-validation-error` supports CommonJS out-of-the-box. All you need to do is import it using `require`. 535 | 536 | #### Example 537 | 538 | ```typescript 539 | const { ValidationError } = require('zod-validation-error'); 540 | ``` 541 | 542 | ## Contribute 543 | 544 | Source code contributions are most welcome. Please open a PR, ensure the linter is satisfied and all tests pass. 545 | 546 | #### We are hiring 547 | 548 | Causaly is building the world's largest biomedical knowledge platform, using technologies such as TypeScript, React and Node.js. Find out more about our openings at https://apply.workable.com/causaly/. 549 | 550 | ## License 551 | 552 | MIT 553 | -------------------------------------------------------------------------------- /lib/MessageBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | 3 | import { createMessageBuilder } from './MessageBuilder.ts'; 4 | import { isZodErrorLike } from './isZodErrorLike.ts'; 5 | import { isNonEmptyArray } from './utils/NonEmptyArray.ts'; 6 | 7 | describe('MessageBuilder', () => { 8 | test('handles zod.string() schema errors', () => { 9 | const schema = zod.string().email(); 10 | 11 | const messageBuilder = createMessageBuilder(); 12 | 13 | try { 14 | schema.parse('foobar'); 15 | } catch (err) { 16 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 17 | const message = messageBuilder(err.issues); 18 | expect(message).toMatchInlineSnapshot( 19 | `"Validation error: Invalid email"` 20 | ); 21 | } 22 | } 23 | }); 24 | 25 | test('handles zod.object() schema errors', () => { 26 | const schema = zod.object({ 27 | id: zod.number().int().positive(), 28 | name: zod.string().min(2), 29 | }); 30 | 31 | const messageBuilder = createMessageBuilder(); 32 | 33 | try { 34 | schema.parse({ 35 | id: -1, 36 | name: 'a', 37 | }); 38 | } catch (err) { 39 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 40 | const message = messageBuilder(err.issues); 41 | expect(message).toMatchInlineSnapshot( 42 | `"Validation error: Number must be greater than 0 at "id"; String must contain at least 2 character(s) at "name""` 43 | ); 44 | } 45 | } 46 | }); 47 | 48 | test('handles zod.array() schema errors', () => { 49 | const schema = zod.array(zod.number().int()); 50 | 51 | const messageBuilder = createMessageBuilder(); 52 | 53 | try { 54 | schema.parse([1, 'a', true, 1.23]); 55 | } catch (err) { 56 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 57 | const message = messageBuilder(err.issues); 58 | expect(message).toMatchInlineSnapshot( 59 | `"Validation error: Expected number, received string at index 1; Expected number, received boolean at index 2; Expected integer, received float at index 3"` 60 | ); 61 | } 62 | } 63 | }); 64 | 65 | test('handles nested zod.object() schema errors', () => { 66 | const schema = zod.object({ 67 | id: zod.number().int().positive(), 68 | arr: zod.array(zod.number().int()), 69 | nestedObj: zod.object({ 70 | name: zod.string().min(2), 71 | }), 72 | }); 73 | 74 | const messageBuilder = createMessageBuilder(); 75 | 76 | try { 77 | schema.parse({ 78 | id: -1, 79 | arr: [1, 'a'], 80 | nestedObj: { 81 | name: 'a', 82 | }, 83 | }); 84 | } catch (err) { 85 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 86 | const message = messageBuilder(err.issues); 87 | expect(message).toMatchInlineSnapshot( 88 | `"Validation error: Number must be greater than 0 at "id"; Expected number, received string at "arr[1]"; String must contain at least 2 character(s) at "nestedObj.name""` 89 | ); 90 | } 91 | } 92 | }); 93 | 94 | test('schema.parse() path param to be part of error message', () => { 95 | const schema = zod.object({ 96 | status: zod.literal('success'), 97 | }); 98 | 99 | const messageBuilder = createMessageBuilder(); 100 | 101 | try { 102 | schema.parse( 103 | {}, 104 | { 105 | path: ['custom-path'], 106 | } 107 | ); 108 | } catch (err) { 109 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 110 | const message = messageBuilder(err.issues); 111 | expect(message).toMatchInlineSnapshot( 112 | `"Validation error: Invalid literal value, expected "success" at "custom-path.status""` 113 | ); 114 | } 115 | } 116 | }); 117 | 118 | test('handles zod.or() schema errors', () => { 119 | const success = zod.object({ 120 | status: zod.literal('success'), 121 | data: zod.object({ 122 | id: zod.string(), 123 | }), 124 | }); 125 | 126 | const error = zod.object({ 127 | status: zod.literal('error'), 128 | }); 129 | 130 | const schema = success.or(error); 131 | 132 | const messageBuilder = createMessageBuilder(); 133 | 134 | try { 135 | schema.parse({}); 136 | } catch (err) { 137 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 138 | const message = messageBuilder(err.issues); 139 | expect(message).toMatchInlineSnapshot( 140 | `"Validation error: Invalid literal value, expected "success" at "status"; Required at "data", or Invalid literal value, expected "error" at "status""` 141 | ); 142 | } 143 | } 144 | }); 145 | 146 | test('handles zod.or() schema duplicate errors', () => { 147 | const schema = zod.object({ 148 | terms: zod.array(zod.string()).or(zod.string()), 149 | }); 150 | 151 | const messageBuilder = createMessageBuilder(); 152 | 153 | try { 154 | schema.parse({}); 155 | } catch (err) { 156 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 157 | const message = messageBuilder(err.issues); 158 | expect(message).toMatchInlineSnapshot( 159 | `"Validation error: Required at "terms""` 160 | ); 161 | } 162 | } 163 | }); 164 | 165 | test('handles zod.and() schema errors', () => { 166 | const part1 = zod.object({ 167 | prop1: zod.literal('value1'), 168 | }); 169 | const part2 = zod.object({ 170 | prop2: zod.literal('value2'), 171 | }); 172 | 173 | const schema = part1.and(part2); 174 | 175 | const messageBuilder = createMessageBuilder(); 176 | 177 | try { 178 | schema.parse({}); 179 | } catch (err) { 180 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 181 | const message = messageBuilder(err.issues); 182 | expect(message).toMatchInlineSnapshot( 183 | `"Validation error: Invalid literal value, expected "value1" at "prop1"; Invalid literal value, expected "value2" at "prop2""` 184 | ); 185 | } 186 | } 187 | }); 188 | 189 | test('handles special characters in property name', () => { 190 | const schema = zod.object({ 191 | '.': zod.string(), 192 | './*': zod.string(), 193 | }); 194 | 195 | const messageBuilder = createMessageBuilder(); 196 | 197 | try { 198 | schema.parse({ 199 | '.': 123, 200 | './*': false, 201 | }); 202 | } catch (err) { 203 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 204 | const message = messageBuilder(err.issues); 205 | expect(message).toMatchInlineSnapshot( 206 | `"Validation error: Expected string, received number at "."; Expected string, received boolean at "./*""` 207 | ); 208 | } 209 | } 210 | }); 211 | 212 | test('handles zod.function() argument errors', () => { 213 | const fn = zod 214 | .function() 215 | .args(zod.number()) 216 | .implement((num) => num * 2); 217 | 218 | const messageBuilder = createMessageBuilder(); 219 | 220 | try { 221 | // @ts-expect-error Intentionally wrong to exercise runtime checking 222 | fn('foo'); 223 | } catch (err) { 224 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 225 | const message = messageBuilder(err.issues); 226 | expect(message).toMatchInlineSnapshot( 227 | `"Validation error: Invalid function arguments; Expected number, received string at index 0"` 228 | ); 229 | } 230 | } 231 | }); 232 | 233 | test('handles zod.function() return value errors', () => { 234 | const fn = zod 235 | .function() 236 | .returns(zod.number()) 237 | // @ts-expect-error Intentionally wrong to exercise runtime checking 238 | .implement(() => 'foo'); 239 | 240 | const messageBuilder = createMessageBuilder(); 241 | 242 | try { 243 | fn(); 244 | } catch (err) { 245 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 246 | const message = messageBuilder(err.issues); 247 | expect(message).toMatchInlineSnapshot( 248 | `"Validation error: Invalid function return type; Expected number, received string"` 249 | ); 250 | } 251 | } 252 | }); 253 | 254 | test('respects `includePath` prop when set to `false`', () => { 255 | const schema = zod.object({ 256 | name: zod.string().min(3, '"Name" must be at least 3 characters'), 257 | }); 258 | 259 | const messageBuilder = createMessageBuilder({ 260 | includePath: false, 261 | }); 262 | 263 | try { 264 | schema.parse({ name: 'jo' }); 265 | } catch (err) { 266 | if (isZodErrorLike(err) && isNonEmptyArray(err.issues)) { 267 | const message = messageBuilder(err.issues); 268 | expect(message).toMatchInlineSnapshot( 269 | `"Validation error: "Name" must be at least 3 characters"` 270 | ); 271 | } 272 | } 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /lib/MessageBuilder.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | import { type NonEmptyArray, isNonEmptyArray } from './utils/NonEmptyArray.ts'; 3 | import { joinPath } from './utils/joinPath.ts'; 4 | import { 5 | ISSUE_SEPARATOR, 6 | MAX_ISSUES_IN_MESSAGE, 7 | PREFIX, 8 | PREFIX_SEPARATOR, 9 | UNION_SEPARATOR, 10 | } from './config.ts'; 11 | 12 | export type ZodIssue = zod.ZodIssue; 13 | 14 | export type MessageBuilder = (issues: NonEmptyArray) => string; 15 | 16 | export type CreateMessageBuilderProps = { 17 | issueSeparator?: string; 18 | unionSeparator?: string; 19 | prefix?: string | null; 20 | prefixSeparator?: string; 21 | includePath?: boolean; 22 | maxIssuesInMessage?: number; 23 | }; 24 | 25 | export function createMessageBuilder( 26 | props: CreateMessageBuilderProps = {} 27 | ): MessageBuilder { 28 | const { 29 | issueSeparator = ISSUE_SEPARATOR, 30 | unionSeparator = UNION_SEPARATOR, 31 | prefixSeparator = PREFIX_SEPARATOR, 32 | prefix = PREFIX, 33 | includePath = true, 34 | maxIssuesInMessage = MAX_ISSUES_IN_MESSAGE, 35 | } = props; 36 | return (issues) => { 37 | const message = issues 38 | // limit max number of issues printed in the reason section 39 | .slice(0, maxIssuesInMessage) 40 | // format error message 41 | .map((issue) => 42 | getMessageFromZodIssue({ 43 | issue, 44 | issueSeparator, 45 | unionSeparator, 46 | includePath, 47 | }) 48 | ) 49 | // concat as string 50 | .join(issueSeparator); 51 | 52 | return prefixMessage(message, prefix, prefixSeparator); 53 | }; 54 | } 55 | 56 | function getMessageFromZodIssue(props: { 57 | issue: ZodIssue; 58 | issueSeparator: string; 59 | unionSeparator: string; 60 | includePath: boolean; 61 | }): string { 62 | const { issue, issueSeparator, unionSeparator, includePath } = props; 63 | 64 | if (issue.code === zod.ZodIssueCode.invalid_union) { 65 | return issue.unionErrors 66 | .reduce((acc, zodError) => { 67 | const newIssues = zodError.issues 68 | .map((issue) => 69 | getMessageFromZodIssue({ 70 | issue, 71 | issueSeparator, 72 | unionSeparator, 73 | includePath, 74 | }) 75 | ) 76 | .join(issueSeparator); 77 | 78 | if (!acc.includes(newIssues)) { 79 | acc.push(newIssues); 80 | } 81 | 82 | return acc; 83 | }, []) 84 | .join(unionSeparator); 85 | } 86 | 87 | if (issue.code === zod.ZodIssueCode.invalid_arguments) { 88 | return [ 89 | issue.message, 90 | ...issue.argumentsError.issues.map((issue) => 91 | getMessageFromZodIssue({ 92 | issue, 93 | issueSeparator, 94 | unionSeparator, 95 | includePath, 96 | }) 97 | ), 98 | ].join(issueSeparator); 99 | } 100 | 101 | if (issue.code === zod.ZodIssueCode.invalid_return_type) { 102 | return [ 103 | issue.message, 104 | ...issue.returnTypeError.issues.map((issue) => 105 | getMessageFromZodIssue({ 106 | issue, 107 | issueSeparator, 108 | unionSeparator, 109 | includePath, 110 | }) 111 | ), 112 | ].join(issueSeparator); 113 | } 114 | 115 | if (includePath && isNonEmptyArray(issue.path)) { 116 | // handle array indices 117 | if (issue.path.length === 1) { 118 | const identifier = issue.path[0]; 119 | 120 | if (typeof identifier === 'number') { 121 | return `${issue.message} at index ${identifier}`; 122 | } 123 | } 124 | 125 | return `${issue.message} at "${joinPath(issue.path)}"`; 126 | } 127 | 128 | return issue.message; 129 | } 130 | 131 | function prefixMessage( 132 | message: string, 133 | prefix: string | null, 134 | prefixSeparator: string 135 | ): string { 136 | if (prefix !== null) { 137 | if (message.length > 0) { 138 | return [prefix, message].join(prefixSeparator); 139 | } 140 | 141 | return prefix; 142 | } 143 | 144 | if (message.length > 0) { 145 | return message; 146 | } 147 | 148 | // if both reason and prefix are empty, return default prefix 149 | // to avoid having an empty error message 150 | return PREFIX; 151 | } 152 | -------------------------------------------------------------------------------- /lib/ValidationError.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | import { ValidationError } from './ValidationError.ts'; 3 | 4 | describe('ValidationError', () => { 5 | describe('constructor', () => { 6 | test('accepts message', () => { 7 | const message = 'Invalid email coyote@acme'; 8 | 9 | const err = new ValidationError(message); 10 | expect(err.message).toBe(message); 11 | // @ts-ignore 12 | expect(err.cause).toBeUndefined(); 13 | expect(err.details).toEqual([]); 14 | }); 15 | 16 | test('accepts message with cause', () => { 17 | const message = 'Invalid email coyote@acme'; 18 | const cause = new Error('foobar'); 19 | 20 | const err = new ValidationError(message, { cause }); 21 | expect(err.message).toBe(message); 22 | // @ts-ignore 23 | expect(err.cause).toEqual(cause); 24 | expect(err.details).toEqual([]); 25 | }); 26 | 27 | test('accepts ZodError as cause', () => { 28 | const message = 'Invalid email coyote@acme'; 29 | const issues: Array = [ 30 | { 31 | code: 'invalid_string', 32 | message: 'Invalid email', 33 | path: [], 34 | validation: 'email', 35 | }, 36 | ]; 37 | const cause = new zod.ZodError(issues); 38 | 39 | const err = new ValidationError(message, { 40 | cause, 41 | }); 42 | expect(err.message).toBe(message); 43 | // @ts-ignore 44 | expect(err.cause).toEqual(cause); 45 | expect(err.details).toEqual(issues); 46 | }); 47 | }); 48 | 49 | describe('toString()', () => { 50 | test('converts error to string', () => { 51 | const error = new ValidationError('Invalid email coyote@acme'); 52 | expect(error.toString()).toMatchInlineSnapshot( 53 | `"Invalid email coyote@acme"` 54 | ); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /lib/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { isZodErrorLike } from './isZodErrorLike.ts'; 2 | import type * as zod from 'zod'; 3 | 4 | // make zod-validation-error compatible with 5 | // earlier to es2022 typescript configurations 6 | // @see https://github.com/causaly/zod-validation-error/issues/226 7 | export interface ErrorOptions { 8 | cause?: unknown; 9 | } 10 | 11 | export class ValidationError extends Error { 12 | name: 'ZodValidationError'; 13 | details: Array; 14 | 15 | constructor(message?: string, options?: ErrorOptions) { 16 | super(message, options); 17 | this.name = 'ZodValidationError'; 18 | this.details = getIssuesFromErrorOptions(options); 19 | } 20 | 21 | toString(): string { 22 | return this.message; 23 | } 24 | } 25 | 26 | function getIssuesFromErrorOptions( 27 | options?: ErrorOptions 28 | ): Array { 29 | if (options) { 30 | const cause = options.cause; 31 | 32 | if (isZodErrorLike(cause)) { 33 | return cause.issues; 34 | } 35 | } 36 | 37 | return []; 38 | } 39 | -------------------------------------------------------------------------------- /lib/config.ts: -------------------------------------------------------------------------------- 1 | export const ISSUE_SEPARATOR = '; '; 2 | export const MAX_ISSUES_IN_MESSAGE = 99; // I've got 99 problems but the b$tch ain't one 3 | export const PREFIX = 'Validation error'; 4 | export const PREFIX_SEPARATOR = ': '; 5 | export const UNION_SEPARATOR = ', or '; 6 | -------------------------------------------------------------------------------- /lib/errorMap.ts: -------------------------------------------------------------------------------- 1 | import { fromZodIssue } from './fromZodIssue.ts'; 2 | import type * as zod from 'zod'; 3 | 4 | export const errorMap: zod.ZodErrorMap = (issue, ctx) => { 5 | const error = fromZodIssue({ 6 | ...issue, 7 | // fallback to the default error message 8 | // when issue does not have a message 9 | message: issue.message ?? ctx.defaultError, 10 | }); 11 | 12 | return { 13 | message: error.message, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /lib/fromError.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | 3 | import { fromError } from './fromError.ts'; 4 | 5 | describe('fromError()', () => { 6 | test('handles a ZodError', () => { 7 | const schema = zod.string().email(); 8 | const { error } = schema.safeParse('foobar'); 9 | 10 | const validationError = fromError(error); 11 | 12 | expect(validationError).toMatchInlineSnapshot( 13 | `[ZodValidationError: Validation error: Invalid email]` 14 | ); 15 | }); 16 | 17 | test('handles a generic Error', () => { 18 | const error = new Error('Something went wrong'); 19 | 20 | const validationError = fromError(error); 21 | 22 | expect(validationError).toMatchInlineSnapshot( 23 | `[ZodValidationError: Something went wrong]` 24 | ); 25 | }); 26 | 27 | test('handles other ecmascript-native errors, such as ReferenceError', () => { 28 | const error = new ReferenceError('Something went wrong'); 29 | 30 | const validationError = fromError(error); 31 | 32 | expect(validationError).toMatchInlineSnapshot( 33 | `[ZodValidationError: Something went wrong]` 34 | ); 35 | }); 36 | 37 | test('handles other ecmascript-native errors, such as TypeError', () => { 38 | const error = new TypeError('Something went wrong'); 39 | 40 | const validationError = fromError(error); 41 | 42 | expect(validationError).toMatchInlineSnapshot( 43 | `[ZodValidationError: Something went wrong]` 44 | ); 45 | }); 46 | 47 | test('handles a random input', () => { 48 | const error = 'I am pretending to be an error'; 49 | 50 | const validationError = fromError(error); 51 | 52 | expect(validationError).toMatchInlineSnapshot( 53 | `[ZodValidationError: Unknown error]` 54 | ); 55 | }); 56 | 57 | test('respects options provided', () => { 58 | const schema = zod.object({ 59 | username: zod.string().min(1), 60 | password: zod.string().min(1), 61 | }); 62 | const { error } = schema.safeParse({}); 63 | 64 | const validationError = fromError(error, { issueSeparator: ', ' }); 65 | 66 | expect(validationError).toMatchInlineSnapshot( 67 | `[ZodValidationError: Validation error: Required at "username", Required at "password"]` 68 | ); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /lib/fromError.ts: -------------------------------------------------------------------------------- 1 | import { toValidationError } from './toValidationError.ts'; 2 | import type { FromZodErrorOptions } from './fromZodError.ts'; 3 | import type { ValidationError } from './ValidationError.ts'; 4 | 5 | /** 6 | * This function is a non-curried version of `toValidationError` 7 | */ 8 | export function fromError( 9 | err: unknown, 10 | options: FromZodErrorOptions = {} 11 | ): ValidationError { 12 | return toValidationError(options)(err); 13 | } 14 | -------------------------------------------------------------------------------- /lib/fromZodError.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | import { ZodError } from 'zod'; 3 | 4 | import { fromZodError } from './fromZodError.ts'; 5 | import { ValidationError } from './ValidationError.ts'; 6 | 7 | describe('fromZodError()', () => { 8 | test('handles ZodError', () => { 9 | const schema = zod.string().email(); 10 | 11 | try { 12 | schema.parse('foobar'); 13 | } catch (err) { 14 | if (err instanceof ZodError) { 15 | const validationError = fromZodError(err); 16 | expect(validationError).toBeInstanceOf(ValidationError); 17 | expect(validationError.message).toMatchInlineSnapshot( 18 | `"Validation error: Invalid email"` 19 | ); 20 | expect(validationError.details).toMatchInlineSnapshot(` 21 | [ 22 | { 23 | "code": "invalid_string", 24 | "message": "Invalid email", 25 | "path": [], 26 | "validation": "email", 27 | }, 28 | ] 29 | `); 30 | } 31 | } 32 | }); 33 | 34 | test('throws a dev-friendly TypeError on invalid input', () => { 35 | const input = new Error("I wish I was a ZodError, but I'm not"); 36 | 37 | try { 38 | // @ts-expect-error 39 | fromZodError(input); 40 | } catch (err) { 41 | expect(err).toBeInstanceOf(TypeError); 42 | // @ts-expect-error 43 | expect(err.message).toMatchInlineSnapshot( 44 | `"Invalid zodError param; expected instance of ZodError. Did you mean to use the "fromError" method instead?"` 45 | ); 46 | } 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /lib/fromZodError.ts: -------------------------------------------------------------------------------- 1 | import { fromError } from './fromError.ts'; 2 | import { isZodErrorLike } from './isZodErrorLike.ts'; 3 | import { 4 | createMessageBuilder, 5 | type CreateMessageBuilderProps, 6 | type MessageBuilder, 7 | } from './MessageBuilder.ts'; 8 | import { isNonEmptyArray } from './utils/NonEmptyArray.ts'; 9 | import { ValidationError } from './ValidationError.ts'; 10 | import type * as zod from 'zod'; 11 | 12 | export type ZodError = zod.ZodError; 13 | 14 | export type FromZodErrorOptions = 15 | | { 16 | messageBuilder: MessageBuilder; 17 | } 18 | // maintain backwards compatibility 19 | | CreateMessageBuilderProps; 20 | 21 | export function fromZodError( 22 | zodError: ZodError, 23 | options: FromZodErrorOptions = {} 24 | ): ValidationError { 25 | // perform runtime check to ensure the input is a ZodError 26 | // why? because people have been historically using this function incorrectly 27 | if (!isZodErrorLike(zodError)) { 28 | throw new TypeError( 29 | `Invalid zodError param; expected instance of ZodError. Did you mean to use the "${fromError.name}" method instead?` 30 | ); 31 | } 32 | 33 | return fromZodErrorWithoutRuntimeCheck(zodError, options); 34 | } 35 | 36 | export function fromZodErrorWithoutRuntimeCheck( 37 | zodError: ZodError, 38 | options: FromZodErrorOptions = {} 39 | ): ValidationError { 40 | const zodIssues = zodError.errors; 41 | 42 | let message: string; 43 | if (isNonEmptyArray(zodIssues)) { 44 | const messageBuilder = createMessageBuilderFromOptions(options); 45 | message = messageBuilder(zodIssues); 46 | } else { 47 | message = zodError.message; 48 | } 49 | 50 | return new ValidationError(message, { cause: zodError }); 51 | } 52 | 53 | function createMessageBuilderFromOptions( 54 | options: FromZodErrorOptions 55 | ): MessageBuilder { 56 | if ('messageBuilder' in options) { 57 | return options.messageBuilder; 58 | } 59 | 60 | return createMessageBuilder(options); 61 | } 62 | -------------------------------------------------------------------------------- /lib/fromZodIssue.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | 3 | import { fromZodIssue } from './fromZodIssue.ts'; 4 | import { ValidationError } from './ValidationError.ts'; 5 | import { isZodErrorLike } from './isZodErrorLike.ts'; 6 | 7 | describe('fromZodIssue()', () => { 8 | test('handles ZodIssue', () => { 9 | const schema = zod.string().email(); 10 | 11 | try { 12 | schema.parse('foobar'); 13 | } catch (err) { 14 | if (isZodErrorLike(err)) { 15 | const validationError = fromZodIssue(err.issues[0]); 16 | expect(validationError).toBeInstanceOf(ValidationError); 17 | expect(validationError.message).toMatchInlineSnapshot( 18 | `"Validation error: Invalid email"` 19 | ); 20 | expect(validationError.details).toMatchInlineSnapshot(` 21 | [ 22 | { 23 | "code": "invalid_string", 24 | "message": "Invalid email", 25 | "path": [], 26 | "validation": "email", 27 | }, 28 | ] 29 | `); 30 | } 31 | } 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /lib/fromZodIssue.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | 3 | import { 4 | type MessageBuilder, 5 | type CreateMessageBuilderProps, 6 | type ZodIssue, 7 | createMessageBuilder, 8 | } from './MessageBuilder.ts'; 9 | import { ValidationError } from './ValidationError.ts'; 10 | 11 | export type FromZodIssueOptions = 12 | | { 13 | messageBuilder: MessageBuilder; 14 | } 15 | // maintain backwards compatibility 16 | | Omit; 17 | 18 | export function fromZodIssue( 19 | issue: ZodIssue, 20 | options: FromZodIssueOptions = {} 21 | ): ValidationError { 22 | const messageBuilder = createMessageBuilderFromOptions(options); 23 | const message = messageBuilder([issue]); 24 | 25 | return new ValidationError(message, { cause: new zod.ZodError([issue]) }); 26 | } 27 | 28 | function createMessageBuilderFromOptions( 29 | options: FromZodIssueOptions 30 | ): MessageBuilder { 31 | if ('messageBuilder' in options) { 32 | return options.messageBuilder; 33 | } 34 | 35 | return createMessageBuilder(options); 36 | } 37 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | export { ValidationError, type ErrorOptions } from './ValidationError.ts'; 2 | export { isValidationError } from './isValidationError.ts'; 3 | export { isValidationErrorLike } from './isValidationErrorLike.ts'; 4 | export { isZodErrorLike } from './isZodErrorLike.ts'; 5 | export { errorMap } from './errorMap.ts'; 6 | export { fromError } from './fromError.ts'; 7 | export { fromZodIssue, type FromZodIssueOptions } from './fromZodIssue.ts'; 8 | export { 9 | fromZodError, 10 | type FromZodErrorOptions, 11 | type ZodError, 12 | } from './fromZodError.ts'; 13 | export { toValidationError } from './toValidationError.ts'; 14 | export { 15 | type MessageBuilder, 16 | type ZodIssue, 17 | createMessageBuilder, 18 | } from './MessageBuilder.ts'; 19 | export { type NonEmptyArray } from './utils/NonEmptyArray.ts'; 20 | -------------------------------------------------------------------------------- /lib/isValidationError.test.ts: -------------------------------------------------------------------------------- 1 | import { isValidationError } from './isValidationError.ts'; 2 | import { ValidationError } from './ValidationError.ts'; 3 | 4 | describe('isValidationError()', () => { 5 | test('returns true when argument is instance of ValidationError', () => { 6 | expect(isValidationError(new ValidationError('foobar'))).toEqual(true); 7 | }); 8 | 9 | test('returns false when argument is plain Error', () => { 10 | expect(isValidationError(new Error('foobar'))).toEqual(false); 11 | }); 12 | 13 | test('returns false when argument is not an Error', () => { 14 | expect(isValidationError('foobar')).toEqual(false); 15 | expect(isValidationError(123)).toEqual(false); 16 | expect( 17 | isValidationError({ 18 | message: 'foobar', 19 | }) 20 | ).toEqual(false); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /lib/isValidationError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from './ValidationError.ts'; 2 | 3 | export function isValidationError(err: unknown): err is ValidationError { 4 | return err instanceof ValidationError; 5 | } 6 | -------------------------------------------------------------------------------- /lib/isValidationErrorLike.test.ts: -------------------------------------------------------------------------------- 1 | import { isValidationErrorLike } from './isValidationErrorLike.ts'; 2 | import { ValidationError } from './ValidationError.ts'; 3 | 4 | describe('isValidationErrorLike()', () => { 5 | test('returns true when argument is an actual instance of ValidationError', () => { 6 | expect(isValidationErrorLike(new ValidationError('foobar'))).toEqual(true); 7 | }); 8 | 9 | test('returns true when argument resembles a ValidationError', () => { 10 | const err = new ValidationError('foobar'); 11 | expect(isValidationErrorLike(err)).toEqual(true); 12 | }); 13 | 14 | test('returns false when argument is generic Error', () => { 15 | expect(isValidationErrorLike(new Error('foobar'))).toEqual(false); 16 | }); 17 | 18 | test('returns false when argument is not an Error instance', () => { 19 | expect(isValidationErrorLike('foobar')).toEqual(false); 20 | expect(isValidationErrorLike(123)).toEqual(false); 21 | expect( 22 | isValidationErrorLike({ 23 | message: 'foobar', 24 | }) 25 | ).toEqual(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /lib/isValidationErrorLike.ts: -------------------------------------------------------------------------------- 1 | import type { ValidationError } from './ValidationError.ts'; 2 | 3 | export function isValidationErrorLike(err: unknown): err is ValidationError { 4 | return err instanceof Error && err.name === 'ZodValidationError'; 5 | } 6 | -------------------------------------------------------------------------------- /lib/isZodErrorLike.test.ts: -------------------------------------------------------------------------------- 1 | import * as zod from 'zod'; 2 | import { isZodErrorLike } from './isZodErrorLike.ts'; 3 | 4 | class CustomZodError extends Error { 5 | issues: zod.ZodIssue[]; 6 | 7 | constructor(message: string) { 8 | super(message); 9 | this.name = 'ZodError'; 10 | this.issues = []; 11 | } 12 | } 13 | 14 | describe('isZodErrorLike()', () => { 15 | test('returns true when argument is an actual instance of ZodError', () => { 16 | const err = new zod.ZodError([ 17 | { 18 | code: zod.ZodIssueCode.custom, 19 | path: [], 20 | message: 'foobar', 21 | fatal: true, 22 | }, 23 | ]); 24 | 25 | expect(isZodErrorLike(err)).toEqual(true); 26 | }); 27 | 28 | test('returns true when argument resembles a ZodError', () => { 29 | const err = new CustomZodError('foobar'); 30 | 31 | expect(isZodErrorLike(err)).toEqual(true); 32 | }); 33 | 34 | test('returns false when argument resembles a ZodError but does not have issues', () => { 35 | const err = new CustomZodError('foobar'); 36 | // @ts-expect-error 37 | err.issues = undefined; 38 | 39 | expect(isZodErrorLike(err)).toEqual(false); 40 | }); 41 | 42 | test('returns false when argument resembles a ZodError but has the wrong name', () => { 43 | const err = new CustomZodError('foobar'); 44 | err.name = 'foobar'; 45 | 46 | expect(isZodErrorLike(err)).toEqual(false); 47 | }); 48 | 49 | test('returns false when argument is generic Error', () => { 50 | const err = new Error('foobar'); 51 | 52 | expect(isZodErrorLike(err)).toEqual(false); 53 | }); 54 | 55 | test('returns false when argument is string', () => { 56 | const err = 'error message'; 57 | 58 | expect(isZodErrorLike(err)).toEqual(false); 59 | }); 60 | 61 | test('returns false when argument is number', () => { 62 | const err = 123; 63 | 64 | expect(isZodErrorLike(err)).toEqual(false); 65 | }); 66 | 67 | test('returns false when argument is object', () => { 68 | const err = {}; 69 | 70 | expect(isZodErrorLike(err)).toEqual(false); 71 | }); 72 | 73 | test('returns false when argument is object, even if it carries the same props as an actual ZodError', () => { 74 | const err = { 75 | name: 'ZodError', 76 | issues: [], 77 | }; 78 | 79 | expect(isZodErrorLike(err)).toEqual(false); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /lib/isZodErrorLike.ts: -------------------------------------------------------------------------------- 1 | import type * as zod from 'zod'; 2 | 3 | export function isZodErrorLike(err: unknown): err is zod.ZodError { 4 | return ( 5 | err instanceof Error && 6 | err.name === 'ZodError' && 7 | 'issues' in err && 8 | Array.isArray(err.issues) 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /lib/toValidationError.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from './ValidationError.ts'; 2 | import { isZodErrorLike } from './isZodErrorLike.ts'; 3 | import { 4 | fromZodErrorWithoutRuntimeCheck, 5 | type FromZodErrorOptions, 6 | } from './fromZodError.ts'; 7 | 8 | export const toValidationError = 9 | (options: FromZodErrorOptions = {}) => 10 | (err: unknown): ValidationError => { 11 | if (isZodErrorLike(err)) { 12 | return fromZodErrorWithoutRuntimeCheck(err, options); 13 | } 14 | 15 | if (err instanceof Error) { 16 | return new ValidationError(err.message, { cause: err }); 17 | } 18 | 19 | return new ValidationError('Unknown error'); 20 | }; 21 | -------------------------------------------------------------------------------- /lib/utils/NonEmptyArray.ts: -------------------------------------------------------------------------------- 1 | export type NonEmptyArray = [T, ...T[]]; 2 | 3 | export function isNonEmptyArray(value: T[]): value is NonEmptyArray { 4 | return value.length !== 0; 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils/joinPath.test.ts: -------------------------------------------------------------------------------- 1 | import { joinPath } from './joinPath.ts'; 2 | 3 | describe('joinPath()', () => { 4 | test('handles flat object path', () => { 5 | expect(joinPath(['a'])).toEqual('a'); 6 | }); 7 | 8 | test('handles nested object path', () => { 9 | expect(joinPath(['a', 'b', 'c'])).toEqual('a.b.c'); 10 | }); 11 | 12 | test('handles ideograms', () => { 13 | expect(joinPath(['你好'])).toEqual('你好'); 14 | }); 15 | 16 | test('handles nested object path with ideograms', () => { 17 | expect(joinPath(['a', 'b', '你好'])).toEqual('a.b.你好'); 18 | }); 19 | 20 | test('handles numeric index', () => { 21 | expect(joinPath([0])).toEqual('0'); 22 | }); 23 | 24 | test('handles nested object path with numeric indices', () => { 25 | expect(joinPath(['a', 0, 'b', 'c', 1, 2])).toEqual('a[0].b.c[1][2]'); 26 | }); 27 | 28 | test('handles special characters', () => { 29 | expect(joinPath(['exports', './*'])).toEqual('exports["./*"]'); 30 | }); 31 | 32 | test('handles quote corner-case', () => { 33 | expect(joinPath(['a', 'b', '"'])).toEqual('a.b["\\""]'); 34 | }); 35 | 36 | test('handles quoted values', () => { 37 | expect(joinPath(['a', 'b', '"foo"'])).toEqual('a.b["\\"foo\\""]'); 38 | }); 39 | 40 | test('handles unicode characters', () => { 41 | expect(joinPath(['a', 'b', '💩'])).toEqual('a.b["💩"]'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /lib/utils/joinPath.ts: -------------------------------------------------------------------------------- 1 | import type { NonEmptyArray } from './NonEmptyArray.ts'; 2 | 3 | /** 4 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#identifiers 5 | */ 6 | const identifierRegex = /[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*/u; 7 | 8 | export function joinPath(path: NonEmptyArray): string { 9 | if (path.length === 1) { 10 | return path[0].toString(); 11 | } 12 | 13 | return path.reduce((acc, item) => { 14 | // handle numeric indices 15 | if (typeof item === 'number') { 16 | return acc + '[' + item.toString() + ']'; 17 | } 18 | 19 | // handle quoted values 20 | if (item.includes('"')) { 21 | return acc + '["' + escapeQuotes(item) + '"]'; 22 | } 23 | 24 | // handle special characters 25 | if (!identifierRegex.test(item)) { 26 | return acc + '["' + item + '"]'; 27 | } 28 | 29 | // handle normal values 30 | const separator = acc.length === 0 ? '' : '.'; 31 | return acc + separator + item; 32 | }, ''); 33 | } 34 | 35 | function escapeQuotes(str: string): string { 36 | return str.replace(/"/g, '\\"'); 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zod-validation-error", 3 | "version": "3.4.1", 4 | "description": "Wrap zod validation errors in user-friendly readable messages", 5 | "keywords": [ 6 | "zod", 7 | "error", 8 | "validation" 9 | ], 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/causaly/zod-validation-error.git" 14 | }, 15 | "author": { 16 | "name": "Dimitrios C. Michalakos", 17 | "email": "dimitris@jmike.gr", 18 | "url": "https://github.com/jmike" 19 | }, 20 | "contributors": [ 21 | { 22 | "name": "Thanos Karagiannis", 23 | "email": "hey@maestros.io", 24 | "url": "https://github.com/thanoskrg" 25 | }, 26 | { 27 | "name": "Nikos Kalogridis", 28 | "url": "https://github.com/nikoskalogridis" 29 | } 30 | ], 31 | "main": "./dist/index.js", 32 | "module": "./dist/index.mjs", 33 | "types": "./dist/index.d.ts", 34 | "exports": { 35 | ".": { 36 | "types": "./dist/index.d.ts", 37 | "require": "./dist/index.js", 38 | "import": "./dist/index.mjs" 39 | } 40 | }, 41 | "files": [ 42 | "dist" 43 | ], 44 | "publishConfig": { 45 | "access": "public" 46 | }, 47 | "sideEffects": false, 48 | "engines": { 49 | "node": ">=18.0.0" 50 | }, 51 | "scripts": { 52 | "typecheck": "tsc --noEmit", 53 | "build": "tsup --config ./tsup.config.ts", 54 | "lint": "eslint lib --ext .ts", 55 | "format": "prettier --config ./.prettierrc --ignore-path .gitignore -w .", 56 | "test": "vitest run", 57 | "changeset": "changeset", 58 | "prerelease": "npm run build && npm run test", 59 | "release": "changeset publish", 60 | "prepare": "husky install" 61 | }, 62 | "lint-staged": { 63 | "*.{js,jsx,ts,tsx}": [ 64 | "eslint --fix", 65 | "prettier --config ./.prettierrc --write" 66 | ] 67 | }, 68 | "devDependencies": { 69 | "@changesets/changelog-github": "^0.5.0", 70 | "@changesets/cli": "^2.27.7", 71 | "@commitlint/cli": "^18.0.0", 72 | "@commitlint/config-conventional": "^18.0.0", 73 | "@types/node": "^20.5.0", 74 | "@typescript-eslint/eslint-plugin": "^6.4.1", 75 | "@typescript-eslint/parser": "^6.4.1", 76 | "concurrently": "^8.2.0", 77 | "eslint": "^8.4.1", 78 | "eslint-config-prettier": "^9.0.0", 79 | "eslint-plugin-import": "^2.29.1", 80 | "eslint-plugin-prettier": "^4.2.1", 81 | "husky": "^8.0.3", 82 | "lint-staged": "^15.0.1", 83 | "prettier": "^2.8.8", 84 | "rimraf": "^5.0.1", 85 | "tsup": "^8.0.2", 86 | "typescript": "^5.1.6", 87 | "vitest": "^3.1.2", 88 | "zod": "3.25.50" 89 | }, 90 | "peerDependencies": { 91 | "zod": "^3.24.4" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "esnext", 6 | "dom" 7 | ], 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "declaration": true, 11 | "removeComments": true, 12 | "esModuleInterop": true, 13 | "allowImportingTsExtensions": true, 14 | "emitDeclarationOnly": true, 15 | "types": [ 16 | "vitest/globals" 17 | ], 18 | }, 19 | "include": [ 20 | "./lib/**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['lib/index.ts'], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | format: ['cjs', 'esm'], 9 | dts: true, 10 | outDir: 'dist', 11 | }); 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | minWorkers: 1, 8 | maxWorkers: process.env.CI ? 1 : 4, 9 | }, 10 | }); 11 | --------------------------------------------------------------------------------