├── src ├── index.ts ├── functions │ ├── index.ts │ ├── generate-error.ts │ ├── parse.ts │ ├── generate-error-message.ts │ ├── safe-parse.ts │ ├── parse-async.ts │ ├── parse.test.ts │ ├── safe-parse-async.ts │ ├── parse-async.test.ts │ ├── safe-parse.test.ts │ ├── safe-parse-async.test.ts │ ├── generate-error.test.ts │ └── generate-error-message.test.ts ├── utils │ ├── get-error-delimiter.ts │ ├── get-component-delimiter.ts │ ├── get-zod-path-array.test.ts │ ├── index.ts │ ├── get-error-delimiter.test.ts │ ├── get-zod-path-array.ts │ ├── get-component-delimiter.test.ts │ ├── get-component-labels.ts │ ├── get-breadcrumbs.ts │ ├── get-label.ts │ ├── get-object-notation.test.ts │ ├── get-object-notation.ts │ ├── get-breadcrumbs.test.ts │ ├── get-path-string.ts │ ├── get-label.test.ts │ ├── get-path-string.test.ts │ ├── get-error-message.ts │ ├── get-component-labels.test.ts │ └── get-error-message.test.ts └── types.ts ├── .prettierrc ├── vitest.config.ts ├── .gitignore ├── .eslintrc.json ├── LICENSE ├── package.json ├── tsconfig.json └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './functions'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /src/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generate-error-message'; 2 | export * from './generate-error'; 3 | export * from './parse-async'; 4 | export * from './parse'; 5 | export * from './safe-parse-async'; 6 | export * from './safe-parse'; 7 | -------------------------------------------------------------------------------- /src/utils/get-error-delimiter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets a error delimiter. 3 | * Defaults to |. 4 | * @export 5 | * @param {(string | undefined)} delimiter 6 | * @return {*} {string} 7 | */ 8 | export function getErrorDelimiter(delimiter: string | undefined): string { 9 | return delimiter ?? ' | '; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/get-component-delimiter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets a component delimiter. 3 | * Defaults to ~. 4 | * @export 5 | * @param {(string | undefined)} delimiter 6 | * @return {*} {string} 7 | */ 8 | export function getComponentDelimiter(delimiter: string | undefined): string { 9 | return delimiter ?? ' ~ '; 10 | } 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...configDefaults.exclude, 'lib/**'], 6 | coverage: { 7 | exclude: [...(configDefaults.coverage.exclude ?? []), 'lib/**', '**/index.ts', '**/types.ts'], 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/get-zod-path-array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getZodPathArray } from './get-zod-path-array'; 3 | 4 | describe('getZodPathArray', () => { 5 | it('should convert a zod path to a zod path array string', () => { 6 | expect(getZodPathArray(['car', 'wheels', 0])).toBe('["car", "wheels", 0]'); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-breadcrumbs'; 2 | export * from './get-component-delimiter'; 3 | export * from './get-component-labels'; 4 | export * from './get-error-delimiter'; 5 | export * from './get-error-message'; 6 | export * from './get-label'; 7 | export * from './get-object-notation'; 8 | export * from './get-path-string'; 9 | export * from './get-zod-path-array'; 10 | -------------------------------------------------------------------------------- /src/utils/get-error-delimiter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getErrorDelimiter } from './get-error-delimiter'; 3 | 4 | describe('getErrorDelimiter', () => { 5 | it('should get a default delimiter', () => { 6 | expect(getErrorDelimiter(undefined)).toBe(' | '); 7 | }); 8 | 9 | it('should get a custom delimiter', () => { 10 | expect(getErrorDelimiter(' . ')).toBe(' . '); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/get-zod-path-array.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | /** 4 | * Gets a string representation of a Zod Issue path. 5 | * @export 6 | * @param {z.core.$ZodIssue['path']} path 7 | * @return {*} {string} 8 | */ 9 | export function getZodPathArray(path: z.core.$ZodIssue['path']): string { 10 | const elements = path.map((p) => (typeof p === 'string' ? `"${p}"` : p)).join(', '); 11 | return ['[', ...elements, ']'].join(''); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/get-component-delimiter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getComponentDelimiter } from './get-component-delimiter'; 3 | 4 | describe('getComponentDelimiter', () => { 5 | it('should get a default delimiter', () => { 6 | expect(getComponentDelimiter(undefined)).toBe(' ~ '); 7 | }); 8 | 9 | it('should get a custom delimiter', () => { 10 | expect(getComponentDelimiter(' . ')).toBe(' . '); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | dist/**/* 39 | 40 | # ignore yarn.lock 41 | yarn.lock 42 | 43 | # misc 44 | src/test.ts 45 | lib -------------------------------------------------------------------------------- /src/utils/get-component-labels.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessageOptions, Labels } from '../types'; 2 | 3 | import { getLabel } from './get-label'; 4 | 5 | /** 6 | * Gets component labels. 7 | * Defaults to 'Code: ', 'Message: ' and 'Path: '. 8 | * @export 9 | * @param {ErrorMessageOptions} [options] 10 | * @return {*} {Labels} 11 | */ 12 | export function getComponentLabels(options?: ErrorMessageOptions): Labels { 13 | const code = getLabel(options?.code, 'Code: '); 14 | const message = getLabel(options?.message, 'Message: '); 15 | const path = getLabel(options?.path, 'Path: '); 16 | return { code, message, path }; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/get-breadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs } from '../types'; 2 | import { z } from 'zod'; 3 | 4 | /** 5 | * Adds breadcrumbs to a path. 6 | * Delimiter defaults to >. 7 | * @export 8 | * @param {z.core.$ZodIssue['path']} path 9 | * @param {Breadcrumbs} options 10 | * @return {*} {string} 11 | */ 12 | export function getBreadcrumbs(path: z.core.$ZodIssue['path'], options: Breadcrumbs): string { 13 | const arraySquareBrackets = options.arraySquareBrackets ?? true; 14 | const delimeter = options.delimeter ?? ' > '; 15 | return path.map((key) => (typeof key === 'number' && arraySquareBrackets ? `[${key}]` : key)).join(delimeter); 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/get-label.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from '../types'; 2 | 3 | /** 4 | * Gets a component label with fallback support. 5 | * @export 6 | * @template T 7 | * @param {T} component 8 | * @param {string} fallback 9 | * @return {*} {string} 10 | */ 11 | export function getLabel(component: T, fallback: string): string { 12 | if (component === undefined) { 13 | return fallback; 14 | } 15 | if (component.enabled === false) { 16 | return ''; 17 | } 18 | if (component.label === undefined) { 19 | return fallback; 20 | } 21 | if (component.label === null) { 22 | return ''; 23 | } 24 | return component.label; 25 | } 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended", "prettier"], 3 | "parser": "@typescript-eslint/parser", 4 | "rules": { 5 | "@typescript-eslint/ban-ts-comment": "off", 6 | "@typescript-eslint/explicit-module-boundary-types": [ 7 | "error", 8 | { 9 | "allowArgumentsExplicitlyTypedAsAny": true 10 | } 11 | ], 12 | "@typescript-eslint/no-explicit-any": "off", 13 | "@typescript-eslint/no-unused-vars": [ 14 | "error", 15 | { 16 | "argsIgnorePattern": "^_" 17 | } 18 | ], 19 | "no-duplicate-imports": "error", 20 | "object-shorthand": "error", 21 | "spaced-comment": "error" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/get-object-notation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getObjectNotation } from './get-object-notation'; 3 | 4 | describe('getObjectNotation', () => { 5 | const path = ['car', 'wheels', 0]; 6 | 7 | it('should convert a zod path to object notation when using default options', () => { 8 | expect(getObjectNotation(path, { enabled: true, type: 'objectNotation' })).toBe('car.wheels[0]'); 9 | }); 10 | 11 | it('should convert a zod path to object notation when using custom options', () => { 12 | expect(getObjectNotation(path, { enabled: true, type: 'objectNotation', arraySquareBrackets: false })).toBe( 13 | 'car.wheels.0', 14 | ); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/functions/generate-error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessageOptions } from '../types'; 2 | import { generateErrorMessage } from './generate-error-message'; 3 | import { z } from 'zod'; 4 | 5 | /** 6 | * Converts Zod Errors to generic Errors. 7 | * @export 8 | * @param {unknown} error 9 | * @param {ErrorMessageOptions} [options] 10 | * @return {*} {Error} 11 | */ 12 | export function generateError(error: unknown, options?: ErrorMessageOptions): Error { 13 | if (error instanceof z.ZodError) { 14 | const message = generateErrorMessage(error.issues, options); 15 | return new Error(message); 16 | } 17 | if (error instanceof Error) { 18 | return error; 19 | } 20 | return new Error('Unknown error'); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/get-object-notation.ts: -------------------------------------------------------------------------------- 1 | import { ObjectNotation } from '../types'; 2 | import { z } from 'zod'; 3 | 4 | /** 5 | * Converts a Zod Issue path to object notation. 6 | * @export 7 | * @param {z.core.$ZodIssue['path']} path 8 | * @param {ObjectNotation} options 9 | * @return {*} {string} 10 | */ 11 | export function getObjectNotation(path: z.core.$ZodIssue['path'], options: ObjectNotation): string { 12 | const arraySquareBrackets = options.arraySquareBrackets ?? true; 13 | return path.reduce((str, key) => { 14 | if (typeof key === 'number' && arraySquareBrackets) { 15 | return `${str}[${key}]`; 16 | } 17 | return [str, key].filter((s) => typeof s === 'number' || !!s).join('.'); 18 | }, ''); 19 | } 20 | -------------------------------------------------------------------------------- /src/functions/parse.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { ErrorMessageOptions } from '../types'; 3 | import { generateErrorMessage } from './generate-error-message'; 4 | 5 | /** 6 | * Parses a Zod schema throws a generic error. 7 | * @export 8 | * @template T 9 | * @param {T} schema 10 | * @param {unknown} data 11 | * @param {ErrorMessageOptions} [options] 12 | * @return {*} {T['_output']} 13 | */ 14 | export function parse(schema: T, data: unknown, options?: ErrorMessageOptions): T['_output'] { 15 | const result = schema.safeParse(data); 16 | if (!result.success) { 17 | const message = generateErrorMessage(result.error.issues, options); 18 | throw new Error(message); 19 | } 20 | return result.data; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/get-breadcrumbs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getBreadcrumbs } from './get-breadcrumbs'; 3 | 4 | describe('getBreadcrumbs', () => { 5 | const path = ['car', 'wheels', 1]; 6 | 7 | it('should convert a zod path to breadcrumbs when using default options', () => { 8 | expect(getBreadcrumbs(path, { enabled: true, type: 'breadcrumbs' })).toBe('car > wheels > [1]'); 9 | }); 10 | 11 | it('should convert a zod path to breadcrumbs when using custom options', () => { 12 | expect( 13 | getBreadcrumbs(path, { 14 | enabled: true, 15 | type: 'breadcrumbs', 16 | arraySquareBrackets: false, 17 | delimeter: '#', 18 | }), 19 | ).toBe('car#wheels#1'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/functions/generate-error-message.ts: -------------------------------------------------------------------------------- 1 | import { getErrorDelimiter, getErrorMessage } from '../utils'; 2 | 3 | import { ErrorMessageOptions } from '../types'; 4 | import { z } from 'zod'; 5 | 6 | /** 7 | * Generates an error message from Zod issues. 8 | * @export 9 | * @param {z.core.$ZodIssue[]} issues 10 | * @param {ErrorMessageOptions} [options] 11 | * @return {*} {string} 12 | */ 13 | export function generateErrorMessage(issues: z.core.$ZodIssue[], options?: ErrorMessageOptions): string { 14 | const errorDelimiter = getErrorDelimiter(options?.delimiter?.error); 15 | const errorMessage = issues 16 | .slice(0, options?.maxErrors) 17 | .map((issue, index) => getErrorMessage(issue, index, options)) 18 | .join(errorDelimiter); 19 | return `${options?.prefix ?? ''}${errorMessage}${options?.suffix ?? ''}`; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/get-path-string.ts: -------------------------------------------------------------------------------- 1 | import { getBreadcrumbs, getObjectNotation, getZodPathArray } from '.'; 2 | 3 | import { ErrorMessageOptions } from '../types'; 4 | import { z } from 'zod'; 5 | 6 | export function getPathString(path: z.core.$ZodIssue['path'], options?: ErrorMessageOptions['path']): string { 7 | if (options?.enabled === undefined) { 8 | return getObjectNotation(path, { enabled: true, type: 'objectNotation' }); 9 | } 10 | if (options.enabled === false) { 11 | return ''; 12 | } 13 | switch (options.type) { 14 | case 'zodPathArray': { 15 | return getZodPathArray(path); 16 | } 17 | case 'breadcrumbs': { 18 | return getBreadcrumbs(path, options); 19 | } 20 | case 'objectNotation': 21 | default: { 22 | return getObjectNotation(path, options); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/functions/safe-parse.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessageOptions, SafeParseReturnType } from '../types'; 2 | 3 | import { z } from 'zod'; 4 | import { generateErrorMessage } from './generate-error-message'; 5 | 6 | /** 7 | * Safe parses a Zod schema. 8 | * @export 9 | * @template T 10 | * @param {T} schema 11 | * @param {unknown} data 12 | * @param {ErrorMessageOptions} [options] 13 | * @return {*} {SafeParseReturnType} 14 | */ 15 | export function safeParse( 16 | schema: T, 17 | data: unknown, 18 | options?: ErrorMessageOptions, 19 | ): SafeParseReturnType { 20 | const result = schema.safeParse(data); 21 | if (!result.success) { 22 | const message = generateErrorMessage(result.error.issues, options); 23 | return { success: false, error: { message } }; 24 | } 25 | return { 26 | success: true, 27 | data: result.data, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/get-label.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getLabel } from './get-label'; 3 | 4 | describe('getLabel', () => { 5 | it('should return label when component options is enabled with label specified', () => { 6 | expect(getLabel({ enabled: true, label: 'hello' }, 'Fallback: ')).toBe('hello'); 7 | }); 8 | 9 | it('should return fallback when component options are not specified', () => { 10 | expect(getLabel(undefined, 'Fallback: ')).toBe('Fallback: '); 11 | }); 12 | 13 | it('should return empty string when component options are enabled but component is disabled', () => { 14 | expect(getLabel({ enabled: false }, 'Fallback: ')).toBe(''); 15 | }); 16 | 17 | it('should return empty string when component options are enabled with the label disabled', () => { 18 | expect(getLabel({ enabled: true, label: null }, 'Fallback: ')).toBe(''); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/functions/parse-async.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { ErrorMessageOptions } from '../types'; 3 | import { generateErrorMessage } from './generate-error-message'; 4 | 5 | /** 6 | * Asynchronously parses a Zod schema 7 | * and throws a generic error. 8 | * Only required if schema contains async 9 | * .refine() or .transform() functions. 10 | * @export 11 | * @template T 12 | * @param {T} schema 13 | * @param {unknown} data 14 | * @param {ErrorMessageOptions} [options] 15 | * @return {*} {Promise} 16 | */ 17 | export async function parseAsync( 18 | schema: T, 19 | data: unknown, 20 | options?: ErrorMessageOptions, 21 | ): Promise { 22 | const result = await schema.safeParseAsync(data); 23 | if (!result.success) { 24 | const message = generateErrorMessage(result.error.issues, options); 25 | throw new Error(message); 26 | } 27 | return result.data; 28 | } 29 | -------------------------------------------------------------------------------- /src/functions/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parse } from './parse'; 3 | import { z } from 'zod'; 4 | 5 | describe('parse', () => { 6 | const schema = z.object({ 7 | animal: z.enum(['🐶', '🐱', '🐵']), 8 | quantity: z.number().gte(1), 9 | }); 10 | 11 | const invalidData = { animal: '🐼', quantity: 0 }; 12 | 13 | const validData = { animal: '🐶', quantity: 1 }; 14 | 15 | it('should throw a generic error when parsing invalid data', () => { 16 | expect(() => parse(schema, invalidData)).toThrowError( 17 | new Error( 18 | 'Code: invalid_value ~ Path: animal ~ Message: Invalid option: expected one of "🐶"|"🐱"|"🐵" | Code: too_small ~ Path: quantity ~ Message: Too small: expected number to be >=1', 19 | ), 20 | ); 21 | }); 22 | 23 | it('should return the valid data when parsing valid data', () => { 24 | expect(parse(schema, validData)).toStrictEqual(validData); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/functions/safe-parse-async.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessageOptions, SafeParseReturnType } from '../types'; 2 | 3 | import { z } from 'zod'; 4 | import { generateErrorMessage } from './generate-error-message'; 5 | 6 | /** 7 | * Asynchronously safe parses a Zod schema. 8 | * Only required if schema contains async 9 | * .refine() or .transform() functions. 10 | * @export 11 | * @template T 12 | * @param {T} schema 13 | * @param {unknown} data 14 | * @param {ErrorMessageOptions} [options] 15 | * @return {*} {Promise>} 16 | */ 17 | export async function safeParseAsync( 18 | schema: T, 19 | data: unknown, 20 | options?: ErrorMessageOptions, 21 | ): Promise> { 22 | const result = await schema.safeParseAsync(data); 23 | if (!result.success) { 24 | const message = generateErrorMessage(result.error.issues, options); 25 | return { success: false, error: { message } }; 26 | } 27 | return { success: true, data: result.data }; 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Andrew Vo-Nguyen 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 | -------------------------------------------------------------------------------- /src/functions/parse-async.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { parseAsync } from './parse-async'; 3 | import { z } from 'zod'; 4 | 5 | describe('parseAsync', () => { 6 | const schema = z.object({ 7 | animal: z.enum(['🐶', '🐱', '🐵']).transform(async (value) => { 8 | await new Promise((res) => setTimeout(res, 1)); 9 | return value; 10 | }), 11 | quantity: z.number().gte(1), 12 | }); 13 | 14 | const invalidData = { animal: '🐼', quantity: 0 }; 15 | const validData = { animal: '🐶', quantity: 1 }; 16 | 17 | it('should throw a generic error when parsing invalid data', async () => { 18 | await expect(parseAsync(schema, invalidData)).rejects.toThrowError( 19 | new Error( 20 | 'Code: invalid_value ~ Path: animal ~ Message: Invalid option: expected one of "🐶"|"🐱"|"🐵" | Code: too_small ~ Path: quantity ~ Message: Too small: expected number to be >=1', 21 | ), 22 | ); 23 | }); 24 | 25 | it('should return valid data when parsing valid data', async () => { 26 | await expect(parseAsync(schema, validData)).resolves.toStrictEqual(validData); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/functions/safe-parse.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { safeParse } from './safe-parse'; 3 | import { z } from 'zod'; 4 | 5 | describe('safeParse', () => { 6 | const now = new Date(); 7 | 8 | const schema = z.object({ 9 | id: z.uuid(), 10 | timestamp: z.number(), 11 | message: z.string().min(5), 12 | }); 13 | 14 | const invalidData = { 15 | id: 'ID001', 16 | timestamp: now, 17 | message: 'lol!', 18 | }; 19 | 20 | const validData = { 21 | id: '6511febf-b312-4456-a19a-05ddb86a6b74', 22 | timestamp: now.valueOf(), 23 | message: 'lolol!', 24 | }; 25 | 26 | it('should return an error object when parsing invalid data', () => { 27 | expect(safeParse(schema, invalidData)).toStrictEqual({ 28 | success: false, 29 | error: { 30 | message: 31 | 'Code: invalid_format ~ Path: id ~ Message: Invalid UUID | Code: invalid_type ~ Path: timestamp ~ Message: Invalid input: expected number, received Date | Code: too_small ~ Path: message ~ Message: Too small: expected string to have >=5 characters', 32 | }, 33 | }); 34 | }); 35 | 36 | it('should return a success object when parsing valid data', () => { 37 | expect(safeParse(schema, validData)).toStrictEqual({ success: true, data: validData }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/functions/safe-parse-async.test.ts: -------------------------------------------------------------------------------- 1 | import { safeParseAsync } from './safe-parse-async'; 2 | import { z } from 'zod'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('safeParseAsync', () => { 6 | const now = new Date(); 7 | 8 | const schema = z.object({ 9 | id: z.uuid().transform(async (value) => { 10 | await new Promise((res) => setTimeout(res, 1)); 11 | return value; 12 | }), 13 | timestamp: z.number(), 14 | message: z.string().min(5), 15 | }); 16 | 17 | const invalidData = { 18 | id: 'ID001', 19 | timestamp: now, 20 | message: 'lol!', 21 | }; 22 | 23 | const validData = { 24 | id: '6511febf-b312-4456-a19a-05ddb86a6b74', 25 | timestamp: now.valueOf(), 26 | message: 'lolol!', 27 | }; 28 | 29 | it('should return an error object when parsing invalid data', async () => { 30 | await expect(safeParseAsync(schema, invalidData)).resolves.toStrictEqual({ 31 | success: false, 32 | error: { 33 | message: 34 | 'Code: invalid_format ~ Path: id ~ Message: Invalid UUID | Code: invalid_type ~ Path: timestamp ~ Message: Invalid input: expected number, received Date | Code: too_small ~ Path: message ~ Message: Too small: expected string to have >=5 characters', 35 | }, 36 | }); 37 | }); 38 | 39 | it('should return a success object when parsing valid data', async () => { 40 | await expect(safeParseAsync(schema, validData)).resolves.toStrictEqual({ success: true, data: validData }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/utils/get-path-string.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getPathString } from './get-path-string'; 3 | 4 | describe('getPathString', () => { 5 | const path = ['car', 'wheels', 0]; 6 | 7 | it('should convert a zod path to object notation by default', () => { 8 | expect(getPathString(path)).toBe('car.wheels[0]'); 9 | }); 10 | 11 | it('should convert a zod path to object notation with default settings', () => { 12 | expect(getPathString(path, { enabled: true, type: 'objectNotation' })).toBe('car.wheels[0]'); 13 | }); 14 | 15 | it('should convert a zod path to object notation with custom settings', () => { 16 | expect(getPathString(path, { enabled: true, type: 'objectNotation', arraySquareBrackets: false })).toBe( 17 | 'car.wheels.0', 18 | ); 19 | }); 20 | 21 | it('should convert a zod path to breadcrumbs with default options', () => { 22 | expect(getPathString(path, { enabled: true, type: 'breadcrumbs' })).toBe('car > wheels > [0]'); 23 | }); 24 | 25 | it('should convert a zod path to breadcrumbs with custom options', () => { 26 | expect( 27 | getPathString(path, { 28 | enabled: true, 29 | type: 'breadcrumbs', 30 | arraySquareBrackets: false, 31 | delimeter: '#', 32 | }), 33 | ).toBe('car#wheels#0'); 34 | }); 35 | 36 | it('should convert a zod path to zod path array string', () => { 37 | expect(getPathString(path, { enabled: true, type: 'zodPathArray' })).toBe('["car", "wheels", 0]'); 38 | }); 39 | 40 | it('should return empty string when path is disabled', () => { 41 | expect(getPathString(path, { enabled: false })).toBe(''); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/functions/generate-error.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { z } from 'zod'; 3 | import { generateError } from './generate-error'; 4 | 5 | describe('generateError', () => { 6 | it('should transform to a generic error when Zod error is thrown', () => { 7 | const schema = z.object({ 8 | dates: z.object({ 9 | purchased: z.date(), 10 | fulfilled: z.date(), 11 | }), 12 | item: z.string(), 13 | price: z.number(), 14 | }); 15 | 16 | const data = { 17 | dates: { purchased: 'yesterday' }, 18 | item: 1, 19 | price: '1,000', 20 | }; 21 | 22 | let error: unknown; 23 | 24 | try { 25 | schema.parse(data); 26 | } catch (e) { 27 | error = e; 28 | } 29 | 30 | expect(generateError(error)).toStrictEqual( 31 | new Error( 32 | 'Code: invalid_type ~ Path: dates.purchased ~ Message: Invalid input: expected date, received string | Code: invalid_type ~ Path: dates.fulfilled ~ Message: Invalid input: expected date, received undefined | Code: invalid_type ~ Path: item ~ Message: Invalid input: expected string, received number | Code: invalid_type ~ Path: price ~ Message: Invalid input: expected number, received string', 33 | ), 34 | ); 35 | }); 36 | 37 | it('should return the same error when a generic error is passed through', () => { 38 | const nonZodError = new Error('This is not a Zod Error'); 39 | expect(generateError(nonZodError)).toStrictEqual(nonZodError); 40 | }); 41 | 42 | it('should return an unknown error when a non-error type is passed', () => { 43 | expect(generateError('nonZodError')).toStrictEqual(new Error('Unknown error')); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zod-error", 3 | "license": "MIT", 4 | "author": "Andrew Vo-Nguyen (https://andrewvo.co)", 5 | "version": "2.0.0", 6 | "description": "Utilities to format and customize Zod error messages", 7 | "homepage": "https://github.com/andrewvo89/zod-error", 8 | "repository": "https://github.com/andrewvo89/zod-error", 9 | "keywords": [ 10 | "zod", 11 | "error", 12 | "validation", 13 | "nodejs", 14 | "typescript" 15 | ], 16 | "scripts": { 17 | "build": "eslint && vitest run && tsc --noEmit && rm -r -f lib && tsc -p tsconfig.json", 18 | "commit": "cz", 19 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"", 20 | "lint": "eslint", 21 | "pack": "npm pack", 22 | "release:major": "yarn build && npm version major && npm publish", 23 | "release:minor": "yarn build && npm version minor && npm publish", 24 | "release:patch": "yarn build && npm version patch && npm publish", 25 | "test": "vitest run", 26 | "test:coverage": "vitest run --coverage", 27 | "typecheck": "tsc --noEmit" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^18.0.3", 31 | "@typescript-eslint/eslint-plugin": "^5.30.6", 32 | "@typescript-eslint/parser": "^5.30.6", 33 | "@vitest/coverage-v8": "3.2.4", 34 | "@vitest/ui": "3.2.4", 35 | "@zod/core": "^0.11.6", 36 | "cz-conventional-changelog": "3.3.0", 37 | "eslint": "^8.19.0", 38 | "eslint-config-prettier": "^8.5.0", 39 | "prettier": "^2.7.1", 40 | "typescript": "^5.9.3", 41 | "vitest": "^3.2.4", 42 | "zod": "^4.1.12" 43 | }, 44 | "peerDependencies": { 45 | "zod": "^4.0.0" 46 | }, 47 | "config": { 48 | "commitizen": { 49 | "path": "./node_modules/cz-conventional-changelog" 50 | } 51 | }, 52 | "main": "lib/index.js", 53 | "types": "lib/index.d.ts", 54 | "files": [ 55 | "lib/**/*", 56 | "LICENSE" 57 | ], 58 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/get-error-message.ts: -------------------------------------------------------------------------------- 1 | import { getComponentDelimiter, getComponentLabels, getPathString } from '.'; 2 | 3 | import { ErrorMessageOptions } from '../types'; 4 | import { z } from 'zod'; 5 | 6 | /** 7 | * Converts a Zod issue to a string message. 8 | * @export 9 | * @param {z.core.$ZodIssue} issue 10 | * @param {number} index 11 | * @param {ErrorMessageOptions} [options] 12 | * @return {*} {string} 13 | */ 14 | export function getErrorMessage(issue: z.core.$ZodIssue, index: number, options?: ErrorMessageOptions): string { 15 | const {} = issue; 16 | const componentDelimeter = getComponentDelimiter(options?.delimiter?.component); 17 | const labels = getComponentLabels(options); 18 | const components: string[] = []; 19 | 20 | let codeComponent = `${labels.code}${issue.code}`; 21 | const codeEnabled = options?.code?.enabled ?? true; 22 | if (codeEnabled) { 23 | if (options?.code?.enabled && options.code.transform) { 24 | codeComponent = options.code.transform({ component: codeComponent, label: labels.code, value: issue.code }); 25 | } 26 | components.push(codeComponent); 27 | } 28 | 29 | const pathString = getPathString(issue.path, options?.path); 30 | let pathComponent = `${labels.path}${pathString}`; 31 | const pathEnabled = options?.path?.enabled ?? true; 32 | if (pathEnabled) { 33 | if (options?.path?.enabled && options.path.transform) { 34 | pathComponent = options.path.transform({ component: pathComponent, label: labels.path, value: pathString }); 35 | } 36 | components.push(pathComponent); 37 | } 38 | 39 | let messageComponent = `${labels.message}${issue.message}`; 40 | const messageEnabled = options?.message?.enabled ?? true; 41 | if (messageEnabled) { 42 | if (options?.message?.enabled && options.message.transform) { 43 | messageComponent = options.message.transform({ 44 | component: messageComponent, 45 | label: labels.message, 46 | value: issue.message, 47 | }); 48 | } 49 | components.push(messageComponent); 50 | } 51 | 52 | const errorMessage = components.join(componentDelimeter); 53 | if (options?.transform) { 54 | return options.transform({ pathComponent, messageComponent, issue, index, errorMessage, codeComponent }); 55 | } 56 | return errorMessage; 57 | } 58 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export type EnableCode = { 4 | enabled: true; 5 | label?: string | null; 6 | transform?: (params: TransformComponentParams) => string; 7 | }; 8 | 9 | export type DisableCode = { 10 | enabled: false; 11 | }; 12 | 13 | export type CodeOptions = EnableCode | DisableCode; 14 | 15 | export type EnablePathOptions = { 16 | enabled: true; 17 | label?: string | null; 18 | transform?: (params: TransformComponentParams) => string; 19 | }; 20 | 21 | export type DisablePath = { 22 | enabled: false; 23 | }; 24 | 25 | export type ObjectNotation = EnablePathOptions & { 26 | type: 'objectNotation'; 27 | arraySquareBrackets?: boolean; 28 | }; 29 | 30 | export type ZodPathArray = EnablePathOptions & { 31 | type: 'zodPathArray'; 32 | }; 33 | 34 | export type Breadcrumbs = EnablePathOptions & { 35 | type: 'breadcrumbs'; 36 | delimeter?: string; 37 | arraySquareBrackets?: boolean; 38 | }; 39 | 40 | export type PathOptions = ObjectNotation | ZodPathArray | Breadcrumbs | DisablePath; 41 | 42 | export type EnableMessage = { 43 | enabled: true; 44 | label?: string | null; 45 | transform?: (params: TransformComponentParams) => string; 46 | }; 47 | 48 | export type DisableMessage = { 49 | enabled: false; 50 | }; 51 | 52 | export type MessageOptions = EnableMessage | DisableMessage; 53 | 54 | export type DelimiterOptions = { 55 | error?: string; 56 | component?: string; 57 | }; 58 | 59 | export type TransformComponentParams = { component: string; label: string; value: string }; 60 | 61 | export type TransformErrorParams = { 62 | codeComponent: string; 63 | errorMessage: string; 64 | index: number; 65 | issue: z.core.$ZodIssue; 66 | messageComponent: string; 67 | pathComponent: string; 68 | }; 69 | 70 | export type SafeParseSuccess = { 71 | success: true; 72 | data: T; 73 | }; 74 | 75 | export type SafeParseFail = { 76 | success: false; 77 | error: { message: string }; 78 | }; 79 | 80 | export type SafeParseReturnType = SafeParseSuccess | SafeParseFail; 81 | 82 | export type Labels = { 83 | code: string; 84 | path: string; 85 | message: string; 86 | }; 87 | 88 | export interface ErrorMessageOptions { 89 | code?: CodeOptions; 90 | delimiter?: DelimiterOptions; 91 | maxErrors?: number; 92 | message?: MessageOptions; 93 | path?: PathOptions; 94 | prefix?: string; 95 | suffix?: string; 96 | transform?: (params: TransformErrorParams) => string; 97 | } 98 | 99 | export type Component = { enabled: true; label?: string | null } | { enabled: false } | undefined; 100 | -------------------------------------------------------------------------------- /src/functions/generate-error-message.test.ts: -------------------------------------------------------------------------------- 1 | import { generateErrorMessage } from './generate-error-message'; 2 | import { z } from 'zod'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('generateErrorMessage', () => { 6 | enum Color { 7 | Red = 'Red', 8 | Blue = 'Blue', 9 | } 10 | 11 | const schema = z.object({ 12 | color: z.enum(Color), 13 | shape: z.string(), 14 | size: z.number().gt(0), 15 | }); 16 | 17 | const data = { 18 | color: 'Green', 19 | size: -1, 20 | }; 21 | 22 | const result = schema.safeParse(data); 23 | 24 | const issues = !result.success ? result.error.issues : []; 25 | 26 | const now = new Date().valueOf(); 27 | 28 | it('should generate an error message with default options', () => { 29 | expect(generateErrorMessage(issues)).toBe( 30 | 'Code: invalid_value ~ Path: color ~ Message: Invalid option: expected one of "Red"|"Blue" | Code: invalid_type ~ Path: shape ~ Message: Invalid input: expected string, received undefined | Code: too_small ~ Path: size ~ Message: Too small: expected number to be >0', 31 | ); 32 | }); 33 | 34 | it('should generate an error message with maximum 2 errors', () => { 35 | expect(generateErrorMessage(issues, { maxErrors: 2 })).toBe( 36 | 'Code: invalid_value ~ Path: color ~ Message: Invalid option: expected one of "Red"|"Blue" | Code: invalid_type ~ Path: shape ~ Message: Invalid input: expected string, received undefined', 37 | ); 38 | }); 39 | 40 | it('should generate an error message with custom error delimiter', () => { 41 | expect(generateErrorMessage(issues, { delimiter: { error: ' 🔥 ' } })).toBe( 42 | 'Code: invalid_value ~ Path: color ~ Message: Invalid option: expected one of "Red"|"Blue" 🔥 Code: invalid_type ~ Path: shape ~ Message: Invalid input: expected string, received undefined 🔥 Code: too_small ~ Path: size ~ Message: Too small: expected number to be >0', 43 | ); 44 | }); 45 | 46 | it('should generate an error message with custom prefix', () => { 47 | expect(generateErrorMessage(issues, { prefix: `🕒 ${now}: ` })).toBe( 48 | `🕒 ${now}: Code: invalid_value ~ Path: color ~ Message: Invalid option: expected one of "Red"|"Blue" | Code: invalid_type ~ Path: shape ~ Message: Invalid input: expected string, received undefined | Code: too_small ~ Path: size ~ Message: Too small: expected number to be >0`, 49 | ); 50 | }); 51 | 52 | it('should generate an error message with custom suffix', () => { 53 | expect(generateErrorMessage(issues, { suffix: ' 😵' })).toBe( 54 | `Code: invalid_value ~ Path: color ~ Message: Invalid option: expected one of \"Red\"|\"Blue\" | Code: invalid_type ~ Path: shape ~ Message: Invalid input: expected string, received undefined | Code: too_small ~ Path: size ~ Message: Too small: expected number to be >0 😵`, 55 | ); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/utils/get-component-labels.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getComponentLabels } from './get-component-labels'; 3 | 4 | describe('getComponentLabels', () => { 5 | it('should return component labels with default options', () => { 6 | expect(getComponentLabels()).toStrictEqual({ 7 | code: 'Code: ', 8 | message: 'Message: ', 9 | path: 'Path: ', 10 | }); 11 | }); 12 | 13 | it('should return component labels with custom options', () => { 14 | expect( 15 | getComponentLabels({ 16 | code: { enabled: true, label: 'CODE - ' }, 17 | path: { enabled: false }, 18 | message: { enabled: true, label: 'MESSAGE - ' }, 19 | }), 20 | ).toStrictEqual({ code: 'CODE - ', message: 'MESSAGE - ', path: '' }); 21 | }); 22 | 23 | it('should return component labels when only code is customized', () => { 24 | expect( 25 | getComponentLabels({ 26 | code: { enabled: true, label: 'Error Code: ' }, 27 | }), 28 | ).toStrictEqual({ 29 | code: 'Error Code: ', 30 | message: 'Message: ', 31 | path: 'Path: ', 32 | }); 33 | }); 34 | 35 | it('should return component labels when only message is customized', () => { 36 | expect( 37 | getComponentLabels({ 38 | message: { enabled: true, label: 'Error Message: ' }, 39 | }), 40 | ).toStrictEqual({ 41 | code: 'Code: ', 42 | message: 'Error Message: ', 43 | path: 'Path: ', 44 | }); 45 | }); 46 | 47 | it('should return component labels when only path is customized', () => { 48 | expect( 49 | getComponentLabels({ 50 | path: { enabled: true, label: 'Field Path: ', type: 'breadcrumbs' }, 51 | }), 52 | ).toStrictEqual({ 53 | code: 'Code: ', 54 | message: 'Message: ', 55 | path: 'Field Path: ', 56 | }); 57 | }); 58 | 59 | it('should return empty labels when all components are disabled', () => { 60 | expect( 61 | getComponentLabels({ 62 | code: { enabled: false }, 63 | message: { enabled: false }, 64 | path: { enabled: false }, 65 | }), 66 | ).toStrictEqual({ 67 | code: '', 68 | message: '', 69 | path: '', 70 | }); 71 | }); 72 | 73 | it('should return component labels with mixed enabled and disabled options', () => { 74 | expect( 75 | getComponentLabels({ 76 | code: { enabled: true, label: 'Custom Code: ' }, 77 | message: { enabled: false }, 78 | path: { enabled: true, label: 'Custom Path: ', type: 'breadcrumbs' }, 79 | }), 80 | ).toStrictEqual({ 81 | code: 'Custom Code: ', 82 | message: '', 83 | path: 'Custom Path: ', 84 | }); 85 | }); 86 | 87 | it('should return component labels with empty custom labels', () => { 88 | expect( 89 | getComponentLabels({ 90 | code: { enabled: true, label: '' }, 91 | message: { enabled: true, label: '' }, 92 | path: { enabled: true, label: '', type: 'breadcrumbs' }, 93 | }), 94 | ).toStrictEqual({ 95 | code: '', 96 | message: '', 97 | path: '', 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/utils/get-error-message.test.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from './get-error-message'; 2 | import { z } from 'zod'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | const issues: z.core.$ZodIssue[] = [ 6 | { 7 | code: 'invalid_type', 8 | expected: 'date', 9 | path: ['dates', 'purchased'], 10 | message: 'Invalid input: expected date, received string', 11 | }, 12 | { 13 | code: 'invalid_type', 14 | expected: 'date', 15 | path: ['dates', 'fulfilled'], 16 | message: 'Invalid input: expected date, received undefined', 17 | }, 18 | { 19 | code: 'invalid_type', 20 | expected: 'string', 21 | path: ['item'], 22 | message: 'Invalid input: expected string, received number', 23 | }, 24 | { 25 | code: 'invalid_type', 26 | expected: 'number', 27 | path: ['price'], 28 | message: 'Invalid input: expected number, received string', 29 | }, 30 | ]; 31 | 32 | describe('getErrorMessage', () => { 33 | it('should return default error message', () => { 34 | expect(getErrorMessage(issues[0], 0)).toBe( 35 | 'Code: invalid_type ~ Path: dates.purchased ~ Message: Invalid input: expected date, received string', 36 | ); 37 | }); 38 | 39 | it('should return error message with code disabled', () => { 40 | expect(getErrorMessage(issues[0], 0, { code: { enabled: false } })).toBe( 41 | 'Path: dates.purchased ~ Message: Invalid input: expected date, received string', 42 | ); 43 | }); 44 | 45 | it('should return error message with message disabled', () => { 46 | expect(getErrorMessage(issues[0], 0, { message: { enabled: false } })).toBe( 47 | 'Code: invalid_type ~ Path: dates.purchased', 48 | ); 49 | }); 50 | 51 | it('should return error message with path disabled', () => { 52 | expect(getErrorMessage(issues[0], 0, { path: { enabled: false } })).toBe( 53 | 'Code: invalid_type ~ Message: Invalid input: expected date, received string', 54 | ); 55 | }); 56 | 57 | it('should return error message with a transform function', () => { 58 | expect( 59 | getErrorMessage(issues[0], 0, { transform: ({ index, errorMessage }) => `Error #${index + 1}: ${errorMessage}` }), 60 | ).toBe( 61 | 'Error #1: Code: invalid_type ~ Path: dates.purchased ~ Message: Invalid input: expected date, received string', 62 | ); 63 | }); 64 | 65 | it('should return error message with a transform function for each component', () => { 66 | expect( 67 | getErrorMessage(issues[0], 0, { 68 | code: { 69 | enabled: true, 70 | transform: ({ component }) => `<${component}>`, 71 | }, 72 | message: { 73 | enabled: true, 74 | transform: ({ component }) => `<${component}>`, 75 | }, 76 | path: { 77 | enabled: true, 78 | type: 'objectNotation', 79 | transform: ({ component }) => `<${component}>`, 80 | }, 81 | }), 82 | ).toBe(' ~ ~ '); 83 | }); 84 | 85 | it('should pass component transformations to final transform', () => { 86 | expect( 87 | getErrorMessage(issues[0], 0, { 88 | code: { 89 | enabled: true, 90 | transform: ({ component }) => `<${component}>`, 91 | }, 92 | message: { 93 | enabled: true, 94 | transform: ({ component }) => `<${component}>`, 95 | }, 96 | path: { 97 | enabled: true, 98 | type: 'objectNotation', 99 | transform: ({ component }) => `<${component}>`, 100 | }, 101 | transform: ({ codeComponent, messageComponent, pathComponent }) => 102 | `<${codeComponent}> ~ <${pathComponent}> ~ <${messageComponent}>`, 103 | }), 104 | ).toBe( 105 | '<> ~ <> ~ <>', 106 | ); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 13 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 14 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 15 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 16 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 17 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 18 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 19 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 20 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 21 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 22 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 23 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 24 | /* Modules */ 25 | "module": "commonjs" /* Specify what module code is generated. */, 26 | "rootDir": "./src" /* Specify the root folder within your source files. */, 27 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 28 | // "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ 29 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 30 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 31 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 32 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 33 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 34 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 35 | // "resolveJsonModule": true, /* Enable importing .json files. */ 36 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 37 | /* JavaScript Support */ 38 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 39 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 40 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 41 | /* Emit */ 42 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 43 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 44 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 45 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 46 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 47 | "outDir": "./lib" /* Specify an output folder for all emitted files. */, 48 | // "removeComments": true, /* Disable emitting comments. */ 49 | // "noEmit": true, /* Disable emitting files from a compilation. */ 50 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 51 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 52 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 53 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 56 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 57 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 58 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 59 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 60 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 61 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 62 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 63 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 64 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 65 | /* Interop Constraints */ 66 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 67 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 68 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 69 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 70 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 71 | /* Type Checking */ 72 | "strict": true /* Enable all strict type-checking options. */, 73 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 74 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 75 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 76 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 77 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 78 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 79 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 80 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 81 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 82 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 83 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 84 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 85 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 86 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 87 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 88 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 89 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 90 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 91 | /* Completeness */ 92 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 93 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 94 | }, 95 | "exclude": ["vitest.config.ts"] 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Zod Error

2 | 3 |
4 | 5 | [![Status](https://img.shields.io/badge/status-active-blue)](https://github.com/andrewvo89/zod-error) 6 | [![GitHub Issues](https://img.shields.io/github/issues/andrewvo89/zod-error?color=blue)](https://github.com/andrewvo89/zod-error/issues) 7 | [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/andrewvo89/zod-error?color=blue)](https://github.com/andrewvo89/zod-error/pulls) 8 | [![License](https://img.shields.io/github/license/andrewvo89/zod-error?color=blue)](/LICENSE) 9 | 10 |
11 | 12 | --- 13 | 14 |

Utilities to format and customize Zod error messages.

15 | 16 | ## Table of Contents 17 | 18 | - [About](#about) 19 | - [Installation](#installation) 20 | - [Usage](#usage) 21 | - [Authors](#authors) 22 | - [Acknowledgments](#acknowledgements) 23 | 24 | ## About 25 | 26 | Zod Error converts and formats Zod Issues into a customizable error message string that can be consumed by various applications such as browser error message modals or server api error messages. 27 | 28 | [Zod v4](https://zod.dev/v4) has a simple API to stringify errors. It may be sufficient enough for your needs: 29 | https://zod.dev/error-formatting?id=zprettifyerror 30 | 31 | ### Basic Usage 32 | 33 | Zod Error converts an array of Zod Issues that look like this: 34 | 35 | ```ts 36 | [ 37 | { 38 | code: 'invalid_type', 39 | expected: 'string', 40 | received: 'undefined', 41 | path: ['name'], 42 | message: 'Required', 43 | }, 44 | { 45 | code: 'invalid_type', 46 | expected: 'string', 47 | received: 'number', 48 | path: ['pets', 1], 49 | message: 'Expected string, received number', 50 | }, 51 | ]; 52 | ``` 53 | 54 | into this: 55 | 56 | ``` 57 | Error #1: Code: invalid_type ~ Path: name ~ Message: Required | Error #2: Code: invalid_type ~ Path: pets[1] ~ Message: Expected string, received number 58 | ``` 59 | 60 | ## Versions 61 | 62 | With the release of [Zod v4](https://zod.dev/v4), `zod-error` has moved to v2 to meet the new API. 63 | | Zod | Zod Error | 64 | |-----|-----------| 65 | | 3.x.x | 1.x.x | 66 | | 4.x.x | 2.x.x | 67 | 68 | ## Installation 69 | 70 | Install the package using your favorite package manager: 71 | 72 | ``` 73 | npm install zod-error 74 | yarn add zod-error 75 | pnpm add zod-error 76 | ``` 77 | 78 | ## Usage 79 | 80 | ### Message Format 81 | 82 | ``` 83 | 84 | 🕓 2022-07-14T20:19:52.290Z ~ Error #1: Code: invalid_type ~ Path: ratings[0].speed ~ Message: Expected number, received string 🔥 Error #2: Code: invalid_enum_value ~ Path: position ~ Message: Invalid enum value. Expected 'C' | 'PF' | 'SF' | 'SG' | 'PG', received 'Center'🔚 85 | 86 | ``` 87 | 88 | | Value | Description | 89 | | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | 90 | | `🕓 2022-07-14T20:19:15.660Z ~` | Prefix | 91 | | `~` | Component delimiter | 92 | | `Error #1:` | Added using `options.transform()` | 93 | | `Code: ` | Code label | 94 | | `invalid_type` | Code value | 95 | | `Path: ` | Path label | 96 | | `ratings[0].speed` | Path value | 97 | | `Message: ` | Message label | 98 | | `Expected number, received string` | Message value | 99 | | `🔥` | Error delimiter | 100 | | `Error #2: Code: invalid_enum_value ~ Path: position ~ Message: Invalid enum value. Expected 'C' \| 'PF' \| 'SF' \| 'SG'\| 'PG', received 'Center'` | Error from second ZodIssue from Issues array input | 101 | | `🔚` | Suffix | 102 | 103 | ### Options 104 | 105 | Error messages are completely customizable from label names to delimiters, prefixes, suffixes and the inclusion/exclusion of components (code, path, message). An options argument can be passed to any Zod Error function as the last argument to customize the error message. 106 | 107 | | Property | Value | Description | 108 | | ---------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | 109 | | code? | [CodeOptions](#codeoptions) | Options to customize the code component of the error message. | 110 | | delimiter? | [DelimiterOptions](#delimiteroptions) | Set the delimiter between error messages and between components. | 111 | | maxErrors? | number | Maximum amount of error messages to display in final concatenated string. | 112 | | message? | [MessageOptions](#messageoptions) | Options to customize the message component of the error message. | 113 | | path? | [PathOptions](#pathoptions) | Options to customize the code path of the error message. | 114 | | prefix? | string | Add a prefix to the start of the final concatenated message. | 115 | | suffix? | string | Add a suffix to the end of the final concatenated string. | 116 | | transform? | (params: [TransformErrorParams](#transformerrorparams)) => string | A custom function to transform the format of each error message. | 117 | 118 | ### CodeOptions 119 | 120 | | Property | Value | Description | 121 | | ---------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | 122 | | enabled | boolean | Display or hide the code component of the error message. Defaults to `true`. | 123 | | label? | string \| null | Set a custom label. Defaults to `Code: `. Only available if `enabled` is `true`. | 124 | | transform? | (params: [TransformComponentParams](#transformcomponentparams)) => string | A custom function to transform the format of the code component. Only available if `enabled` is `true`. | 125 | 126 | ### DelimiterOptions 127 | 128 | | Property | Value | Description | 129 | | ---------- | ------ | --------------------------------------------------------------------------------------------- | 130 | | component? | string | The delimiter between each component during the concatentation process. Defaults to `~`. | 131 | | error? | string | The delimiter between each error message during the concatentation process. Defaults to `\|`. | 132 | 133 | ### MessageOptions 134 | 135 | | Property | Value | Description | 136 | | ---------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | 137 | | enabled | boolean | Display or hide the message component of the error message. Defaults to `true`. | 138 | | label? | string \| null | Set a custom label. Defaults to `Message: `. Only available if `enabled` is `true`. | 139 | | transform? | (params: [TransformComponentParams](#transformcomponentparams)) => string | A custom function to transform the format of the message component. Only available if `enabled` is `true`. | 140 | 141 | ### PathOptions 142 | 143 | | Property | Value | Description | 144 | | -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 145 | | arraySquareBrackets? | boolean | Adds square brackets around index number in the path. Only available if `enabled` is `true` and `type` is `objectNotation` or `breadcrumbs`. Defaults to `true`. | 146 | | delimiter? | string | Set a custom delimeter between each path element. Only available if `enabled` is `true` and `type` is `breadcrumbs`. Defaults to `>`. | 147 | | enabled | boolean | Display or hide the path component of the error message. Defaults to `true`. | 148 | | label? | string \| null | Set a custom label. Defaults to `Message: `. Only available if `enabled` is `true`. | 149 | | transform? | (params: [TransformComponentParams](#transformcomponentparams)) => string | A custom function to transform the format of the message component. Only available if `enabled` is `true`. | 150 | | type | 'objectNotation' \| 'zodPathArray' \| 'breadcrumbs' | Sets the style of the path string.
objectNotation = car.wheels[1].tyre
zodPathArray = ["car", "wheels", 1, "tyre"]
breadcrumbs = car > wheels > [1] > tyre. | 151 | 152 | ### TransformComponentParams 153 | 154 | | Property | Value | Description | 155 | | --------- | ------ | ----------------------------------------------------------------- | 156 | | component | string | The transformed component string. Defaults to `${label}${value}`. | 157 | | label | string | The label of the component. | 158 | | value | string | The value of the component. | 159 | 160 | ### TransformErrorParams 161 | 162 | | Property | Value | Description | 163 | | ---------------- | ---------------- | ------------------------------------------------------------------------- | 164 | | codeComponent | string | The transformed code component string. Defaults to `${label}${value}`. | 165 | | errorMessage | string | The transformed error message consisting of all components concatentated. | 166 | | index | string | The index of the current error message. | 167 | | issue | z.core.$ZodIssue | The original ZodIssue object. | 168 | | messageComponent | string | The transformed message component string. Defaults to `${label}${value}`. | 169 | | pathComponent | string | The transformed path component string. Defaults to `${label}${value}`. | 170 | 171 | ### Examples 172 | 173 | There are 6 ways to consume Zod Error. `generateErrorMessage()`, `generateError()`, `parse()`, `parseAsync()`, `safeParse()` and `safeParseAsync()`. 174 | 175 | #### `generateErrorMessage(issues: z.core.$ZodIssue[], options?: ErrorMessageOptions): string` 176 | 177 | Formats an array of Zod Issues as a result of `z.parse()`, `z.parseAsync()`, `z.safeParse()` or `z.safeParseAsync()` and outputs as a single string. Multiple errors are concatenated into a single readable string. 178 | 179 | ```ts 180 | import { generateErrorMessage, ErrorMessageOptions } from 'zod-error'; 181 | import { z } from 'zod'; 182 | 183 | enum Color { 184 | Red = 'Red', 185 | Blue = 'Blue', 186 | } 187 | 188 | const options: ErrorMessageOptions = { 189 | delimiter: { 190 | error: ' 🔥 ', 191 | }, 192 | transform: ({ errorMessage, index }) => `Error #${index + 1}: ${errorMessage}`, 193 | }; 194 | 195 | const schema = z.object({ 196 | color: z.enum(Color), 197 | shape: z.string(), 198 | size: z.number().gt(0), 199 | }); 200 | 201 | const data = { 202 | color: 'Green', 203 | size: -1, 204 | }; 205 | 206 | const result = schema.safeParse(data); 207 | if (!result.success) { 208 | const errorMessage = generateErrorMessage(result.error.issues, options); 209 | throw new Error(errorMessage); 210 | } 211 | ``` 212 | 213 | Error Message: 214 | 215 | ``` 216 | Error #1: Code: invalid_enum_value ~ Path: color ~ Message: Invalid enum value. Expected 'Red' | 'Blue', received 'Green' 🔥 Error #2: Code: invalid_type ~ Path: shape ~ Message: Required 🔥 Error #3: Code: too_small ~ Path: size ~ Message: Number must be greater than 0 217 | ``` 218 | 219 | #### `generateError(issues: z.core.$ZodIssue[], options?: ErrorMessageOptions): Error` 220 | 221 | Formats an array of Zod Issues as a result of `z.parse()`, `z.parseAsync()`, `z.safeParse()` or `z.safeParseAsync()` and outputs as a JavaScript Error object. Multiple errors are concatenated into a single readable string. 222 | 223 | ```ts 224 | import { ErrorMessageOptions, generateError } from 'zod-error'; 225 | import { z } from 'zod'; 226 | 227 | const options: ErrorMessageOptions = { 228 | maxErrors: 2, 229 | delimiter: { 230 | component: ' - ', 231 | }, 232 | path: { 233 | enabled: true, 234 | type: 'zodPathArray', 235 | label: 'Zod Path: ', 236 | }, 237 | code: { 238 | enabled: false, 239 | }, 240 | message: { 241 | enabled: true, 242 | label: '', 243 | }, 244 | }; 245 | 246 | const schema = z.object({ 247 | dates: z.object({ 248 | purchased: z.date(), 249 | fulfilled: z.date(), 250 | }), 251 | item: z.string(), 252 | price: z.number(), 253 | }); 254 | 255 | const data = { 256 | dates: { purchased: 'yesterday' }, 257 | item: 1, 258 | price: '1,000', 259 | }; 260 | 261 | try { 262 | schema.parse(data); 263 | } catch (error) { 264 | const genericError = generateError(error, options); 265 | throw genericError; 266 | } 267 | ``` 268 | 269 | Error Message: 270 | 271 | ``` 272 | Zod Path: ["dates", "purchased"] - Expected date, received string | Zod Path: ["dates", "fulfilled"] - Required 273 | ``` 274 | 275 | #### `parse(schema: T, data: unknown, options?: ErrorMessageOptions): T['_output']` 276 | 277 | Replaces Zod's `.parse()` function by replacing Zod's `ZodError` with a generic JavaScript `Error` object where the custom formatted message can be accessed on `error.message`. 278 | 279 | ```ts 280 | import { ErrorMessageOptions, parse } from 'zod-error'; 281 | import { z } from 'zod'; 282 | 283 | const options: ErrorMessageOptions = { 284 | delimiter: { 285 | error: ' ', 286 | }, 287 | path: { 288 | enabled: true, 289 | type: 'objectNotation', 290 | transform: ({ label, value }) => `<${label}: ${value}>`, 291 | }, 292 | code: { 293 | enabled: true, 294 | transform: ({ label, value }) => `<${label}: ${value}>`, 295 | }, 296 | message: { 297 | enabled: true, 298 | transform: ({ label, value }) => `<${label}: ${value}>`, 299 | }, 300 | transform: ({ errorMessage }) => `👉 ${errorMessage} 👈`, 301 | }; 302 | 303 | const schema = z.object({ 304 | animal: z.enum(['🐶', '🐱', '🐵']), 305 | quantity: z.number().gte(1), 306 | }); 307 | 308 | const data = { 309 | animal: '🐼', 310 | quantity: 0, 311 | }; 312 | 313 | try { 314 | const safeData = parse(schema, data, options); 315 | /** 316 | * Asynchronous version 317 | * const safeData = await parseAsync(schema, data, options); 318 | */ 319 | } catch (error) { 320 | /** 321 | * Replaces ZodError with a JavaScript 322 | * Error object with custom formatted message. 323 | */ 324 | if (error instanceof Error) { 325 | console.error(error.message); 326 | } 327 | } 328 | ``` 329 | 330 | Error Message: 331 | 332 | ``` 333 | 👉 ~ ~ 👈 👉 ~ ~ 👈 334 | ``` 335 | 336 | Note: 337 | 338 | > If your schema contains an async `.refine()` or `.transform()` function, use `parseAsync()` instead. 339 | 340 | #### `safeParse(schema: T, data: unknown, options?: ErrorMessageOptions): SafeParseReturnType If your schema contains an async `.refine()` or `.transform()` function, use `safeParseAsync()` instead. 391 | 392 | ## Authors 393 | 394 | - [@andrewvo89](https://github.com/andrewvo89) - Idea & Initial work. 395 | 396 | See also the list of [contributors](https://github.com/andrewvo89/zod-error/contributors) who participated in this project. 397 | 398 | ## Acknowledgements 399 | 400 | - [Zod](https://zod.dev/) for an amazing validation library. 401 | --------------------------------------------------------------------------------