├── .nvmrc ├── src ├── globals.d.ts ├── util │ ├── isAbsent.ts │ ├── toArray.ts │ ├── isSchema.ts │ ├── parseJson.ts │ ├── sortByKeyOrder.ts │ ├── ReferenceSet.ts │ ├── sortFields.ts │ ├── types.ts │ ├── cloneDeep.ts │ ├── printValue.ts │ ├── objectTypes.ts │ ├── parseIsoDate.ts │ ├── reach.ts │ └── createValidation.ts ├── setLocale.ts ├── Condition.ts ├── Reference.ts ├── mixed.ts ├── types.ts ├── ValidationError.ts ├── boolean.ts ├── index.ts ├── standardSchema.ts ├── date.ts ├── tuple.ts ├── locale.ts ├── number.ts ├── Lazy.ts ├── array.ts ├── string.ts └── object.ts ├── .yarnrc.yml ├── .eslintignore ├── test ├── .eslintignore ├── tsconfig.json ├── types │ └── .eslintrc.js ├── setLocale.ts ├── helpers.ts ├── ValidationError.ts ├── bool.ts ├── lazy.ts ├── tuple.ts ├── date.ts ├── yup.js ├── number.ts ├── array.ts ├── util │ └── parseIsoDate.ts └── string.ts ├── renovate.json ├── tsconfig.json ├── .babelrc.js ├── .github ├── workflows │ └── ci.yml └── ISSUE_TEMPLATE │ ├── question.md │ └── bug_report.md ├── .gitattributes ├── .yarn └── patches │ └── @4c-rollout-npm-4.0.2-ab2b6d0bab.patch ├── test-setup.mjs ├── .eslintrc ├── vitest.config.ts ├── rollup.config.js ├── LICENSE.md ├── .gitignore ├── package.json └── docs └── extending.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.19.0 2 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /test/.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>4Catalyzer/renovate-config:library", ":automergeMinor"] 3 | } 4 | -------------------------------------------------------------------------------- /src/util/isAbsent.ts: -------------------------------------------------------------------------------- 1 | const isAbsent = (value: any): value is undefined | null => value == null; 2 | 3 | export default isAbsent; 4 | -------------------------------------------------------------------------------- /src/util/toArray.ts: -------------------------------------------------------------------------------- 1 | export default function toArray(value?: null | T | readonly T[]) { 2 | return value == null ? [] : ([] as T[]).concat(value); 3 | } 4 | -------------------------------------------------------------------------------- /src/util/isSchema.ts: -------------------------------------------------------------------------------- 1 | import type { ISchema } from '../types'; 2 | 3 | const isSchema = (obj: any): obj is ISchema => obj && obj.__isYupSchema__; 4 | 5 | export default isSchema; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "strictFunctionTypes": true 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "noImplicitAny": true, 6 | "types": ["node"], 7 | "rootDir": "../" 8 | }, 9 | "include": ["../src", "."] 10 | } 11 | -------------------------------------------------------------------------------- /test/types/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | tsconfigRootDir: __dirname, 5 | project: ['../tsconfig.json'], 6 | }, 7 | plugins: ['ts-expect'], 8 | rules: { 9 | 'ts-expect/expect': 'error', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/setLocale.ts: -------------------------------------------------------------------------------- 1 | import locale, { LocaleObject } from './locale'; 2 | 3 | export type { LocaleObject }; 4 | 5 | export default function setLocale(custom: LocaleObject) { 6 | Object.keys(custom).forEach((type) => { 7 | // @ts-ignore 8 | Object.keys(custom[type]!).forEach((method) => { 9 | // @ts-ignore 10 | locale[type][method] = custom[type][method]; 11 | }); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/util/parseJson.ts: -------------------------------------------------------------------------------- 1 | import type { AnySchema, TransformFunction } from '../types'; 2 | 3 | const parseJson: TransformFunction = (value, _, schema: AnySchema) => { 4 | if (typeof value !== 'string') { 5 | return value; 6 | } 7 | 8 | let parsed = value; 9 | try { 10 | parsed = JSON.parse(value); 11 | } catch (err) { 12 | /* */ 13 | } 14 | return schema.isType(parsed) ? parsed : value; 15 | }; 16 | 17 | export default parseJson; 18 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => ({ 2 | presets: [ 3 | [ 4 | 'babel-preset-env-modules', 5 | api.env() !== 'test' 6 | ? { 7 | ignoreBrowserslistConfig: true, 8 | modules: api.env() === 'esm' ? false : 'commonjs', 9 | } 10 | : { 11 | target: 'node', 12 | targets: { node: 'current' }, 13 | }, 14 | ], 15 | ['@babel/preset-typescript', { allowDeclareFields: true }], 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [master, next] 5 | pull_request: 6 | branches: [master, next] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 'lts/*' 16 | - run: | 17 | corepack enable 18 | - run: yarn install --frozen-lockfile 19 | - run: yarn test 20 | -------------------------------------------------------------------------------- /src/util/sortByKeyOrder.ts: -------------------------------------------------------------------------------- 1 | import ValidationError from '../ValidationError'; 2 | 3 | function findIndex(arr: readonly string[], err: ValidationError) { 4 | let idx = Infinity; 5 | arr.some((key, ii) => { 6 | if (err.path?.includes(key)) { 7 | idx = ii; 8 | return true; 9 | } 10 | }); 11 | return idx; 12 | } 13 | 14 | export default function sortByKeyOrder(keys: readonly string[]) { 15 | return (a: ValidationError, b: ValidationError) => { 16 | return findIndex(keys, a) - findIndex(keys, b); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: General questions about yup or how it works 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | - Write a title that summarizes the specific problem 11 | - Describe what you are trying to accomplish AND what you have tried 12 | 13 | **Help Others Reproduce** 14 | 15 | Write a **runnable** test case using the code sandbox template: https://codesandbox.io/s/yup-test-case-gg1g1 16 | 17 | > NOTE: if you do not provide a runnable reproduction the chances of getting feedback are significantly lower 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch: -------------------------------------------------------------------------------- 1 | diff --git a/command.js b/command.js 2 | index 9608bffb4a52e5b8066e263d1286420ae92988bb..86ca58d70f315dae45fc739707dcff07a99a2be4 100644 3 | --- a/command.js 4 | +++ b/command.js 5 | @@ -292,8 +292,7 @@ const handlerImpl = async (argv) => { 6 | task: () => 7 | exec('yarn', [ 8 | 'install', 9 | - '--frozen-lockfile', 10 | - '--production=false', 11 | + '--immutable', 12 | ]), 13 | } 14 | : { 15 | -------------------------------------------------------------------------------- /test-setup.mjs: -------------------------------------------------------------------------------- 1 | import { beforeAll } from 'vitest'; 2 | import { SynchronousPromise } from 'synchronous-promise'; 3 | import * as yup from './src/index.ts'; 4 | 5 | beforeAll(() => { 6 | if (global.YUP_USE_SYNC) { 7 | const { Schema } = yup; 8 | const { validateSync } = Schema.prototype; 9 | 10 | Schema.prototype.validate = function (value, options = {}) { 11 | return new SynchronousPromise((resolve, reject) => { 12 | let result; 13 | try { 14 | result = validateSync.call(this, value, options); 15 | } catch (err) { 16 | reject(err); 17 | } 18 | 19 | resolve(result); 20 | }); 21 | }; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["jason", "prettier"], 3 | "env": { 4 | "browser": true 5 | }, 6 | "parserOptions": { 7 | "requireConfigFile": false 8 | }, 9 | "rules": { 10 | "@typescript-eslint/no-shadow": "off", 11 | "@typescript-eslint/no-empty-interface": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "files": ["test/**"], 16 | "plugins": ["jest"], 17 | "env": { 18 | "jest/globals": true 19 | }, 20 | "rules": { 21 | "global-require": "off", 22 | "no-await-in-loop": "off", 23 | "jest/no-disabled-tests": "warn", 24 | "jest/no-focused-tests": "error", 25 | "jest/no-identical-title": "error", 26 | "jest/prefer-to-have-length": "warn", 27 | "@typescript-eslint/no-empty-function": "off" 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | setupFiles: ['./test-setup.mjs'], 7 | include: ['test/**/*.{js,ts}'], 8 | exclude: [ 9 | 'test/helpers.ts', 10 | 'test/.eslintrc.js', 11 | 'test/**/.eslintrc.js', 12 | 'test/types/types.ts', 13 | ], 14 | globals: false, 15 | projects: [ 16 | { 17 | extends: true, 18 | test: { 19 | name: 'async', 20 | }, 21 | define: { 22 | 'global.YUP_USE_SYNC': false, 23 | }, 24 | }, 25 | { 26 | extends: true, 27 | test: { 28 | name: 'sync', 29 | }, 30 | define: { 31 | 'global.YUP_USE_SYNC': true, 32 | }, 33 | }, 34 | ], 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | 15 | Write a **runnable** test case using the code sandbox template: https://codesandbox.io/s/yup-test-case-gg1g1 16 | In the `index.test.js` file change the passing test to a failing one demostrating your issue 17 | 18 | > NOTE: if you do not provide a runnable reproduction the chances of getting feedback are significantly lower 19 | 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Platform (please complete the following information):** 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve'; 2 | import babel from '@rollup/plugin-babel'; 3 | import dts from 'rollup-plugin-dts'; 4 | import filesize from 'rollup-plugin-filesize'; 5 | 6 | const base = { 7 | input: './src/index.ts', 8 | plugins: [ 9 | nodeResolve({ extensions: ['.js', '.ts'] }), 10 | babel({ 11 | babelrc: true, 12 | envName: 'esm', 13 | extensions: ['.js', '.ts'], 14 | }), 15 | ], 16 | external: ['tiny-case', 'toposort', 'fn-name', 'property-expr'], 17 | }; 18 | 19 | module.exports = [ 20 | { 21 | input: './dts/index.d.ts', 22 | output: [{ file: 'lib/index.d.ts', format: 'es' }], 23 | plugins: [dts()], 24 | }, 25 | { 26 | ...base, 27 | output: [ 28 | { 29 | file: 'lib/index.js', 30 | format: 'cjs', 31 | }, 32 | { 33 | file: 'lib/index.esm.js', 34 | format: 'es', 35 | }, 36 | ], 37 | plugins: [...base.plugins, filesize()], 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /src/util/ReferenceSet.ts: -------------------------------------------------------------------------------- 1 | import type { SchemaRefDescription } from '../schema'; 2 | import Reference from '../Reference'; 3 | 4 | export default class ReferenceSet extends Set { 5 | describe() { 6 | const description = [] as Array; 7 | 8 | for (const item of this.values()) { 9 | description.push(Reference.isRef(item) ? item.describe() : item); 10 | } 11 | return description; 12 | } 13 | 14 | resolveAll(resolve: (v: unknown | Reference) => unknown) { 15 | let result = [] as unknown[]; 16 | for (const item of this.values()) { 17 | result.push(resolve(item)); 18 | } 19 | return result; 20 | } 21 | 22 | clone() { 23 | return new ReferenceSet(this.values()); 24 | } 25 | 26 | merge(newItems: ReferenceSet, removeItems: ReferenceSet) { 27 | const next = this.clone(); 28 | 29 | newItems.forEach((value) => next.add(value)); 30 | removeItems.forEach((value) => next.delete(value)); 31 | return next; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jason Quense 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. -------------------------------------------------------------------------------- /src/util/sortFields.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error 2 | import toposort from 'toposort'; 3 | import { split } from 'property-expr'; 4 | 5 | import Ref from '../Reference'; 6 | import isSchema from './isSchema'; 7 | import { ObjectShape } from './objectTypes'; 8 | 9 | export default function sortFields( 10 | fields: ObjectShape, 11 | excludedEdges: readonly [string, string][] = [], 12 | ) { 13 | let edges = [] as Array<[string, string]>; 14 | let nodes = new Set(); 15 | let excludes = new Set(excludedEdges.map(([a, b]) => `${a}-${b}`)); 16 | 17 | function addNode(depPath: string, key: string) { 18 | let node = split(depPath)[0]; 19 | 20 | nodes.add(node); 21 | if (!excludes.has(`${key}-${node}`)) edges.push([key, node]); 22 | } 23 | 24 | for (const key of Object.keys(fields)) { 25 | let value = fields[key]; 26 | 27 | nodes.add(key); 28 | 29 | if (Ref.isRef(value) && value.isSibling) addNode(value.path, key); 30 | else if (isSchema(value) && 'deps' in value) 31 | (value as any).deps.forEach((path: string) => addNode(path, key)); 32 | } 33 | 34 | return toposort.array(Array.from(nodes), edges).reverse() as string[]; 35 | } 36 | -------------------------------------------------------------------------------- /test/setLocale.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { setLocale } from '../src'; 3 | import locale from '../src/locale'; 4 | 5 | describe('Custom locale', () => { 6 | it('should get default locale', () => { 7 | expect(locale.string?.email).toBe('${path} must be a valid email'); 8 | }); 9 | 10 | it('should set a new locale', () => { 11 | const dict = { 12 | string: { 13 | email: 'Invalid email', 14 | }, 15 | }; 16 | 17 | setLocale(dict); 18 | 19 | expect(locale.string?.email).toBe(dict.string.email); 20 | }); 21 | 22 | it('should update the main locale', () => { 23 | expect(locale.string?.email).toBe('Invalid email'); 24 | }); 25 | 26 | it('should not allow prototype pollution', () => { 27 | const payload = JSON.parse( 28 | '{"__proto__":{"polluted":"Yes! Its Polluted"}}', 29 | ); 30 | 31 | expect(() => setLocale(payload)).toThrowError(); 32 | 33 | expect(payload).not.toHaveProperty('polluted'); 34 | }); 35 | 36 | it('should not pollute Object.prototype builtins', () => { 37 | const payload: any = { toString: { polluted: 'oh no' } }; 38 | 39 | expect(() => setLocale(payload)).toThrowError(); 40 | 41 | expect(Object.prototype.toString).not.toHaveProperty('polluted'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/util/types.ts: -------------------------------------------------------------------------------- 1 | export type IfAny = 0 extends 1 & T ? Y : N; 2 | 3 | export type Maybe = T | null | undefined; 4 | 5 | export type Preserve = T extends U ? U : never; 6 | 7 | export type Optionals = Extract; 8 | 9 | export type Defined = T extends undefined ? never : T; 10 | 11 | export type NotNull = T extends null ? never : T; 12 | 13 | /* this seems to force TS to show the full type instead of all the wrapped generics */ 14 | export type _ = T extends {} ? { [k in keyof T]: T[k] } : T; 15 | 16 | // 17 | // Schema Config 18 | // 19 | 20 | export type Flags = 's' | 'd' | ''; 21 | 22 | export type SetFlag = Exclude | F; 23 | 24 | export type UnsetFlag = Exclude< 25 | Old, 26 | F 27 | > extends never 28 | ? '' 29 | : Exclude; 30 | 31 | export type ToggleDefault = Preserve< 32 | D, 33 | undefined 34 | > extends never 35 | ? SetFlag 36 | : UnsetFlag; 37 | 38 | export type ResolveFlags = Extract< 39 | F, 40 | 'd' 41 | > extends never 42 | ? T 43 | : D extends undefined 44 | ? T 45 | : Defined; 46 | 47 | export type Concat = NonNullable & NonNullable extends never 48 | ? never 49 | : (NonNullable & NonNullable) | Optionals; 50 | -------------------------------------------------------------------------------- /src/util/cloneDeep.ts: -------------------------------------------------------------------------------- 1 | // tweaked from https://github.com/Kelin2025/nanoclone/blob/0abeb7635bda9b68ef2277093f76dbe3bf3948e1/src/index.js 2 | // MIT licensed 3 | 4 | import isSchema from './isSchema'; 5 | 6 | function clone(src: unknown, seen: Map = new Map()) { 7 | if (isSchema(src) || !src || typeof src !== 'object') return src; 8 | if (seen.has(src)) return seen.get(src); 9 | 10 | let copy: any; 11 | if (src instanceof Date) { 12 | // Date 13 | copy = new Date(src.getTime()); 14 | seen.set(src, copy); 15 | } else if (src instanceof RegExp) { 16 | // RegExp 17 | copy = new RegExp(src); 18 | seen.set(src, copy); 19 | } else if (Array.isArray(src)) { 20 | // Array 21 | copy = new Array(src.length); 22 | seen.set(src, copy); 23 | for (let i = 0; i < src.length; i++) copy[i] = clone(src[i], seen); 24 | } else if (src instanceof Map) { 25 | // Map 26 | copy = new Map(); 27 | seen.set(src, copy); 28 | for (const [k, v] of src.entries()) copy.set(k, clone(v, seen)); 29 | } else if (src instanceof Set) { 30 | // Set 31 | copy = new Set(); 32 | seen.set(src, copy); 33 | for (const v of src) copy.add(clone(v, seen)); 34 | } else if (src instanceof Object) { 35 | // Object 36 | copy = {}; 37 | seen.set(src, copy); 38 | for (const [k, v] of Object.entries(src)) copy[k] = clone(v, seen); 39 | } else { 40 | throw Error(`Unable to clone ${src}`); 41 | } 42 | return copy; 43 | } 44 | 45 | export default clone; 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | dts/ 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # ========================= 31 | # Operating System Files 32 | # ========================= 33 | 34 | # OSX 35 | # ========================= 36 | 37 | .DS_Store 38 | .AppleDouble 39 | .LSOverride 40 | 41 | # Icon must end with two \r 42 | Icon 43 | 44 | 45 | # Thumbnails 46 | ._* 47 | 48 | # Files that might appear on external disk 49 | .Spotlight-V100 50 | .Trashes 51 | 52 | # Directories potentially created on remote AFP share 53 | .AppleDB 54 | .AppleDesktop 55 | Network Trash Folder 56 | Temporary Items 57 | .apdisk 58 | 59 | # Windows 60 | # ========================= 61 | 62 | # Windows image file caches 63 | Thumbs.db 64 | ehthumbs.db 65 | 66 | # Folder config file 67 | Desktop.ini 68 | 69 | # Recycle Bin used on file shares 70 | $RECYCLE.BIN/ 71 | 72 | # Windows Installer files 73 | *.cab 74 | *.msi 75 | *.msm 76 | *.msp 77 | 78 | # Ignore build files 79 | lib/ 80 | es/ 81 | -------------------------------------------------------------------------------- /src/util/printValue.ts: -------------------------------------------------------------------------------- 1 | const toString = Object.prototype.toString; 2 | const errorToString = Error.prototype.toString; 3 | const regExpToString = RegExp.prototype.toString; 4 | const symbolToString = 5 | typeof Symbol !== 'undefined' ? Symbol.prototype.toString : () => ''; 6 | 7 | const SYMBOL_REGEXP = /^Symbol\((.*)\)(.*)$/; 8 | 9 | function printNumber(val: any) { 10 | if (val != +val) return 'NaN'; 11 | const isNegativeZero = val === 0 && 1 / val < 0; 12 | return isNegativeZero ? '-0' : '' + val; 13 | } 14 | 15 | function printSimpleValue(val: any, quoteStrings = false) { 16 | if (val == null || val === true || val === false) return '' + val; 17 | 18 | const typeOf = typeof val; 19 | if (typeOf === 'number') return printNumber(val); 20 | if (typeOf === 'string') return quoteStrings ? `"${val}"` : val; 21 | if (typeOf === 'function') 22 | return '[Function ' + (val.name || 'anonymous') + ']'; 23 | if (typeOf === 'symbol') 24 | return symbolToString.call(val).replace(SYMBOL_REGEXP, 'Symbol($1)'); 25 | 26 | const tag = toString.call(val).slice(8, -1); 27 | if (tag === 'Date') 28 | return isNaN(val.getTime()) ? '' + val : val.toISOString(val); 29 | if (tag === 'Error' || val instanceof Error) 30 | return '[' + errorToString.call(val) + ']'; 31 | if (tag === 'RegExp') return regExpToString.call(val); 32 | 33 | return null; 34 | } 35 | 36 | export default function printValue(value: any, quoteStrings?: boolean) { 37 | let result = printSimpleValue(value, quoteStrings); 38 | if (result !== null) return result; 39 | 40 | return JSON.stringify( 41 | value, 42 | function (key, value) { 43 | let result = printSimpleValue(this[key], quoteStrings); 44 | if (result !== null) return result; 45 | return value; 46 | }, 47 | 2, 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/util/objectTypes.ts: -------------------------------------------------------------------------------- 1 | import type { Maybe, Optionals } from './types'; 2 | import type Reference from '../Reference'; 3 | import type { ISchema } from '../types'; 4 | 5 | export type ObjectShape = { [k: string]: ISchema | Reference }; 6 | 7 | export type AnyObject = { [k: string]: any }; 8 | 9 | export type ResolveStrip> = T extends ISchema< 10 | any, 11 | any, 12 | infer F 13 | > 14 | ? Extract extends never 15 | ? T['__outputType'] 16 | : never 17 | : T['__outputType']; 18 | 19 | export type TypeFromShape = { 20 | [K in keyof S]: S[K] extends ISchema 21 | ? ResolveStrip 22 | : S[K] extends Reference 23 | ? T 24 | : unknown; 25 | }; 26 | 27 | export type DefaultFromShape = { 28 | [K in keyof Shape]: Shape[K] extends ISchema 29 | ? Shape[K]['__default'] 30 | : undefined; 31 | }; 32 | 33 | export type MergeObjectTypes, U extends AnyObject> = 34 | | ({ [P in keyof T]: P extends keyof U ? U[P] : T[P] } & U) 35 | | Optionals; 36 | 37 | export type ConcatObjectTypes< 38 | T extends Maybe, 39 | U extends Maybe, 40 | > = 41 | | ({ 42 | [P in keyof T]: P extends keyof NonNullable ? NonNullable[P] : T[P]; 43 | } & U) 44 | | Optionals; 45 | 46 | export type PartialDeep = T extends 47 | | string 48 | | number 49 | | bigint 50 | | boolean 51 | | null 52 | | undefined 53 | | symbol 54 | | Date 55 | ? T | undefined 56 | : T extends Array 57 | ? Array> 58 | : T extends ReadonlyArray 59 | ? ReadonlyArray 60 | : { [K in keyof T]?: PartialDeep }; 61 | 62 | type OptionalKeys = { 63 | [k in keyof T]: undefined extends T[k] ? k : never; 64 | }[keyof T]; 65 | 66 | type RequiredKeys = Exclude>; 67 | 68 | export type MakePartial = { 69 | [k in OptionalKeys as T[k] extends never ? never : k]?: T[k]; 70 | } & { [k in RequiredKeys as T[k] extends never ? never : k]: T[k] }; 71 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { ISchema } from '../src/types'; 3 | import printValue from '../src/util/printValue'; 4 | 5 | export let castAndShouldFail = (schema: ISchema, value: any) => { 6 | expect(() => schema.cast(value)).toThrowError(TypeError); 7 | }; 8 | 9 | type Options = { 10 | invalid?: any[]; 11 | valid?: any[]; 12 | }; 13 | export let castAll = ( 14 | inst: ISchema, 15 | { invalid = [], valid = [] }: Options, 16 | ) => { 17 | valid.forEach(([value, result, schema = inst]) => { 18 | it(`should cast ${printValue(value)} to ${printValue(result)}`, () => { 19 | expect(schema.cast(value)).toBe(result); 20 | }); 21 | }); 22 | 23 | invalid.forEach((value) => { 24 | it(`should not cast ${printValue(value)}`, () => { 25 | castAndShouldFail(inst, value); 26 | }); 27 | }); 28 | }; 29 | 30 | export let validateAll = ( 31 | inst: ISchema, 32 | { valid = [], invalid = [] }: Options, 33 | ) => { 34 | describe('valid:', () => { 35 | runValidations(valid, true); 36 | }); 37 | 38 | describe('invalid:', () => { 39 | runValidations(invalid, false); 40 | }); 41 | 42 | function runValidations(arr: any[], isValid: boolean) { 43 | arr.forEach((config) => { 44 | let message = '', 45 | value = config, 46 | schema = inst; 47 | 48 | if (Array.isArray(config)) [value, schema, message = ''] = config; 49 | 50 | it(`${printValue(value)}${message && ` (${message})`}`, async () => { 51 | await expect((schema as any).isValid(value)).resolves.toEqual(isValid); 52 | }); 53 | }); 54 | } 55 | }; 56 | 57 | export function validationErrorWithMessages(...errors: any[]) { 58 | return expect.objectContaining({ 59 | errors, 60 | }); 61 | } 62 | 63 | export function ensureSync(fn: () => Promise) { 64 | let run = false; 65 | let resolve = (t: any) => { 66 | if (!run) return t; 67 | throw new Error('Did not execute synchronously'); 68 | }; 69 | let err = (t: any) => { 70 | if (!run) throw t; 71 | throw new Error('Did not execute synchronously'); 72 | }; 73 | 74 | let result = fn().then(resolve, err); 75 | 76 | run = true; 77 | return result; 78 | } 79 | -------------------------------------------------------------------------------- /test/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import ValidationError from '../src/ValidationError'; 3 | 4 | describe('ValidationError', () => { 5 | describe('formatError', () => { 6 | it('should insert the params into the message', () => { 7 | const str = ValidationError.formatError('Some message ${param}', { 8 | param: 'here', 9 | }); 10 | expect(str).toContain('here'); 11 | }); 12 | 13 | it(`should auto include any param named 'label' or 'path' as the 'path' param`, () => { 14 | const str = ValidationError.formatError('${path} goes here', { 15 | label: 'label', 16 | }); 17 | expect(str).toContain('label'); 18 | }); 19 | 20 | it(`should use 'this' if a 'label' or 'path' param is not provided`, () => { 21 | const str = ValidationError.formatError('${path} goes here', {}); 22 | expect(str).toContain('this'); 23 | }); 24 | 25 | it(`should include "undefined" in the message if undefined is provided as a param`, () => { 26 | const str = ValidationError.formatError('${path} value is ${min}', { 27 | min: undefined, 28 | }); 29 | expect(str).toContain('undefined'); 30 | }); 31 | 32 | it(`should include "null" in the message if null is provided as a param`, () => { 33 | const str = ValidationError.formatError('${path} value is ${min}', { 34 | min: null, 35 | }); 36 | expect(str).toContain('null'); 37 | }); 38 | 39 | it(`should include "NaN" in the message if null is provided as a param`, () => { 40 | const str = ValidationError.formatError('${path} value is ${min}', { 41 | min: NaN, 42 | }); 43 | expect(str).toContain('NaN'); 44 | }); 45 | 46 | it(`should include 0 in the message if 0 is provided as a param`, () => { 47 | const str = ValidationError.formatError('${path} value is ${min}', { 48 | min: 0, 49 | }); 50 | expect(str).toContain('0'); 51 | }); 52 | }); 53 | 54 | it('should disable stacks', () => { 55 | const disabled = new ValidationError('error', 1, 'field', 'type', true); 56 | 57 | expect(disabled.constructor.name).toEqual('ValidationErrorNoStack'); 58 | expect(disabled).toBeInstanceOf(ValidationError); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/Condition.ts: -------------------------------------------------------------------------------- 1 | import isSchema from './util/isSchema'; 2 | import Reference from './Reference'; 3 | import type { ISchema } from './types'; 4 | 5 | export type ConditionBuilder> = ( 6 | values: any[], 7 | schema: T, 8 | options: ResolveOptions, 9 | ) => ISchema; 10 | 11 | export type ConditionConfig> = { 12 | is: any | ((...values: any[]) => boolean); 13 | then?: (schema: T) => ISchema; 14 | otherwise?: (schema: T) => ISchema; 15 | }; 16 | 17 | export type ResolveOptions = { 18 | value?: any; 19 | parent?: any; 20 | context?: TContext; 21 | }; 22 | 23 | class Condition = ISchema> { 24 | fn: ConditionBuilder; 25 | 26 | static fromOptions>( 27 | refs: Reference[], 28 | config: ConditionConfig, 29 | ) { 30 | if (!config.then && !config.otherwise) 31 | throw new TypeError( 32 | 'either `then:` or `otherwise:` is required for `when()` conditions', 33 | ); 34 | 35 | let { is, then, otherwise } = config; 36 | 37 | let check = 38 | typeof is === 'function' 39 | ? is 40 | : (...values: any[]) => values.every((value) => value === is); 41 | 42 | return new Condition(refs, (values, schema: any) => { 43 | let branch = check(...values) ? then : otherwise; 44 | 45 | return branch?.(schema) ?? schema; 46 | }); 47 | } 48 | 49 | constructor( 50 | public refs: readonly Reference[], 51 | builder: ConditionBuilder, 52 | ) { 53 | this.refs = refs; 54 | this.fn = builder; 55 | } 56 | 57 | resolve(base: TIn, options: ResolveOptions) { 58 | let values = this.refs.map((ref) => 59 | // TODO: ? operator here? 60 | ref.getValue(options?.value, options?.parent, options?.context), 61 | ); 62 | 63 | let schema = this.fn(values, base, options); 64 | 65 | if ( 66 | schema === undefined || 67 | // @ts-ignore this can be base 68 | schema === base 69 | ) { 70 | return base; 71 | } 72 | 73 | if (!isSchema(schema)) 74 | throw new TypeError('conditions must return a schema object'); 75 | 76 | return schema.resolve(options); 77 | } 78 | } 79 | 80 | export default Condition; 81 | -------------------------------------------------------------------------------- /test/bool.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { bool } from '../src'; 3 | import * as TestHelpers from './helpers'; 4 | 5 | describe('Boolean types', () => { 6 | it('should CAST correctly', () => { 7 | let inst = bool(); 8 | 9 | expect(inst.cast('true')).toBe(true); 10 | expect(inst.cast('True')).toBe(true); 11 | expect(inst.cast('false')).toBe(false); 12 | expect(inst.cast('False')).toBe(false); 13 | expect(inst.cast(1)).toBe(true); 14 | expect(inst.cast(0)).toBe(false); 15 | 16 | TestHelpers.castAndShouldFail(inst, 'foo'); 17 | 18 | TestHelpers.castAndShouldFail(inst, 'bar1'); 19 | }); 20 | 21 | it('should handle DEFAULT', () => { 22 | let inst = bool(); 23 | 24 | expect(inst.getDefault()).toBeUndefined(); 25 | expect(inst.default(true).required().getDefault()).toBe(true); 26 | }); 27 | 28 | it('should type check', () => { 29 | let inst = bool(); 30 | 31 | expect(inst.isType(1)).toBe(false); 32 | expect(inst.isType(false)).toBe(true); 33 | expect(inst.isType('true')).toBe(false); 34 | expect(inst.isType(NaN)).toBe(false); 35 | expect(inst.isType(new Number('foooo'))).toBe(false); 36 | 37 | expect(inst.isType(34545)).toBe(false); 38 | expect(inst.isType(new Boolean(false))).toBe(true); 39 | 40 | expect(inst.isType(null)).toBe(false); 41 | 42 | expect(inst.nullable().isType(null)).toBe(true); 43 | }); 44 | 45 | it('bool should VALIDATE correctly', () => { 46 | let inst = bool().required(); 47 | 48 | return Promise.all([ 49 | expect(bool().isValid('1')).resolves.toBe(true), 50 | expect(bool().strict().isValid(null)).resolves.toBe(false), 51 | expect(bool().nullable().isValid(null)).resolves.toBe(true), 52 | expect(inst.validate(undefined)).rejects.toEqual( 53 | expect.objectContaining({ 54 | errors: ['this is a required field'], 55 | }), 56 | ), 57 | ]); 58 | }); 59 | 60 | it('should check isTrue correctly', () => { 61 | return Promise.all([ 62 | expect(bool().isTrue().isValid(true)).resolves.toBe(true), 63 | expect(bool().isTrue().isValid(false)).resolves.toBe(false), 64 | ]); 65 | }); 66 | 67 | it('should check isFalse correctly', () => { 68 | return Promise.all([ 69 | expect(bool().isFalse().isValid(false)).resolves.toBe(true), 70 | expect(bool().isFalse().isValid(true)).resolves.toBe(false), 71 | ]); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/lazy.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { lazy, object, mixed, ValidationError } from '../src'; 3 | 4 | describe('lazy', function () { 5 | it('should throw on a non-schema value', () => { 6 | // @ts-expect-error testing incorrect usage 7 | expect(() => lazy(() => undefined).validateSync(undefined)).toThrowError(); 8 | }); 9 | 10 | describe('mapper', () => { 11 | const value = 1; 12 | let mapper: any; 13 | 14 | beforeEach(() => { 15 | mapper = vi.fn(() => mixed()); 16 | }); 17 | 18 | it('should call with value', () => { 19 | lazy(mapper).validate(value); 20 | expect(mapper).toHaveBeenCalledWith(value, expect.any(Object)); 21 | }); 22 | 23 | it('should call with context', () => { 24 | const context = { 25 | a: 1, 26 | }; 27 | let options = { context }; 28 | lazy(mapper).validate(value, options); 29 | expect(mapper).toHaveBeenCalledWith(value, options); 30 | }); 31 | 32 | it('should call with context when nested: #1799', () => { 33 | let context = { a: 1 }; 34 | let value = { lazy: 1 }; 35 | let options = { context }; 36 | 37 | object({ 38 | lazy: lazy(mapper), 39 | }).validate(value, options); 40 | 41 | lazy(mapper).validate(value, options); 42 | expect(mapper).toHaveBeenCalledWith(value, options); 43 | }); 44 | 45 | it('should allow meta', () => { 46 | const meta = { a: 1 }; 47 | const schema = lazy(mapper).meta(meta); 48 | 49 | expect(schema.meta()).toEqual(meta); 50 | 51 | expect(schema.meta({ added: true })).not.toEqual(schema.meta()); 52 | 53 | expect(schema.meta({ added: true }).meta()).toEqual({ 54 | a: 1, 55 | added: true, 56 | }); 57 | }); 58 | 59 | it('should allow throwing validation error in builder', async () => { 60 | const schema = lazy(() => { 61 | throw new ValidationError('oops'); 62 | }); 63 | 64 | await expect(schema.validate(value)).rejects.toThrowError('oops'); 65 | await expect(schema.isValid(value)).resolves.toEqual(false); 66 | 67 | expect(() => schema.validateSync(value)).toThrowError('oops'); 68 | 69 | const schema2 = lazy(() => { 70 | throw new Error('error'); 71 | }); 72 | // none validation errors are thrown sync to maintain back compat 73 | expect(() => schema2.validate(value)).toThrowError('error'); 74 | expect(() => schema2.isValid(value)).toThrowError('error'); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/Reference.ts: -------------------------------------------------------------------------------- 1 | import { getter } from 'property-expr'; 2 | import type { SchemaRefDescription } from './schema'; 3 | 4 | const prefixes = { 5 | context: '$', 6 | value: '.', 7 | } as const; 8 | 9 | export type ReferenceOptions = { 10 | map?: (value: unknown) => TValue; 11 | }; 12 | 13 | export function create( 14 | key: string, 15 | options?: ReferenceOptions, 16 | ) { 17 | return new Reference(key, options); 18 | } 19 | 20 | export default class Reference { 21 | readonly key: string; 22 | readonly isContext: boolean; 23 | readonly isValue: boolean; 24 | readonly isSibling: boolean; 25 | readonly path: any; 26 | 27 | readonly getter: (data: unknown) => unknown; 28 | readonly map?: (value: unknown) => TValue; 29 | 30 | declare readonly __isYupRef: boolean; 31 | 32 | constructor(key: string, options: ReferenceOptions = {}) { 33 | if (typeof key !== 'string') 34 | throw new TypeError('ref must be a string, got: ' + key); 35 | 36 | this.key = key.trim(); 37 | 38 | if (key === '') throw new TypeError('ref must be a non-empty string'); 39 | 40 | this.isContext = this.key[0] === prefixes.context; 41 | this.isValue = this.key[0] === prefixes.value; 42 | this.isSibling = !this.isContext && !this.isValue; 43 | 44 | let prefix = this.isContext 45 | ? prefixes.context 46 | : this.isValue 47 | ? prefixes.value 48 | : ''; 49 | 50 | this.path = this.key.slice(prefix.length); 51 | this.getter = this.path && getter(this.path, true); 52 | this.map = options.map; 53 | } 54 | 55 | getValue(value: any, parent?: {}, context?: {}): TValue { 56 | let result = this.isContext ? context : this.isValue ? value : parent; 57 | 58 | if (this.getter) result = this.getter(result || {}); 59 | 60 | if (this.map) result = this.map(result); 61 | 62 | return result; 63 | } 64 | 65 | /** 66 | * 67 | * @param {*} value 68 | * @param {Object} options 69 | * @param {Object=} options.context 70 | * @param {Object=} options.parent 71 | */ 72 | cast(value: any, options?: { parent?: {}; context?: {} }) { 73 | return this.getValue(value, options?.parent, options?.context); 74 | } 75 | 76 | resolve() { 77 | return this; 78 | } 79 | 80 | describe(): SchemaRefDescription { 81 | return { 82 | type: 'ref', 83 | key: this.key, 84 | }; 85 | } 86 | 87 | toString() { 88 | return `Ref(${this.key})`; 89 | } 90 | 91 | static isRef(value: any): value is Reference { 92 | return value && value.__isYupRef; 93 | } 94 | } 95 | 96 | // @ts-ignore 97 | Reference.prototype.__isYupRef = true; 98 | -------------------------------------------------------------------------------- /src/util/parseIsoDate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a modified version of the file from the following repository: 3 | * Date.parse with progressive enhancement for ISO 8601 4 | * NON-CONFORMANT EDITION. 5 | * © 2011 Colin Snover 6 | * Released under MIT license. 7 | */ 8 | 9 | // prettier-ignore 10 | // 1 YYYY 2 MM 3 DD 4 HH 5 mm 6 ss 7 msec 8 Z 9 ± 10 tzHH 11 tzmm 11 | const isoReg = /^(\d{4}|[+-]\d{6})(?:-?(\d{2})(?:-?(\d{2}))?)?(?:[ T]?(\d{2}):?(\d{2})(?::?(\d{2})(?:[,.](\d{1,}))?)?(?:(Z)|([+-])(\d{2})(?::?(\d{2}))?)?)?$/; 12 | 13 | export function parseIsoDate(date: string): number { 14 | const struct = parseDateStruct(date); 15 | if (!struct) return Date.parse ? Date.parse(date) : Number.NaN; 16 | 17 | // timestamps without timezone identifiers should be considered local time 18 | if (struct.z === undefined && struct.plusMinus === undefined) { 19 | return new Date( 20 | struct.year, 21 | struct.month, 22 | struct.day, 23 | struct.hour, 24 | struct.minute, 25 | struct.second, 26 | struct.millisecond, 27 | ).valueOf(); 28 | } 29 | 30 | let totalMinutesOffset = 0; 31 | if (struct.z !== 'Z' && struct.plusMinus !== undefined) { 32 | totalMinutesOffset = struct.hourOffset * 60 + struct.minuteOffset; 33 | if (struct.plusMinus === '+') totalMinutesOffset = 0 - totalMinutesOffset; 34 | } 35 | 36 | return Date.UTC( 37 | struct.year, 38 | struct.month, 39 | struct.day, 40 | struct.hour, 41 | struct.minute + totalMinutesOffset, 42 | struct.second, 43 | struct.millisecond, 44 | ); 45 | } 46 | 47 | export function parseDateStruct(date: string) { 48 | const regexResult = isoReg.exec(date); 49 | if (!regexResult) return null; 50 | 51 | // use of toNumber() avoids NaN timestamps caused by “undefined” 52 | // values being passed to Date constructor 53 | return { 54 | year: toNumber(regexResult[1]), 55 | month: toNumber(regexResult[2], 1) - 1, 56 | day: toNumber(regexResult[3], 1), 57 | hour: toNumber(regexResult[4]), 58 | minute: toNumber(regexResult[5]), 59 | second: toNumber(regexResult[6]), 60 | millisecond: regexResult[7] 61 | ? // allow arbitrary sub-second precision beyond milliseconds 62 | toNumber(regexResult[7].substring(0, 3)) 63 | : 0, 64 | precision: regexResult[7]?.length ?? undefined, 65 | z: regexResult[8] || undefined, 66 | plusMinus: regexResult[9] || undefined, 67 | hourOffset: toNumber(regexResult[10]), 68 | minuteOffset: toNumber(regexResult[11]), 69 | }; 70 | } 71 | 72 | function toNumber(str: string, defaultValue = 0) { 73 | return Number(str) || defaultValue; 74 | } 75 | -------------------------------------------------------------------------------- /src/util/reach.ts: -------------------------------------------------------------------------------- 1 | import { forEach } from 'property-expr'; 2 | import type Reference from '../Reference'; 3 | import type { InferType, ISchema } from '../types'; 4 | import type { Get } from 'type-fest'; 5 | 6 | export function getIn( 7 | schema: any, 8 | path: string, 9 | value?: any, 10 | context: C = value, 11 | ): { 12 | schema: ISchema | Reference; 13 | parent: any; 14 | parentPath: string; 15 | } { 16 | let parent: any, lastPart: string, lastPartDebug: string; 17 | 18 | // root path: '' 19 | if (!path) return { parent, parentPath: path, schema }; 20 | 21 | forEach(path, (_part, isBracket, isArray) => { 22 | let part = isBracket ? _part.slice(1, _part.length - 1) : _part; 23 | 24 | schema = schema.resolve({ context, parent, value }); 25 | 26 | let isTuple = schema.type === 'tuple'; 27 | let idx = isArray ? parseInt(part, 10) : 0; 28 | 29 | if (schema.innerType || isTuple) { 30 | if (isTuple && !isArray) 31 | throw new Error( 32 | `Yup.reach cannot implicitly index into a tuple type. the path part "${lastPartDebug}" must contain an index to the tuple element, e.g. "${lastPartDebug}[0]"`, 33 | ); 34 | if (value && idx >= value.length) { 35 | throw new Error( 36 | `Yup.reach cannot resolve an array item at index: ${_part}, in the path: ${path}. ` + 37 | `because there is no value at that index. `, 38 | ); 39 | } 40 | parent = value; 41 | value = value && value[idx]; 42 | schema = isTuple ? schema.spec.types[idx] : schema.innerType!; 43 | } 44 | 45 | // sometimes the array index part of a path doesn't exist: "nested.arr.child" 46 | // in these cases the current part is the next schema and should be processed 47 | // in this iteration. For cases where the index signature is included this 48 | // check will fail and we'll handle the `child` part on the next iteration like normal 49 | if (!isArray) { 50 | if (!schema.fields || !schema.fields[part]) 51 | throw new Error( 52 | `The schema does not contain the path: ${path}. ` + 53 | `(failed at: ${lastPartDebug} which is a type: "${schema.type}")`, 54 | ); 55 | 56 | parent = value; 57 | value = value && value[part]; 58 | schema = schema.fields[part]; 59 | } 60 | 61 | lastPart = part; 62 | lastPartDebug = isBracket ? '[' + _part + ']' : '.' + _part; 63 | }); 64 | 65 | return { schema, parent, parentPath: lastPart! }; 66 | } 67 | 68 | function reach

>( 69 | obj: S, 70 | path: P, 71 | value?: any, 72 | context?: any, 73 | ): 74 | | Reference, P>> 75 | | ISchema, P>, S['__context']> { 76 | return getIn(obj, path, value, context).schema as any; 77 | } 78 | 79 | export default reach; 80 | -------------------------------------------------------------------------------- /src/mixed.ts: -------------------------------------------------------------------------------- 1 | import { AnyObject, DefaultThunk, Message } from './types'; 2 | import type { 3 | Concat, 4 | Defined, 5 | Flags, 6 | SetFlag, 7 | Maybe, 8 | ToggleDefault, 9 | UnsetFlag, 10 | } from './util/types'; 11 | import Schema from './schema'; 12 | 13 | const returnsTrue: any = () => true; 14 | 15 | type AnyPresentValue = {}; 16 | 17 | export type TypeGuard = (value: any) => value is NonNullable; 18 | export interface MixedOptions { 19 | type?: string; 20 | check?: TypeGuard; 21 | } 22 | 23 | export function create( 24 | spec?: MixedOptions | TypeGuard, 25 | ) { 26 | return new MixedSchema(spec); 27 | } 28 | 29 | export default class MixedSchema< 30 | TType extends Maybe = AnyPresentValue | undefined, 31 | TContext = AnyObject, 32 | TDefault = undefined, 33 | TFlags extends Flags = '', 34 | > extends Schema { 35 | constructor(spec?: MixedOptions | TypeGuard) { 36 | super( 37 | typeof spec === 'function' 38 | ? { type: 'mixed', check: spec } 39 | : { type: 'mixed', check: returnsTrue as TypeGuard, ...spec }, 40 | ); 41 | } 42 | } 43 | 44 | export default interface MixedSchema< 45 | TType extends Maybe = AnyPresentValue | undefined, 46 | TContext = AnyObject, 47 | TDefault = undefined, 48 | TFlags extends Flags = '', 49 | > extends Schema { 50 | default>( 51 | def: DefaultThunk, 52 | ): MixedSchema>; 53 | 54 | concat( 55 | schema: MixedSchema, 56 | ): MixedSchema, TContext & IC, ID, TFlags | IF>; 57 | concat( 58 | schema: Schema, 59 | ): MixedSchema, TContext & IC, ID, TFlags | IF>; 60 | concat(schema: this): this; 61 | 62 | defined( 63 | msg?: Message, 64 | ): MixedSchema, TContext, TDefault, TFlags>; 65 | optional(): MixedSchema; 66 | 67 | required( 68 | msg?: Message, 69 | ): MixedSchema, TContext, TDefault, TFlags>; 70 | notRequired(): MixedSchema, TContext, TDefault, TFlags>; 71 | 72 | nullable( 73 | msg?: Message, 74 | ): MixedSchema; 75 | 76 | nonNullable( 77 | msg?: Message, 78 | ): MixedSchema, TContext, TDefault, TFlags>; 79 | 80 | strip( 81 | enabled: false, 82 | ): MixedSchema>; 83 | strip( 84 | enabled?: true, 85 | ): MixedSchema>; 86 | } 87 | 88 | create.prototype = MixedSchema.prototype; 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yup", 3 | "version": "1.7.1", 4 | "description": "Dead simple Object schema validation", 5 | "main": "lib/index.js", 6 | "module": "lib/index.esm.js", 7 | "runkitExampleFilename": "./runkit-example.js", 8 | "scripts": { 9 | "test": "yarn lint && yarn testonly", 10 | "testonly": "vitest run", 11 | "test-sync": "vitest run --project sync", 12 | "tdd": "vitest --project async", 13 | "lint": "eslint src test", 14 | "precommit": "lint-staged", 15 | "toc": "doctoc README.md --github", 16 | "release": "rollout", 17 | "build:dts": "yarn tsc --emitDeclarationOnly -p . --outDir dts", 18 | "build": "rm -rf dts && yarn build:dts && yarn rollup -c rollup.config.js && yarn toc", 19 | "prepublishOnly": "yarn build" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/jquense/yup.git" 24 | }, 25 | "author": { 26 | "name": "@monasticpanic Jason Quense" 27 | }, 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/jquense/yup/issues" 31 | }, 32 | "homepage": "https://github.com/jquense/yup", 33 | "release": { 34 | "conventionalCommits": true, 35 | "publishDir": "lib" 36 | }, 37 | "prettier": { 38 | "singleQuote": true, 39 | "trailingComma": "all" 40 | }, 41 | "lint-staged": { 42 | "*.{js,json,css,md}": [ 43 | "prettier --write", 44 | "git add" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@4c/cli": "^4.0.4", 49 | "@4c/rollout": "patch:@4c/rollout@npm%3A4.0.2#~/.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch", 50 | "@4c/tsconfig": "^0.4.1", 51 | "@babel/cli": "^7.28.3", 52 | "@babel/core": "^7.28.4", 53 | "@babel/preset-typescript": "^7.27.1", 54 | "@rollup/plugin-babel": "^5.3.1", 55 | "@rollup/plugin-node-resolve": "^13.3.0", 56 | "@standard-schema/spec": "^1.0.0", 57 | "@typescript-eslint/eslint-plugin": "^5.62.0", 58 | "@typescript-eslint/parser": "^5.62.0", 59 | "babel-jest": "^27.5.1", 60 | "babel-preset-env-modules": "^1.0.1", 61 | "doctoc": "^2.2.1", 62 | "dts-bundle-generator": "^6.13.0", 63 | "eslint": "^8.57.1", 64 | "eslint-config-jason": "^8.2.2", 65 | "eslint-config-prettier": "^8.10.2", 66 | "eslint-plugin-import": "^2.32.0", 67 | "eslint-plugin-jest": "^25.7.0", 68 | "eslint-plugin-react": "^7.37.5", 69 | "eslint-plugin-react-hooks": "^4.6.2", 70 | "eslint-plugin-ts-expect": "^2.1.0", 71 | "eslint-plugin-typescript": "^0.14.0", 72 | "hookem": "^2.0.1", 73 | "lint-staged": "^13.3.0", 74 | "prettier": "^2.8.8", 75 | "rollup": "^2.79.2", 76 | "rollup-plugin-babel": "^4.4.0", 77 | "rollup-plugin-dts": "^4.2.3", 78 | "rollup-plugin-filesize": "^9.1.2", 79 | "rollup-plugin-node-resolve": "^5.2.0", 80 | "synchronous-promise": "^2.0.17", 81 | "typescript": "^4.9.5", 82 | "vitest": "^3.2.4" 83 | }, 84 | "dependencies": { 85 | "property-expr": "^2.0.5", 86 | "tiny-case": "^1.0.3", 87 | "toposort": "^2.0.2", 88 | "type-fest": "^2.19.0" 89 | }, 90 | "packageManager": "yarn@4.10.0+sha512.8dd111dbb1658cf17089636e5bf490795958158755f36cb75c5a2db0bda6be4d84b95447753627f3330d1457cb6f7e8c1e466eaed959073c82be0242c2cd41e7", 91 | "resolutions": { 92 | "@4c/rollout@npm:^4.0.2": "patch:@4c/rollout@npm%3A4.0.2#~/.yarn/patches/@4c-rollout-npm-4.0.2-ab2b6d0bab.patch" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ResolveOptions } from './Condition'; 2 | import type { 3 | AnySchema, 4 | CastOptionalityOptions, 5 | CastOptions, 6 | SchemaFieldDescription, 7 | SchemaSpec, 8 | } from './schema'; 9 | import type { Test } from './util/createValidation'; 10 | import type { AnyObject } from './util/objectTypes'; 11 | import type { Flags } from './util/types'; 12 | 13 | export type { AnyObject, AnySchema }; 14 | 15 | export interface ISchema { 16 | __flags: F; 17 | __context: C; 18 | __outputType: T; 19 | __default: D; 20 | 21 | cast(value: any, options?: CastOptions): T; 22 | cast(value: any, options: CastOptionalityOptions): T | null | undefined; 23 | 24 | validate(value: any, options?: ValidateOptions): Promise; 25 | 26 | asNestedTest(config: NestedTestConfig): Test; 27 | 28 | describe(options?: ResolveOptions): SchemaFieldDescription; 29 | resolve(options: ResolveOptions): ISchema; 30 | } 31 | 32 | export type DefaultThunk = T | ((options?: ResolveOptions) => T); 33 | 34 | export type InferType> = T['__outputType']; 35 | 36 | export type TransformFunction = ( 37 | this: T, 38 | value: any, 39 | originalValue: any, 40 | schema: T, 41 | options: CastOptions, 42 | ) => any; 43 | 44 | export interface Ancester { 45 | schema: ISchema; 46 | value: any; 47 | } 48 | export interface ValidateOptions { 49 | /** 50 | * Only validate the input, skipping type casting and transformation. Default - false 51 | */ 52 | strict?: boolean; 53 | /** 54 | * Return from validation methods on the first error rather than after all validations run. Default - true 55 | */ 56 | abortEarly?: boolean; 57 | /** 58 | * Remove unspecified keys from objects. Default - false 59 | */ 60 | stripUnknown?: boolean; 61 | /** 62 | * When false validations will not descend into nested schema (relevant for objects or arrays). Default - true 63 | */ 64 | recursive?: boolean; 65 | /** 66 | * When true ValidationError instance won't include stack trace information. Default - false 67 | */ 68 | disableStackTrace?: boolean; 69 | /** 70 | * Any context needed for validating schema conditions (see: when()) 71 | */ 72 | context?: TContext; 73 | } 74 | 75 | export interface InternalOptions 76 | extends ValidateOptions { 77 | __validating?: boolean; 78 | originalValue?: any; 79 | index?: number; 80 | key?: string; 81 | parent?: any; 82 | path?: string; 83 | sync?: boolean; 84 | from?: Ancester[]; 85 | } 86 | 87 | export interface MessageParams { 88 | path: string; 89 | value: any; 90 | originalValue: any; 91 | originalPath: string; 92 | label: string; 93 | type: string; 94 | spec: SchemaSpec & Record; 95 | } 96 | 97 | export type Message = any> = 98 | | string 99 | | ((params: Extra & MessageParams) => unknown) 100 | | Record; 101 | 102 | export type ExtraParams = Record; 103 | 104 | export type AnyMessageParams = MessageParams & ExtraParams; 105 | 106 | export interface NestedTestConfig { 107 | options: InternalOptions; 108 | parent: any; 109 | originalParent: any; 110 | parentPath: string | undefined; 111 | key?: string; 112 | index?: number; 113 | } 114 | -------------------------------------------------------------------------------- /src/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import printValue from './util/printValue'; 2 | import toArray from './util/toArray'; 3 | 4 | let strReg = /\$\{\s*(\w+)\s*\}/g; 5 | 6 | type Params = Record; 7 | 8 | class ValidationErrorNoStack implements ValidationError { 9 | name: string; 10 | message: string; 11 | 12 | value: any; 13 | path?: string; 14 | type?: string; 15 | params?: Params; 16 | 17 | errors: string[]; 18 | inner: ValidationError[]; 19 | 20 | constructor( 21 | errorOrErrors: string | ValidationError | readonly ValidationError[], 22 | value?: any, 23 | field?: string, 24 | type?: string, 25 | ) { 26 | this.name = 'ValidationError'; 27 | this.value = value; 28 | this.path = field; 29 | this.type = type; 30 | 31 | this.errors = []; 32 | this.inner = []; 33 | 34 | toArray(errorOrErrors).forEach((err) => { 35 | if (ValidationError.isError(err)) { 36 | this.errors.push(...err.errors); 37 | const innerErrors = err.inner.length ? err.inner : [err]; 38 | this.inner.push(...innerErrors); 39 | } else { 40 | this.errors.push(err); 41 | } 42 | }); 43 | 44 | this.message = 45 | this.errors.length > 1 46 | ? `${this.errors.length} errors occurred` 47 | : this.errors[0]; 48 | } 49 | 50 | [Symbol.toStringTag] = 'Error'; 51 | } 52 | 53 | export default class ValidationError extends Error { 54 | value: any; 55 | path?: string; 56 | type?: string; 57 | params?: Params; 58 | 59 | errors: string[] = []; 60 | inner: ValidationError[] = []; 61 | 62 | static formatError( 63 | message: string | ((params: Params) => string) | unknown, 64 | params: Params, 65 | ) { 66 | // Attempt to make the path more friendly for error message interpolation. 67 | const path = params.label || params.path || 'this'; 68 | // Store the original path under `originalPath` so it isn't lost to custom 69 | // message functions; e.g., ones provided in `setLocale()` calls. 70 | params = { ...params, path, originalPath: params.path }; 71 | 72 | if (typeof message === 'string') 73 | return message.replace(strReg, (_, key) => printValue(params[key])); 74 | if (typeof message === 'function') return message(params); 75 | 76 | return message; 77 | } 78 | 79 | static isError(err: any): err is ValidationError { 80 | return err && err.name === 'ValidationError'; 81 | } 82 | 83 | constructor( 84 | errorOrErrors: string | ValidationError | readonly ValidationError[], 85 | value?: any, 86 | field?: string, 87 | type?: string, 88 | disableStack?: boolean, 89 | ) { 90 | const errorNoStack = new ValidationErrorNoStack( 91 | errorOrErrors, 92 | value, 93 | field, 94 | type, 95 | ); 96 | 97 | if (disableStack) { 98 | return errorNoStack; 99 | } 100 | 101 | super(); 102 | 103 | this.name = errorNoStack.name; 104 | this.message = errorNoStack.message; 105 | this.type = errorNoStack.type; 106 | this.value = errorNoStack.value; 107 | this.path = errorNoStack.path; 108 | this.errors = errorNoStack.errors; 109 | this.inner = errorNoStack.inner; 110 | 111 | if (Error.captureStackTrace) { 112 | Error.captureStackTrace(this, ValidationError); 113 | } 114 | } 115 | 116 | static [Symbol.hasInstance](inst: any) { 117 | return ( 118 | ValidationErrorNoStack[Symbol.hasInstance](inst) || 119 | super[Symbol.hasInstance](inst) 120 | ); 121 | } 122 | 123 | [Symbol.toStringTag] = 'Error'; 124 | } 125 | -------------------------------------------------------------------------------- /src/boolean.ts: -------------------------------------------------------------------------------- 1 | import Schema from './schema'; 2 | import type { AnyObject, DefaultThunk, Message } from './types'; 3 | import type { 4 | Defined, 5 | Flags, 6 | NotNull, 7 | SetFlag, 8 | ToggleDefault, 9 | UnsetFlag, 10 | Maybe, 11 | Optionals, 12 | } from './util/types'; 13 | import { boolean as locale } from './locale'; 14 | import isAbsent from './util/isAbsent'; 15 | 16 | export function create(): BooleanSchema; 17 | export function create< 18 | T extends boolean, 19 | TContext extends Maybe = AnyObject, 20 | >(): BooleanSchema; 21 | export function create() { 22 | return new BooleanSchema(); 23 | } 24 | 25 | export default class BooleanSchema< 26 | TType extends Maybe = boolean | undefined, 27 | TContext = AnyObject, 28 | TDefault = undefined, 29 | TFlags extends Flags = '', 30 | > extends Schema { 31 | constructor() { 32 | super({ 33 | type: 'boolean', 34 | check(v: any): v is NonNullable { 35 | if (v instanceof Boolean) v = v.valueOf(); 36 | 37 | return typeof v === 'boolean'; 38 | }, 39 | }); 40 | 41 | this.withMutation(() => { 42 | this.transform((value, _raw) => { 43 | if (this.spec.coerce && !this.isType(value)) { 44 | if (/^(true|1)$/i.test(String(value))) return true; 45 | if (/^(false|0)$/i.test(String(value))) return false; 46 | } 47 | return value; 48 | }); 49 | }); 50 | } 51 | 52 | isTrue( 53 | message = locale.isValue, 54 | ): BooleanSchema, TContext, TFlags> { 55 | return this.test({ 56 | message, 57 | name: 'is-value', 58 | exclusive: true, 59 | params: { value: 'true' }, 60 | test(value) { 61 | return isAbsent(value) || value === true; 62 | }, 63 | }) as any; 64 | } 65 | 66 | isFalse( 67 | message = locale.isValue, 68 | ): BooleanSchema, TContext, TFlags> { 69 | return this.test({ 70 | message, 71 | name: 'is-value', 72 | exclusive: true, 73 | params: { value: 'false' }, 74 | test(value) { 75 | return isAbsent(value) || value === false; 76 | }, 77 | }) as any; 78 | } 79 | 80 | override default>( 81 | def: DefaultThunk, 82 | ): BooleanSchema> { 83 | return super.default(def); 84 | } 85 | 86 | defined( 87 | msg?: Message, 88 | ): BooleanSchema, TContext, TDefault, TFlags> { 89 | return super.defined(msg); 90 | } 91 | optional(): BooleanSchema { 92 | return super.optional(); 93 | } 94 | required( 95 | msg?: Message, 96 | ): BooleanSchema, TContext, TDefault, TFlags> { 97 | return super.required(msg); 98 | } 99 | notRequired(): BooleanSchema, TContext, TDefault, TFlags> { 100 | return super.notRequired(); 101 | } 102 | nullable(): BooleanSchema { 103 | return super.nullable(); 104 | } 105 | nonNullable( 106 | msg?: Message, 107 | ): BooleanSchema, TContext, TDefault, TFlags> { 108 | return super.nonNullable(msg); 109 | } 110 | 111 | strip( 112 | enabled: false, 113 | ): BooleanSchema>; 114 | strip( 115 | enabled?: true, 116 | ): BooleanSchema>; 117 | strip(v: any) { 118 | return super.strip(v); 119 | } 120 | } 121 | 122 | create.prototype = BooleanSchema.prototype; 123 | -------------------------------------------------------------------------------- /docs/extending.md: -------------------------------------------------------------------------------- 1 | # Extending Schema 2 | 3 | For simple cases where you want to reuse common schema configurations, creating 4 | and passing around instances works great and is automatically typed correctly 5 | 6 | ```js 7 | import * as yup from 'yup'; 8 | 9 | const requiredString = yup.string().required().default(''); 10 | 11 | const momentDate = (parseFormats = ['MMM dd, yyy']) => 12 | yup.date().transform((value, originalValue, schema) => { 13 | if (schema.isType(value)) return value; 14 | 15 | // the default coercion transform failed so let's try it with Moment instead 16 | value = Moment(originalValue, parseFormats); 17 | return value.isValid() ? value.toDate() : yup.date.INVALID_DATE; 18 | }); 19 | 20 | export { momentDate, requiredString }; 21 | ``` 22 | 23 | Schema are immutable so each can be configured further without changing the original. 24 | 25 | ## Extending Schema with new methods 26 | 27 | `yup` provides a `addMethod()` utility for extending built-in schema: 28 | 29 | ```js 30 | function parseDateFromFormats(formats, parseStrict) { 31 | return this.transform((value, originalValue, schema) => { 32 | if (schema.isType(value)) return value; 33 | 34 | value = Moment(originalValue, formats, parseStrict); 35 | 36 | return value.isValid() ? value.toDate() : yup.date.INVALID_DATE; 37 | }); 38 | } 39 | 40 | yup.addMethod(yup.date, 'format', parseDateFromFormats); 41 | ``` 42 | 43 | Note that `addMethod` isn't magic, it mutates the prototype of the passed in schema. 44 | 45 | > Note: if you are using TypeScript you also need to adjust the class or interface 46 | > see the [typescript](./typescript.md) docs for details. 47 | 48 | ## Creating new Schema types 49 | 50 | If you're using case calls for creating an entirely new type, inheriting from 51 | an existing schema class may be best: Generally you should not be inheriting from 52 | the abstract `Schema` unless you know what you are doing. The other types are fair game though. 53 | 54 | You should keep in mind some basic guidelines when extending schemas: 55 | 56 | - never mutate an existing schema, always `clone()` and then mutate the new one before returning it. 57 | Built-in methods like `test` and `transform` take care of this for you, so you can safely use them (see below) without worrying 58 | 59 | - transforms should never mutate the `value` passed in, and should return an invalid object when one exists 60 | (`NaN`, `InvalidDate`, etc) instead of `null` for bad values. 61 | 62 | - by the time validations run, the `value` is guaranteed to be the correct type, however it still may 63 | be `null` or `undefined` 64 | 65 | ```js 66 | import { DateSchema } from 'yup'; 67 | 68 | class MomentDateSchema extends DateSchema { 69 | static create() { 70 | return MomentDateSchema(); 71 | } 72 | 73 | constructor() { 74 | super(); 75 | this._validFormats = []; 76 | 77 | this.withMutation(() => { 78 | this.transform(function (value, originalValue) { 79 | if (this.isType(value)) 80 | // we have a valid value 81 | return value; 82 | return Moment(originalValue, this._validFormats, true); 83 | }); 84 | }); 85 | } 86 | 87 | _typeCheck(value) { 88 | return ( 89 | super._typeCheck(value) || (moment.isMoment(value) && value.isValid()) 90 | ); 91 | } 92 | 93 | format(formats) { 94 | if (!formats) throw new Error('must enter a valid format'); 95 | let next = this.clone(); 96 | next._validFormats = {}.concat(formats); 97 | } 98 | } 99 | 100 | let schema = new MomentDateSchema(); 101 | 102 | schema.format('YYYY-MM-DD').cast('It is 2012-05-25'); // => Fri May 25 2012 00:00:00 GMT-0400 (Eastern Daylight Time) 103 | ``` 104 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import MixedSchema, { 2 | create as mixedCreate, 3 | MixedOptions, 4 | TypeGuard, 5 | } from './mixed'; 6 | import BooleanSchema, { create as boolCreate } from './boolean'; 7 | import StringSchema, { create as stringCreate } from './string'; 8 | import NumberSchema, { create as numberCreate } from './number'; 9 | import DateSchema, { create as dateCreate } from './date'; 10 | import ObjectSchema, { AnyObject, create as objectCreate } from './object'; 11 | import ArraySchema, { create as arrayCreate } from './array'; 12 | import TupleSchema, { create as tupleCreate } from './tuple'; 13 | import Reference, { create as refCreate } from './Reference'; 14 | import Lazy, { create as lazyCreate } from './Lazy'; 15 | import ValidationError from './ValidationError'; 16 | import reach, { getIn } from './util/reach'; 17 | import isSchema from './util/isSchema'; 18 | import printValue from './util/printValue'; 19 | import setLocale, { LocaleObject } from './setLocale'; 20 | import defaultLocale from './locale'; 21 | import Schema, { 22 | AnySchema, 23 | CastOptions as BaseCastOptions, 24 | SchemaSpec, 25 | SchemaRefDescription, 26 | SchemaInnerTypeDescription, 27 | SchemaObjectDescription, 28 | SchemaLazyDescription, 29 | SchemaFieldDescription, 30 | SchemaDescription, 31 | SchemaMetadata, 32 | CustomSchemaMetadata, 33 | } from './schema'; 34 | import type { 35 | AnyMessageParams, 36 | InferType, 37 | ISchema, 38 | Message, 39 | MessageParams, 40 | ValidateOptions, 41 | DefaultThunk, 42 | } from './types'; 43 | 44 | function addMethod>( 45 | schemaType: (...arg: any[]) => T, 46 | name: string, 47 | fn: (this: T, ...args: any[]) => T, 48 | ): void; 49 | function addMethod ISchema>( 50 | schemaType: T, 51 | name: string, 52 | fn: (this: InstanceType, ...args: any[]) => InstanceType, 53 | ): void; 54 | function addMethod(schemaType: any, name: string, fn: any) { 55 | if (!schemaType || !isSchema(schemaType.prototype)) 56 | throw new TypeError('You must provide a yup schema constructor function'); 57 | 58 | if (typeof name !== 'string') 59 | throw new TypeError('A Method name must be provided'); 60 | if (typeof fn !== 'function') 61 | throw new TypeError('Method function must be provided'); 62 | 63 | schemaType.prototype[name] = fn; 64 | } 65 | 66 | export type AnyObjectSchema = ObjectSchema; 67 | 68 | export type CastOptions = Omit; 69 | 70 | export type { 71 | AnyMessageParams, 72 | AnyObject, 73 | InferType, 74 | InferType as Asserts, 75 | ISchema, 76 | Message, 77 | MessageParams, 78 | AnySchema, 79 | MixedOptions, 80 | TypeGuard as MixedTypeGuard, 81 | SchemaSpec, 82 | SchemaRefDescription, 83 | SchemaInnerTypeDescription, 84 | SchemaObjectDescription, 85 | SchemaLazyDescription, 86 | SchemaFieldDescription, 87 | SchemaDescription, 88 | SchemaMetadata, 89 | CustomSchemaMetadata, 90 | LocaleObject, 91 | ValidateOptions, 92 | DefaultThunk, 93 | Lazy, 94 | Reference, 95 | }; 96 | 97 | export { 98 | mixedCreate as mixed, 99 | boolCreate as bool, 100 | boolCreate as boolean, 101 | stringCreate as string, 102 | numberCreate as number, 103 | dateCreate as date, 104 | objectCreate as object, 105 | arrayCreate as array, 106 | refCreate as ref, 107 | lazyCreate as lazy, 108 | tupleCreate as tuple, 109 | reach, 110 | getIn, 111 | isSchema, 112 | printValue, 113 | addMethod, 114 | setLocale, 115 | defaultLocale, 116 | ValidationError, 117 | }; 118 | 119 | export { 120 | Schema, 121 | MixedSchema, 122 | BooleanSchema, 123 | StringSchema, 124 | NumberSchema, 125 | DateSchema, 126 | ObjectSchema, 127 | ArraySchema, 128 | TupleSchema, 129 | Lazy as LazySchema, 130 | }; 131 | 132 | export type { 133 | CreateErrorOptions, 134 | TestContext, 135 | TestFunction, 136 | TestOptions, 137 | TestConfig, 138 | } from './util/createValidation'; 139 | 140 | export type { 141 | ObjectShape, 142 | TypeFromShape, 143 | DefaultFromShape, 144 | MakePartial, 145 | } from './util/objectTypes'; 146 | 147 | export type { 148 | Maybe, 149 | Flags, 150 | Optionals, 151 | ToggleDefault, 152 | Defined, 153 | NotNull, 154 | UnsetFlag, 155 | SetFlag, 156 | } from './util/types'; 157 | -------------------------------------------------------------------------------- /src/standardSchema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied from @standard-schema/spec to avoid having a dependency on it. 3 | * https://github.com/standard-schema/standard-schema/blob/main/packages/spec/src/index.ts 4 | */ 5 | 6 | import ValidationError from './ValidationError'; 7 | 8 | export interface StandardSchema { 9 | readonly '~standard': StandardSchemaProps; 10 | } 11 | 12 | export interface StandardSchemaProps { 13 | readonly version: 1; 14 | readonly vendor: string; 15 | readonly validate: ( 16 | value: unknown, 17 | ) => StandardResult | Promise>; 18 | readonly types?: StandardTypes | undefined; 19 | } 20 | 21 | export type StandardResult = 22 | | StandardSuccessResult 23 | | StandardFailureResult; 24 | 25 | export interface StandardSuccessResult { 26 | readonly value: Output; 27 | readonly issues?: undefined; 28 | } 29 | 30 | export interface StandardFailureResult { 31 | readonly issues: ReadonlyArray; 32 | } 33 | 34 | export interface StandardIssue { 35 | readonly message: string; 36 | readonly path?: ReadonlyArray | undefined; 37 | } 38 | 39 | export interface StandardPathSegment { 40 | readonly key: PropertyKey; 41 | } 42 | 43 | export interface StandardTypes { 44 | readonly input: Input; 45 | readonly output: Output; 46 | } 47 | 48 | export function createStandardPath( 49 | path: string | undefined, 50 | ): StandardIssue['path'] { 51 | if (!path?.length) { 52 | return undefined; 53 | } 54 | 55 | // Array to store the final path segments 56 | const segments: string[] = []; 57 | // Buffer for building the current segment 58 | let currentSegment = ''; 59 | // Track if we're inside square brackets (array/property access) 60 | let inBrackets = false; 61 | // Track if we're inside quotes (for property names with special chars) 62 | let inQuotes = false; 63 | 64 | for (let i = 0; i < path.length; i++) { 65 | const char = path[i]; 66 | 67 | if (char === '[' && !inQuotes) { 68 | // When entering brackets, push any accumulated segment after splitting on dots 69 | if (currentSegment) { 70 | segments.push(...currentSegment.split('.').filter(Boolean)); 71 | currentSegment = ''; 72 | } 73 | inBrackets = true; 74 | continue; 75 | } 76 | 77 | if (char === ']' && !inQuotes) { 78 | if (currentSegment) { 79 | // Handle numeric indices (e.g. arr[0]) 80 | if (/^\d+$/.test(currentSegment)) { 81 | segments.push(currentSegment); 82 | } else { 83 | // Handle quoted property names (e.g. obj["foo.bar"]) 84 | segments.push(currentSegment.replace(/^"|"$/g, '')); 85 | } 86 | currentSegment = ''; 87 | } 88 | inBrackets = false; 89 | continue; 90 | } 91 | 92 | if (char === '"') { 93 | // Toggle quote state for handling quoted property names 94 | inQuotes = !inQuotes; 95 | continue; 96 | } 97 | 98 | if (char === '.' && !inBrackets && !inQuotes) { 99 | // On dots outside brackets/quotes, push current segment 100 | if (currentSegment) { 101 | segments.push(currentSegment); 102 | currentSegment = ''; 103 | } 104 | continue; 105 | } 106 | 107 | currentSegment += char; 108 | } 109 | 110 | // Push any remaining segment after splitting on dots 111 | if (currentSegment) { 112 | segments.push(...currentSegment.split('.').filter(Boolean)); 113 | } 114 | 115 | return segments; 116 | } 117 | 118 | export function createStandardIssues( 119 | error: ValidationError, 120 | parentPath?: string, 121 | ): StandardIssue[] { 122 | const path = parentPath ? `${parentPath}.${error.path}` : error.path; 123 | 124 | return error.errors.map( 125 | (err) => 126 | ({ 127 | message: err, 128 | path: createStandardPath(path), 129 | } satisfies StandardIssue), 130 | ); 131 | } 132 | 133 | export function issuesFromValidationError( 134 | error: ValidationError, 135 | parentPath?: string, 136 | ): StandardIssue[] { 137 | if (!error.inner?.length && error.errors.length) { 138 | return createStandardIssues(error, parentPath); 139 | } 140 | 141 | const path = parentPath ? `${parentPath}.${error.path}` : error.path; 142 | 143 | return error.inner.flatMap((err) => issuesFromValidationError(err, path)); 144 | } 145 | -------------------------------------------------------------------------------- /test/tuple.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test } from 'vitest'; 2 | import { string, number, object, tuple, mixed } from '../src'; 3 | 4 | describe('Array types', () => { 5 | describe('casting', () => { 6 | it('should failed casts return input', () => { 7 | expect( 8 | tuple([number(), number()]).cast('asfasf', { assert: false }), 9 | ).toEqual('asfasf'); 10 | }); 11 | 12 | it('should recursively cast fields', () => { 13 | expect(tuple([number(), number()]).cast(['4', '5'])).toEqual([4, 5]); 14 | 15 | expect( 16 | tuple([string(), string(), string()]).cast(['4', 5, false]), 17 | ).toEqual(['4', '5', 'false']); 18 | }); 19 | 20 | it('should pass array options to descendants when casting', async () => { 21 | let value = ['1', '2']; 22 | 23 | let itemSchema = string().when([], function (_, _s, opts: any) { 24 | 25 | const parent = opts.parent; 26 | const idx = opts.index; 27 | const val = opts.value; 28 | const originalValue = opts.originalValue; 29 | 30 | expect(parent).toEqual(value); 31 | expect(typeof idx).toBe('number'); 32 | expect(val).toEqual(parent[idx]); 33 | expect(originalValue).toEqual(parent[idx]); 34 | 35 | return string(); 36 | }); 37 | 38 | await tuple([itemSchema, itemSchema]).validate(value); 39 | }); 40 | }); 41 | 42 | it('should handle DEFAULT', () => { 43 | expect(tuple([number(), number(), number()]).getDefault()).toBeUndefined(); 44 | 45 | expect( 46 | tuple([number(), number(), number()]) 47 | .default(() => [1, 2, 3]) 48 | .getDefault(), 49 | ).toEqual([1, 2, 3]); 50 | }); 51 | 52 | it('should type check', () => { 53 | let inst = tuple([number()]); 54 | 55 | expect(inst.isType([1])).toBe(true); 56 | expect(inst.isType({})).toBe(false); 57 | expect(inst.isType('true')).toBe(false); 58 | expect(inst.isType(NaN)).toBe(false); 59 | expect(inst.isType(34545)).toBe(false); 60 | 61 | expect(inst.isType(null)).toBe(false); 62 | 63 | expect(inst.nullable().isType(null)).toBe(true); 64 | }); 65 | 66 | it('should pass options to children', () => { 67 | expect( 68 | tuple([object({ name: string() })]).cast([{ id: 1, name: 'john' }], { 69 | stripUnknown: true, 70 | }), 71 | ).toEqual([{ name: 'john' }]); 72 | }); 73 | 74 | describe('validation', () => { 75 | test.each([ 76 | ['required', undefined, tuple([mixed()]).required()], 77 | ['required', null, tuple([mixed()]).required()], 78 | ['null', null, tuple([mixed()])], 79 | ])('Basic validations fail: %s %p', async (_, value, schema) => { 80 | expect(await schema.isValid(value)).toBe(false); 81 | }); 82 | 83 | test.each([ 84 | ['required', ['any'], tuple([mixed()]).required()], 85 | ['nullable', null, tuple([mixed()]).nullable()], 86 | ])('Basic validations pass: %s %p', async (_, value, schema) => { 87 | expect(await schema.isValid(value)).toBe(true); 88 | }); 89 | 90 | it('should allow undefined', async () => { 91 | await expect( 92 | tuple([number().defined()]).isValid(undefined), 93 | ).resolves.toBe(true); 94 | }); 95 | 96 | it('should respect subtype validations', async () => { 97 | let inst = tuple([number().max(5), string()]); 98 | 99 | await expect(inst.isValid(['gg', 'any'])).resolves.toBe(false); 100 | await expect(inst.isValid([7, 3])).resolves.toBe(false); 101 | 102 | expect(await inst.validate(['4', 3])).toEqual([4, '3']); 103 | }); 104 | 105 | it('should use labels', async () => { 106 | let schema = tuple([ 107 | string().label('name'), 108 | number().positive().integer().label('age'), 109 | ]); 110 | 111 | await expect(schema.validate(['James', -24.55])).rejects.toThrow( 112 | 'age must be a positive number', 113 | ); 114 | }); 115 | 116 | it('should throw useful type error for length', async () => { 117 | let schema = tuple([string().label('name'), number().label('age')]); 118 | 119 | await expect(schema.validate(['James'])).rejects.toThrowError( 120 | 'this tuple value has too few items, expected a length of 2 but got 1 for value', 121 | ); 122 | 123 | await expect(schema.validate(['James', 2, 4])).rejects.toThrowError( 124 | 'this tuple value has too many items, expected a length of 2 but got 3 for value', 125 | ); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /src/date.ts: -------------------------------------------------------------------------------- 1 | import { parseIsoDate } from './util/parseIsoDate'; 2 | import { date as locale } from './locale'; 3 | import Ref from './Reference'; 4 | import type { AnyObject, DefaultThunk, Message } from './types'; 5 | import type { 6 | Defined, 7 | Flags, 8 | NotNull, 9 | SetFlag, 10 | Maybe, 11 | ToggleDefault, 12 | UnsetFlag, 13 | } from './util/types'; 14 | import Schema from './schema'; 15 | 16 | let invalidDate = new Date(''); 17 | 18 | let isDate = (obj: any): obj is Date => 19 | Object.prototype.toString.call(obj) === '[object Date]'; 20 | 21 | export function create(): DateSchema; 22 | export function create< 23 | T extends Date, 24 | TContext extends Maybe = AnyObject, 25 | >(): DateSchema; 26 | export function create() { 27 | return new DateSchema(); 28 | } 29 | 30 | export default class DateSchema< 31 | TType extends Maybe = Date | undefined, 32 | TContext = AnyObject, 33 | TDefault = undefined, 34 | TFlags extends Flags = '', 35 | > extends Schema { 36 | static INVALID_DATE = invalidDate; 37 | 38 | constructor() { 39 | super({ 40 | type: 'date', 41 | check(v: any): v is NonNullable { 42 | return isDate(v) && !isNaN(v.getTime()); 43 | }, 44 | }); 45 | 46 | this.withMutation(() => { 47 | this.transform((value, _raw) => { 48 | // null -> InvalidDate isn't useful; treat all nulls as null and let it fail on 49 | // nullability check vs TypeErrors 50 | if (!this.spec.coerce || this.isType(value) || value === null) 51 | return value; 52 | 53 | value = parseIsoDate(value); 54 | 55 | // 0 is a valid timestamp equivalent to 1970-01-01T00:00:00Z(unix epoch) or before. 56 | return !isNaN(value) ? new Date(value) : DateSchema.INVALID_DATE; 57 | }); 58 | }); 59 | } 60 | 61 | private prepareParam( 62 | ref: unknown | Ref, 63 | name: string, 64 | ): Date | Ref { 65 | let param: Date | Ref; 66 | 67 | if (!Ref.isRef(ref)) { 68 | let cast = this.cast(ref); 69 | if (!this._typeCheck(cast)) 70 | throw new TypeError( 71 | `\`${name}\` must be a Date or a value that can be \`cast()\` to a Date`, 72 | ); 73 | param = cast; 74 | } else { 75 | param = ref as Ref; 76 | } 77 | return param; 78 | } 79 | 80 | min(min: unknown | Ref, message = locale.min) { 81 | let limit = this.prepareParam(min, 'min'); 82 | 83 | return this.test({ 84 | message, 85 | name: 'min', 86 | exclusive: true, 87 | params: { min }, 88 | skipAbsent: true, 89 | test(value) { 90 | return value! >= this.resolve(limit); 91 | }, 92 | }); 93 | } 94 | 95 | max(max: unknown | Ref, message = locale.max) { 96 | let limit = this.prepareParam(max, 'max'); 97 | 98 | return this.test({ 99 | message, 100 | name: 'max', 101 | exclusive: true, 102 | params: { max }, 103 | skipAbsent: true, 104 | test(value) { 105 | return value! <= this.resolve(limit); 106 | }, 107 | }); 108 | } 109 | } 110 | 111 | create.prototype = DateSchema.prototype; 112 | create.INVALID_DATE = invalidDate; 113 | 114 | export default interface DateSchema< 115 | TType extends Maybe, 116 | TContext = AnyObject, 117 | TDefault = undefined, 118 | TFlags extends Flags = '', 119 | > extends Schema { 120 | default>( 121 | def: DefaultThunk, 122 | ): DateSchema>; 123 | 124 | concat>(schema: TOther): TOther; 125 | 126 | defined( 127 | msg?: Message, 128 | ): DateSchema, TContext, TDefault, TFlags>; 129 | optional(): DateSchema; 130 | 131 | required( 132 | msg?: Message, 133 | ): DateSchema, TContext, TDefault, TFlags>; 134 | notRequired(): DateSchema, TContext, TDefault, TFlags>; 135 | 136 | nullable(msg?: Message): DateSchema; 137 | nonNullable( 138 | msg?: Message, 139 | ): DateSchema, TContext, TDefault, TFlags>; 140 | 141 | strip( 142 | enabled: false, 143 | ): DateSchema>; 144 | strip( 145 | enabled?: true, 146 | ): DateSchema>; 147 | } 148 | -------------------------------------------------------------------------------- /test/date.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { ref, date } from '../src'; 3 | import * as TestHelpers from './helpers'; 4 | 5 | function isInvalidDate(date: any): date is Date { 6 | return date instanceof Date && isNaN(date.getTime()); 7 | } 8 | 9 | describe('Date types', () => { 10 | it('should CAST correctly', () => { 11 | let inst = date(); 12 | 13 | expect(inst.cast(new Date())).toBeInstanceOf(Date); 14 | expect(inst.cast('jan 15 2014')).toEqual(new Date(2014, 0, 15)); 15 | expect(inst.cast('2014-09-23T19:25:25Z')).toEqual(new Date(1411500325000)); 16 | // Leading-zero milliseconds 17 | expect(inst.cast('2016-08-10T11:32:19.012Z')).toEqual( 18 | new Date(1470828739012), 19 | ); 20 | // Microsecond precision 21 | expect(inst.cast('2016-08-10T11:32:19.2125Z')).toEqual( 22 | new Date(1470828739212), 23 | ); 24 | 25 | expect(inst.cast(null, { assert: false })).toEqual(null); 26 | }); 27 | 28 | it('should return invalid date for failed non-null casts', function () { 29 | let inst = date(); 30 | 31 | expect(inst.cast(null, { assert: false })).toEqual(null); 32 | expect(inst.cast(undefined, { assert: false })).toEqual(undefined); 33 | 34 | expect(isInvalidDate(inst.cast('', { assert: false }))).toBe(true); 35 | expect(isInvalidDate(inst.cast({}, { assert: false }))).toBe(true); 36 | }); 37 | 38 | it('should type check', () => { 39 | let inst = date(); 40 | 41 | expect(inst.isType(new Date())).toBe(true); 42 | expect(inst.isType(false)).toBe(false); 43 | expect(inst.isType(null)).toBe(false); 44 | expect(inst.isType(NaN)).toBe(false); 45 | expect(inst.nullable().isType(new Date())).toBe(true); 46 | }); 47 | 48 | it('should VALIDATE correctly', () => { 49 | let inst = date().max(new Date(2014, 5, 15)); 50 | 51 | return Promise.all([ 52 | expect(date().isValid(null)).resolves.toBe(false), 53 | expect(date().nullable().isValid(null)).resolves.toBe(true), 54 | 55 | expect(inst.isValid(new Date(2014, 0, 15))).resolves.toBe(true), 56 | expect(inst.isValid(new Date(2014, 7, 15))).resolves.toBe(false), 57 | expect(inst.isValid('5')).resolves.toBe(true), 58 | 59 | expect(inst.required().validate(undefined)).rejects.toEqual( 60 | expect.objectContaining({ 61 | errors: ['this is a required field'], 62 | }), 63 | ), 64 | 65 | expect(inst.required().validate(undefined)).rejects.toEqual( 66 | TestHelpers.validationErrorWithMessages( 67 | expect.stringContaining('required'), 68 | ), 69 | ), 70 | expect(inst.validate(null)).rejects.toEqual( 71 | TestHelpers.validationErrorWithMessages( 72 | expect.stringContaining('cannot be null'), 73 | ), 74 | ), 75 | expect(inst.validate({})).rejects.toEqual( 76 | TestHelpers.validationErrorWithMessages( 77 | expect.stringContaining('must be a `date` type'), 78 | ), 79 | ), 80 | ]); 81 | }); 82 | 83 | it('should check MIN correctly', () => { 84 | let min = new Date(2014, 3, 15), 85 | invalid = new Date(2014, 1, 15), 86 | valid = new Date(2014, 5, 15); 87 | expect(function () { 88 | date().max('hello'); 89 | }).toThrowError(TypeError); 90 | expect(function () { 91 | date().max(ref('$foo')); 92 | }).not.toThrowError(); 93 | 94 | return Promise.all([ 95 | expect(date().min(min).isValid(valid)).resolves.toBe(true), 96 | expect(date().min(min).isValid(invalid)).resolves.toBe(false), 97 | expect(date().min(min).isValid(null)).resolves.toBe(false), 98 | 99 | expect( 100 | date() 101 | .min(ref('$foo')) 102 | .isValid(valid, { context: { foo: min } }), 103 | ).resolves.toBe(true), 104 | expect( 105 | date() 106 | .min(ref('$foo')) 107 | .isValid(invalid, { context: { foo: min } }), 108 | ).resolves.toBe(false), 109 | ]); 110 | }); 111 | 112 | it('should check MAX correctly', () => { 113 | let max = new Date(2014, 7, 15), 114 | invalid = new Date(2014, 9, 15), 115 | valid = new Date(2014, 5, 15); 116 | expect(function () { 117 | date().max('hello'); 118 | }).toThrowError(TypeError); 119 | expect(function () { 120 | date().max(ref('$foo')); 121 | }).not.toThrowError(); 122 | 123 | return Promise.all([ 124 | expect(date().max(max).isValid(valid)).resolves.toBe(true), 125 | expect(date().max(max).isValid(invalid)).resolves.toBe(false), 126 | expect(date().max(max).nullable().isValid(null)).resolves.toBe(true), 127 | 128 | expect( 129 | date() 130 | .max(ref('$foo')) 131 | .isValid(valid, { context: { foo: max } }), 132 | ).resolves.toBe(true), 133 | expect( 134 | date() 135 | .max(ref('$foo')) 136 | .isValid(invalid, { context: { foo: max } }), 137 | ).resolves.toBe(false), 138 | ]); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /src/tuple.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | 3 | import type { 4 | AnyObject, 5 | DefaultThunk, 6 | InternalOptions, 7 | ISchema, 8 | Message, 9 | } from './types'; 10 | import type { 11 | Defined, 12 | Flags, 13 | NotNull, 14 | SetFlag, 15 | ToggleDefault, 16 | UnsetFlag, 17 | Maybe, 18 | } from './util/types'; 19 | import type { ResolveOptions } from './Condition'; 20 | import Schema, { 21 | RunTest, 22 | SchemaInnerTypeDescription, 23 | SchemaSpec, 24 | } from './schema'; 25 | import ValidationError from './ValidationError'; 26 | import { tuple as tupleLocale } from './locale'; 27 | 28 | type AnyTuple = [unknown, ...unknown[]]; 29 | 30 | export function create(schemas: { 31 | [K in keyof T]: ISchema; 32 | }) { 33 | return new TupleSchema(schemas); 34 | } 35 | 36 | export default interface TupleSchema< 37 | TType extends Maybe = AnyTuple | undefined, 38 | TContext = AnyObject, 39 | TDefault = undefined, 40 | TFlags extends Flags = '', 41 | > extends Schema { 42 | default>( 43 | def: DefaultThunk, 44 | ): TupleSchema>; 45 | 46 | concat>(schema: TOther): TOther; 47 | 48 | defined( 49 | msg?: Message, 50 | ): TupleSchema, TContext, TDefault, TFlags>; 51 | optional(): TupleSchema; 52 | 53 | required( 54 | msg?: Message, 55 | ): TupleSchema, TContext, TDefault, TFlags>; 56 | notRequired(): TupleSchema, TContext, TDefault, TFlags>; 57 | 58 | nullable( 59 | msg?: Message, 60 | ): TupleSchema; 61 | nonNullable( 62 | msg?: Message 63 | ): TupleSchema, TContext, TDefault, TFlags>; 64 | 65 | strip( 66 | enabled: false, 67 | ): TupleSchema>; 68 | strip( 69 | enabled?: true, 70 | ): TupleSchema>; 71 | } 72 | 73 | interface TupleSchemaSpec extends SchemaSpec { 74 | types: T extends any[] 75 | ? { 76 | [K in keyof T]: ISchema; 77 | } 78 | : never; 79 | } 80 | 81 | export default class TupleSchema< 82 | TType extends Maybe = AnyTuple | undefined, 83 | TContext = AnyObject, 84 | TDefault = undefined, 85 | TFlags extends Flags = '', 86 | > extends Schema { 87 | declare spec: TupleSchemaSpec; 88 | 89 | constructor(schemas: [ISchema, ...ISchema[]]) { 90 | super({ 91 | type: 'tuple', 92 | spec: { types: schemas } as any, 93 | check(v: any): v is NonNullable { 94 | const types = (this.spec as TupleSchemaSpec).types; 95 | return Array.isArray(v) && v.length === types.length; 96 | }, 97 | }); 98 | 99 | this.withMutation(() => { 100 | this.typeError(tupleLocale.notType); 101 | }); 102 | } 103 | 104 | protected _cast(inputValue: any, options: InternalOptions) { 105 | const { types } = this.spec; 106 | const value = super._cast(inputValue, options); 107 | 108 | if (!this._typeCheck(value)) { 109 | return value; 110 | } 111 | 112 | let isChanged = false; 113 | const castArray = types.map((type, idx) => { 114 | const castElement = type.cast(value[idx], { 115 | ...options, 116 | path: `${options.path || ''}[${idx}]`, 117 | parent: value, 118 | originalValue: value[idx], 119 | value: value[idx], 120 | index: idx, 121 | }); 122 | if (castElement !== value[idx]) isChanged = true; 123 | return castElement; 124 | }); 125 | 126 | return isChanged ? castArray : value; 127 | } 128 | 129 | protected _validate( 130 | _value: any, 131 | options: InternalOptions = {}, 132 | panic: (err: Error, value: unknown) => void, 133 | next: (err: ValidationError[], value: unknown) => void, 134 | ) { 135 | let itemTypes = this.spec.types; 136 | 137 | super._validate(_value, options, panic, (tupleErrors, value) => { 138 | // intentionally not respecting recursive 139 | if (!this._typeCheck(value)) { 140 | next(tupleErrors, value); 141 | return; 142 | } 143 | 144 | let tests: RunTest[] = []; 145 | for (let [index, itemSchema] of itemTypes.entries()) { 146 | tests[index] = itemSchema!.asNestedTest({ 147 | options, 148 | index, 149 | parent: value, 150 | parentPath: options.path, 151 | originalParent: options.originalValue ?? _value, 152 | }); 153 | } 154 | 155 | this.runTests( 156 | { 157 | value, 158 | tests, 159 | originalValue: options.originalValue ?? _value, 160 | options, 161 | }, 162 | panic, 163 | (innerTypeErrors) => next(innerTypeErrors.concat(tupleErrors), value), 164 | ); 165 | }); 166 | } 167 | 168 | describe(options?: ResolveOptions) { 169 | const next = (options ? this.resolve(options) : this).clone(); 170 | const base = super.describe(options) as SchemaInnerTypeDescription; 171 | base.innerType = next.spec.types.map((schema, index) => { 172 | let innerOptions = options; 173 | if (innerOptions?.value) { 174 | innerOptions = { 175 | ...innerOptions, 176 | parent: innerOptions.value, 177 | value: innerOptions.value[index], 178 | }; 179 | } 180 | return schema.describe(innerOptions); 181 | }); 182 | return base; 183 | } 184 | } 185 | 186 | create.prototype = TupleSchema.prototype; 187 | -------------------------------------------------------------------------------- /src/util/createValidation.ts: -------------------------------------------------------------------------------- 1 | import ValidationError from '../ValidationError'; 2 | import Ref from '../Reference'; 3 | import { 4 | ValidateOptions, 5 | Message, 6 | InternalOptions, 7 | ExtraParams, 8 | ISchema, 9 | } from '../types'; 10 | import Reference from '../Reference'; 11 | import type { AnySchema } from '../schema'; 12 | import isAbsent from './isAbsent'; 13 | import { ResolveOptions } from '../Condition'; 14 | 15 | export type PanicCallback = (err: Error) => void; 16 | 17 | export type NextCallback = ( 18 | err: ValidationError[] | ValidationError | null, 19 | ) => void; 20 | 21 | export type CreateErrorOptions = { 22 | path?: string; 23 | message?: Message; 24 | params?: ExtraParams; 25 | type?: string; 26 | disableStackTrace?: boolean; 27 | }; 28 | 29 | export type TestContext = { 30 | path: string; 31 | options: ValidateOptions; 32 | originalValue: any; 33 | parent: any; 34 | from?: Array<{ schema: ISchema; value: any }>; 35 | schema: any; 36 | resolve: (value: T | Reference) => T; 37 | createError: (params?: CreateErrorOptions) => ValidationError; 38 | }; 39 | 40 | export type TestFunction = ( 41 | this: TestContext, 42 | value: T, 43 | context: TestContext, 44 | ) => void | boolean | ValidationError | Promise; 45 | 46 | export type TestOptions = { 47 | value: any; 48 | path?: string; 49 | options: InternalOptions; 50 | originalValue: any; 51 | schema: TSchema; 52 | }; 53 | 54 | export type TestConfig = { 55 | name?: string; 56 | message?: Message; 57 | test: TestFunction; 58 | params?: ExtraParams; 59 | exclusive?: boolean; 60 | skipAbsent?: boolean; 61 | }; 62 | 63 | export type Test = (( 64 | opts: TestOptions, 65 | panic: PanicCallback, 66 | next: NextCallback, 67 | ) => void) & { 68 | OPTIONS?: TestConfig; 69 | }; 70 | 71 | export default function createValidation(config: { 72 | name?: string; 73 | test: TestFunction; 74 | params?: ExtraParams; 75 | message?: Message; 76 | skipAbsent?: boolean; 77 | }) { 78 | function validate( 79 | { value, path = '', options, originalValue, schema }: TestOptions, 80 | panic: PanicCallback, 81 | next: NextCallback, 82 | ) { 83 | const { name, test, params, message, skipAbsent } = config; 84 | let { 85 | parent, 86 | context, 87 | abortEarly = schema.spec.abortEarly, 88 | disableStackTrace = schema.spec.disableStackTrace, 89 | } = options; 90 | const resolveOptions = { value, parent, context }; 91 | function createError(overrides: CreateErrorOptions = {}) { 92 | const nextParams = resolveParams( 93 | { 94 | value, 95 | originalValue, 96 | label: schema.spec.label, 97 | path: overrides.path || path, 98 | spec: schema.spec, 99 | disableStackTrace: overrides.disableStackTrace || disableStackTrace, 100 | ...params, 101 | ...overrides.params, 102 | }, 103 | resolveOptions, 104 | ); 105 | 106 | const error = new ValidationError( 107 | ValidationError.formatError(overrides.message || message, nextParams), 108 | value, 109 | nextParams.path, 110 | overrides.type || name, 111 | nextParams.disableStackTrace, 112 | ); 113 | error.params = nextParams; 114 | return error; 115 | } 116 | 117 | const invalid = abortEarly ? panic : next; 118 | 119 | let ctx = { 120 | path, 121 | parent, 122 | type: name, 123 | from: options.from, 124 | createError, 125 | resolve(item: T | Reference) { 126 | return resolveMaybeRef(item, resolveOptions); 127 | }, 128 | options, 129 | originalValue, 130 | schema, 131 | }; 132 | 133 | const handleResult = (validOrError: ReturnType) => { 134 | if (ValidationError.isError(validOrError)) invalid(validOrError); 135 | else if (!validOrError) invalid(createError()); 136 | else next(null); 137 | }; 138 | 139 | const handleError = (err: any) => { 140 | if (ValidationError.isError(err)) invalid(err); 141 | else panic(err); 142 | }; 143 | 144 | const shouldSkip = skipAbsent && isAbsent(value); 145 | 146 | if (shouldSkip) { 147 | return handleResult(true); 148 | } 149 | 150 | let result: ReturnType; 151 | try { 152 | result = test.call(ctx, value, ctx); 153 | if (typeof (result as any)?.then === 'function') { 154 | if (options.sync) { 155 | throw new Error( 156 | `Validation test of type: "${ctx.type}" returned a Promise during a synchronous validate. ` + 157 | `This test will finish after the validate call has returned`, 158 | ); 159 | } 160 | return Promise.resolve(result).then(handleResult, handleError); 161 | } 162 | } catch (err: any) { 163 | handleError(err); 164 | return; 165 | } 166 | 167 | handleResult(result); 168 | } 169 | 170 | validate.OPTIONS = config; 171 | 172 | return validate; 173 | } 174 | 175 | // Warning: mutates the input 176 | export function resolveParams( 177 | params: T, 178 | options: ResolveOptions, 179 | ) { 180 | if (!params) return params; 181 | 182 | type Keys = (keyof typeof params)[]; 183 | for (const key of Object.keys(params) as Keys) { 184 | params[key] = resolveMaybeRef(params[key], options); 185 | } 186 | 187 | return params; 188 | } 189 | 190 | function resolveMaybeRef(item: T | Reference, options: ResolveOptions) { 191 | return Ref.isRef(item) 192 | ? item.getValue(options.value, options.parent, options.context) 193 | : item; 194 | } 195 | -------------------------------------------------------------------------------- /src/locale.ts: -------------------------------------------------------------------------------- 1 | import printValue from './util/printValue'; 2 | import { Message } from './types'; 3 | import ValidationError from './ValidationError'; 4 | 5 | export interface MixedLocale { 6 | default?: Message; 7 | required?: Message; 8 | oneOf?: Message<{ values: any }>; 9 | notOneOf?: Message<{ values: any }>; 10 | notNull?: Message; 11 | notType?: Message; 12 | defined?: Message; 13 | } 14 | 15 | export interface StringLocale { 16 | length?: Message<{ length: number }>; 17 | min?: Message<{ min: number }>; 18 | max?: Message<{ max: number }>; 19 | matches?: Message<{ regex: RegExp }>; 20 | email?: Message<{ regex: RegExp }>; 21 | url?: Message<{ regex: RegExp }>; 22 | uuid?: Message<{ regex: RegExp }>; 23 | datetime?: Message; 24 | datetime_offset?: Message; 25 | datetime_precision?: Message<{ precision: number }>; 26 | trim?: Message; 27 | lowercase?: Message; 28 | uppercase?: Message; 29 | } 30 | 31 | export interface NumberLocale { 32 | min?: Message<{ min: number }>; 33 | max?: Message<{ max: number }>; 34 | lessThan?: Message<{ less: number }>; 35 | moreThan?: Message<{ more: number }>; 36 | positive?: Message<{ more: number }>; 37 | negative?: Message<{ less: number }>; 38 | integer?: Message; 39 | } 40 | 41 | export interface DateLocale { 42 | min?: Message<{ min: Date | string }>; 43 | max?: Message<{ max: Date | string }>; 44 | } 45 | 46 | export interface ObjectLocale { 47 | noUnknown?: Message<{ unknown: string[] }>; 48 | exact?: Message<{ properties: string[] }>; 49 | } 50 | 51 | export interface ArrayLocale { 52 | length?: Message<{ length: number }>; 53 | min?: Message<{ min: number }>; 54 | max?: Message<{ max: number }>; 55 | } 56 | 57 | export interface TupleLocale { 58 | notType?: Message; 59 | } 60 | 61 | export interface BooleanLocale { 62 | isValue?: Message; 63 | } 64 | 65 | export interface LocaleObject { 66 | mixed?: MixedLocale; 67 | string?: StringLocale; 68 | number?: NumberLocale; 69 | date?: DateLocale; 70 | boolean?: BooleanLocale; 71 | object?: ObjectLocale; 72 | array?: ArrayLocale; 73 | tuple?: TupleLocale; 74 | } 75 | 76 | export let mixed: Required = { 77 | default: '${path} is invalid', 78 | required: '${path} is a required field', 79 | defined: '${path} must be defined', 80 | notNull: '${path} cannot be null', 81 | oneOf: '${path} must be one of the following values: ${values}', 82 | notOneOf: '${path} must not be one of the following values: ${values}', 83 | notType: ({ path, type, value, originalValue }) => { 84 | const castMsg = 85 | originalValue != null && originalValue !== value 86 | ? ` (cast from the value \`${printValue(originalValue, true)}\`).` 87 | : '.'; 88 | 89 | return type !== 'mixed' 90 | ? `${path} must be a \`${type}\` type, ` + 91 | `but the final value was: \`${printValue(value, true)}\`` + 92 | castMsg 93 | : `${path} must match the configured type. ` + 94 | `The validated value was: \`${printValue(value, true)}\`` + 95 | castMsg; 96 | }, 97 | }; 98 | 99 | export let string: Required = { 100 | length: '${path} must be exactly ${length} characters', 101 | min: '${path} must be at least ${min} characters', 102 | max: '${path} must be at most ${max} characters', 103 | matches: '${path} must match the following: "${regex}"', 104 | email: '${path} must be a valid email', 105 | url: '${path} must be a valid URL', 106 | uuid: '${path} must be a valid UUID', 107 | datetime: '${path} must be a valid ISO date-time', 108 | datetime_precision: 109 | '${path} must be a valid ISO date-time with a sub-second precision of exactly ${precision} digits', 110 | datetime_offset: 111 | '${path} must be a valid ISO date-time with UTC "Z" timezone', 112 | trim: '${path} must be a trimmed string', 113 | lowercase: '${path} must be a lowercase string', 114 | uppercase: '${path} must be a upper case string', 115 | }; 116 | 117 | export let number: Required = { 118 | min: '${path} must be greater than or equal to ${min}', 119 | max: '${path} must be less than or equal to ${max}', 120 | lessThan: '${path} must be less than ${less}', 121 | moreThan: '${path} must be greater than ${more}', 122 | positive: '${path} must be a positive number', 123 | negative: '${path} must be a negative number', 124 | integer: '${path} must be an integer', 125 | }; 126 | 127 | export let date: Required = { 128 | min: '${path} field must be later than ${min}', 129 | max: '${path} field must be at earlier than ${max}', 130 | }; 131 | 132 | export let boolean: BooleanLocale = { 133 | isValue: '${path} field must be ${value}', 134 | }; 135 | 136 | export let object: Required = { 137 | noUnknown: '${path} field has unspecified keys: ${unknown}', 138 | exact: '${path} object contains unknown properties: ${properties}', 139 | }; 140 | 141 | export let array: Required = { 142 | min: '${path} field must have at least ${min} items', 143 | max: '${path} field must have less than or equal to ${max} items', 144 | length: '${path} must have ${length} items', 145 | }; 146 | 147 | export let tuple: Required = { 148 | notType: (params) => { 149 | const { path, value, spec } = params; 150 | const typeLen = spec.types.length; 151 | if (Array.isArray(value)) { 152 | if (value.length < typeLen) 153 | return `${path} tuple value has too few items, expected a length of ${typeLen} but got ${ 154 | value.length 155 | } for value: \`${printValue(value, true)}\``; 156 | if (value.length > typeLen) 157 | return `${path} tuple value has too many items, expected a length of ${typeLen} but got ${ 158 | value.length 159 | } for value: \`${printValue(value, true)}\``; 160 | } 161 | 162 | return ValidationError.formatError(mixed.notType, params); 163 | }, 164 | }; 165 | 166 | export default Object.assign(Object.create(null), { 167 | mixed, 168 | string, 169 | number, 170 | date, 171 | object, 172 | array, 173 | boolean, 174 | tuple, 175 | }) as LocaleObject; 176 | -------------------------------------------------------------------------------- /src/number.ts: -------------------------------------------------------------------------------- 1 | import { number as locale } from './locale'; 2 | import isAbsent from './util/isAbsent'; 3 | import type { AnyObject, DefaultThunk, Message } from './types'; 4 | import type Reference from './Reference'; 5 | import type { 6 | Concat, 7 | Defined, 8 | Flags, 9 | NotNull, 10 | SetFlag, 11 | Maybe, 12 | ToggleDefault, 13 | UnsetFlag, 14 | } from './util/types'; 15 | import Schema from './schema'; 16 | 17 | let isNaN = (value: Maybe) => value != +value!; 18 | 19 | export function create(): NumberSchema; 20 | export function create< 21 | T extends number, 22 | TContext extends Maybe = AnyObject, 23 | >(): NumberSchema; 24 | export function create() { 25 | return new NumberSchema(); 26 | } 27 | 28 | export default class NumberSchema< 29 | TType extends Maybe = number | undefined, 30 | TContext = AnyObject, 31 | TDefault = undefined, 32 | TFlags extends Flags = '', 33 | > extends Schema { 34 | constructor() { 35 | super({ 36 | type: 'number', 37 | check(value: any): value is NonNullable { 38 | if (value instanceof Number) value = value.valueOf(); 39 | 40 | return typeof value === 'number' && !isNaN(value); 41 | }, 42 | }); 43 | 44 | this.withMutation(() => { 45 | this.transform((value, _raw) => { 46 | if (!this.spec.coerce) return value; 47 | 48 | let parsed = value; 49 | if (typeof parsed === 'string') { 50 | parsed = parsed.replace(/\s/g, ''); 51 | if (parsed === '') return NaN; 52 | // don't use parseFloat to avoid positives on alpha-numeric strings 53 | parsed = +parsed; 54 | } 55 | 56 | // null -> NaN isn't useful; treat all nulls as null and let it fail on 57 | // nullability check vs TypeErrors 58 | if (this.isType(parsed) || parsed === null) return parsed; 59 | 60 | return parseFloat(parsed); 61 | }); 62 | }); 63 | } 64 | 65 | min(min: number | Reference, message = locale.min) { 66 | return this.test({ 67 | message, 68 | name: 'min', 69 | exclusive: true, 70 | params: { min }, 71 | skipAbsent: true, 72 | test(value: Maybe) { 73 | return value! >= this.resolve(min); 74 | }, 75 | }); 76 | } 77 | 78 | max(max: number | Reference, message = locale.max) { 79 | return this.test({ 80 | message, 81 | name: 'max', 82 | exclusive: true, 83 | params: { max }, 84 | skipAbsent: true, 85 | test(value: Maybe) { 86 | return value! <= this.resolve(max); 87 | }, 88 | }); 89 | } 90 | 91 | lessThan(less: number | Reference, message = locale.lessThan) { 92 | return this.test({ 93 | message, 94 | name: 'max', 95 | exclusive: true, 96 | params: { less }, 97 | skipAbsent: true, 98 | test(value: Maybe) { 99 | return value! < this.resolve(less); 100 | }, 101 | }); 102 | } 103 | 104 | moreThan(more: number | Reference, message = locale.moreThan) { 105 | return this.test({ 106 | message, 107 | name: 'min', 108 | exclusive: true, 109 | params: { more }, 110 | skipAbsent: true, 111 | test(value: Maybe) { 112 | return value! > this.resolve(more); 113 | }, 114 | }); 115 | } 116 | 117 | positive(msg = locale.positive) { 118 | return this.moreThan(0, msg); 119 | } 120 | 121 | negative(msg = locale.negative) { 122 | return this.lessThan(0, msg); 123 | } 124 | 125 | integer(message = locale.integer) { 126 | return this.test({ 127 | name: 'integer', 128 | message, 129 | skipAbsent: true, 130 | test: (val) => Number.isInteger(val), 131 | }); 132 | } 133 | 134 | truncate() { 135 | return this.transform((value) => (!isAbsent(value) ? value | 0 : value)); 136 | } 137 | 138 | round(method?: 'ceil' | 'floor' | 'round' | 'trunc') { 139 | let avail = ['ceil', 'floor', 'round', 'trunc']; 140 | method = (method?.toLowerCase() as any) || ('round' as const); 141 | 142 | // this exists for symemtry with the new Math.trunc 143 | if (method === 'trunc') return this.truncate(); 144 | 145 | if (avail.indexOf(method!.toLowerCase()) === -1) 146 | throw new TypeError( 147 | 'Only valid options for round() are: ' + avail.join(', '), 148 | ); 149 | 150 | return this.transform((value) => 151 | !isAbsent(value) ? Math[method!](value) : value, 152 | ); 153 | } 154 | } 155 | 156 | create.prototype = NumberSchema.prototype; 157 | 158 | // 159 | // Number Interfaces 160 | // 161 | 162 | export default interface NumberSchema< 163 | TType extends Maybe = number | undefined, 164 | TContext = AnyObject, 165 | TDefault = undefined, 166 | TFlags extends Flags = '', 167 | > extends Schema { 168 | default>( 169 | def: DefaultThunk, 170 | ): NumberSchema>; 171 | 172 | concat, UContext, UFlags extends Flags, UDefault>( 173 | schema: NumberSchema, 174 | ): NumberSchema< 175 | Concat, 176 | TContext & UContext, 177 | UDefault, 178 | TFlags | UFlags 179 | >; 180 | concat(schema: this): this; 181 | 182 | defined( 183 | msg?: Message, 184 | ): NumberSchema, TContext, TDefault, TFlags>; 185 | optional(): NumberSchema; 186 | 187 | required( 188 | msg?: Message, 189 | ): NumberSchema, TContext, TDefault, TFlags>; 190 | notRequired(): NumberSchema, TContext, TDefault, TFlags>; 191 | 192 | nullable( 193 | msg?: Message, 194 | ): NumberSchema; 195 | nonNullable( 196 | msg?: Message, 197 | ): NumberSchema, TContext, TDefault, TFlags>; 198 | 199 | strip( 200 | enabled: false, 201 | ): NumberSchema>; 202 | strip( 203 | enabled?: true, 204 | ): NumberSchema>; 205 | } 206 | -------------------------------------------------------------------------------- /src/Lazy.ts: -------------------------------------------------------------------------------- 1 | import isSchema from './util/isSchema'; 2 | import type { 3 | AnyObject, 4 | ISchema, 5 | ValidateOptions, 6 | NestedTestConfig, 7 | InferType, 8 | } from './types'; 9 | import type { ResolveOptions } from './Condition'; 10 | 11 | import type { 12 | CastOptionalityOptions, 13 | CastOptions, 14 | SchemaFieldDescription, 15 | SchemaLazyDescription, 16 | } from './schema'; 17 | import { Flags, Maybe, ResolveFlags } from './util/types'; 18 | import ValidationError from './ValidationError'; 19 | import Schema from './schema'; 20 | import { 21 | issuesFromValidationError, 22 | StandardResult, 23 | StandardSchemaProps, 24 | } from './standardSchema'; 25 | 26 | export type LazyBuilder< 27 | TSchema extends ISchema, 28 | TContext = AnyObject, 29 | > = (value: any, options: ResolveOptions) => TSchema; 30 | 31 | export function create< 32 | TSchema extends ISchema, 33 | TContext extends Maybe = AnyObject, 34 | >(builder: (value: any, options: ResolveOptions) => TSchema) { 35 | return new Lazy, TContext>(builder); 36 | } 37 | 38 | function catchValidationError(fn: () => any) { 39 | try { 40 | return fn(); 41 | } catch (err) { 42 | if (ValidationError.isError(err)) return Promise.reject(err); 43 | throw err; 44 | } 45 | } 46 | 47 | export interface LazySpec { 48 | meta: Record | undefined; 49 | optional: boolean; 50 | } 51 | 52 | class Lazy 53 | implements ISchema 54 | { 55 | type = 'lazy' as const; 56 | 57 | __isYupSchema__ = true; 58 | 59 | declare readonly __outputType: T; 60 | declare readonly __context: TContext; 61 | declare readonly __flags: TFlags; 62 | declare readonly __default: undefined; 63 | 64 | spec: LazySpec; 65 | 66 | constructor(private builder: any) { 67 | this.spec = { meta: undefined, optional: false }; 68 | } 69 | 70 | clone(spec?: Partial): Lazy { 71 | const next = new Lazy(this.builder); 72 | next.spec = { ...this.spec, ...spec }; 73 | return next; 74 | } 75 | 76 | private _resolve = ( 77 | value: any, 78 | options: ResolveOptions = {}, 79 | ): Schema => { 80 | let schema = this.builder(value, options) as Schema< 81 | T, 82 | TContext, 83 | undefined, 84 | TFlags 85 | >; 86 | 87 | if (!isSchema(schema)) 88 | throw new TypeError('lazy() functions must return a valid schema'); 89 | 90 | if (this.spec.optional) schema = schema.optional(); 91 | 92 | return schema.resolve(options); 93 | }; 94 | 95 | private optionality(optional: boolean) { 96 | const next = this.clone({ optional }); 97 | return next; 98 | } 99 | 100 | optional(): Lazy { 101 | return this.optionality(true); 102 | } 103 | 104 | resolve(options: ResolveOptions) { 105 | return this._resolve(options.value, options); 106 | } 107 | 108 | cast(value: any, options?: CastOptions): T; 109 | cast( 110 | value: any, 111 | options?: CastOptionalityOptions, 112 | ): T | null | undefined; 113 | cast( 114 | value: any, 115 | options?: CastOptions | CastOptionalityOptions, 116 | ): any { 117 | return this._resolve(value, options).cast(value, options as any); 118 | } 119 | 120 | asNestedTest(config: NestedTestConfig) { 121 | let { key, index, parent, options } = config; 122 | let value = parent[index ?? key!]; 123 | 124 | return this._resolve(value, { 125 | ...options, 126 | value, 127 | parent, 128 | }).asNestedTest(config); 129 | } 130 | 131 | validate(value: any, options?: ValidateOptions): Promise { 132 | return catchValidationError(() => 133 | this._resolve(value, options).validate(value, options), 134 | ); 135 | } 136 | 137 | validateSync(value: any, options?: ValidateOptions): T { 138 | return this._resolve(value, options).validateSync(value, options); 139 | } 140 | 141 | validateAt(path: string, value: any, options?: ValidateOptions) { 142 | return catchValidationError(() => 143 | this._resolve(value, options).validateAt(path, value, options), 144 | ); 145 | } 146 | 147 | validateSyncAt( 148 | path: string, 149 | value: any, 150 | options?: ValidateOptions, 151 | ) { 152 | return this._resolve(value, options).validateSyncAt(path, value, options); 153 | } 154 | 155 | isValid(value: any, options?: ValidateOptions) { 156 | try { 157 | return this._resolve(value, options).isValid(value, options); 158 | } catch (err) { 159 | if (ValidationError.isError(err)) { 160 | return Promise.resolve(false); 161 | } 162 | throw err; 163 | } 164 | } 165 | 166 | isValidSync(value: any, options?: ValidateOptions) { 167 | return this._resolve(value, options).isValidSync(value, options); 168 | } 169 | 170 | describe( 171 | options?: ResolveOptions, 172 | ): SchemaLazyDescription | SchemaFieldDescription { 173 | return options 174 | ? this.resolve(options).describe(options) 175 | : { type: 'lazy', meta: this.spec.meta, label: undefined }; 176 | } 177 | 178 | meta(): Record | undefined; 179 | meta(obj: Record): Lazy; 180 | meta(...args: [Record?]) { 181 | if (args.length === 0) return this.spec.meta; 182 | 183 | let next = this.clone(); 184 | next.spec.meta = Object.assign(next.spec.meta || {}, args[0]); 185 | return next; 186 | } 187 | 188 | get ['~standard']() { 189 | const schema = this; 190 | 191 | const standard: StandardSchemaProps< 192 | T, 193 | ResolveFlags 194 | > = { 195 | version: 1, 196 | vendor: 'yup', 197 | async validate( 198 | value: unknown, 199 | ): Promise>> { 200 | try { 201 | const result = await schema.validate(value, { 202 | abortEarly: false, 203 | }); 204 | 205 | return { 206 | value: result as ResolveFlags, 207 | }; 208 | } catch (err) { 209 | if (ValidationError.isError(err)) { 210 | return { 211 | issues: issuesFromValidationError(err), 212 | }; 213 | } 214 | 215 | throw err; 216 | } 217 | }, 218 | }; 219 | 220 | return standard; 221 | } 222 | } 223 | 224 | export default Lazy; 225 | -------------------------------------------------------------------------------- /test/yup.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test } from 'vitest'; 2 | import reach, { getIn } from '../src/util/reach'; 3 | 4 | import { 5 | addMethod, 6 | object, 7 | array, 8 | string, 9 | lazy, 10 | number, 11 | boolean, 12 | date, 13 | Schema, 14 | ObjectSchema, 15 | ArraySchema, 16 | StringSchema, 17 | NumberSchema, 18 | BooleanSchema, 19 | DateSchema, 20 | mixed, 21 | MixedSchema, 22 | tuple, 23 | } from '../src'; 24 | 25 | describe('Yup', function () { 26 | it('cast should not assert on undefined', () => { 27 | expect(() => string().cast(undefined)).not.toThrowError(); 28 | }); 29 | 30 | it('cast should assert on undefined cast results', () => { 31 | expect(() => 32 | string() 33 | .defined() 34 | .transform(() => undefined) 35 | .cast('foo'), 36 | ).toThrowError( 37 | 'The value of field could not be cast to a value that satisfies the schema type: "string".', 38 | ); 39 | }); 40 | 41 | it('cast should respect assert option', () => { 42 | expect(() => string().cast(null)).toThrowError(); 43 | 44 | expect(() => string().cast(null, { assert: false })).not.toThrowError(); 45 | }); 46 | 47 | it('should getIn correctly', async () => { 48 | let num = number(); 49 | let shape = object({ 'num-1': num }); 50 | let inst = object({ 51 | num: number().max(4), 52 | 53 | nested: object({ 54 | arr: array().of(shape), 55 | }), 56 | }); 57 | 58 | const value = { nested: { arr: [{}, { 'num-1': 2 }] } }; 59 | let { schema, parent, parentPath } = getIn( 60 | inst, 61 | 'nested.arr[1].num-1', 62 | value, 63 | ); 64 | 65 | expect(schema).toBe(num); 66 | expect(parentPath).toBe('num-1'); 67 | expect(parent).toBe(value.nested.arr[1]); 68 | }); 69 | 70 | it('should getIn array correctly', async () => { 71 | let num = number(); 72 | let shape = object({ 'num-1': num }); 73 | let inst = object({ 74 | num: number().max(4), 75 | 76 | nested: object({ 77 | arr: array().of(shape), 78 | }), 79 | }); 80 | 81 | const value = { 82 | nested: { 83 | arr: [{}, { 'num-1': 2 }], 84 | }, 85 | }; 86 | 87 | const { schema, parent, parentPath } = getIn(inst, 'nested.arr[1]', value); 88 | 89 | expect(schema).toBe(shape); 90 | expect(parentPath).toBe('1'); 91 | expect(parent).toBe(value.nested.arr); 92 | }); 93 | 94 | it('should REACH correctly', async () => { 95 | let num = number(); 96 | let shape = object({ num }); 97 | 98 | let inst = object({ 99 | num: number().max(4), 100 | 101 | nested: tuple([ 102 | string(), 103 | object({ 104 | arr: array().of(shape), 105 | }), 106 | ]), 107 | }); 108 | 109 | expect(reach(inst, '')).toBe(inst); 110 | 111 | expect(reach(inst, 'nested[1].arr[0].num')).toBe(num); 112 | expect(reach(inst, 'nested[1].arr[].num')).toBe(num); 113 | expect(reach(inst, 'nested[1].arr.num')).toBe(num); 114 | expect(reach(inst, 'nested[1].arr[1].num')).toBe(num); 115 | expect(reach(inst, 'nested[1].arr[1]')).toBe(shape); 116 | 117 | expect(() => reach(inst, 'nested.arr[1].num')).toThrowError( 118 | 'Yup.reach cannot implicitly index into a tuple type. the path part ".nested" must contain an index to the tuple element, e.g. ".nested[0]"', 119 | ); 120 | 121 | await expect(reach(inst, 'nested[1].arr[0].num').isValid(5)).resolves.toBe( 122 | true, 123 | ); 124 | }); 125 | 126 | it('should REACH conditionally correctly', async function () { 127 | let num = number().oneOf([4]), 128 | inst = object().shape({ 129 | num: number().max(4), 130 | nested: object().shape({ 131 | arr: array().when('$bar', function ([bar]) { 132 | return bar !== 3 133 | ? array().of(number()) 134 | : array().of( 135 | object().shape({ 136 | foo: number(), 137 | num: number().when('foo', ([foo]) => { 138 | if (foo === 5) return num; 139 | }), 140 | }), 141 | ); 142 | }), 143 | }), 144 | }); 145 | 146 | let context = { bar: 3 }; 147 | let value = { 148 | bar: 3, 149 | nested: { 150 | arr: [{ foo: 5 }, { foo: 3 }], 151 | }, 152 | }; 153 | 154 | let options = {}; 155 | options.parent = value.nested.arr[0]; 156 | options.value = options.parent.num; 157 | expect(reach(inst, 'nested.arr.num', value).resolve(options)).toBe(num); 158 | expect(reach(inst, 'nested.arr[].num', value).resolve(options)).toBe(num); 159 | 160 | options.context = context; 161 | expect(reach(inst, 'nested.arr.num', value, context).resolve(options)).toBe( 162 | num, 163 | ); 164 | expect( 165 | reach(inst, 'nested.arr[].num', value, context).resolve(options), 166 | ).toBe(num); 167 | expect( 168 | reach(inst, 'nested.arr[0].num', value, context).resolve(options), 169 | ).toBe(num); 170 | 171 | // // should fail b/c item[1] is used to resolve the schema 172 | options.parent = value.nested.arr[1]; 173 | options.value = options.parent.num; 174 | expect( 175 | reach(inst, 'nested["arr"][1].num', value, context).resolve(options), 176 | ).not.toBe(num); 177 | 178 | let reached = reach(inst, 'nested.arr[].num', value, context); 179 | 180 | await expect( 181 | reached.validate(5, { context, parent: { foo: 4 } }), 182 | ).resolves.toBeDefined(); 183 | 184 | await expect( 185 | reached.validate(5, { context, parent: { foo: 5 } }), 186 | ).rejects.toThrowError(/one of the following/); 187 | }); 188 | 189 | it('should reach through lazy', async () => { 190 | let types = { 191 | 1: object({ foo: string() }), 192 | 2: object({ foo: number() }), 193 | }; 194 | 195 | await expect( 196 | object({ 197 | x: array(lazy((val) => types[val.type])), 198 | }) 199 | .strict() 200 | .validate({ 201 | x: [ 202 | { type: 1, foo: '4' }, 203 | { type: 2, foo: '5' }, 204 | ], 205 | }), 206 | ).rejects.toThrowError(/must be a `number` type/); 207 | }); 208 | 209 | describe('addMethod', () => { 210 | it('extending Schema should make method accessible everywhere', () => { 211 | addMethod(Schema, 'foo', () => 'here'); 212 | 213 | expect(string().foo()).toBe('here'); 214 | }); 215 | 216 | test.each([ 217 | ['mixed', mixed], 218 | ['object', object], 219 | ['array', array], 220 | ['string', string], 221 | ['number', number], 222 | ['boolean', boolean], 223 | ['date', date], 224 | ])('should work with factories: %s', (_msg, factory) => { 225 | addMethod(factory, 'foo', () => 'here'); 226 | 227 | expect(factory().foo()).toBe('here'); 228 | }); 229 | 230 | test.each([ 231 | ['mixed', MixedSchema], 232 | ['object', ObjectSchema], 233 | ['array', ArraySchema], 234 | ['string', StringSchema], 235 | ['number', NumberSchema], 236 | ['boolean', BooleanSchema], 237 | ['date', DateSchema], 238 | ])('should work with classes: %s', (_msg, ctor) => { 239 | addMethod(ctor, 'foo', () => 'here'); 240 | 241 | expect(new ctor().foo()).toBe('here'); 242 | }); 243 | }); 244 | }); 245 | -------------------------------------------------------------------------------- /test/number.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import * as TestHelpers from './helpers'; 3 | 4 | import { number, NumberSchema, object, ref } from '../src'; 5 | 6 | describe('Number types', function () { 7 | it('is extensible', () => { 8 | class MyNumber extends NumberSchema { 9 | foo() { 10 | return this; 11 | } 12 | } 13 | 14 | new MyNumber().foo().integer().required(); 15 | }); 16 | 17 | describe('casting', () => { 18 | let schema = number(); 19 | 20 | TestHelpers.castAll(schema, { 21 | valid: [ 22 | ['5', 5], 23 | [3, 3], 24 | //[new Number(5), 5], 25 | [' 5.656 ', 5.656], 26 | ], 27 | invalid: ['', false, true, new Date(), new Number('foo')], 28 | }); 29 | 30 | it('should round', () => { 31 | // @ts-expect-error stricter type than accepted 32 | expect(schema.round('ceIl').cast(45.1111)).toBe(46); 33 | 34 | expect(schema.round().cast(45.444444)).toBe(45); 35 | 36 | expect(schema.nullable().integer().round().cast(null)).toBeNull(); 37 | expect(function () { 38 | // @ts-expect-error testing incorrectness 39 | schema.round('fasf'); 40 | }).toThrowError(TypeError); 41 | }); 42 | 43 | it('should truncate', () => { 44 | expect(schema.truncate().cast(45.55)).toBe(45); 45 | }); 46 | 47 | it('should return NaN for failed casts', () => { 48 | expect(number().cast('asfasf', { assert: false })).toEqual(NaN); 49 | 50 | expect(number().cast(new Date(), { assert: false })).toEqual(NaN); 51 | expect(number().cast(null, { assert: false })).toEqual(null); 52 | }); 53 | }); 54 | 55 | it('should handle DEFAULT', function () { 56 | let inst = number().default(0); 57 | 58 | expect(inst.getDefault()).toBe(0); 59 | expect(inst.default(5).required().getDefault()).toBe(5); 60 | }); 61 | 62 | it('should type check', function () { 63 | let inst = number(); 64 | 65 | expect(inst.isType(5)).toBe(true); 66 | expect(inst.isType(new Number(5))).toBe(true); 67 | expect(inst.isType(new Number('foo'))).toBe(false); 68 | expect(inst.isType(false)).toBe(false); 69 | expect(inst.isType(null)).toBe(false); 70 | expect(inst.isType(NaN)).toBe(false); 71 | expect(inst.nullable().isType(null)).toBe(true); 72 | }); 73 | 74 | it('should VALIDATE correctly', function () { 75 | let inst = number().min(4); 76 | 77 | return Promise.all([ 78 | expect(number().isValid(null)).resolves.toBe(false), 79 | expect(number().nullable().isValid(null)).resolves.toBe(true), 80 | expect(number().isValid(' ')).resolves.toBe(false), 81 | expect(number().isValid('12abc')).resolves.toBe(false), 82 | expect(number().isValid(0xff)).resolves.toBe(true), 83 | expect(number().isValid('0xff')).resolves.toBe(true), 84 | 85 | expect(inst.isValid(5)).resolves.toBe(true), 86 | expect(inst.isValid(2)).resolves.toBe(false), 87 | 88 | expect(inst.required().validate(undefined)).rejects.toEqual( 89 | TestHelpers.validationErrorWithMessages( 90 | expect.stringContaining('required'), 91 | ), 92 | ), 93 | expect(inst.validate(null)).rejects.toEqual( 94 | TestHelpers.validationErrorWithMessages( 95 | expect.stringContaining('cannot be null'), 96 | ), 97 | ), 98 | expect(inst.validate({})).rejects.toEqual( 99 | TestHelpers.validationErrorWithMessages( 100 | expect.stringContaining('must be a `number` type'), 101 | ), 102 | ), 103 | ]); 104 | }); 105 | 106 | describe('min', () => { 107 | let schema = number().min(5); 108 | 109 | TestHelpers.validateAll(schema, { 110 | valid: [7, 35738787838, [null, schema.nullable()]], 111 | invalid: [2, null, [14, schema.min(10).min(15)]], 112 | }); 113 | }); 114 | 115 | describe('max', () => { 116 | let schema = number().max(5); 117 | 118 | TestHelpers.validateAll(schema, { 119 | valid: [4, -5222, [null, schema.nullable()]], 120 | invalid: [10, null, [16, schema.max(20).max(15)]], 121 | }); 122 | }); 123 | 124 | describe('lessThan', () => { 125 | let schema = number().lessThan(5); 126 | 127 | TestHelpers.validateAll(schema, { 128 | valid: [4, -10, [null, schema.nullable()]], 129 | invalid: [5, 7, null, [14, schema.lessThan(10).lessThan(14)]], 130 | }); 131 | 132 | it('should return default message', async () => { 133 | await expect(schema.validate(6)).rejects.toEqual( 134 | TestHelpers.validationErrorWithMessages('this must be less than 5'), 135 | ); 136 | }); 137 | }); 138 | 139 | describe('moreThan', () => { 140 | let schema = number().moreThan(5); 141 | 142 | TestHelpers.validateAll(schema, { 143 | valid: [6, 56445435, [null, schema.nullable()]], 144 | invalid: [5, -10, null, [64, schema.moreThan(52).moreThan(74)]], 145 | }); 146 | 147 | it('should return default message', async () => { 148 | await expect(schema.validate(4)).rejects.toEqual( 149 | TestHelpers.validationErrorWithMessages( 150 | expect.stringContaining('this must be greater than 5'), 151 | ), 152 | ); 153 | }); 154 | }); 155 | 156 | describe('integer', () => { 157 | let schema = number().integer(); 158 | 159 | TestHelpers.validateAll(schema, { 160 | valid: [4, -5222, 3.12312e51], 161 | invalid: [10.53, 0.1 * 0.2, -34512535.626, new Date()], 162 | }); 163 | 164 | it('should return default message', async () => { 165 | await expect(schema.validate(10.53)).rejects.toEqual( 166 | TestHelpers.validationErrorWithMessages('this must be an integer'), 167 | ); 168 | }); 169 | }); 170 | 171 | it('should check POSITIVE correctly', function () { 172 | let v = number().positive(); 173 | 174 | return Promise.all([ 175 | expect(v.isValid(7)).resolves.toBe(true), 176 | 177 | expect(v.isValid(0)).resolves.toBe(false), 178 | 179 | expect(v.validate(0)).rejects.toEqual( 180 | TestHelpers.validationErrorWithMessages( 181 | 'this must be a positive number', 182 | ), 183 | ), 184 | ]); 185 | }); 186 | 187 | it('should check NEGATIVE correctly', function () { 188 | let v = number().negative(); 189 | 190 | return Promise.all([ 191 | expect(v.isValid(-4)).resolves.toBe(true), 192 | 193 | expect(v.isValid(0)).resolves.toBe(false), 194 | 195 | expect(v.validate(10)).rejects.toEqual( 196 | TestHelpers.validationErrorWithMessages( 197 | 'this must be a negative number', 198 | ), 199 | ), 200 | ]); 201 | }); 202 | 203 | it('should resolve param refs when describing', () => { 204 | let schema = number().min(ref('$foo')); 205 | 206 | expect(schema.describe({ value: 10, context: { foo: 5 } })).toEqual( 207 | expect.objectContaining({ 208 | tests: [ 209 | expect.objectContaining({ 210 | params: { 211 | min: 5, 212 | }, 213 | }), 214 | ], 215 | }), 216 | ); 217 | 218 | let schema2 = object({ 219 | x: number().min(0), 220 | y: number().min(ref('x')), 221 | }).required(); 222 | 223 | expect( 224 | schema2.describe({ value: { x: 10 }, context: { foo: 5 } }).fields.y, 225 | ).toEqual( 226 | expect.objectContaining({ 227 | tests: [ 228 | expect.objectContaining({ 229 | params: { 230 | min: 10, 231 | }, 232 | }), 233 | ], 234 | }), 235 | ); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /test/array.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, test, vi } from 'vitest'; 2 | import { 3 | string, 4 | number, 5 | object, 6 | array, 7 | StringSchema, 8 | AnySchema, 9 | ValidationError, 10 | } from '../src'; 11 | 12 | describe('Array types', () => { 13 | describe('casting', () => { 14 | it('should parse json strings', () => { 15 | expect(array().json().cast('[2,3,5,6]')).toEqual([2, 3, 5, 6]); 16 | }); 17 | 18 | it('should failed casts return input', () => { 19 | expect(array().cast('asfasf', { assert: false })).toEqual('asfasf'); 20 | 21 | expect(array().cast('{}', { assert: false })).toEqual('{}'); 22 | }); 23 | 24 | it('should recursively cast fields', () => { 25 | expect(array().of(number()).cast(['4', '5'])).toEqual([4, 5]); 26 | 27 | expect(array().of(string()).cast(['4', 5, false])).toEqual([ 28 | '4', 29 | '5', 30 | 'false', 31 | ]); 32 | }); 33 | 34 | it('should pass array options to descendants when casting', async () => { 35 | let value = ['1', '2']; 36 | 37 | let itemSchema = string().when([], function (_, _s, opts: any) { 38 | 39 | const parent = opts.parent; 40 | const idx = opts.index; 41 | const val = opts.value; 42 | const originalValue = opts.originalValue; 43 | 44 | expect(parent).toEqual(value); 45 | expect(typeof idx).toBe('number'); 46 | expect(val).toEqual(parent[idx]); 47 | expect(originalValue).toEqual(parent[idx]); 48 | 49 | return string().transform((value, _originalValue, _schema, options: any) => { 50 | expect(parent).toEqual(options.parent); 51 | expect(typeof options.index).toBe('number'); 52 | expect(val).toEqual(value); 53 | 54 | return value; 55 | }); 56 | }); 57 | 58 | await array().of(itemSchema).validate(value, { context: { name: 'test'} }); 59 | }); 60 | }); 61 | 62 | it('should handle DEFAULT', () => { 63 | expect(array().getDefault()).toBeUndefined(); 64 | 65 | expect( 66 | array() 67 | .default(() => [1, 2, 3]) 68 | .getDefault(), 69 | ).toEqual([1, 2, 3]); 70 | }); 71 | 72 | it('should type check', () => { 73 | let inst = array(); 74 | 75 | expect(inst.isType([])).toBe(true); 76 | expect(inst.isType({})).toBe(false); 77 | expect(inst.isType('true')).toBe(false); 78 | expect(inst.isType(NaN)).toBe(false); 79 | expect(inst.isType(34545)).toBe(false); 80 | 81 | expect(inst.isType(null)).toBe(false); 82 | 83 | expect(inst.nullable().isType(null)).toBe(true); 84 | }); 85 | 86 | it('should cast children', () => { 87 | expect(array().of(number()).cast(['1', '3'])).toEqual([1, 3]); 88 | }); 89 | 90 | it('should concat subType correctly', async () => { 91 | expect(array(number()).concat(array()).innerType).toBeDefined(); 92 | 93 | let merged = array(number()).concat(array(number().required())); 94 | 95 | const ve = new ValidationError(''); 96 | // expect(ve.toString()).toBe('[object Error]'); 97 | expect(Object.prototype.toString.call(ve)).toBe('[object Error]'); 98 | expect((merged.innerType as AnySchema).type).toBe('number'); 99 | 100 | await expect(merged.validateAt('[0]', undefined)).rejects.toThrowError(); 101 | }); 102 | 103 | it('should pass options to children', () => { 104 | expect( 105 | array(object({ name: string() })).cast([{ id: 1, name: 'john' }], { 106 | stripUnknown: true, 107 | }), 108 | ).toEqual([{ name: 'john' }]); 109 | }); 110 | 111 | describe('validation', () => { 112 | test.each([ 113 | ['required', undefined, array().required()], 114 | ['required', null, array().required()], 115 | ['null', null, array()], 116 | ['length', [1, 2, 3], array().length(2)], 117 | ])('Basic validations fail: %s %p', async (_, value, schema) => { 118 | expect(await schema.isValid(value)).toBe(false); 119 | }); 120 | 121 | test.each([ 122 | ['required', [], array().required()], 123 | ['nullable', null, array().nullable()], 124 | ['length', [1, 2, 3], array().length(3)], 125 | ])('Basic validations pass: %s %p', async (_, value, schema) => { 126 | expect(await schema.isValid(value)).toBe(true); 127 | }); 128 | 129 | it('should allow undefined', async () => { 130 | await expect( 131 | array().of(number().max(5)).isValid(undefined), 132 | ).resolves.toBe(true); 133 | }); 134 | 135 | it('max should replace earlier tests', async () => { 136 | expect(await array().max(4).max(10).isValid(Array(5).fill(0))).toBe(true); 137 | }); 138 | 139 | it('min should replace earlier tests', async () => { 140 | expect(await array().min(10).min(4).isValid(Array(5).fill(0))).toBe(true); 141 | }); 142 | 143 | it('should respect subtype validations', async () => { 144 | let inst = array().of(number().max(5)); 145 | 146 | await expect(inst.isValid(['gg', 3])).resolves.toBe(false); 147 | await expect(inst.isValid([7, 3])).resolves.toBe(false); 148 | 149 | let value = await inst.validate(['4', 3]); 150 | 151 | expect(value).toEqual([4, 3]); 152 | }); 153 | 154 | it('should prevent recursive casting', async () => { 155 | // @ts-ignore 156 | let castSpy = vi.spyOn(StringSchema.prototype, '_cast'); 157 | 158 | let value = await array(string()).defined().validate([5]); 159 | 160 | expect(value[0]).toBe('5'); 161 | 162 | expect(castSpy).toHaveBeenCalledTimes(1); 163 | castSpy.mockRestore(); 164 | }); 165 | }); 166 | 167 | it('should respect abortEarly', async () => { 168 | let inst = array() 169 | .of(object({ str: string().required() })) 170 | .test('name', 'oops', () => false); 171 | 172 | await expect(inst.validate([{ str: '' }])).rejects.toEqual( 173 | expect.objectContaining({ 174 | value: [{ str: '' }], 175 | errors: ['oops'], 176 | }), 177 | ); 178 | 179 | await expect( 180 | inst.validate([{ str: '' }], { abortEarly: false }), 181 | ).rejects.toEqual( 182 | expect.objectContaining({ 183 | value: [{ str: '' }], 184 | errors: ['[0].str is a required field', 'oops'], 185 | }), 186 | ); 187 | }); 188 | 189 | it('should respect disableStackTrace', async () => { 190 | let inst = array().of(object({ str: string().required() })); 191 | 192 | const data = [{ str: undefined }, { str: undefined }]; 193 | return Promise.all([ 194 | expect(inst.strict().validate(data)).rejects.toHaveProperty('stack'), 195 | 196 | expect( 197 | inst.strict().validate(data, { disableStackTrace: true }), 198 | ).rejects.not.toHaveProperty('stack'), 199 | ]); 200 | }); 201 | 202 | it('should compact arrays', () => { 203 | let arr = ['', 1, 0, 4, false, null], 204 | inst = array(); 205 | 206 | expect(inst.compact().cast(arr)).toEqual([1, 4]); 207 | 208 | expect(inst.compact((v) => v == null).cast(arr)).toEqual([ 209 | '', 210 | 1, 211 | 0, 212 | 4, 213 | false, 214 | ]); 215 | }); 216 | 217 | it('should ensure arrays', () => { 218 | let inst = array().ensure(); 219 | 220 | const a = [1, 4]; 221 | expect(inst.cast(a)).toBe(a); 222 | 223 | expect(inst.cast(null)).toEqual([]); 224 | // nullable is redundant since this should always produce an array 225 | // but we want to ensure that null is actually turned into an array 226 | expect(inst.nullable().cast(null)).toEqual([]); 227 | 228 | expect(inst.cast(1)).toEqual([1]); 229 | expect(inst.nullable().cast(1)).toEqual([1]); 230 | }); 231 | 232 | it('should pass resolved path to descendants', async () => { 233 | let value = ['2', '3']; 234 | let expectedPaths = ['[0]', '[1]']; 235 | 236 | let itemSchema = string().when([], function (_, _s, opts: any) { 237 | let path = opts.path; 238 | expect(expectedPaths).toContain(path); 239 | return string().required(); 240 | }); 241 | 242 | await array().of(itemSchema).validate(value); 243 | }); 244 | 245 | it('should pass deeply resolved path to descendants', async () => { 246 | let value = ['2', '3']; 247 | let expectedPaths = ['items[0]', 'items[1]']; 248 | 249 | let itemSchema = string().when([], function (_, _s, opts: any) { 250 | let path = opts.path; 251 | expect(expectedPaths).toContain(path); 252 | return string().required(); 253 | }); 254 | 255 | const schema = object({ 256 | items: array().of(itemSchema) 257 | }) 258 | 259 | await schema.validate({ items: value }); 260 | }); 261 | 262 | it('should maintain array sparseness through validation', async () => { 263 | let sparseArray = new Array(2); 264 | sparseArray[1] = 1; 265 | let value = await array().of(number()).validate(sparseArray); 266 | expect(0 in sparseArray).toBe(false); 267 | expect(0 in value!).toBe(false); 268 | 269 | // eslint-disable-next-line no-sparse-arrays 270 | expect(value).toEqual([, 1]); 271 | }); 272 | 273 | it('should validate empty slots in sparse array', async () => { 274 | let sparseArray = new Array(2); 275 | sparseArray[1] = 1; 276 | await expect( 277 | array().of(number().required()).isValid(sparseArray), 278 | ).resolves.toEqual(false); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import isSchema from './util/isSchema'; 2 | import printValue from './util/printValue'; 3 | import parseJson from './util/parseJson'; 4 | import { array as locale } from './locale'; 5 | import type { 6 | AnyObject, 7 | InternalOptions, 8 | Message, 9 | ISchema, 10 | DefaultThunk, 11 | } from './types'; 12 | import type Reference from './Reference'; 13 | import type { 14 | Defined, 15 | Flags, 16 | NotNull, 17 | SetFlag, 18 | Maybe, 19 | Optionals, 20 | ToggleDefault, 21 | UnsetFlag, 22 | Concat, 23 | } from './util/types'; 24 | import Schema, { 25 | RunTest, 26 | SchemaInnerTypeDescription, 27 | SchemaSpec, 28 | } from './schema'; 29 | import type { ResolveOptions } from './Condition'; 30 | import type ValidationError from './ValidationError'; 31 | 32 | type InnerType = T extends Array ? I : never; 33 | 34 | export type RejectorFn = ( 35 | value: any, 36 | index: number, 37 | array: readonly any[], 38 | ) => boolean; 39 | 40 | export function create = AnyObject, T = any>( 41 | type?: ISchema, 42 | ) { 43 | return new ArraySchema(type as any); 44 | } 45 | 46 | interface ArraySchemaSpec extends SchemaSpec { 47 | types?: ISchema, TContext>; 48 | } 49 | 50 | export default class ArraySchema< 51 | TIn extends any[] | null | undefined, 52 | TContext, 53 | TDefault = undefined, 54 | TFlags extends Flags = '', 55 | > extends Schema { 56 | declare spec: ArraySchemaSpec; 57 | readonly innerType?: ISchema, TContext>; 58 | 59 | constructor(type?: ISchema, TContext>) { 60 | super({ 61 | type: 'array', 62 | spec: { types: type } as ArraySchemaSpec, 63 | check(v: any): v is NonNullable { 64 | return Array.isArray(v); 65 | }, 66 | }); 67 | 68 | // `undefined` specifically means uninitialized, as opposed to "no subtype" 69 | this.innerType = type; 70 | } 71 | 72 | protected _cast(_value: any, _opts: InternalOptions) { 73 | const value = super._cast(_value, _opts); 74 | 75 | // should ignore nulls here 76 | if (!this._typeCheck(value) || !this.innerType) { 77 | return value; 78 | } 79 | 80 | let isChanged = false; 81 | const castArray = value.map((v, idx) => { 82 | const castElement = this.innerType!.cast(v, { 83 | ..._opts, 84 | path: `${_opts.path || ''}[${idx}]`, 85 | parent: value, 86 | originalValue: v, 87 | value: v, 88 | index: idx, 89 | 90 | }); 91 | 92 | if (castElement !== v) { 93 | isChanged = true; 94 | } 95 | 96 | return castElement; 97 | }); 98 | 99 | return isChanged ? castArray : value; 100 | } 101 | 102 | protected _validate( 103 | _value: any, 104 | options: InternalOptions = {}, 105 | 106 | panic: (err: Error, value: unknown) => void, 107 | next: (err: ValidationError[], value: unknown) => void, 108 | ) { 109 | // let sync = options.sync; 110 | // let path = options.path; 111 | let innerType = this.innerType; 112 | // let endEarly = options.abortEarly ?? this.spec.abortEarly; 113 | let recursive = options.recursive ?? this.spec.recursive; 114 | 115 | let originalValue = 116 | options.originalValue != null ? options.originalValue : _value; 117 | 118 | super._validate(_value, options, panic, (arrayErrors, value) => { 119 | if (!recursive || !innerType || !this._typeCheck(value)) { 120 | next(arrayErrors, value); 121 | return; 122 | } 123 | 124 | originalValue = originalValue || value; 125 | 126 | // #950 Ensure that sparse array empty slots are validated 127 | let tests: RunTest[] = new Array(value.length); 128 | for (let index = 0; index < value.length; index++) { 129 | tests[index] = innerType!.asNestedTest({ 130 | options, 131 | index, 132 | parent: value, 133 | parentPath: options.path, 134 | originalParent: options.originalValue ?? _value, 135 | }); 136 | } 137 | 138 | this.runTests( 139 | { 140 | value, 141 | tests, 142 | originalValue: options.originalValue ?? _value, 143 | options, 144 | }, 145 | panic, 146 | (innerTypeErrors) => next(innerTypeErrors.concat(arrayErrors), value), 147 | ); 148 | }); 149 | } 150 | 151 | clone(spec?: SchemaSpec) { 152 | const next = super.clone(spec); 153 | // @ts-expect-error readonly 154 | next.innerType = this.innerType; 155 | return next; 156 | } 157 | 158 | /** Parse an input JSON string to an object */ 159 | json() { 160 | return this.transform(parseJson); 161 | } 162 | 163 | concat, IC, ID, IF extends Flags>( 164 | schema: ArraySchema, 165 | ): ArraySchema< 166 | Concat, 167 | TContext & IC, 168 | Extract extends never ? TDefault : ID, 169 | TFlags | IF 170 | >; 171 | concat(schema: this): this; 172 | concat(schema: any): any { 173 | let next = super.concat(schema) as this; 174 | 175 | // @ts-expect-error readonly 176 | next.innerType = this.innerType; 177 | 178 | if (schema.innerType) 179 | // @ts-expect-error readonly 180 | next.innerType = next.innerType 181 | ? // @ts-expect-error Lazy doesn't have concat and will break 182 | next.innerType.concat(schema.innerType) 183 | : schema.innerType; 184 | 185 | return next; 186 | } 187 | 188 | of( 189 | schema: ISchema, 190 | ): ArraySchema, TContext, TFlags> { 191 | // FIXME: this should return a new instance of array without the default to be 192 | let next = this.clone(); 193 | 194 | if (!isSchema(schema)) 195 | throw new TypeError( 196 | '`array.of()` sub-schema must be a valid yup schema not: ' + 197 | printValue(schema), 198 | ); 199 | 200 | // @ts-expect-error readonly 201 | next.innerType = schema; 202 | 203 | next.spec = { 204 | ...next.spec, 205 | types: schema as ISchema, TContext>, 206 | }; 207 | 208 | return next as any; 209 | } 210 | 211 | length( 212 | length: number | Reference, 213 | message: Message<{ length: number }> = locale.length, 214 | ) { 215 | return this.test({ 216 | message, 217 | name: 'length', 218 | exclusive: true, 219 | params: { length }, 220 | skipAbsent: true, 221 | test(value) { 222 | return value!.length === this.resolve(length); 223 | }, 224 | }); 225 | } 226 | 227 | min(min: number | Reference, message?: Message<{ min: number }>) { 228 | message = message || locale.min; 229 | 230 | return this.test({ 231 | message, 232 | name: 'min', 233 | exclusive: true, 234 | params: { min }, 235 | skipAbsent: true, 236 | // FIXME(ts): Array 237 | test(value) { 238 | return value!.length >= this.resolve(min); 239 | }, 240 | }); 241 | } 242 | 243 | max(max: number | Reference, message?: Message<{ max: number }>) { 244 | message = message || locale.max; 245 | return this.test({ 246 | message, 247 | name: 'max', 248 | exclusive: true, 249 | params: { max }, 250 | skipAbsent: true, 251 | test(value) { 252 | return value!.length <= this.resolve(max); 253 | }, 254 | }); 255 | } 256 | 257 | ensure() { 258 | return this.default(() => [] as any).transform( 259 | (val: TIn, original: any) => { 260 | // We don't want to return `null` for nullable schema 261 | if (this._typeCheck(val)) return val; 262 | return original == null ? [] : [].concat(original); 263 | }, 264 | ); 265 | } 266 | 267 | compact(rejector?: RejectorFn) { 268 | let reject: RejectorFn = !rejector 269 | ? (v) => !!v 270 | : (v, i, a) => !rejector(v, i, a); 271 | 272 | return this.transform((values: readonly any[]) => 273 | values != null ? values.filter(reject) : values, 274 | ); 275 | } 276 | 277 | describe(options?: ResolveOptions) { 278 | const next = (options ? this.resolve(options) : this).clone(); 279 | const base = super.describe(options) as SchemaInnerTypeDescription; 280 | if (next.innerType) { 281 | let innerOptions = options; 282 | if (innerOptions?.value) { 283 | innerOptions = { 284 | ...innerOptions, 285 | parent: innerOptions.value, 286 | value: innerOptions.value[0], 287 | }; 288 | } 289 | base.innerType = next.innerType.describe(innerOptions); 290 | } 291 | return base; 292 | } 293 | } 294 | 295 | create.prototype = ArraySchema.prototype; 296 | 297 | export default interface ArraySchema< 298 | TIn extends any[] | null | undefined, 299 | TContext, 300 | TDefault = undefined, 301 | TFlags extends Flags = '', 302 | > extends Schema { 303 | default>( 304 | def: DefaultThunk, 305 | ): ArraySchema>; 306 | 307 | defined(msg?: Message): ArraySchema, TContext, TDefault, TFlags>; 308 | optional(): ArraySchema; 309 | 310 | required( 311 | msg?: Message, 312 | ): ArraySchema, TContext, TDefault, TFlags>; 313 | notRequired(): ArraySchema, TContext, TDefault, TFlags>; 314 | 315 | nullable(msg?: Message): ArraySchema; 316 | nonNullable( 317 | msg?: Message, 318 | ): ArraySchema, TContext, TDefault, TFlags>; 319 | 320 | strip( 321 | enabled: false, 322 | ): ArraySchema>; 323 | strip( 324 | enabled?: true, 325 | ): ArraySchema>; 326 | } 327 | -------------------------------------------------------------------------------- /test/util/parseIsoDate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a modified version of the test file from the following repository: 3 | * Date.parse with progressive enhancement for ISO 8601 4 | * NON-CONFORMANT EDITION. 5 | * © 2011 Colin Snover 6 | * Released under MIT license. 7 | */ 8 | 9 | import { describe, test, expect } from 'vitest'; 10 | import { parseIsoDate } from '../../src/util/parseIsoDate'; 11 | 12 | const sixHours = 6 * 60 * 60 * 1000; 13 | const sixHoursThirty = sixHours + 30 * 60 * 1000; 14 | const epochLocalTime = new Date(1970, 0, 1, 0, 0, 0, 0).valueOf(); 15 | 16 | describe('plain date (no time)', () => { 17 | describe('valid dates', () => { 18 | test('Unix epoch', () => { 19 | const result = parseIsoDate('1970-01-01'); 20 | expect(result).toBe(epochLocalTime); 21 | }); 22 | test('2001', () => { 23 | const result = parseIsoDate('2001'); 24 | const expected = new Date(2001, 0, 1, 0, 0, 0, 0).valueOf(); 25 | expect(result).toBe(expected); 26 | }); 27 | test('2001-02', () => { 28 | const result = parseIsoDate('2001-02'); 29 | const expected = new Date(2001, 1, 1, 0, 0, 0, 0).valueOf(); 30 | expect(result).toBe(expected); 31 | }); 32 | test('2001-02-03', () => { 33 | const result = parseIsoDate('2001-02-03'); 34 | const expected = new Date(2001, 1, 3, 0, 0, 0, 0).valueOf(); 35 | expect(result).toBe(expected); 36 | }); 37 | test('-002001', () => { 38 | const result = parseIsoDate('-002001'); 39 | const expected = new Date(-2001, 0, 1, 0, 0, 0, 0).valueOf(); 40 | expect(result).toBe(expected); 41 | }); 42 | test('-002001-02', () => { 43 | const result = parseIsoDate('-002001-02'); 44 | const expected = new Date(-2001, 1, 1, 0, 0, 0, 0).valueOf(); 45 | expect(result).toBe(expected); 46 | }); 47 | test('-002001-02-03', () => { 48 | const result = parseIsoDate('-002001-02-03'); 49 | const expected = new Date(-2001, 1, 3, 0, 0, 0, 0).valueOf(); 50 | expect(result).toBe(expected); 51 | }); 52 | test('+010000-02', () => { 53 | const result = parseIsoDate('+010000-02'); 54 | const expected = new Date(10000, 1, 1, 0, 0, 0, 0).valueOf(); 55 | expect(result).toBe(expected); 56 | }); 57 | test('+010000-02-03', () => { 58 | const result = parseIsoDate('+010000-02-03'); 59 | const expected = new Date(10000, 1, 3, 0, 0, 0, 0).valueOf(); 60 | expect(result).toBe(expected); 61 | }); 62 | test('-010000-02', () => { 63 | const result = parseIsoDate('-010000-02'); 64 | const expected = new Date(-10000, 1, 1, 0, 0, 0, 0).valueOf(); 65 | expect(result).toBe(expected); 66 | }); 67 | test('-010000-02-03', () => { 68 | const result = parseIsoDate('-010000-02-03'); 69 | const expected = new Date(-10000, 1, 3, 0, 0, 0, 0).valueOf(); 70 | expect(result).toBe(expected); 71 | }); 72 | }); 73 | 74 | describe('invalid dates', () => { 75 | test('invalid YYYY (non-digits)', () => { 76 | expect(parseIsoDate('asdf')).toBeNaN(); 77 | }); 78 | test('invalid YYYY-MM-DD (non-digits)', () => { 79 | expect(parseIsoDate('1970-as-df')).toBeNaN(); 80 | }); 81 | test('invalid YYYY-MM- (extra hyphen)', () => { 82 | expect(parseIsoDate('1970-01-')).toBe(epochLocalTime); 83 | }); 84 | test('invalid YYYY-MM-DD (missing hyphens)', () => { 85 | expect(parseIsoDate('19700101')).toBe(epochLocalTime); 86 | }); 87 | test('ambiguous YYYY-MM/YYYYYY (missing plus/minus or hyphen)', () => { 88 | expect(parseIsoDate('197001')).toBe(epochLocalTime); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('date-time', () => { 94 | describe('no time zone', () => { 95 | test('2001-02-03T04:05', () => { 96 | const result = parseIsoDate('2001-02-03T04:05'); 97 | const expected = new Date(2001, 1, 3, 4, 5, 0, 0).valueOf(); 98 | expect(result).toBe(expected); 99 | }); 100 | test('2001-02-03T04:05:06', () => { 101 | const result = parseIsoDate('2001-02-03T04:05:06'); 102 | const expected = new Date(2001, 1, 3, 4, 5, 6, 0).valueOf(); 103 | expect(result).toBe(expected); 104 | }); 105 | test('2001-02-03T04:05:06.007', () => { 106 | const result = parseIsoDate('2001-02-03T04:05:06.007'); 107 | const expected = new Date(2001, 1, 3, 4, 5, 6, 7).valueOf(); 108 | expect(result).toBe(expected); 109 | }); 110 | }); 111 | 112 | describe('Z time zone', () => { 113 | test('2001-02-03T04:05Z', () => { 114 | const result = parseIsoDate('2001-02-03T04:05Z'); 115 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 116 | expect(result).toBe(expected); 117 | }); 118 | test('2001-02-03T04:05:06Z', () => { 119 | const result = parseIsoDate('2001-02-03T04:05:06Z'); 120 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 121 | expect(result).toBe(expected); 122 | }); 123 | test('2001-02-03T04:05:06.007Z', () => { 124 | const result = parseIsoDate('2001-02-03T04:05:06.007Z'); 125 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 126 | expect(result).toBe(expected); 127 | }); 128 | }); 129 | 130 | describe('offset time zone', () => { 131 | test('2001-02-03T04:05-00:00', () => { 132 | const result = parseIsoDate('2001-02-03T04:05-00:00'); 133 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 134 | expect(result).toBe(expected); 135 | }); 136 | test('2001-02-03T04:05:06-00:00', () => { 137 | const result = parseIsoDate('2001-02-03T04:05:06-00:00'); 138 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 139 | expect(result).toBe(expected); 140 | }); 141 | test('2001-02-03T04:05:06.007-00:00', () => { 142 | const result = parseIsoDate('2001-02-03T04:05:06.007-00:00'); 143 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 144 | expect(result).toBe(expected); 145 | }); 146 | 147 | test('2001-02-03T04:05+00:00', () => { 148 | const result = parseIsoDate('2001-02-03T04:05+00:00'); 149 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 150 | expect(result).toBe(expected); 151 | }); 152 | test('2001-02-03T04:05:06+00:00', () => { 153 | const result = parseIsoDate('2001-02-03T04:05:06+00:00'); 154 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 155 | expect(result).toBe(expected); 156 | }); 157 | test('2001-02-03T04:05:06.007+00:00', () => { 158 | const result = parseIsoDate('2001-02-03T04:05:06.007+00:00'); 159 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 160 | expect(result).toBe(expected); 161 | }); 162 | 163 | test('2001-02-03T04:05-06:30', () => { 164 | const result = parseIsoDate('2001-02-03T04:05-06:30'); 165 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) + sixHoursThirty; 166 | expect(result).toBe(expected); 167 | }); 168 | test('2001-02-03T04:05:06-06:30', () => { 169 | const result = parseIsoDate('2001-02-03T04:05:06-06:30'); 170 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) + sixHoursThirty; 171 | expect(result).toBe(expected); 172 | }); 173 | test('2001-02-03T04:05:06.007-06:30', () => { 174 | const result = parseIsoDate('2001-02-03T04:05:06.007-06:30'); 175 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) + sixHoursThirty; 176 | expect(result).toBe(expected); 177 | }); 178 | 179 | test('2001-02-03T04:05+06:30', () => { 180 | const result = parseIsoDate('2001-02-03T04:05+06:30'); 181 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) - sixHoursThirty; 182 | expect(result).toBe(expected); 183 | }); 184 | test('2001-02-03T04:05:06+06:30', () => { 185 | const result = parseIsoDate('2001-02-03T04:05:06+06:30'); 186 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) - sixHoursThirty; 187 | expect(result).toBe(expected); 188 | }); 189 | test('2001-02-03T04:05:06.007+06:30', () => { 190 | const result = parseIsoDate('2001-02-03T04:05:06.007+06:30'); 191 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) - sixHoursThirty; 192 | expect(result).toBe(expected); 193 | }); 194 | }); 195 | 196 | describe('incomplete dates', () => { 197 | test('2001T04:05:06.007', () => { 198 | const result = parseIsoDate('2001T04:05:06.007'); 199 | const expected = new Date(2001, 0, 1, 4, 5, 6, 7).valueOf(); 200 | expect(result).toBe(expected); 201 | }); 202 | test('2001-02T04:05:06.007', () => { 203 | const result = parseIsoDate('2001-02T04:05:06.007'); 204 | const expected = new Date(2001, 1, 1, 4, 5, 6, 7).valueOf(); 205 | expect(result).toBe(expected); 206 | }); 207 | 208 | test('-010000T04:05', () => { 209 | const result = parseIsoDate('-010000T04:05'); 210 | const expected = new Date(-10000, 0, 1, 4, 5, 0, 0).valueOf(); 211 | expect(result).toBe(expected); 212 | }); 213 | test('-010000-02T04:05', () => { 214 | const result = parseIsoDate('-010000-02T04:05'); 215 | const expected = new Date(-10000, 1, 1, 4, 5, 0, 0).valueOf(); 216 | expect(result).toBe(expected); 217 | }); 218 | test('-010000-02-03T04:05', () => { 219 | const result = parseIsoDate('-010000-02-03T04:05'); 220 | const expected = new Date(-10000, 1, 3, 4, 5, 0, 0).valueOf(); 221 | expect(result).toBe(expected); 222 | }); 223 | }); 224 | 225 | describe('invalid date-times', () => { 226 | test('missing T', () => { 227 | expect(parseIsoDate('1970-01-01 00:00:00')).toBe(epochLocalTime); 228 | }); 229 | test('too many characters in millisecond part', () => { 230 | expect(parseIsoDate('1970-01-01T00:00:00.000000')).toBe(epochLocalTime); 231 | }); 232 | test('comma instead of dot', () => { 233 | expect(parseIsoDate('1970-01-01T00:00:00,000')).toBe(epochLocalTime); 234 | }); 235 | test('missing colon in timezone part', () => { 236 | const subject = '1970-01-01T00:00:00+0630'; 237 | expect(parseIsoDate(subject)).toBe(Date.parse(subject)); 238 | }); 239 | test('missing colon in time part', () => { 240 | expect(parseIsoDate('1970-01-01T0000')).toBe(epochLocalTime); 241 | }); 242 | test('msec with missing seconds', () => { 243 | expect(parseIsoDate('1970-01-01T00:00.000')).toBeNaN(); 244 | }); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /src/string.ts: -------------------------------------------------------------------------------- 1 | import { MixedLocale, mixed as mixedLocale, string as locale } from './locale'; 2 | import isAbsent from './util/isAbsent'; 3 | import type Reference from './Reference'; 4 | import type { Message, AnyObject, DefaultThunk } from './types'; 5 | import type { 6 | Concat, 7 | Defined, 8 | Flags, 9 | NotNull, 10 | SetFlag, 11 | ToggleDefault, 12 | UnsetFlag, 13 | Maybe, 14 | Optionals, 15 | } from './util/types'; 16 | import Schema from './schema'; 17 | import { parseDateStruct } from './util/parseIsoDate'; 18 | 19 | // Taken from HTML spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address 20 | let rEmail = 21 | // eslint-disable-next-line 22 | /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; 23 | 24 | let rUrl = 25 | // eslint-disable-next-line 26 | /^((https?|ftp):)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i; 27 | 28 | // eslint-disable-next-line 29 | let rUUID = 30 | /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; 31 | 32 | let yearMonthDay = '^\\d{4}-\\d{2}-\\d{2}'; 33 | let hourMinuteSecond = '\\d{2}:\\d{2}:\\d{2}'; 34 | let zOrOffset = '(([+-]\\d{2}(:?\\d{2})?)|Z)'; 35 | let rIsoDateTime = new RegExp( 36 | `${yearMonthDay}T${hourMinuteSecond}(\\.\\d+)?${zOrOffset}$`, 37 | ); 38 | 39 | let isTrimmed = (value: Maybe) => 40 | isAbsent(value) || value === value.trim(); 41 | 42 | export type MatchOptions = { 43 | excludeEmptyString?: boolean; 44 | message: Message<{ regex: RegExp }>; 45 | name?: string; 46 | }; 47 | 48 | export type DateTimeOptions = { 49 | message: Message<{ allowOffset?: boolean; precision?: number }>; 50 | /** Allow a time zone offset. False requires UTC 'Z' timezone. (default: false) */ 51 | allowOffset?: boolean; 52 | /** Require a certain sub-second precision on the date. (default: undefined -- any or no sub-second precision) */ 53 | precision?: number; 54 | }; 55 | 56 | let objStringTag = {}.toString(); 57 | 58 | function create(): StringSchema; 59 | function create< 60 | T extends string, 61 | TContext extends Maybe = AnyObject, 62 | >(): StringSchema; 63 | function create() { 64 | return new StringSchema(); 65 | } 66 | 67 | export { create }; 68 | 69 | export default class StringSchema< 70 | TType extends Maybe = string | undefined, 71 | TContext = AnyObject, 72 | TDefault = undefined, 73 | TFlags extends Flags = '', 74 | > extends Schema { 75 | constructor() { 76 | super({ 77 | type: 'string', 78 | check(value): value is NonNullable { 79 | if (value instanceof String) value = value.valueOf(); 80 | return typeof value === 'string'; 81 | }, 82 | }); 83 | 84 | this.withMutation(() => { 85 | this.transform((value, _raw) => { 86 | if (!this.spec.coerce || this.isType(value)) return value; 87 | 88 | // don't ever convert arrays 89 | if (Array.isArray(value)) return value; 90 | 91 | const strValue = 92 | value != null && value.toString ? value.toString() : value; 93 | 94 | // no one wants plain objects converted to [Object object] 95 | if (strValue === objStringTag) return value; 96 | 97 | return strValue; 98 | }); 99 | }); 100 | } 101 | 102 | required(message?: Message) { 103 | return super.required(message).withMutation((schema: this) => 104 | schema.test({ 105 | message: message || mixedLocale.required, 106 | name: 'required', 107 | skipAbsent: true, 108 | test: (value) => !!value!.length, 109 | }), 110 | ); 111 | } 112 | 113 | notRequired() { 114 | return super.notRequired().withMutation((schema: this) => { 115 | schema.tests = schema.tests.filter((t) => t.OPTIONS!.name !== 'required'); 116 | return schema; 117 | }); 118 | } 119 | 120 | length( 121 | length: number | Reference, 122 | message: Message<{ length: number }> = locale.length, 123 | ) { 124 | return this.test({ 125 | message, 126 | name: 'length', 127 | exclusive: true, 128 | params: { length }, 129 | skipAbsent: true, 130 | test(value: Maybe) { 131 | return value!.length === this.resolve(length); 132 | }, 133 | }); 134 | } 135 | 136 | min( 137 | min: number | Reference, 138 | message: Message<{ min: number }> = locale.min, 139 | ) { 140 | return this.test({ 141 | message, 142 | name: 'min', 143 | exclusive: true, 144 | params: { min }, 145 | skipAbsent: true, 146 | test(value: Maybe) { 147 | return value!.length >= this.resolve(min); 148 | }, 149 | }); 150 | } 151 | 152 | max( 153 | max: number | Reference, 154 | message: Message<{ max: number }> = locale.max, 155 | ) { 156 | return this.test({ 157 | name: 'max', 158 | exclusive: true, 159 | message, 160 | params: { max }, 161 | skipAbsent: true, 162 | test(value: Maybe) { 163 | return value!.length <= this.resolve(max); 164 | }, 165 | }); 166 | } 167 | 168 | matches(regex: RegExp, options?: MatchOptions | MatchOptions['message']) { 169 | let excludeEmptyString = false; 170 | let message; 171 | let name; 172 | 173 | if (options) { 174 | if (typeof options === 'object') { 175 | ({ 176 | excludeEmptyString = false, 177 | message, 178 | name, 179 | } = options as MatchOptions); 180 | } else { 181 | message = options; 182 | } 183 | } 184 | 185 | return this.test({ 186 | name: name || 'matches', 187 | message: message || locale.matches, 188 | params: { regex }, 189 | skipAbsent: true, 190 | test: (value: Maybe) => 191 | (value === '' && excludeEmptyString) || value!.search(regex) !== -1, 192 | }); 193 | } 194 | 195 | email(message = locale.email) { 196 | return this.matches(rEmail, { 197 | name: 'email', 198 | message, 199 | excludeEmptyString: true, 200 | }); 201 | } 202 | 203 | url(message = locale.url) { 204 | return this.matches(rUrl, { 205 | name: 'url', 206 | message, 207 | excludeEmptyString: true, 208 | }); 209 | } 210 | 211 | uuid(message = locale.uuid) { 212 | return this.matches(rUUID, { 213 | name: 'uuid', 214 | message, 215 | excludeEmptyString: false, 216 | }); 217 | } 218 | 219 | datetime(options?: DateTimeOptions | DateTimeOptions['message']) { 220 | let message: DateTimeOptions['message'] = ''; 221 | let allowOffset: DateTimeOptions['allowOffset']; 222 | let precision: DateTimeOptions['precision']; 223 | 224 | if (options) { 225 | if (typeof options === 'object') { 226 | ({ 227 | message = '', 228 | allowOffset = false, 229 | precision = undefined, 230 | } = options as DateTimeOptions); 231 | } else { 232 | message = options; 233 | } 234 | } 235 | 236 | return this.matches(rIsoDateTime, { 237 | name: 'datetime', 238 | message: message || locale.datetime, 239 | excludeEmptyString: true, 240 | }) 241 | .test({ 242 | name: 'datetime_offset', 243 | message: message || locale.datetime_offset, 244 | params: { allowOffset }, 245 | skipAbsent: true, 246 | test: (value: Maybe) => { 247 | if (!value || allowOffset) return true; 248 | const struct = parseDateStruct(value); 249 | if (!struct) return false; 250 | return !!struct.z; 251 | }, 252 | }) 253 | .test({ 254 | name: 'datetime_precision', 255 | message: message || locale.datetime_precision, 256 | params: { precision }, 257 | skipAbsent: true, 258 | test: (value: Maybe) => { 259 | if (!value || precision == undefined) return true; 260 | const struct = parseDateStruct(value); 261 | if (!struct) return false; 262 | return struct.precision === precision; 263 | }, 264 | }); 265 | } 266 | 267 | //-- transforms -- 268 | ensure(): StringSchema> { 269 | return this.default('' as Defined).transform((val) => 270 | val === null ? '' : val, 271 | ) as any; 272 | } 273 | 274 | trim(message = locale.trim) { 275 | return this.transform((val) => (val != null ? val.trim() : val)).test({ 276 | message, 277 | name: 'trim', 278 | test: isTrimmed, 279 | }); 280 | } 281 | 282 | lowercase(message = locale.lowercase) { 283 | return this.transform((value) => 284 | !isAbsent(value) ? value.toLowerCase() : value, 285 | ).test({ 286 | message, 287 | name: 'string_case', 288 | exclusive: true, 289 | skipAbsent: true, 290 | test: (value: Maybe) => 291 | isAbsent(value) || value === value.toLowerCase(), 292 | }); 293 | } 294 | 295 | uppercase(message = locale.uppercase) { 296 | return this.transform((value) => 297 | !isAbsent(value) ? value.toUpperCase() : value, 298 | ).test({ 299 | message, 300 | name: 'string_case', 301 | exclusive: true, 302 | skipAbsent: true, 303 | test: (value: Maybe) => 304 | isAbsent(value) || value === value.toUpperCase(), 305 | }); 306 | } 307 | } 308 | 309 | create.prototype = StringSchema.prototype; 310 | 311 | // 312 | // String Interfaces 313 | // 314 | 315 | export default interface StringSchema< 316 | TType extends Maybe = string | undefined, 317 | TContext = AnyObject, 318 | TDefault = undefined, 319 | TFlags extends Flags = '', 320 | > extends Schema { 321 | default>( 322 | def: DefaultThunk, 323 | ): StringSchema>; 324 | 325 | oneOf( 326 | arrayOfValues: ReadonlyArray>, 327 | message?: MixedLocale['oneOf'], 328 | ): StringSchema, TContext, TDefault, TFlags>; 329 | oneOf( 330 | enums: ReadonlyArray, 331 | message?: Message<{ values: any }>, 332 | ): this; 333 | 334 | concat, UContext, UDefault, UFlags extends Flags>( 335 | schema: StringSchema, 336 | ): StringSchema< 337 | Concat, 338 | TContext & UContext, 339 | UDefault, 340 | TFlags | UFlags 341 | >; 342 | concat(schema: this): this; 343 | 344 | defined( 345 | msg?: Message, 346 | ): StringSchema, TContext, TDefault, TFlags>; 347 | optional(): StringSchema; 348 | 349 | required( 350 | msg?: Message, 351 | ): StringSchema, TContext, TDefault, TFlags>; 352 | notRequired(): StringSchema, TContext, TDefault, TFlags>; 353 | 354 | nullable( 355 | msg?: Message, 356 | ): StringSchema; 357 | nonNullable( 358 | msg?: Message 359 | ): StringSchema, TContext, TDefault, TFlags>; 360 | 361 | strip( 362 | enabled: false, 363 | ): StringSchema>; 364 | strip( 365 | enabled?: true, 366 | ): StringSchema>; 367 | } 368 | -------------------------------------------------------------------------------- /test/string.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, assert } from 'vitest'; 2 | import * as TestHelpers from './helpers'; 3 | 4 | import { 5 | string, 6 | number, 7 | object, 8 | ref, 9 | ValidationError, 10 | AnySchema, 11 | } from '../src'; 12 | 13 | describe('String types', () => { 14 | describe('casting', () => { 15 | let schema = string(); 16 | 17 | TestHelpers.castAll(schema, { 18 | valid: [ 19 | [5, '5'], 20 | ['3', '3'], 21 | // [new String('foo'), 'foo'], 22 | ['', ''], 23 | [true, 'true'], 24 | [false, 'false'], 25 | [0, '0'], 26 | [null, null, schema.nullable()], 27 | [ 28 | { 29 | toString: () => 'hey', 30 | }, 31 | 'hey', 32 | ], 33 | ], 34 | invalid: [null, {}, []], 35 | }); 36 | 37 | describe('ensure', () => { 38 | let schema = string().ensure(); 39 | 40 | TestHelpers.castAll(schema, { 41 | valid: [ 42 | [5, '5'], 43 | ['3', '3'], 44 | [null, ''], 45 | [undefined, ''], 46 | [null, '', schema.default('foo')], 47 | [undefined, 'foo', schema.default('foo')], 48 | ], 49 | }); 50 | }); 51 | 52 | it('should trim', () => { 53 | expect(schema.trim().cast(' 3 ')).toBe('3'); 54 | }); 55 | 56 | it('should transform to lowercase', () => { 57 | expect(schema.lowercase().cast('HellO JohN')).toBe('hello john'); 58 | }); 59 | 60 | it('should transform to uppercase', () => { 61 | expect(schema.uppercase().cast('HellO JohN')).toBe('HELLO JOHN'); 62 | }); 63 | 64 | it('should handle nulls', () => { 65 | expect( 66 | schema.nullable().trim().lowercase().uppercase().cast(null), 67 | ).toBeNull(); 68 | }); 69 | }); 70 | 71 | it('should handle DEFAULT', function () { 72 | let inst = string(); 73 | 74 | expect(inst.default('my_value').required().getDefault()).toBe('my_value'); 75 | }); 76 | 77 | it('should type check', function () { 78 | let inst = string(); 79 | 80 | expect(inst.isType('5')).toBe(true); 81 | expect(inst.isType(new String('5'))).toBe(true); 82 | expect(inst.isType(false)).toBe(false); 83 | expect(inst.isType(null)).toBe(false); 84 | expect(inst.nullable().isType(null)).toBe(true); 85 | }); 86 | 87 | it('should VALIDATE correctly', function () { 88 | let inst = string().required().min(4).strict(); 89 | 90 | return Promise.all([ 91 | expect(string().strict().isValid(null)).resolves.toBe(false), 92 | 93 | expect(string().strict().nullable().isValid(null)).resolves.toBe(true), 94 | 95 | expect(inst.isValid('hello')).resolves.toBe(true), 96 | 97 | expect(inst.isValid('hel')).resolves.toBe(false), 98 | 99 | expect(inst.validate('')).rejects.toEqual( 100 | TestHelpers.validationErrorWithMessages(expect.any(String)), 101 | ), 102 | ]); 103 | }); 104 | 105 | it('should handle NOTREQUIRED correctly', function () { 106 | let v = string().required().notRequired(); 107 | 108 | return Promise.all([ 109 | expect(v.isValid(undefined)).resolves.toBe(true), 110 | expect(v.isValid('')).resolves.toBe(true), 111 | ]); 112 | }); 113 | 114 | it('should check MATCHES correctly', function () { 115 | let v = string().matches(/(hi|bye)/, 'A message'); 116 | 117 | return Promise.all([ 118 | expect(v.isValid('hi')).resolves.toBe(true), 119 | expect(v.isValid('nope')).resolves.toBe(false), 120 | expect(v.isValid('bye')).resolves.toBe(true), 121 | ]); 122 | }); 123 | 124 | it('should check MATCHES correctly with global and sticky flags', function () { 125 | let v = string().matches(/hi/gy); 126 | 127 | return Promise.all([ 128 | expect(v.isValid('hi')).resolves.toBe(true), 129 | expect(v.isValid('hi')).resolves.toBe(true), 130 | ]); 131 | }); 132 | 133 | it('MATCHES should include empty strings', () => { 134 | let v = string().matches(/(hi|bye)/); 135 | 136 | return expect(v.isValid('')).resolves.toBe(false); 137 | }); 138 | 139 | it('MATCHES should exclude empty strings', () => { 140 | let v = string().matches(/(hi|bye)/, { excludeEmptyString: true }); 141 | 142 | return expect(v.isValid('')).resolves.toBe(true); 143 | }); 144 | 145 | it('EMAIL should exclude empty strings', () => { 146 | let v = string().email(); 147 | 148 | return expect(v.isValid('')).resolves.toBe(true); 149 | }); 150 | 151 | it('should check MIN correctly', function () { 152 | let v = string().min(5); 153 | let obj = object({ 154 | len: number(), 155 | name: string().min(ref('len')), 156 | }); 157 | 158 | return Promise.all([ 159 | expect(v.isValid('hiiofff')).resolves.toBe(true), 160 | expect(v.isValid('big')).resolves.toBe(false), 161 | expect(v.isValid('noffasfasfasf saf')).resolves.toBe(true), 162 | 163 | expect(v.isValid(null)).resolves.toBe(false), 164 | expect(v.nullable().isValid(null)).resolves.toBe(true), 165 | 166 | expect(obj.isValid({ len: 10, name: 'john' })).resolves.toBe(false), 167 | ]); 168 | }); 169 | 170 | it('should check MAX correctly', function () { 171 | let v = string().max(5); 172 | let obj = object({ 173 | len: number(), 174 | name: string().max(ref('len')), 175 | }); 176 | return Promise.all([ 177 | expect(v.isValid('adgf')).resolves.toBe(true), 178 | expect(v.isValid('bigdfdsfsdf')).resolves.toBe(false), 179 | expect(v.isValid('no')).resolves.toBe(true), 180 | 181 | expect(v.isValid(null)).resolves.toBe(false), 182 | 183 | expect(v.nullable().isValid(null)).resolves.toBe(true), 184 | 185 | expect(obj.isValid({ len: 3, name: 'john' })).resolves.toBe(false), 186 | ]); 187 | }); 188 | 189 | it('should check LENGTH correctly', function () { 190 | let v = string().length(5); 191 | let obj = object({ 192 | len: number(), 193 | name: string().length(ref('len')), 194 | }); 195 | 196 | return Promise.all([ 197 | expect(v.isValid('exact')).resolves.toBe(true), 198 | expect(v.isValid('sml')).resolves.toBe(false), 199 | expect(v.isValid('biiiig')).resolves.toBe(false), 200 | 201 | expect(v.isValid(null)).resolves.toBe(false), 202 | expect(v.nullable().isValid(null)).resolves.toBe(true), 203 | 204 | expect(obj.isValid({ len: 5, name: 'foo' })).resolves.toBe(false), 205 | ]); 206 | }); 207 | 208 | it('should check url correctly', function () { 209 | let v = string().url(); 210 | 211 | return Promise.all([ 212 | expect(v.isValid('//www.github.com/')).resolves.toBe(true), 213 | expect(v.isValid('https://www.github.com/')).resolves.toBe(true), 214 | expect(v.isValid('this is not a url')).resolves.toBe(false), 215 | ]); 216 | }); 217 | 218 | it('should check UUID correctly', function () { 219 | let v = string().uuid(); 220 | 221 | return Promise.all([ 222 | expect(v.isValid('0c40428c-d88d-4ff0-a5dc-a6755cb4f4d1')).resolves.toBe( 223 | true, 224 | ), 225 | expect(v.isValid('42c4a747-3e3e-42be-af30-469cfb9c1913')).resolves.toBe( 226 | true, 227 | ), 228 | expect(v.isValid('42c4a747-3e3e-zzzz-af30-469cfb9c1913')).resolves.toBe( 229 | false, 230 | ), 231 | expect(v.isValid('this is not a uuid')).resolves.toBe(false), 232 | expect(v.isValid('')).resolves.toBe(false), 233 | ]); 234 | }); 235 | 236 | describe('DATETIME', function () { 237 | it('should check DATETIME correctly', function () { 238 | let v = string().datetime(); 239 | 240 | return Promise.all([ 241 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true), 242 | expect(v.isValid('1977-00-28T12:34:56.0Z')).resolves.toBe(true), 243 | expect(v.isValid('1900-10-29T12:34:56.00Z')).resolves.toBe(true), 244 | expect(v.isValid('1000-11-30T12:34:56.000Z')).resolves.toBe(true), 245 | expect(v.isValid('4444-12-31T12:34:56.0000Z')).resolves.toBe(true), 246 | 247 | // Should not allow time zone offset by default 248 | expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(false), 249 | expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(false), 250 | expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(false), 251 | 252 | expect(v.isValid('this is not a datetime')).resolves.toBe(false), 253 | expect(v.isValid('2023-08-16T12:34:56')).resolves.toBe(false), 254 | expect(v.isValid('2023-08-1612:34:56Z')).resolves.toBe(false), 255 | expect(v.isValid('1970-01-01 00:00:00Z')).resolves.toBe(false), 256 | expect(v.isValid('1970-01-01T00:00:00,000Z')).resolves.toBe(false), 257 | expect(v.isValid('1970-01-01T0000')).resolves.toBe(false), 258 | expect(v.isValid('1970-01-01T00:00.000')).resolves.toBe(false), 259 | expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false), 260 | expect(v.isValid('2023-08-16')).resolves.toBe(false), 261 | expect(v.isValid('1970-as-df')).resolves.toBe(false), 262 | expect(v.isValid('19700101')).resolves.toBe(false), 263 | expect(v.isValid('197001')).resolves.toBe(false), 264 | ]); 265 | }); 266 | 267 | it('should support DATETIME allowOffset option', function () { 268 | let v = string().datetime({ allowOffset: true }); 269 | 270 | return Promise.all([ 271 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true), 272 | expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(true), 273 | expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(true), 274 | expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(true), 275 | expect(v.isValid('1970-01-01T00:00:00+0630')).resolves.toBe(true), 276 | ]); 277 | }); 278 | 279 | it('should support DATETIME precision option', function () { 280 | let v = string().datetime({ precision: 4 }); 281 | 282 | return Promise.all([ 283 | expect(v.isValid('2023-01-09T12:34:56.0000Z')).resolves.toBe(true), 284 | expect(v.isValid('2023-01-09T12:34:56.00000Z')).resolves.toBe(false), 285 | expect(v.isValid('2023-01-09T12:34:56.000Z')).resolves.toBe(false), 286 | expect(v.isValid('2023-01-09T12:34:56.00Z')).resolves.toBe(false), 287 | expect(v.isValid('2023-01-09T12:34:56.0Z')).resolves.toBe(false), 288 | expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false), 289 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(false), 290 | expect(v.isValid('2010-04-10T14:06:14.0000+00:00')).resolves.toBe( 291 | false, 292 | ), 293 | ]); 294 | }); 295 | 296 | describe('DATETIME error strings', function () { 297 | function getErrorString(schema: AnySchema, value: string) { 298 | try { 299 | schema.validateSync(value); 300 | assert.fail('should have thrown validation error'); 301 | } catch (e) { 302 | const err = e as ValidationError; 303 | return err.errors[0]; 304 | } 305 | } 306 | 307 | it('should use the default locale string on error', function () { 308 | let v = string().datetime(); 309 | expect(getErrorString(v, 'asdf')).toBe( 310 | 'this must be a valid ISO date-time', 311 | ); 312 | }); 313 | 314 | it('should use the allowOffset locale string on error when offset caused error', function () { 315 | let v = string().datetime(); 316 | expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe( 317 | 'this must be a valid ISO date-time with UTC "Z" timezone', 318 | ); 319 | }); 320 | 321 | it('should use the precision locale string on error when precision caused error', function () { 322 | let v = string().datetime({ precision: 2 }); 323 | expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe( 324 | 'this must be a valid ISO date-time with a sub-second precision of exactly 2 digits', 325 | ); 326 | }); 327 | 328 | it('should prefer options.message over all default error messages', function () { 329 | let msg = 'hello'; 330 | let v = string().datetime({ message: msg }); 331 | expect(getErrorString(v, 'asdf')).toBe(msg); 332 | expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(msg); 333 | 334 | v = string().datetime({ message: msg, precision: 2 }); 335 | expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(msg); 336 | }); 337 | }); 338 | }); 339 | 340 | // eslint-disable-next-line jest/no-disabled-tests 341 | it.skip('should check allowed values at the end', () => { 342 | return Promise.all([ 343 | expect( 344 | string() 345 | .required('Required') 346 | .notOneOf([ref('$someKey')]) 347 | .validate('', { context: { someKey: '' } }), 348 | ).rejects.toEqual( 349 | TestHelpers.validationErrorWithMessages( 350 | expect.stringContaining('Ref($someKey)'), 351 | ), 352 | ), 353 | expect( 354 | object({ 355 | email: string().required('Email Required'), 356 | password: string() 357 | .required('Password Required') 358 | .notOneOf([ref('email')]), 359 | }) 360 | .validate({ email: '', password: '' }, { abortEarly: false }) 361 | .catch(console.log), 362 | ).rejects.toEqual( 363 | TestHelpers.validationErrorWithMessages( 364 | expect.stringContaining('Email Required'), 365 | expect.stringContaining('Password Required'), 366 | ), 367 | ), 368 | ]); 369 | }); 370 | 371 | it('should validate transforms', function () { 372 | return Promise.all([ 373 | expect(string().trim().isValid(' 3 ')).resolves.toBe(true), 374 | 375 | expect(string().lowercase().isValid('HellO JohN')).resolves.toBe(true), 376 | 377 | expect(string().uppercase().isValid('HellO JohN')).resolves.toBe(true), 378 | 379 | expect(string().trim().isValid(' 3 ', { strict: true })).resolves.toBe( 380 | false, 381 | ), 382 | 383 | expect( 384 | string().lowercase().isValid('HellO JohN', { strict: true }), 385 | ).resolves.toBe(false), 386 | 387 | expect( 388 | string().uppercase().isValid('HellO JohN', { strict: true }), 389 | ).resolves.toBe(false), 390 | ]); 391 | }); 392 | }); 393 | -------------------------------------------------------------------------------- /src/object.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { getter, normalizePath, join } from 'property-expr'; 3 | import { camelCase, snakeCase } from 'tiny-case'; 4 | 5 | import { Flags, Maybe, SetFlag, ToggleDefault, UnsetFlag } from './util/types'; 6 | import { object as locale } from './locale'; 7 | import sortFields from './util/sortFields'; 8 | import sortByKeyOrder from './util/sortByKeyOrder'; 9 | import { DefaultThunk, InternalOptions, ISchema, Message } from './types'; 10 | import type { Defined, NotNull, _ } from './util/types'; 11 | import Reference from './Reference'; 12 | import Schema, { SchemaObjectDescription, SchemaSpec } from './schema'; 13 | import { ResolveOptions } from './Condition'; 14 | import type { 15 | AnyObject, 16 | ConcatObjectTypes, 17 | DefaultFromShape, 18 | MakePartial, 19 | MergeObjectTypes, 20 | ObjectShape, 21 | PartialDeep, 22 | TypeFromShape, 23 | } from './util/objectTypes'; 24 | import parseJson from './util/parseJson'; 25 | import type { Test } from './util/createValidation'; 26 | import type ValidationError from './ValidationError'; 27 | export type { AnyObject }; 28 | 29 | type MakeKeysOptional = T extends AnyObject ? _> : T; 30 | 31 | export type Shape, C = any> = { 32 | [field in keyof T]-?: ISchema | Reference; 33 | }; 34 | 35 | export type ObjectSchemaSpec = SchemaSpec & { 36 | noUnknown?: boolean; 37 | }; 38 | 39 | function deepPartial(schema: any) { 40 | if ('fields' in schema) { 41 | const partial: any = {}; 42 | for (const [key, fieldSchema] of Object.entries(schema.fields)) { 43 | partial[key] = deepPartial(fieldSchema); 44 | } 45 | return schema.setFields(partial); 46 | } 47 | if (schema.type === 'array') { 48 | const nextArray = schema.optional(); 49 | if (nextArray.innerType) 50 | nextArray.innerType = deepPartial(nextArray.innerType); 51 | return nextArray; 52 | } 53 | if (schema.type === 'tuple') { 54 | return schema 55 | .optional() 56 | .clone({ types: schema.spec.types.map(deepPartial) }); 57 | } 58 | if ('optional' in schema) { 59 | return schema.optional(); 60 | } 61 | return schema; 62 | } 63 | 64 | const deepHas = (obj: any, p: string) => { 65 | const path = [...normalizePath(p)]; 66 | if (path.length === 1) return path[0] in obj; 67 | let last = path.pop()!; 68 | let parent = getter(join(path), true)(obj); 69 | return !!(parent && last in parent); 70 | }; 71 | 72 | let isObject = (obj: any): obj is Record => 73 | Object.prototype.toString.call(obj) === '[object Object]'; 74 | 75 | function unknown(ctx: ObjectSchema, value: any) { 76 | let known = Object.keys(ctx.fields); 77 | return Object.keys(value).filter((key) => known.indexOf(key) === -1); 78 | } 79 | 80 | const defaultSort = sortByKeyOrder([]); 81 | 82 | export function create< 83 | C extends Maybe = AnyObject, 84 | S extends ObjectShape = {}, 85 | >(spec?: S) { 86 | type TIn = _>; 87 | type TDefault = _>; 88 | 89 | return new ObjectSchema(spec as any); 90 | } 91 | 92 | export default interface ObjectSchema< 93 | TIn extends Maybe, 94 | TContext = AnyObject, 95 | // important that this is `any` so that using `ObjectSchema`'s default 96 | // will match object schema regardless of defaults 97 | TDefault = any, 98 | TFlags extends Flags = '', 99 | > extends Schema, TContext, TDefault, TFlags> { 100 | default>( 101 | def: DefaultThunk, 102 | ): ObjectSchema>; 103 | 104 | defined( 105 | msg?: Message, 106 | ): ObjectSchema, TContext, TDefault, TFlags>; 107 | optional(): ObjectSchema; 108 | 109 | required( 110 | msg?: Message, 111 | ): ObjectSchema, TContext, TDefault, TFlags>; 112 | notRequired(): ObjectSchema, TContext, TDefault, TFlags>; 113 | 114 | nullable(msg?: Message): ObjectSchema; 115 | nonNullable( 116 | msg?: Message, 117 | ): ObjectSchema, TContext, TDefault, TFlags>; 118 | 119 | strip( 120 | enabled: false, 121 | ): ObjectSchema>; 122 | strip( 123 | enabled?: true, 124 | ): ObjectSchema>; 125 | } 126 | 127 | export default class ObjectSchema< 128 | TIn extends Maybe, 129 | TContext = AnyObject, 130 | TDefault = any, 131 | TFlags extends Flags = '', 132 | > extends Schema, TContext, TDefault, TFlags> { 133 | fields: Shape, TContext> = Object.create(null); 134 | 135 | declare spec: ObjectSchemaSpec; 136 | 137 | private _sortErrors = defaultSort; 138 | private _nodes: string[] = []; 139 | 140 | private _excludedEdges: readonly [nodeA: string, nodeB: string][] = []; 141 | 142 | constructor(spec?: Shape) { 143 | super({ 144 | type: 'object', 145 | check(value): value is NonNullable> { 146 | return isObject(value) || typeof value === 'function'; 147 | }, 148 | }); 149 | 150 | this.withMutation(() => { 151 | if (spec) { 152 | this.shape(spec as any); 153 | } 154 | }); 155 | } 156 | 157 | protected _cast(_value: any, options: InternalOptions = {}) { 158 | let value = super._cast(_value, options); 159 | 160 | //should ignore nulls here 161 | if (value === undefined) return this.getDefault(options); 162 | 163 | if (!this._typeCheck(value)) return value; 164 | 165 | let fields = this.fields; 166 | 167 | let strip = options.stripUnknown ?? this.spec.noUnknown; 168 | let props = ([] as string[]).concat( 169 | this._nodes, 170 | Object.keys(value).filter((v) => !this._nodes.includes(v)), 171 | ); 172 | 173 | let intermediateValue: Record = {}; // is filled during the transform below 174 | let innerOptions: InternalOptions = { 175 | ...options, 176 | parent: intermediateValue, 177 | __validating: options.__validating || false, 178 | }; 179 | 180 | let isChanged = false; 181 | for (const prop of props) { 182 | let field = fields[prop]; 183 | let exists = prop in (value as {})!; 184 | let inputValue = value[prop]; 185 | 186 | if (field) { 187 | let fieldValue; 188 | 189 | // safe to mutate since this is fired in sequence 190 | innerOptions.path = (options.path ? `${options.path}.` : '') + prop; 191 | 192 | field = field.resolve({ 193 | value: inputValue, 194 | context: options.context, 195 | parent: intermediateValue, 196 | }); 197 | 198 | let fieldSpec = field instanceof Schema ? field.spec : undefined; 199 | let strict = fieldSpec?.strict; 200 | 201 | if (fieldSpec?.strip) { 202 | isChanged = isChanged || prop in (value as {}); 203 | continue; 204 | } 205 | 206 | fieldValue = 207 | !options.__validating || !strict 208 | ? (field as ISchema).cast(inputValue, innerOptions) 209 | : inputValue; 210 | 211 | if (fieldValue !== undefined) { 212 | intermediateValue[prop] = fieldValue; 213 | } 214 | } else if (exists && !strip) { 215 | intermediateValue[prop] = inputValue; 216 | } 217 | 218 | if ( 219 | exists !== prop in intermediateValue || 220 | intermediateValue[prop] !== inputValue 221 | ) { 222 | isChanged = true; 223 | } 224 | } 225 | 226 | return isChanged ? intermediateValue : value; 227 | } 228 | 229 | protected _validate( 230 | _value: any, 231 | options: InternalOptions = {}, 232 | panic: (err: Error, value: unknown) => void, 233 | next: (err: ValidationError[], value: unknown) => void, 234 | ) { 235 | let { 236 | from = [], 237 | originalValue = _value, 238 | recursive = this.spec.recursive, 239 | } = options; 240 | 241 | options.from = [{ schema: this, value: originalValue }, ...from]; 242 | // this flag is needed for handling `strict` correctly in the context of 243 | // validation vs just casting. e.g strict() on a field is only used when validating 244 | options.__validating = true; 245 | options.originalValue = originalValue; 246 | 247 | super._validate(_value, options, panic, (objectErrors, value) => { 248 | if (!recursive || !isObject(value)) { 249 | next(objectErrors, value); 250 | return; 251 | } 252 | 253 | originalValue = originalValue || value; 254 | 255 | let tests = [] as Test[]; 256 | for (let key of this._nodes) { 257 | let field = this.fields[key]; 258 | 259 | if (!field || Reference.isRef(field)) { 260 | continue; 261 | } 262 | 263 | tests.push( 264 | field.asNestedTest({ 265 | options, 266 | key, 267 | parent: value, 268 | parentPath: options.path, 269 | originalParent: originalValue, 270 | }), 271 | ); 272 | } 273 | 274 | this.runTests( 275 | { tests, value, originalValue, options }, 276 | panic, 277 | (fieldErrors) => { 278 | next(fieldErrors.sort(this._sortErrors).concat(objectErrors), value); 279 | }, 280 | ); 281 | }); 282 | } 283 | 284 | clone(spec?: Partial): this { 285 | const next = super.clone(spec); 286 | next.fields = { ...this.fields }; 287 | next._nodes = this._nodes; 288 | next._excludedEdges = this._excludedEdges; 289 | next._sortErrors = this._sortErrors; 290 | 291 | return next; 292 | } 293 | 294 | concat, IC, ID, IF extends Flags>( 295 | schema: ObjectSchema, 296 | ): ObjectSchema< 297 | ConcatObjectTypes, 298 | TContext & IC, 299 | Extract extends never 300 | ? // this _attempts_ to cover the default from shape case 301 | TDefault extends AnyObject 302 | ? ID extends AnyObject 303 | ? _> 304 | : ID 305 | : ID 306 | : ID, 307 | TFlags | IF 308 | >; 309 | concat(schema: this): this; 310 | concat(schema: any): any { 311 | let next = super.concat(schema) as any; 312 | 313 | let nextFields = next.fields; 314 | for (let [field, schemaOrRef] of Object.entries(this.fields)) { 315 | const target = nextFields[field]; 316 | nextFields[field] = target === undefined ? schemaOrRef : target; 317 | } 318 | 319 | return next.withMutation((s: any) => 320 | // XXX: excludes here is wrong 321 | s.setFields(nextFields, [ 322 | ...this._excludedEdges, 323 | ...schema._excludedEdges, 324 | ]), 325 | ); 326 | } 327 | 328 | protected _getDefault(options?: ResolveOptions) { 329 | if ('default' in this.spec) { 330 | return super._getDefault(options); 331 | } 332 | 333 | // if there is no default set invent one 334 | if (!this._nodes.length) { 335 | return undefined; 336 | } 337 | 338 | let dft: any = {}; 339 | this._nodes.forEach((key) => { 340 | const field = this.fields[key] as any; 341 | 342 | let innerOptions = options; 343 | if (innerOptions?.value) { 344 | innerOptions = { 345 | ...innerOptions, 346 | parent: innerOptions.value, 347 | value: innerOptions.value[key], 348 | }; 349 | } 350 | 351 | dft[key] = 352 | field && 'getDefault' in field 353 | ? field.getDefault(innerOptions) 354 | : undefined; 355 | }); 356 | 357 | return dft; 358 | } 359 | 360 | private setFields, TDefaultNext>( 361 | shape: Shape, 362 | excludedEdges?: readonly [string, string][], 363 | ): ObjectSchema { 364 | let next = this.clone() as any; 365 | next.fields = shape; 366 | 367 | next._nodes = sortFields(shape, excludedEdges); 368 | next._sortErrors = sortByKeyOrder(Object.keys(shape)); 369 | // XXX: this carries over edges which may not be what you want 370 | if (excludedEdges) next._excludedEdges = excludedEdges; 371 | return next; 372 | } 373 | 374 | shape( 375 | additions: U, 376 | excludes: readonly [string, string][] = [], 377 | ) { 378 | type UIn = TypeFromShape; 379 | type UDefault = Extract extends never 380 | ? // not defaulted then assume the default is derived and should be merged 381 | _> 382 | : TDefault; 383 | 384 | return this.clone().withMutation((next) => { 385 | let edges = next._excludedEdges; 386 | if (excludes.length) { 387 | if (!Array.isArray(excludes[0])) excludes = [excludes as any]; 388 | 389 | edges = [...next._excludedEdges, ...excludes]; 390 | } 391 | 392 | // XXX: excludes here is wrong 393 | return next.setFields<_>, UDefault>( 394 | Object.assign(next.fields, additions) as any, 395 | edges, 396 | ); 397 | }); 398 | } 399 | 400 | partial() { 401 | const partial: any = {}; 402 | for (const [key, schema] of Object.entries(this.fields)) { 403 | partial[key] = 404 | 'optional' in schema && schema.optional instanceof Function 405 | ? schema.optional() 406 | : schema; 407 | } 408 | 409 | return this.setFields, TDefault>(partial); 410 | } 411 | 412 | deepPartial(): ObjectSchema, TContext, TDefault, TFlags> { 413 | const next = deepPartial(this); 414 | return next; 415 | } 416 | 417 | pick(keys: readonly TKey[]) { 418 | const picked: any = {}; 419 | for (const key of keys) { 420 | if (this.fields[key]) picked[key] = this.fields[key]; 421 | } 422 | 423 | return this.setFields<{ [K in TKey]: TIn[K] }, TDefault>( 424 | picked, 425 | this._excludedEdges.filter( 426 | ([a, b]) => keys.includes(a as TKey) && keys.includes(b as TKey), 427 | ), 428 | ); 429 | } 430 | 431 | omit(keys: readonly TKey[]) { 432 | const remaining: TKey[] = []; 433 | 434 | for (const key of Object.keys(this.fields) as TKey[]) { 435 | if (keys.includes(key)) continue; 436 | remaining.push(key); 437 | } 438 | 439 | return this.pick>(remaining as any); 440 | } 441 | 442 | from(from: string, to: keyof TIn, alias?: boolean) { 443 | let fromGetter = getter(from, true); 444 | 445 | return this.transform((obj) => { 446 | if (!obj) return obj; 447 | let newObj = obj; 448 | if (deepHas(obj, from)) { 449 | newObj = { ...obj }; 450 | if (!alias) delete newObj[from]; 451 | 452 | newObj[to] = fromGetter(obj); 453 | } 454 | 455 | return newObj; 456 | }); 457 | } 458 | 459 | /** Parse an input JSON string to an object */ 460 | json() { 461 | return this.transform(parseJson); 462 | } 463 | 464 | /** 465 | * Similar to `noUnknown` but only validates that an object is the right shape without stripping the unknown keys 466 | */ 467 | exact(message?: Message): this { 468 | return this.test({ 469 | name: 'exact', 470 | exclusive: true, 471 | message: message || locale.exact, 472 | test(value) { 473 | if (value == null) return true; 474 | 475 | const unknownKeys = unknown(this.schema, value); 476 | 477 | return ( 478 | unknownKeys.length === 0 || 479 | this.createError({ params: { properties: unknownKeys.join(', ') } }) 480 | ); 481 | }, 482 | }); 483 | } 484 | 485 | stripUnknown(): this { 486 | return this.clone({ noUnknown: true }); 487 | } 488 | 489 | noUnknown(message?: Message): this; 490 | noUnknown(noAllow: boolean, message?: Message): this; 491 | noUnknown(noAllow: Message | boolean = true, message = locale.noUnknown) { 492 | if (typeof noAllow !== 'boolean') { 493 | message = noAllow; 494 | noAllow = true; 495 | } 496 | 497 | let next = this.test({ 498 | name: 'noUnknown', 499 | exclusive: true, 500 | message: message, 501 | test(value) { 502 | if (value == null) return true; 503 | const unknownKeys = unknown(this.schema, value); 504 | return ( 505 | !noAllow || 506 | unknownKeys.length === 0 || 507 | this.createError({ params: { unknown: unknownKeys.join(', ') } }) 508 | ); 509 | }, 510 | }); 511 | 512 | next.spec.noUnknown = noAllow; 513 | 514 | return next; 515 | } 516 | 517 | unknown(allow = true, message = locale.noUnknown) { 518 | return this.noUnknown(!allow, message); 519 | } 520 | 521 | transformKeys(fn: (key: string) => string) { 522 | return this.transform((obj) => { 523 | if (!obj) return obj; 524 | const result: AnyObject = {}; 525 | for (const key of Object.keys(obj)) result[fn(key)] = obj[key]; 526 | return result; 527 | }); 528 | } 529 | 530 | camelCase() { 531 | return this.transformKeys(camelCase); 532 | } 533 | 534 | snakeCase() { 535 | return this.transformKeys(snakeCase); 536 | } 537 | 538 | constantCase() { 539 | return this.transformKeys((key) => snakeCase(key).toUpperCase()); 540 | } 541 | 542 | describe(options?: ResolveOptions) { 543 | const next = (options ? this.resolve(options) : this).clone(); 544 | const base = super.describe(options) as SchemaObjectDescription; 545 | base.fields = {}; 546 | for (const [key, value] of Object.entries(next.fields)) { 547 | let innerOptions = options; 548 | if (innerOptions?.value) { 549 | innerOptions = { 550 | ...innerOptions, 551 | parent: innerOptions.value, 552 | value: innerOptions.value[key], 553 | }; 554 | } 555 | base.fields[key] = value.describe(innerOptions); 556 | } 557 | return base; 558 | } 559 | } 560 | 561 | create.prototype = ObjectSchema.prototype; 562 | --------------------------------------------------------------------------------