├── .babelrc.js ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── question.md └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs └── extending.md ├── jest-sync.config.json ├── package.json ├── renovate.json ├── rollup.config.js ├── runkit-example.js ├── src ├── Condition.ts ├── Lazy.ts ├── Reference.ts ├── ValidationError.ts ├── array.ts ├── boolean.ts ├── date.ts ├── globals.d.ts ├── index.ts ├── locale.ts ├── mixed.ts ├── number.ts ├── object.ts ├── schema.ts ├── setLocale.ts ├── string.ts ├── tuple.ts ├── types.ts └── util │ ├── ReferenceSet.ts │ ├── cloneDeep.ts │ ├── createValidation.ts │ ├── isAbsent.ts │ ├── isSchema.ts │ ├── objectTypes.ts │ ├── parseIsoDate.ts │ ├── parseJson.ts │ ├── printValue.ts │ ├── reach.ts │ ├── sortByKeyOrder.ts │ ├── sortFields.ts │ ├── toArray.ts │ └── types.ts ├── test-setup.js ├── test ├── .eslintignore ├── ValidationError.ts ├── array.ts ├── bool.ts ├── date.ts ├── helpers.ts ├── lazy.ts ├── mixed.ts ├── number.ts ├── object.ts ├── setLocale.ts ├── string.ts ├── tsconfig.json ├── tuple.ts ├── types │ ├── .eslintrc.js │ └── types.ts ├── util │ └── parseIsoDate.ts └── yup.js ├── tsconfig.json └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | cache: 'yarn' 17 | - run: yarn install --frozen-lockfile 18 | - run: yarn test 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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(function (value, originalValue) { 13 | if (this.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(function (value, originalValue) { 32 | if (this.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 | -------------------------------------------------------------------------------- /jest-sync.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "sync", 3 | "globals": { 4 | "YUP_USE_SYNC": true 5 | }, 6 | "testEnvironment": "node", 7 | "setupFilesAfterEnv": ["./test-setup.js"], 8 | "roots": ["test"], 9 | "testRegex": "\\.(t|j)s$", 10 | "testPathIgnorePatterns": ["helpers\\.ts", "\\.eslintrc\\.js", "types\\.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yup", 3 | "version": "1.6.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 test-all --runInBand", 10 | "testonly": "jest", 11 | "test-sync": "yarn testonly --projects ./jest-sync.config.json", 12 | "test-all": "yarn testonly --projects ./jest-sync.config.json --projects ./package.json", 13 | "tdd": "jest --watch", 14 | "lint": "eslint src test", 15 | "precommit": "lint-staged", 16 | "toc": "doctoc README.md --github", 17 | "release": "rollout", 18 | "build:dts": "yarn tsc --emitDeclarationOnly -p . --outDir dts", 19 | "build": "rm -rf dts && yarn build:dts && yarn rollup -c rollup.config.js && yarn toc", 20 | "prepublishOnly": "yarn build" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/jquense/yup.git" 25 | }, 26 | "author": { 27 | "name": "@monasticpanic Jason Quense" 28 | }, 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/jquense/yup/issues" 32 | }, 33 | "homepage": "https://github.com/jquense/yup", 34 | "release": { 35 | "conventionalCommits": true, 36 | "publishDir": "lib" 37 | }, 38 | "prettier": { 39 | "singleQuote": true, 40 | "trailingComma": "all" 41 | }, 42 | "lint-staged": { 43 | "*.{js,json,css,md}": [ 44 | "prettier --write", 45 | "git add" 46 | ] 47 | }, 48 | "jest": { 49 | "globals": { 50 | "YUP_USE_SYNC": false 51 | }, 52 | "testEnvironment": "node", 53 | "setupFilesAfterEnv": [ 54 | "./test-setup.js" 55 | ], 56 | "roots": [ 57 | "test" 58 | ], 59 | "testRegex": "\\.(j|t)s$", 60 | "testPathIgnorePatterns": [ 61 | "helpers\\.ts", 62 | "\\.eslintrc\\.js", 63 | "types\\.ts" 64 | ] 65 | }, 66 | "devDependencies": { 67 | "@4c/cli": "^4.0.4", 68 | "@4c/rollout": "^4.0.2", 69 | "@4c/tsconfig": "^0.4.1", 70 | "@babel/cli": "^7.22.9", 71 | "@babel/core": "^7.22.9", 72 | "@babel/preset-typescript": "^7.22.5", 73 | "@rollup/plugin-babel": "^5.3.1", 74 | "@rollup/plugin-node-resolve": "^13.3.0", 75 | "@types/jest": "^27.5.2", 76 | "@typescript-eslint/eslint-plugin": "^5.62.0", 77 | "@typescript-eslint/parser": "^5.62.0", 78 | "babel-jest": "^27.5.1", 79 | "babel-preset-env-modules": "^1.0.1", 80 | "doctoc": "^2.2.1", 81 | "dts-bundle-generator": "^6.13.0", 82 | "eslint": "^8.45.0", 83 | "eslint-config-jason": "^8.2.2", 84 | "eslint-config-prettier": "^8.8.0", 85 | "eslint-plugin-import": "^2.27.5", 86 | "eslint-plugin-jest": "^25.7.0", 87 | "eslint-plugin-react": "^7.32.2", 88 | "eslint-plugin-react-hooks": "^4.6.0", 89 | "eslint-plugin-ts-expect": "^2.1.0", 90 | "eslint-plugin-typescript": "^0.14.0", 91 | "hookem": "^2.0.1", 92 | "jest": "^27.5.1", 93 | "lint-staged": "^13.2.3", 94 | "prettier": "^2.8.8", 95 | "rollup": "^2.79.1", 96 | "rollup-plugin-babel": "^4.4.0", 97 | "rollup-plugin-dts": "^4.2.3", 98 | "rollup-plugin-filesize": "^9.1.2", 99 | "rollup-plugin-node-resolve": "^5.2.0", 100 | "synchronous-promise": "^2.0.17", 101 | "typescript": "^4.9.5" 102 | }, 103 | "dependencies": { 104 | "property-expr": "^2.0.5", 105 | "tiny-case": "^1.0.3", 106 | "toposort": "^2.0.2", 107 | "type-fest": "^2.19.0" 108 | }, 109 | "packageManager": "yarn@4.5.3+sha512.3003a14012e2987072d244c720506549c1aab73ee728208f1b2580a9fd67b92d61ba6b08fe93f6dce68fd771e3af1e59a0afa28dd242dd0940d73b95fedd4e90" 110 | } 111 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>4Catalyzer/renovate-config:library", ":automergeMinor"] 3 | } 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /runkit-example.js: -------------------------------------------------------------------------------- 1 | const { object, string, number, date } = require('yup') 2 | 3 | const contactSchema = object({ 4 | name: string() 5 | .required(), 6 | age: number() 7 | .required() 8 | .positive() 9 | .integer(), 10 | email: string() 11 | .email(), 12 | website: string() 13 | .url(), 14 | createdOn: date() 15 | .default(() => new Date()) 16 | }) 17 | 18 | contactSchema.cast({ 19 | name: 'jimmy', 20 | age: '24', 21 | createdOn: '2014-09-23T19:25:25Z' 22 | }) 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 } from './util/types'; 18 | import ValidationError from './ValidationError'; 19 | import Schema from './schema'; 20 | 21 | export type LazyBuilder< 22 | TSchema extends ISchema, 23 | TContext = AnyObject, 24 | > = (value: any, options: ResolveOptions) => TSchema; 25 | 26 | export function create< 27 | TSchema extends ISchema, 28 | TContext extends Maybe = AnyObject, 29 | >(builder: (value: any, options: ResolveOptions) => TSchema) { 30 | return new Lazy, TContext>(builder); 31 | } 32 | 33 | function catchValidationError(fn: () => any) { 34 | try { 35 | return fn(); 36 | } catch (err) { 37 | if (ValidationError.isError(err)) return Promise.reject(err); 38 | throw err; 39 | } 40 | } 41 | 42 | export interface LazySpec { 43 | meta: Record | undefined; 44 | optional: boolean; 45 | } 46 | 47 | class Lazy 48 | implements ISchema 49 | { 50 | type = 'lazy' as const; 51 | 52 | __isYupSchema__ = true; 53 | 54 | declare readonly __outputType: T; 55 | declare readonly __context: TContext; 56 | declare readonly __flags: TFlags; 57 | declare readonly __default: undefined; 58 | 59 | spec: LazySpec; 60 | 61 | constructor(private builder: any) { 62 | this.spec = { meta: undefined, optional: false }; 63 | } 64 | 65 | clone(spec?: Partial): Lazy { 66 | const next = new Lazy(this.builder); 67 | next.spec = { ...this.spec, ...spec }; 68 | return next; 69 | } 70 | 71 | private _resolve = ( 72 | value: any, 73 | options: ResolveOptions = {}, 74 | ): Schema => { 75 | let schema = this.builder(value, options) as Schema< 76 | T, 77 | TContext, 78 | undefined, 79 | TFlags 80 | >; 81 | 82 | if (!isSchema(schema)) 83 | throw new TypeError('lazy() functions must return a valid schema'); 84 | 85 | if (this.spec.optional) schema = schema.optional(); 86 | 87 | return schema.resolve(options); 88 | }; 89 | 90 | private optionality(optional: boolean) { 91 | const next = this.clone({ optional }); 92 | return next; 93 | } 94 | 95 | optional(): Lazy { 96 | return this.optionality(true); 97 | } 98 | 99 | resolve(options: ResolveOptions) { 100 | return this._resolve(options.value, options); 101 | } 102 | 103 | cast(value: any, options?: CastOptions): T; 104 | cast( 105 | value: any, 106 | options?: CastOptionalityOptions, 107 | ): T | null | undefined; 108 | cast( 109 | value: any, 110 | options?: CastOptions | CastOptionalityOptions, 111 | ): any { 112 | return this._resolve(value, options).cast(value, options as any); 113 | } 114 | 115 | asNestedTest(config: NestedTestConfig) { 116 | let { key, index, parent, options } = config; 117 | let value = parent[index ?? key!]; 118 | 119 | return this._resolve(value, { 120 | ...options, 121 | value, 122 | parent, 123 | }).asNestedTest(config); 124 | } 125 | 126 | validate(value: any, options?: ValidateOptions): Promise { 127 | return catchValidationError(() => 128 | this._resolve(value, options).validate(value, options), 129 | ); 130 | } 131 | 132 | validateSync(value: any, options?: ValidateOptions): T { 133 | return this._resolve(value, options).validateSync(value, options); 134 | } 135 | 136 | validateAt(path: string, value: any, options?: ValidateOptions) { 137 | return catchValidationError(() => 138 | this._resolve(value, options).validateAt(path, value, options), 139 | ); 140 | } 141 | 142 | validateSyncAt( 143 | path: string, 144 | value: any, 145 | options?: ValidateOptions, 146 | ) { 147 | return this._resolve(value, options).validateSyncAt(path, value, options); 148 | } 149 | 150 | isValid(value: any, options?: ValidateOptions) { 151 | try { 152 | return this._resolve(value, options).isValid(value, options); 153 | } catch (err) { 154 | if (ValidationError.isError(err)) { 155 | return Promise.resolve(false); 156 | } 157 | throw err; 158 | } 159 | } 160 | 161 | isValidSync(value: any, options?: ValidateOptions) { 162 | return this._resolve(value, options).isValidSync(value, options); 163 | } 164 | 165 | describe( 166 | options?: ResolveOptions, 167 | ): SchemaLazyDescription | SchemaFieldDescription { 168 | return options 169 | ? this.resolve(options).describe(options) 170 | : { type: 'lazy', meta: this.spec.meta, label: undefined }; 171 | } 172 | 173 | meta(): Record | undefined; 174 | meta(obj: Record): Lazy; 175 | meta(...args: [Record?]) { 176 | if (args.length === 0) return this.spec.meta; 177 | 178 | let next = this.clone(); 179 | next.spec.meta = Object.assign(next.spec.meta || {}, args[0]); 180 | return next; 181 | } 182 | } 183 | 184 | export default Lazy; 185 | -------------------------------------------------------------------------------- /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/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/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 | }); 86 | if (castElement !== v) { 87 | isChanged = true; 88 | } 89 | 90 | return castElement; 91 | }); 92 | 93 | return isChanged ? castArray : value; 94 | } 95 | 96 | protected _validate( 97 | _value: any, 98 | options: InternalOptions = {}, 99 | 100 | panic: (err: Error, value: unknown) => void, 101 | next: (err: ValidationError[], value: unknown) => void, 102 | ) { 103 | // let sync = options.sync; 104 | // let path = options.path; 105 | let innerType = this.innerType; 106 | // let endEarly = options.abortEarly ?? this.spec.abortEarly; 107 | let recursive = options.recursive ?? this.spec.recursive; 108 | 109 | let originalValue = 110 | options.originalValue != null ? options.originalValue : _value; 111 | 112 | super._validate(_value, options, panic, (arrayErrors, value) => { 113 | if (!recursive || !innerType || !this._typeCheck(value)) { 114 | next(arrayErrors, value); 115 | return; 116 | } 117 | 118 | originalValue = originalValue || value; 119 | 120 | // #950 Ensure that sparse array empty slots are validated 121 | let tests: RunTest[] = new Array(value.length); 122 | for (let index = 0; index < value.length; index++) { 123 | tests[index] = innerType!.asNestedTest({ 124 | options, 125 | index, 126 | parent: value, 127 | parentPath: options.path, 128 | originalParent: options.originalValue ?? _value, 129 | }); 130 | } 131 | 132 | this.runTests( 133 | { 134 | value, 135 | tests, 136 | originalValue: options.originalValue ?? _value, 137 | options, 138 | }, 139 | panic, 140 | (innerTypeErrors) => next(innerTypeErrors.concat(arrayErrors), value), 141 | ); 142 | }); 143 | } 144 | 145 | clone(spec?: SchemaSpec) { 146 | const next = super.clone(spec); 147 | // @ts-expect-error readonly 148 | next.innerType = this.innerType; 149 | return next; 150 | } 151 | 152 | /** Parse an input JSON string to an object */ 153 | json() { 154 | return this.transform(parseJson); 155 | } 156 | 157 | concat, IC, ID, IF extends Flags>( 158 | schema: ArraySchema, 159 | ): ArraySchema< 160 | Concat, 161 | TContext & IC, 162 | Extract extends never ? TDefault : ID, 163 | TFlags | IF 164 | >; 165 | concat(schema: this): this; 166 | concat(schema: any): any { 167 | let next = super.concat(schema) as this; 168 | 169 | // @ts-expect-error readonly 170 | next.innerType = this.innerType; 171 | 172 | if (schema.innerType) 173 | // @ts-expect-error readonly 174 | next.innerType = next.innerType 175 | ? // @ts-expect-error Lazy doesn't have concat and will break 176 | next.innerType.concat(schema.innerType) 177 | : schema.innerType; 178 | 179 | return next; 180 | } 181 | 182 | of( 183 | schema: ISchema, 184 | ): ArraySchema, TContext, TFlags> { 185 | // FIXME: this should return a new instance of array without the default to be 186 | let next = this.clone(); 187 | 188 | if (!isSchema(schema)) 189 | throw new TypeError( 190 | '`array.of()` sub-schema must be a valid yup schema not: ' + 191 | printValue(schema), 192 | ); 193 | 194 | // @ts-expect-error readonly 195 | next.innerType = schema; 196 | 197 | next.spec = { 198 | ...next.spec, 199 | types: schema as ISchema, TContext>, 200 | }; 201 | 202 | return next as any; 203 | } 204 | 205 | length( 206 | length: number | Reference, 207 | message: Message<{ length: number }> = locale.length, 208 | ) { 209 | return this.test({ 210 | message, 211 | name: 'length', 212 | exclusive: true, 213 | params: { length }, 214 | skipAbsent: true, 215 | test(value) { 216 | return value!.length === this.resolve(length); 217 | }, 218 | }); 219 | } 220 | 221 | min(min: number | Reference, message?: Message<{ min: number }>) { 222 | message = message || locale.min; 223 | 224 | return this.test({ 225 | message, 226 | name: 'min', 227 | exclusive: true, 228 | params: { min }, 229 | skipAbsent: true, 230 | // FIXME(ts): Array 231 | test(value) { 232 | return value!.length >= this.resolve(min); 233 | }, 234 | }); 235 | } 236 | 237 | max(max: number | Reference, message?: Message<{ max: number }>) { 238 | message = message || locale.max; 239 | return this.test({ 240 | message, 241 | name: 'max', 242 | exclusive: true, 243 | params: { max }, 244 | skipAbsent: true, 245 | test(value) { 246 | return value!.length <= this.resolve(max); 247 | }, 248 | }); 249 | } 250 | 251 | ensure() { 252 | return this.default(() => [] as any).transform( 253 | (val: TIn, original: any) => { 254 | // We don't want to return `null` for nullable schema 255 | if (this._typeCheck(val)) return val; 256 | return original == null ? [] : [].concat(original); 257 | }, 258 | ); 259 | } 260 | 261 | compact(rejector?: RejectorFn) { 262 | let reject: RejectorFn = !rejector 263 | ? (v) => !!v 264 | : (v, i, a) => !rejector(v, i, a); 265 | 266 | return this.transform((values: readonly any[]) => 267 | values != null ? values.filter(reject) : values, 268 | ); 269 | } 270 | 271 | describe(options?: ResolveOptions) { 272 | const next = (options ? this.resolve(options) : this).clone(); 273 | const base = super.describe(options) as SchemaInnerTypeDescription; 274 | if (next.innerType) { 275 | let innerOptions = options; 276 | if (innerOptions?.value) { 277 | innerOptions = { 278 | ...innerOptions, 279 | parent: innerOptions.value, 280 | value: innerOptions.value[0], 281 | }; 282 | } 283 | base.innerType = next.innerType.describe(innerOptions); 284 | } 285 | return base; 286 | } 287 | } 288 | 289 | create.prototype = ArraySchema.prototype; 290 | 291 | export default interface ArraySchema< 292 | TIn extends any[] | null | undefined, 293 | TContext, 294 | TDefault = undefined, 295 | TFlags extends Flags = '', 296 | > extends Schema { 297 | default>( 298 | def: DefaultThunk, 299 | ): ArraySchema>; 300 | 301 | defined(msg?: Message): ArraySchema, TContext, TDefault, TFlags>; 302 | optional(): ArraySchema; 303 | 304 | required( 305 | msg?: Message, 306 | ): ArraySchema, TContext, TDefault, TFlags>; 307 | notRequired(): ArraySchema, TContext, TDefault, TFlags>; 308 | 309 | nullable(msg?: Message): ArraySchema; 310 | nonNullable( 311 | msg?: Message, 312 | ): ArraySchema, TContext, TDefault, TFlags>; 313 | 314 | strip( 315 | enabled: false, 316 | ): ArraySchema>; 317 | strip( 318 | enabled?: true, 319 | ): ArraySchema>; 320 | } 321 | -------------------------------------------------------------------------------- /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, ctx) => { 43 | if (ctx.spec.coerce && !ctx.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 | -------------------------------------------------------------------------------- /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, ctx) => { 48 | // null -> InvalidDate isn't useful; treat all nulls as null and let it fail on 49 | // nullability check vs TypeErrors 50 | if (!ctx.spec.coerce || ctx.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 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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, ctx) => { 46 | if (!ctx.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 (ctx.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/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 | 185 | if (field) { 186 | let fieldValue; 187 | let inputValue = value[prop]; 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 | ? // TODO: use _cast, this is double resolving 209 | (field as ISchema).cast(value[prop], innerOptions) 210 | : value[prop]; 211 | 212 | if (fieldValue !== undefined) { 213 | intermediateValue[prop] = fieldValue; 214 | } 215 | } else if (exists && !strip) { 216 | intermediateValue[prop] = value[prop]; 217 | } 218 | 219 | if ( 220 | exists !== prop in intermediateValue || 221 | intermediateValue[prop] !== value[prop] 222 | ) { 223 | isChanged = true; 224 | } 225 | } 226 | 227 | return isChanged ? intermediateValue : value; 228 | } 229 | 230 | protected _validate( 231 | _value: any, 232 | options: InternalOptions = {}, 233 | panic: (err: Error, value: unknown) => void, 234 | next: (err: ValidationError[], value: unknown) => void, 235 | ) { 236 | let { 237 | from = [], 238 | originalValue = _value, 239 | recursive = this.spec.recursive, 240 | } = options; 241 | 242 | options.from = [{ schema: this, value: originalValue }, ...from]; 243 | // this flag is needed for handling `strict` correctly in the context of 244 | // validation vs just casting. e.g strict() on a field is only used when validating 245 | options.__validating = true; 246 | options.originalValue = originalValue; 247 | 248 | super._validate(_value, options, panic, (objectErrors, value) => { 249 | if (!recursive || !isObject(value)) { 250 | next(objectErrors, value); 251 | return; 252 | } 253 | 254 | originalValue = originalValue || value; 255 | 256 | let tests = [] as Test[]; 257 | for (let key of this._nodes) { 258 | let field = this.fields[key]; 259 | 260 | if (!field || Reference.isRef(field)) { 261 | continue; 262 | } 263 | 264 | tests.push( 265 | field.asNestedTest({ 266 | options, 267 | key, 268 | parent: value, 269 | parentPath: options.path, 270 | originalParent: originalValue, 271 | }), 272 | ); 273 | } 274 | 275 | this.runTests( 276 | { tests, value, originalValue, options }, 277 | panic, 278 | (fieldErrors) => { 279 | next(fieldErrors.sort(this._sortErrors).concat(objectErrors), value); 280 | }, 281 | ); 282 | }); 283 | } 284 | 285 | clone(spec?: Partial): this { 286 | const next = super.clone(spec); 287 | next.fields = { ...this.fields }; 288 | next._nodes = this._nodes; 289 | next._excludedEdges = this._excludedEdges; 290 | next._sortErrors = this._sortErrors; 291 | 292 | return next; 293 | } 294 | 295 | concat, IC, ID, IF extends Flags>( 296 | schema: ObjectSchema, 297 | ): ObjectSchema< 298 | ConcatObjectTypes, 299 | TContext & IC, 300 | Extract extends never 301 | ? // this _attempts_ to cover the default from shape case 302 | TDefault extends AnyObject 303 | ? ID extends AnyObject 304 | ? _> 305 | : ID 306 | : ID 307 | : ID, 308 | TFlags | IF 309 | >; 310 | concat(schema: this): this; 311 | concat(schema: any): any { 312 | let next = super.concat(schema) as any; 313 | 314 | let nextFields = next.fields; 315 | for (let [field, schemaOrRef] of Object.entries(this.fields)) { 316 | const target = nextFields[field]; 317 | nextFields[field] = target === undefined ? schemaOrRef : target; 318 | } 319 | 320 | return next.withMutation((s: any) => 321 | // XXX: excludes here is wrong 322 | s.setFields(nextFields, [ 323 | ...this._excludedEdges, 324 | ...schema._excludedEdges, 325 | ]), 326 | ); 327 | } 328 | 329 | protected _getDefault(options?: ResolveOptions) { 330 | if ('default' in this.spec) { 331 | return super._getDefault(options); 332 | } 333 | 334 | // if there is no default set invent one 335 | if (!this._nodes.length) { 336 | return undefined; 337 | } 338 | 339 | let dft: any = {}; 340 | this._nodes.forEach((key) => { 341 | const field = this.fields[key] as any; 342 | 343 | let innerOptions = options; 344 | if (innerOptions?.value) { 345 | innerOptions = { 346 | ...innerOptions, 347 | parent: innerOptions.value, 348 | value: innerOptions.value[key], 349 | }; 350 | } 351 | 352 | dft[key] = 353 | field && 'getDefault' in field 354 | ? field.getDefault(innerOptions) 355 | : undefined; 356 | }); 357 | 358 | return dft; 359 | } 360 | 361 | private setFields, TDefaultNext>( 362 | shape: Shape, 363 | excludedEdges?: readonly [string, string][], 364 | ): ObjectSchema { 365 | let next = this.clone() as any; 366 | next.fields = shape; 367 | 368 | next._nodes = sortFields(shape, excludedEdges); 369 | next._sortErrors = sortByKeyOrder(Object.keys(shape)); 370 | // XXX: this carries over edges which may not be what you want 371 | if (excludedEdges) next._excludedEdges = excludedEdges; 372 | return next; 373 | } 374 | 375 | shape( 376 | additions: U, 377 | excludes: readonly [string, string][] = [], 378 | ) { 379 | type UIn = TypeFromShape; 380 | type UDefault = Extract extends never 381 | ? // not defaulted then assume the default is derived and should be merged 382 | _> 383 | : TDefault; 384 | 385 | return this.clone().withMutation((next) => { 386 | let edges = next._excludedEdges; 387 | if (excludes.length) { 388 | if (!Array.isArray(excludes[0])) excludes = [excludes as any]; 389 | 390 | edges = [...next._excludedEdges, ...excludes]; 391 | } 392 | 393 | // XXX: excludes here is wrong 394 | return next.setFields<_>, UDefault>( 395 | Object.assign(next.fields, additions) as any, 396 | edges, 397 | ); 398 | }); 399 | } 400 | 401 | partial() { 402 | const partial: any = {}; 403 | for (const [key, schema] of Object.entries(this.fields)) { 404 | partial[key] = 405 | 'optional' in schema && schema.optional instanceof Function 406 | ? schema.optional() 407 | : schema; 408 | } 409 | 410 | return this.setFields, TDefault>(partial); 411 | } 412 | 413 | deepPartial(): ObjectSchema, TContext, TDefault, TFlags> { 414 | const next = deepPartial(this); 415 | return next; 416 | } 417 | 418 | pick(keys: readonly TKey[]) { 419 | const picked: any = {}; 420 | for (const key of keys) { 421 | if (this.fields[key]) picked[key] = this.fields[key]; 422 | } 423 | 424 | return this.setFields<{ [K in TKey]: TIn[K] }, TDefault>( 425 | picked, 426 | this._excludedEdges.filter( 427 | ([a, b]) => keys.includes(a as TKey) && keys.includes(b as TKey), 428 | ), 429 | ); 430 | } 431 | 432 | omit(keys: readonly TKey[]) { 433 | const remaining: TKey[] = []; 434 | 435 | for (const key of Object.keys(this.fields) as TKey[]) { 436 | if (keys.includes(key)) continue; 437 | remaining.push(key); 438 | } 439 | 440 | return this.pick>(remaining as any); 441 | } 442 | 443 | from(from: string, to: keyof TIn, alias?: boolean) { 444 | let fromGetter = getter(from, true); 445 | 446 | return this.transform((obj) => { 447 | if (!obj) return obj; 448 | let newObj = obj; 449 | if (deepHas(obj, from)) { 450 | newObj = { ...obj }; 451 | if (!alias) delete newObj[from]; 452 | 453 | newObj[to] = fromGetter(obj); 454 | } 455 | 456 | return newObj; 457 | }); 458 | } 459 | 460 | /** Parse an input JSON string to an object */ 461 | json() { 462 | return this.transform(parseJson); 463 | } 464 | 465 | /** 466 | * Similar to `noUnknown` but only validates that an object is the right shape without stripping the unknown keys 467 | */ 468 | exact(message?: Message): this { 469 | return this.test({ 470 | name: 'exact', 471 | exclusive: true, 472 | message: message || locale.exact, 473 | test(value) { 474 | if (value == null) return true; 475 | 476 | const unknownKeys = unknown(this.schema, value); 477 | 478 | return ( 479 | unknownKeys.length === 0 || 480 | this.createError({ params: { properties: unknownKeys.join(', ') } }) 481 | ); 482 | }, 483 | }); 484 | } 485 | 486 | stripUnknown(): this { 487 | return this.clone({ noUnknown: true }); 488 | } 489 | 490 | noUnknown(message?: Message): this; 491 | noUnknown(noAllow: boolean, message?: Message): this; 492 | noUnknown(noAllow: Message | boolean = true, message = locale.noUnknown) { 493 | if (typeof noAllow !== 'boolean') { 494 | message = noAllow; 495 | noAllow = true; 496 | } 497 | 498 | let next = this.test({ 499 | name: 'noUnknown', 500 | exclusive: true, 501 | message: message, 502 | test(value) { 503 | if (value == null) return true; 504 | const unknownKeys = unknown(this.schema, value); 505 | return ( 506 | !noAllow || 507 | unknownKeys.length === 0 || 508 | this.createError({ params: { unknown: unknownKeys.join(', ') } }) 509 | ); 510 | }, 511 | }); 512 | 513 | next.spec.noUnknown = noAllow; 514 | 515 | return next; 516 | } 517 | 518 | unknown(allow = true, message = locale.noUnknown) { 519 | return this.noUnknown(!allow, message); 520 | } 521 | 522 | transformKeys(fn: (key: string) => string) { 523 | return this.transform((obj) => { 524 | if (!obj) return obj; 525 | const result: AnyObject = {}; 526 | for (const key of Object.keys(obj)) result[fn(key)] = obj[key]; 527 | return result; 528 | }); 529 | } 530 | 531 | camelCase() { 532 | return this.transformKeys(camelCase); 533 | } 534 | 535 | snakeCase() { 536 | return this.transformKeys(snakeCase); 537 | } 538 | 539 | constantCase() { 540 | return this.transformKeys((key) => snakeCase(key).toUpperCase()); 541 | } 542 | 543 | describe(options?: ResolveOptions) { 544 | const next = (options ? this.resolve(options) : this).clone(); 545 | const base = super.describe(options) as SchemaObjectDescription; 546 | base.fields = {}; 547 | for (const [key, value] of Object.entries(next.fields)) { 548 | let innerOptions = options; 549 | if (innerOptions?.value) { 550 | innerOptions = { 551 | ...innerOptions, 552 | parent: innerOptions.value, 553 | value: innerOptions.value[key], 554 | }; 555 | } 556 | base.fields[key] = value.describe(innerOptions); 557 | } 558 | return base; 559 | } 560 | } 561 | 562 | create.prototype = ObjectSchema.prototype; 563 | -------------------------------------------------------------------------------- /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/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, ctx) => { 86 | if (!ctx.spec.coerce || ctx.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 | -------------------------------------------------------------------------------- /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 | }); 118 | if (castElement !== value[idx]) isChanged = true; 119 | return castElement; 120 | }); 121 | 122 | return isChanged ? castArray : value; 123 | } 124 | 125 | protected _validate( 126 | _value: any, 127 | options: InternalOptions = {}, 128 | panic: (err: Error, value: unknown) => void, 129 | next: (err: ValidationError[], value: unknown) => void, 130 | ) { 131 | let itemTypes = this.spec.types; 132 | 133 | super._validate(_value, options, panic, (tupleErrors, value) => { 134 | // intentionally not respecting recursive 135 | if (!this._typeCheck(value)) { 136 | next(tupleErrors, value); 137 | return; 138 | } 139 | 140 | let tests: RunTest[] = []; 141 | for (let [index, itemSchema] of itemTypes.entries()) { 142 | tests[index] = itemSchema!.asNestedTest({ 143 | options, 144 | index, 145 | parent: value, 146 | parentPath: options.path, 147 | originalParent: options.originalValue ?? _value, 148 | }); 149 | } 150 | 151 | this.runTests( 152 | { 153 | value, 154 | tests, 155 | originalValue: options.originalValue ?? _value, 156 | options, 157 | }, 158 | panic, 159 | (innerTypeErrors) => next(innerTypeErrors.concat(tupleErrors), value), 160 | ); 161 | }); 162 | } 163 | 164 | describe(options?: ResolveOptions) { 165 | const next = (options ? this.resolve(options) : this).clone(); 166 | const base = super.describe(options) as SchemaInnerTypeDescription; 167 | base.innerType = next.spec.types.map((schema, index) => { 168 | let innerOptions = options; 169 | if (innerOptions?.value) { 170 | innerOptions = { 171 | ...innerOptions, 172 | parent: innerOptions.value, 173 | value: innerOptions.value[index], 174 | }; 175 | } 176 | return schema.describe(innerOptions); 177 | }); 178 | return base; 179 | } 180 | } 181 | 182 | create.prototype = TupleSchema.prototype; 183 | -------------------------------------------------------------------------------- /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 | ) => any; 42 | 43 | export interface Ancester { 44 | schema: ISchema; 45 | value: any; 46 | } 47 | export interface ValidateOptions { 48 | /** 49 | * Only validate the input, skipping type casting and transformation. Default - false 50 | */ 51 | strict?: boolean; 52 | /** 53 | * Return from validation methods on the first error rather than after all validations run. Default - true 54 | */ 55 | abortEarly?: boolean; 56 | /** 57 | * Remove unspecified keys from objects. Default - false 58 | */ 59 | stripUnknown?: boolean; 60 | /** 61 | * When false validations will not descend into nested schema (relevant for objects or arrays). Default - true 62 | */ 63 | recursive?: boolean; 64 | /** 65 | * When true ValidationError instance won't include stack trace information. Default - false 66 | */ 67 | disableStackTrace?: boolean; 68 | /** 69 | * Any context needed for validating schema conditions (see: when()) 70 | */ 71 | context?: TContext; 72 | } 73 | 74 | export interface InternalOptions 75 | extends ValidateOptions { 76 | __validating?: boolean; 77 | originalValue?: any; 78 | index?: number; 79 | key?: string; 80 | parent?: any; 81 | path?: string; 82 | sync?: boolean; 83 | from?: Ancester[]; 84 | } 85 | 86 | export interface MessageParams { 87 | path: string; 88 | value: any; 89 | originalValue: any; 90 | originalPath: string; 91 | label: string; 92 | type: string; 93 | spec: SchemaSpec & Record; 94 | } 95 | 96 | export type Message = any> = 97 | | string 98 | | ((params: Extra & MessageParams) => unknown) 99 | | Record; 100 | 101 | export type ExtraParams = Record; 102 | 103 | export type AnyMessageParams = MessageParams & ExtraParams; 104 | 105 | export interface NestedTestConfig { 106 | options: InternalOptions; 107 | parent: any; 108 | originalParent: any; 109 | parentPath: string | undefined; 110 | key?: string; 111 | index?: number; 112 | } 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/util/isAbsent.ts: -------------------------------------------------------------------------------- 1 | const isAbsent = (value: any): value is undefined | null => value == null; 2 | 3 | export default isAbsent; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/parseJson.ts: -------------------------------------------------------------------------------- 1 | import type { AnySchema, TransformFunction } from '../types'; 2 | 3 | const parseJson: TransformFunction = (value, _, ctx: 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 ctx.isType(parsed) ? parsed : value; 15 | }; 16 | 17 | export default parseJson; 18 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | const { SynchronousPromise } = require('synchronous-promise'); 2 | 3 | global.TestHelpers = require('./test/helpers'); 4 | 5 | if (global.YUP_USE_SYNC) { 6 | const { Schema } = require('./src'); // eslint-disable-line global-require 7 | 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 | -------------------------------------------------------------------------------- /test/.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /test/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import ValidationError from '../src/ValidationError'; 2 | 3 | describe('ValidationError', () => { 4 | describe('formatError', () => { 5 | it('should insert the params into the message', () => { 6 | const str = ValidationError.formatError('Some message ${param}', { 7 | param: 'here', 8 | }); 9 | expect(str).toContain('here'); 10 | }); 11 | 12 | it(`should auto include any param named 'label' or 'path' as the 'path' param`, () => { 13 | const str = ValidationError.formatError('${path} goes here', { 14 | label: 'label', 15 | }); 16 | expect(str).toContain('label'); 17 | }); 18 | 19 | it(`should use 'this' if a 'label' or 'path' param is not provided`, () => { 20 | const str = ValidationError.formatError('${path} goes here', {}); 21 | expect(str).toContain('this'); 22 | }); 23 | 24 | it(`should include "undefined" in the message if undefined is provided as a param`, () => { 25 | const str = ValidationError.formatError('${path} value is ${min}', { 26 | min: undefined, 27 | }); 28 | expect(str).toContain('undefined'); 29 | }); 30 | 31 | it(`should include "null" in the message if null is provided as a param`, () => { 32 | const str = ValidationError.formatError('${path} value is ${min}', { 33 | min: null, 34 | }); 35 | expect(str).toContain('null'); 36 | }); 37 | 38 | it(`should include "NaN" in the message if null is provided as a param`, () => { 39 | const str = ValidationError.formatError('${path} value is ${min}', { 40 | min: NaN, 41 | }); 42 | expect(str).toContain('NaN'); 43 | }); 44 | 45 | it(`should include 0 in the message if 0 is provided as a param`, () => { 46 | const str = ValidationError.formatError('${path} value is ${min}', { 47 | min: 0, 48 | }); 49 | expect(str).toContain('0'); 50 | }); 51 | }); 52 | 53 | it('should disable stacks', () => { 54 | const disabled = new ValidationError('error', 1, 'field', 'type', true); 55 | 56 | expect(disabled.constructor.name).toEqual('ValidationErrorNoStack'); 57 | expect(disabled).toBeInstanceOf(ValidationError); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/array.ts: -------------------------------------------------------------------------------- 1 | import { 2 | string, 3 | number, 4 | object, 5 | array, 6 | StringSchema, 7 | AnySchema, 8 | ValidationError, 9 | } from '../src'; 10 | 11 | describe('Array types', () => { 12 | describe('casting', () => { 13 | it('should parse json strings', () => { 14 | expect(array().json().cast('[2,3,5,6]')).toEqual([2, 3, 5, 6]); 15 | }); 16 | 17 | it('should failed casts return input', () => { 18 | expect(array().cast('asfasf', { assert: false })).toEqual('asfasf'); 19 | 20 | expect(array().cast('{}', { assert: false })).toEqual('{}'); 21 | }); 22 | 23 | it('should recursively cast fields', () => { 24 | expect(array().of(number()).cast(['4', '5'])).toEqual([4, 5]); 25 | 26 | expect(array().of(string()).cast(['4', 5, false])).toEqual([ 27 | '4', 28 | '5', 29 | 'false', 30 | ]); 31 | }); 32 | }); 33 | 34 | it('should handle DEFAULT', () => { 35 | expect(array().getDefault()).toBeUndefined(); 36 | 37 | expect( 38 | array() 39 | .default(() => [1, 2, 3]) 40 | .getDefault(), 41 | ).toEqual([1, 2, 3]); 42 | }); 43 | 44 | it('should type check', () => { 45 | let inst = array(); 46 | 47 | expect(inst.isType([])).toBe(true); 48 | expect(inst.isType({})).toBe(false); 49 | expect(inst.isType('true')).toBe(false); 50 | expect(inst.isType(NaN)).toBe(false); 51 | expect(inst.isType(34545)).toBe(false); 52 | 53 | expect(inst.isType(null)).toBe(false); 54 | 55 | expect(inst.nullable().isType(null)).toBe(true); 56 | }); 57 | 58 | it('should cast children', () => { 59 | expect(array().of(number()).cast(['1', '3'])).toEqual([1, 3]); 60 | }); 61 | 62 | it('should concat subType correctly', async () => { 63 | expect(array(number()).concat(array()).innerType).toBeDefined(); 64 | 65 | let merged = array(number()).concat(array(number().required())); 66 | 67 | const ve = new ValidationError(''); 68 | // expect(ve.toString()).toBe('[object Error]'); 69 | expect(Object.prototype.toString.call(ve)).toBe('[object Error]'); 70 | expect((merged.innerType as AnySchema).type).toBe('number'); 71 | 72 | await expect(merged.validateAt('[0]', undefined)).rejects.toThrowError(); 73 | }); 74 | 75 | it('should pass options to children', () => { 76 | expect( 77 | array(object({ name: string() })).cast([{ id: 1, name: 'john' }], { 78 | stripUnknown: true, 79 | }), 80 | ).toEqual([{ name: 'john' }]); 81 | }); 82 | 83 | describe('validation', () => { 84 | test.each([ 85 | ['required', undefined, array().required()], 86 | ['required', null, array().required()], 87 | ['null', null, array()], 88 | ['length', [1, 2, 3], array().length(2)], 89 | ])('Basic validations fail: %s %p', async (_, value, schema) => { 90 | expect(await schema.isValid(value)).toBe(false); 91 | }); 92 | 93 | test.each([ 94 | ['required', [], array().required()], 95 | ['nullable', null, array().nullable()], 96 | ['length', [1, 2, 3], array().length(3)], 97 | ])('Basic validations pass: %s %p', async (_, value, schema) => { 98 | expect(await schema.isValid(value)).toBe(true); 99 | }); 100 | 101 | it('should allow undefined', async () => { 102 | await expect( 103 | array().of(number().max(5)).isValid(undefined), 104 | ).resolves.toBe(true); 105 | }); 106 | 107 | it('max should replace earlier tests', async () => { 108 | expect(await array().max(4).max(10).isValid(Array(5).fill(0))).toBe(true); 109 | }); 110 | 111 | it('min should replace earlier tests', async () => { 112 | expect(await array().min(10).min(4).isValid(Array(5).fill(0))).toBe(true); 113 | }); 114 | 115 | it('should respect subtype validations', async () => { 116 | let inst = array().of(number().max(5)); 117 | 118 | await expect(inst.isValid(['gg', 3])).resolves.toBe(false); 119 | await expect(inst.isValid([7, 3])).resolves.toBe(false); 120 | 121 | let value = await inst.validate(['4', 3]); 122 | 123 | expect(value).toEqual([4, 3]); 124 | }); 125 | 126 | it('should prevent recursive casting', async () => { 127 | // @ts-ignore 128 | let castSpy = jest.spyOn(StringSchema.prototype, '_cast'); 129 | 130 | let value = await array(string()).defined().validate([5]); 131 | 132 | expect(value[0]).toBe('5'); 133 | 134 | expect(castSpy).toHaveBeenCalledTimes(1); 135 | castSpy.mockRestore(); 136 | }); 137 | }); 138 | 139 | it('should respect abortEarly', async () => { 140 | let inst = array() 141 | .of(object({ str: string().required() })) 142 | .test('name', 'oops', () => false); 143 | 144 | await expect(inst.validate([{ str: '' }])).rejects.toEqual( 145 | expect.objectContaining({ 146 | value: [{ str: '' }], 147 | errors: ['oops'], 148 | }), 149 | ); 150 | 151 | await expect( 152 | inst.validate([{ str: '' }], { abortEarly: false }), 153 | ).rejects.toEqual( 154 | expect.objectContaining({ 155 | value: [{ str: '' }], 156 | errors: ['[0].str is a required field', 'oops'], 157 | }), 158 | ); 159 | }); 160 | 161 | it('should respect disableStackTrace', async () => { 162 | let inst = array().of(object({ str: string().required() })); 163 | 164 | const data = [{ str: undefined }, { str: undefined }]; 165 | return Promise.all([ 166 | expect(inst.strict().validate(data)).rejects.toHaveProperty('stack'), 167 | 168 | expect( 169 | inst.strict().validate(data, { disableStackTrace: true }), 170 | ).rejects.not.toHaveProperty('stack'), 171 | ]); 172 | }); 173 | 174 | it('should compact arrays', () => { 175 | let arr = ['', 1, 0, 4, false, null], 176 | inst = array(); 177 | 178 | expect(inst.compact().cast(arr)).toEqual([1, 4]); 179 | 180 | expect(inst.compact((v) => v == null).cast(arr)).toEqual([ 181 | '', 182 | 1, 183 | 0, 184 | 4, 185 | false, 186 | ]); 187 | }); 188 | 189 | it('should ensure arrays', () => { 190 | let inst = array().ensure(); 191 | 192 | const a = [1, 4]; 193 | expect(inst.cast(a)).toBe(a); 194 | 195 | expect(inst.cast(null)).toEqual([]); 196 | // nullable is redundant since this should always produce an array 197 | // but we want to ensure that null is actually turned into an array 198 | expect(inst.nullable().cast(null)).toEqual([]); 199 | 200 | expect(inst.cast(1)).toEqual([1]); 201 | expect(inst.nullable().cast(1)).toEqual([1]); 202 | }); 203 | 204 | it('should pass resolved path to descendants', async () => { 205 | let value = ['2', '3']; 206 | let expectedPaths = ['[0]', '[1]']; 207 | 208 | let itemSchema = string().when([], function (_, _s, opts: any) { 209 | let path = opts.path; 210 | expect(expectedPaths).toContain(path); 211 | return string().required(); 212 | }); 213 | 214 | await array().of(itemSchema).validate(value); 215 | }); 216 | 217 | it('should maintain array sparseness through validation', async () => { 218 | let sparseArray = new Array(2); 219 | sparseArray[1] = 1; 220 | let value = await array().of(number()).validate(sparseArray); 221 | expect(0 in sparseArray).toBe(false); 222 | expect(0 in value!).toBe(false); 223 | 224 | // eslint-disable-next-line no-sparse-arrays 225 | expect(value).toEqual([, 1]); 226 | }); 227 | 228 | it('should validate empty slots in sparse array', async () => { 229 | let sparseArray = new Array(2); 230 | sparseArray[1] = 1; 231 | await expect( 232 | array().of(number().required()).isValid(sparseArray), 233 | ).resolves.toEqual(false); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /test/bool.ts: -------------------------------------------------------------------------------- 1 | import { bool } from '../src'; 2 | import * as TestHelpers from './helpers'; 3 | 4 | describe('Boolean types', () => { 5 | it('should CAST correctly', () => { 6 | let inst = bool(); 7 | 8 | expect(inst.cast('true')).toBe(true); 9 | expect(inst.cast('True')).toBe(true); 10 | expect(inst.cast('false')).toBe(false); 11 | expect(inst.cast('False')).toBe(false); 12 | expect(inst.cast(1)).toBe(true); 13 | expect(inst.cast(0)).toBe(false); 14 | 15 | TestHelpers.castAndShouldFail(inst, 'foo'); 16 | 17 | TestHelpers.castAndShouldFail(inst, 'bar1'); 18 | }); 19 | 20 | it('should handle DEFAULT', () => { 21 | let inst = bool(); 22 | 23 | expect(inst.getDefault()).toBeUndefined(); 24 | expect(inst.default(true).required().getDefault()).toBe(true); 25 | }); 26 | 27 | it('should type check', () => { 28 | let inst = bool(); 29 | 30 | expect(inst.isType(1)).toBe(false); 31 | expect(inst.isType(false)).toBe(true); 32 | expect(inst.isType('true')).toBe(false); 33 | expect(inst.isType(NaN)).toBe(false); 34 | expect(inst.isType(new Number('foooo'))).toBe(false); 35 | 36 | expect(inst.isType(34545)).toBe(false); 37 | expect(inst.isType(new Boolean(false))).toBe(true); 38 | 39 | expect(inst.isType(null)).toBe(false); 40 | 41 | expect(inst.nullable().isType(null)).toBe(true); 42 | }); 43 | 44 | it('bool should VALIDATE correctly', () => { 45 | let inst = bool().required(); 46 | 47 | return Promise.all([ 48 | expect(bool().isValid('1')).resolves.toBe(true), 49 | expect(bool().strict().isValid(null)).resolves.toBe(false), 50 | expect(bool().nullable().isValid(null)).resolves.toBe(true), 51 | expect(inst.validate(undefined)).rejects.toEqual( 52 | expect.objectContaining({ 53 | errors: ['this is a required field'], 54 | }), 55 | ), 56 | ]); 57 | }); 58 | 59 | it('should check isTrue correctly', () => { 60 | return Promise.all([ 61 | expect(bool().isTrue().isValid(true)).resolves.toBe(true), 62 | expect(bool().isTrue().isValid(false)).resolves.toBe(false), 63 | ]); 64 | }); 65 | 66 | it('should check isFalse correctly', () => { 67 | return Promise.all([ 68 | expect(bool().isFalse().isValid(false)).resolves.toBe(true), 69 | expect(bool().isFalse().isValid(true)).resolves.toBe(false), 70 | ]); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/date.ts: -------------------------------------------------------------------------------- 1 | import { ref, date } from '../src'; 2 | import * as TestHelpers from './helpers'; 3 | 4 | function isInvalidDate(date: any): date is Date { 5 | return date instanceof Date && isNaN(date.getTime()); 6 | } 7 | 8 | describe('Date types', () => { 9 | it('should CAST correctly', () => { 10 | let inst = date(); 11 | 12 | expect(inst.cast(new Date())).toBeInstanceOf(Date); 13 | expect(inst.cast('jan 15 2014')).toEqual(new Date(2014, 0, 15)); 14 | expect(inst.cast('2014-09-23T19:25:25Z')).toEqual(new Date(1411500325000)); 15 | // Leading-zero milliseconds 16 | expect(inst.cast('2016-08-10T11:32:19.012Z')).toEqual( 17 | new Date(1470828739012), 18 | ); 19 | // Microsecond precision 20 | expect(inst.cast('2016-08-10T11:32:19.2125Z')).toEqual( 21 | new Date(1470828739212), 22 | ); 23 | 24 | expect(inst.cast(null, { assert: false })).toEqual(null); 25 | }); 26 | 27 | it('should return invalid date for failed non-null casts', function () { 28 | let inst = date(); 29 | 30 | expect(inst.cast(null, { assert: false })).toEqual(null); 31 | expect(inst.cast(undefined, { assert: false })).toEqual(undefined); 32 | 33 | expect(isInvalidDate(inst.cast('', { assert: false }))).toBe(true); 34 | expect(isInvalidDate(inst.cast({}, { assert: false }))).toBe(true); 35 | }); 36 | 37 | it('should type check', () => { 38 | let inst = date(); 39 | 40 | expect(inst.isType(new Date())).toBe(true); 41 | expect(inst.isType(false)).toBe(false); 42 | expect(inst.isType(null)).toBe(false); 43 | expect(inst.isType(NaN)).toBe(false); 44 | expect(inst.nullable().isType(new Date())).toBe(true); 45 | }); 46 | 47 | it('should VALIDATE correctly', () => { 48 | let inst = date().max(new Date(2014, 5, 15)); 49 | 50 | return Promise.all([ 51 | expect(date().isValid(null)).resolves.toBe(false), 52 | expect(date().nullable().isValid(null)).resolves.toBe(true), 53 | 54 | expect(inst.isValid(new Date(2014, 0, 15))).resolves.toBe(true), 55 | expect(inst.isValid(new Date(2014, 7, 15))).resolves.toBe(false), 56 | expect(inst.isValid('5')).resolves.toBe(true), 57 | 58 | expect(inst.required().validate(undefined)).rejects.toEqual( 59 | expect.objectContaining({ 60 | errors: ['this is a required field'], 61 | }), 62 | ), 63 | 64 | expect(inst.required().validate(undefined)).rejects.toEqual( 65 | TestHelpers.validationErrorWithMessages( 66 | expect.stringContaining('required'), 67 | ), 68 | ), 69 | expect(inst.validate(null)).rejects.toEqual( 70 | TestHelpers.validationErrorWithMessages( 71 | expect.stringContaining('cannot be null'), 72 | ), 73 | ), 74 | expect(inst.validate({})).rejects.toEqual( 75 | TestHelpers.validationErrorWithMessages( 76 | expect.stringContaining('must be a `date` type'), 77 | ), 78 | ), 79 | ]); 80 | }); 81 | 82 | it('should check MIN correctly', () => { 83 | let min = new Date(2014, 3, 15), 84 | invalid = new Date(2014, 1, 15), 85 | valid = new Date(2014, 5, 15); 86 | expect(function () { 87 | date().max('hello'); 88 | }).toThrowError(TypeError); 89 | expect(function () { 90 | date().max(ref('$foo')); 91 | }).not.toThrowError(); 92 | 93 | return Promise.all([ 94 | expect(date().min(min).isValid(valid)).resolves.toBe(true), 95 | expect(date().min(min).isValid(invalid)).resolves.toBe(false), 96 | expect(date().min(min).isValid(null)).resolves.toBe(false), 97 | 98 | expect( 99 | date() 100 | .min(ref('$foo')) 101 | .isValid(valid, { context: { foo: min } }), 102 | ).resolves.toBe(true), 103 | expect( 104 | date() 105 | .min(ref('$foo')) 106 | .isValid(invalid, { context: { foo: min } }), 107 | ).resolves.toBe(false), 108 | ]); 109 | }); 110 | 111 | it('should check MAX correctly', () => { 112 | let max = new Date(2014, 7, 15), 113 | invalid = new Date(2014, 9, 15), 114 | valid = new Date(2014, 5, 15); 115 | expect(function () { 116 | date().max('hello'); 117 | }).toThrowError(TypeError); 118 | expect(function () { 119 | date().max(ref('$foo')); 120 | }).not.toThrowError(); 121 | 122 | return Promise.all([ 123 | expect(date().max(max).isValid(valid)).resolves.toBe(true), 124 | expect(date().max(max).isValid(invalid)).resolves.toBe(false), 125 | expect(date().max(max).nullable().isValid(null)).resolves.toBe(true), 126 | 127 | expect( 128 | date() 129 | .max(ref('$foo')) 130 | .isValid(valid, { context: { foo: max } }), 131 | ).resolves.toBe(true), 132 | expect( 133 | date() 134 | .max(ref('$foo')) 135 | .isValid(invalid, { context: { foo: max } }), 136 | ).resolves.toBe(false), 137 | ]); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ISchema } from '../src/types'; 2 | import printValue from '../src/util/printValue'; 3 | 4 | export let castAndShouldFail = (schema: ISchema, value: any) => { 5 | expect(() => schema.cast(value)).toThrowError(TypeError); 6 | }; 7 | 8 | type Options = { 9 | invalid?: any[]; 10 | valid?: any[]; 11 | }; 12 | export let castAll = ( 13 | inst: ISchema, 14 | { invalid = [], valid = [] }: Options, 15 | ) => { 16 | valid.forEach(([value, result, schema = inst]) => { 17 | it(`should cast ${printValue(value)} to ${printValue(result)}`, () => { 18 | expect(schema.cast(value)).toBe(result); 19 | }); 20 | }); 21 | 22 | invalid.forEach((value) => { 23 | it(`should not cast ${printValue(value)}`, () => { 24 | castAndShouldFail(inst, value); 25 | }); 26 | }); 27 | }; 28 | 29 | export let validateAll = ( 30 | inst: ISchema, 31 | { valid = [], invalid = [] }: Options, 32 | ) => { 33 | describe('valid:', () => { 34 | runValidations(valid, true); 35 | }); 36 | 37 | describe('invalid:', () => { 38 | runValidations(invalid, false); 39 | }); 40 | 41 | function runValidations(arr: any[], isValid: boolean) { 42 | arr.forEach((config) => { 43 | let message = '', 44 | value = config, 45 | schema = inst; 46 | 47 | if (Array.isArray(config)) [value, schema, message = ''] = config; 48 | 49 | it(`${printValue(value)}${message && ` (${message})`}`, async () => { 50 | await expect((schema as any).isValid(value)).resolves.toEqual(isValid); 51 | }); 52 | }); 53 | } 54 | }; 55 | 56 | export function validationErrorWithMessages(...errors: any[]) { 57 | return expect.objectContaining({ 58 | errors, 59 | }); 60 | } 61 | 62 | export function ensureSync(fn: () => Promise) { 63 | let run = false; 64 | let resolve = (t: any) => { 65 | if (!run) return t; 66 | throw new Error('Did not execute synchronously'); 67 | }; 68 | let err = (t: any) => { 69 | if (!run) throw t; 70 | throw new Error('Did not execute synchronously'); 71 | }; 72 | 73 | let result = fn().then(resolve, err); 74 | 75 | run = true; 76 | return result; 77 | } 78 | -------------------------------------------------------------------------------- /test/lazy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | lazy, 3 | object, 4 | mixed, 5 | AnyObject, 6 | MixedSchema, 7 | ValidationError, 8 | } from '../src'; 9 | 10 | describe('lazy', function () { 11 | it('should throw on a non-schema value', () => { 12 | // @ts-expect-error testing incorrect usage 13 | expect(() => lazy(() => undefined).validateSync(undefined)).toThrowError(); 14 | }); 15 | 16 | describe('mapper', () => { 17 | const value = 1; 18 | let mapper: jest.Mock, []>; 19 | 20 | beforeEach(() => { 21 | mapper = jest.fn(() => mixed()); 22 | }); 23 | 24 | it('should call with value', () => { 25 | lazy(mapper).validate(value); 26 | expect(mapper).toHaveBeenCalledWith(value, expect.any(Object)); 27 | }); 28 | 29 | it('should call with context', () => { 30 | const context = { 31 | a: 1, 32 | }; 33 | let options = { context }; 34 | lazy(mapper).validate(value, options); 35 | expect(mapper).toHaveBeenCalledWith(value, options); 36 | }); 37 | 38 | it('should call with context when nested: #1799', () => { 39 | let context = { a: 1 }; 40 | let value = { lazy: 1 }; 41 | let options = { context }; 42 | 43 | object({ 44 | lazy: lazy(mapper), 45 | }).validate(value, options); 46 | 47 | lazy(mapper).validate(value, options); 48 | expect(mapper).toHaveBeenCalledWith(value, options); 49 | }); 50 | 51 | it('should allow meta', () => { 52 | const meta = { a: 1 }; 53 | const schema = lazy(mapper).meta(meta); 54 | 55 | expect(schema.meta()).toEqual(meta); 56 | 57 | expect(schema.meta({ added: true })).not.toEqual(schema.meta()); 58 | 59 | expect(schema.meta({ added: true }).meta()).toEqual({ 60 | a: 1, 61 | added: true, 62 | }); 63 | }); 64 | 65 | it('should allow throwing validation error in builder', async () => { 66 | const schema = lazy(() => { 67 | throw new ValidationError('oops'); 68 | }); 69 | 70 | await expect(schema.validate(value)).rejects.toThrowError('oops'); 71 | await expect(schema.isValid(value)).resolves.toEqual(false); 72 | 73 | expect(() => schema.validateSync(value)).toThrowError('oops'); 74 | 75 | const schema2 = lazy(() => { 76 | throw new Error('error'); 77 | }); 78 | // none validation errors are thrown sync to maintain back compat 79 | expect(() => schema2.validate(value)).toThrowError('error'); 80 | expect(() => schema2.isValid(value)).toThrowError('error'); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/number.ts: -------------------------------------------------------------------------------- 1 | import * as TestHelpers from './helpers'; 2 | 3 | import { number, NumberSchema, object, ref } from '../src'; 4 | 5 | describe('Number types', function () { 6 | it('is extensible', () => { 7 | class MyNumber extends NumberSchema { 8 | foo() { 9 | return this; 10 | } 11 | } 12 | 13 | new MyNumber().foo().integer().required(); 14 | }); 15 | 16 | describe('casting', () => { 17 | let schema = number(); 18 | 19 | TestHelpers.castAll(schema, { 20 | valid: [ 21 | ['5', 5], 22 | [3, 3], 23 | //[new Number(5), 5], 24 | [' 5.656 ', 5.656], 25 | ], 26 | invalid: ['', false, true, new Date(), new Number('foo')], 27 | }); 28 | 29 | it('should round', () => { 30 | // @ts-expect-error stricter type than accepted 31 | expect(schema.round('ceIl').cast(45.1111)).toBe(46); 32 | 33 | expect(schema.round().cast(45.444444)).toBe(45); 34 | 35 | expect(schema.nullable().integer().round().cast(null)).toBeNull(); 36 | expect(function () { 37 | // @ts-expect-error testing incorrectness 38 | schema.round('fasf'); 39 | }).toThrowError(TypeError); 40 | }); 41 | 42 | it('should truncate', () => { 43 | expect(schema.truncate().cast(45.55)).toBe(45); 44 | }); 45 | 46 | it('should return NaN for failed casts', () => { 47 | expect(number().cast('asfasf', { assert: false })).toEqual(NaN); 48 | 49 | expect(number().cast(new Date(), { assert: false })).toEqual(NaN); 50 | expect(number().cast(null, { assert: false })).toEqual(null); 51 | }); 52 | }); 53 | 54 | it('should handle DEFAULT', function () { 55 | let inst = number().default(0); 56 | 57 | expect(inst.getDefault()).toBe(0); 58 | expect(inst.default(5).required().getDefault()).toBe(5); 59 | }); 60 | 61 | it('should type check', function () { 62 | let inst = number(); 63 | 64 | expect(inst.isType(5)).toBe(true); 65 | expect(inst.isType(new Number(5))).toBe(true); 66 | expect(inst.isType(new Number('foo'))).toBe(false); 67 | expect(inst.isType(false)).toBe(false); 68 | expect(inst.isType(null)).toBe(false); 69 | expect(inst.isType(NaN)).toBe(false); 70 | expect(inst.nullable().isType(null)).toBe(true); 71 | }); 72 | 73 | it('should VALIDATE correctly', function () { 74 | let inst = number().min(4); 75 | 76 | return Promise.all([ 77 | expect(number().isValid(null)).resolves.toBe(false), 78 | expect(number().nullable().isValid(null)).resolves.toBe(true), 79 | expect(number().isValid(' ')).resolves.toBe(false), 80 | expect(number().isValid('12abc')).resolves.toBe(false), 81 | expect(number().isValid(0xff)).resolves.toBe(true), 82 | expect(number().isValid('0xff')).resolves.toBe(true), 83 | 84 | expect(inst.isValid(5)).resolves.toBe(true), 85 | expect(inst.isValid(2)).resolves.toBe(false), 86 | 87 | expect(inst.required().validate(undefined)).rejects.toEqual( 88 | TestHelpers.validationErrorWithMessages( 89 | expect.stringContaining('required'), 90 | ), 91 | ), 92 | expect(inst.validate(null)).rejects.toEqual( 93 | TestHelpers.validationErrorWithMessages( 94 | expect.stringContaining('cannot be null'), 95 | ), 96 | ), 97 | expect(inst.validate({})).rejects.toEqual( 98 | TestHelpers.validationErrorWithMessages( 99 | expect.stringContaining('must be a `number` type'), 100 | ), 101 | ), 102 | ]); 103 | }); 104 | 105 | describe('min', () => { 106 | let schema = number().min(5); 107 | 108 | TestHelpers.validateAll(schema, { 109 | valid: [7, 35738787838, [null, schema.nullable()]], 110 | invalid: [2, null, [14, schema.min(10).min(15)]], 111 | }); 112 | }); 113 | 114 | describe('max', () => { 115 | let schema = number().max(5); 116 | 117 | TestHelpers.validateAll(schema, { 118 | valid: [4, -5222, [null, schema.nullable()]], 119 | invalid: [10, null, [16, schema.max(20).max(15)]], 120 | }); 121 | }); 122 | 123 | describe('lessThan', () => { 124 | let schema = number().lessThan(5); 125 | 126 | TestHelpers.validateAll(schema, { 127 | valid: [4, -10, [null, schema.nullable()]], 128 | invalid: [5, 7, null, [14, schema.lessThan(10).lessThan(14)]], 129 | }); 130 | 131 | it('should return default message', async () => { 132 | await expect(schema.validate(6)).rejects.toEqual( 133 | TestHelpers.validationErrorWithMessages('this must be less than 5'), 134 | ); 135 | }); 136 | }); 137 | 138 | describe('moreThan', () => { 139 | let schema = number().moreThan(5); 140 | 141 | TestHelpers.validateAll(schema, { 142 | valid: [6, 56445435, [null, schema.nullable()]], 143 | invalid: [5, -10, null, [64, schema.moreThan(52).moreThan(74)]], 144 | }); 145 | 146 | it('should return default message', async () => { 147 | await expect(schema.validate(4)).rejects.toEqual( 148 | TestHelpers.validationErrorWithMessages( 149 | expect.stringContaining('this must be greater than 5'), 150 | ), 151 | ); 152 | }); 153 | }); 154 | 155 | describe('integer', () => { 156 | let schema = number().integer(); 157 | 158 | TestHelpers.validateAll(schema, { 159 | valid: [4, -5222, 3.12312e51], 160 | invalid: [10.53, 0.1 * 0.2, -34512535.626, new Date()], 161 | }); 162 | 163 | it('should return default message', async () => { 164 | await expect(schema.validate(10.53)).rejects.toEqual( 165 | TestHelpers.validationErrorWithMessages('this must be an integer'), 166 | ); 167 | }); 168 | }); 169 | 170 | it('should check POSITIVE correctly', function () { 171 | let v = number().positive(); 172 | 173 | return Promise.all([ 174 | expect(v.isValid(7)).resolves.toBe(true), 175 | 176 | expect(v.isValid(0)).resolves.toBe(false), 177 | 178 | expect(v.validate(0)).rejects.toEqual( 179 | TestHelpers.validationErrorWithMessages( 180 | 'this must be a positive number', 181 | ), 182 | ), 183 | ]); 184 | }); 185 | 186 | it('should check NEGATIVE correctly', function () { 187 | let v = number().negative(); 188 | 189 | return Promise.all([ 190 | expect(v.isValid(-4)).resolves.toBe(true), 191 | 192 | expect(v.isValid(0)).resolves.toBe(false), 193 | 194 | expect(v.validate(10)).rejects.toEqual( 195 | TestHelpers.validationErrorWithMessages( 196 | 'this must be a negative number', 197 | ), 198 | ), 199 | ]); 200 | }); 201 | 202 | it('should resolve param refs when describing', () => { 203 | let schema = number().min(ref('$foo')); 204 | 205 | expect(schema.describe({ value: 10, context: { foo: 5 } })).toEqual( 206 | expect.objectContaining({ 207 | tests: [ 208 | expect.objectContaining({ 209 | params: { 210 | min: 5, 211 | }, 212 | }), 213 | ], 214 | }), 215 | ); 216 | 217 | let schema2 = object({ 218 | x: number().min(0), 219 | y: number().min(ref('x')), 220 | }).required(); 221 | 222 | expect( 223 | schema2.describe({ value: { x: 10 }, context: { foo: 5 } }).fields.y, 224 | ).toEqual( 225 | expect.objectContaining({ 226 | tests: [ 227 | expect.objectContaining({ 228 | params: { 229 | min: 10, 230 | }, 231 | }), 232 | ], 233 | }), 234 | ); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/setLocale.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { setLocale } from '../src'; 3 | 4 | describe('Custom locale', () => { 5 | it('should get default locale', () => { 6 | const locale = require('../src/locale').default; 7 | expect(locale.string.email).toBe('${path} must be a valid email'); 8 | }); 9 | 10 | it('should set a new locale', () => { 11 | const locale = require('../src/locale').default; 12 | const dict = { 13 | string: { 14 | email: 'Invalid email', 15 | }, 16 | }; 17 | 18 | setLocale(dict); 19 | 20 | expect(locale.string.email).toBe(dict.string.email); 21 | }); 22 | 23 | it('should update the main locale', () => { 24 | const locale = require('../src/locale').default; 25 | expect(locale.string.email).toBe('Invalid email'); 26 | }); 27 | 28 | it('should not allow prototype pollution', () => { 29 | const payload = JSON.parse( 30 | '{"__proto__":{"polluted":"Yes! Its Polluted"}}', 31 | ); 32 | 33 | expect(() => setLocale(payload)).toThrowError(); 34 | 35 | expect(payload).not.toHaveProperty('polluted'); 36 | }); 37 | 38 | it('should not pollute Object.prototype builtins', () => { 39 | const payload: any = { toString: { polluted: 'oh no' } }; 40 | 41 | expect(() => setLocale(payload)).toThrowError(); 42 | 43 | expect(Object.prototype.toString).not.toHaveProperty('polluted'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/string.ts: -------------------------------------------------------------------------------- 1 | import * as TestHelpers from './helpers'; 2 | 3 | import { 4 | string, 5 | number, 6 | object, 7 | ref, 8 | ValidationError, 9 | AnySchema, 10 | } from '../src'; 11 | 12 | describe('String types', () => { 13 | describe('casting', () => { 14 | let schema = string(); 15 | 16 | TestHelpers.castAll(schema, { 17 | valid: [ 18 | [5, '5'], 19 | ['3', '3'], 20 | // [new String('foo'), 'foo'], 21 | ['', ''], 22 | [true, 'true'], 23 | [false, 'false'], 24 | [0, '0'], 25 | [null, null, schema.nullable()], 26 | [ 27 | { 28 | toString: () => 'hey', 29 | }, 30 | 'hey', 31 | ], 32 | ], 33 | invalid: [null, {}, []], 34 | }); 35 | 36 | describe('ensure', () => { 37 | let schema = string().ensure(); 38 | 39 | TestHelpers.castAll(schema, { 40 | valid: [ 41 | [5, '5'], 42 | ['3', '3'], 43 | [null, ''], 44 | [undefined, ''], 45 | [null, '', schema.default('foo')], 46 | [undefined, 'foo', schema.default('foo')], 47 | ], 48 | }); 49 | }); 50 | 51 | it('should trim', () => { 52 | expect(schema.trim().cast(' 3 ')).toBe('3'); 53 | }); 54 | 55 | it('should transform to lowercase', () => { 56 | expect(schema.lowercase().cast('HellO JohN')).toBe('hello john'); 57 | }); 58 | 59 | it('should transform to uppercase', () => { 60 | expect(schema.uppercase().cast('HellO JohN')).toBe('HELLO JOHN'); 61 | }); 62 | 63 | it('should handle nulls', () => { 64 | expect( 65 | schema.nullable().trim().lowercase().uppercase().cast(null), 66 | ).toBeNull(); 67 | }); 68 | }); 69 | 70 | it('should handle DEFAULT', function () { 71 | let inst = string(); 72 | 73 | expect(inst.default('my_value').required().getDefault()).toBe('my_value'); 74 | }); 75 | 76 | it('should type check', function () { 77 | let inst = string(); 78 | 79 | expect(inst.isType('5')).toBe(true); 80 | expect(inst.isType(new String('5'))).toBe(true); 81 | expect(inst.isType(false)).toBe(false); 82 | expect(inst.isType(null)).toBe(false); 83 | expect(inst.nullable().isType(null)).toBe(true); 84 | }); 85 | 86 | it('should VALIDATE correctly', function () { 87 | let inst = string().required().min(4).strict(); 88 | 89 | return Promise.all([ 90 | expect(string().strict().isValid(null)).resolves.toBe(false), 91 | 92 | expect(string().strict().nullable().isValid(null)).resolves.toBe(true), 93 | 94 | expect(inst.isValid('hello')).resolves.toBe(true), 95 | 96 | expect(inst.isValid('hel')).resolves.toBe(false), 97 | 98 | expect(inst.validate('')).rejects.toEqual( 99 | TestHelpers.validationErrorWithMessages(expect.any(String)), 100 | ), 101 | ]); 102 | }); 103 | 104 | it('should handle NOTREQUIRED correctly', function () { 105 | let v = string().required().notRequired(); 106 | 107 | return Promise.all([ 108 | expect(v.isValid(undefined)).resolves.toBe(true), 109 | expect(v.isValid('')).resolves.toBe(true), 110 | ]); 111 | }); 112 | 113 | it('should check MATCHES correctly', function () { 114 | let v = string().matches(/(hi|bye)/, 'A message'); 115 | 116 | return Promise.all([ 117 | expect(v.isValid('hi')).resolves.toBe(true), 118 | expect(v.isValid('nope')).resolves.toBe(false), 119 | expect(v.isValid('bye')).resolves.toBe(true), 120 | ]); 121 | }); 122 | 123 | it('should check MATCHES correctly with global and sticky flags', function () { 124 | let v = string().matches(/hi/gy); 125 | 126 | return Promise.all([ 127 | expect(v.isValid('hi')).resolves.toBe(true), 128 | expect(v.isValid('hi')).resolves.toBe(true), 129 | ]); 130 | }); 131 | 132 | it('MATCHES should include empty strings', () => { 133 | let v = string().matches(/(hi|bye)/); 134 | 135 | return expect(v.isValid('')).resolves.toBe(false); 136 | }); 137 | 138 | it('MATCHES should exclude empty strings', () => { 139 | let v = string().matches(/(hi|bye)/, { excludeEmptyString: true }); 140 | 141 | return expect(v.isValid('')).resolves.toBe(true); 142 | }); 143 | 144 | it('EMAIL should exclude empty strings', () => { 145 | let v = string().email(); 146 | 147 | return expect(v.isValid('')).resolves.toBe(true); 148 | }); 149 | 150 | it('should check MIN correctly', function () { 151 | let v = string().min(5); 152 | let obj = object({ 153 | len: number(), 154 | name: string().min(ref('len')), 155 | }); 156 | 157 | return Promise.all([ 158 | expect(v.isValid('hiiofff')).resolves.toBe(true), 159 | expect(v.isValid('big')).resolves.toBe(false), 160 | expect(v.isValid('noffasfasfasf saf')).resolves.toBe(true), 161 | 162 | expect(v.isValid(null)).resolves.toBe(false), 163 | expect(v.nullable().isValid(null)).resolves.toBe(true), 164 | 165 | expect(obj.isValid({ len: 10, name: 'john' })).resolves.toBe(false), 166 | ]); 167 | }); 168 | 169 | it('should check MAX correctly', function () { 170 | let v = string().max(5); 171 | let obj = object({ 172 | len: number(), 173 | name: string().max(ref('len')), 174 | }); 175 | return Promise.all([ 176 | expect(v.isValid('adgf')).resolves.toBe(true), 177 | expect(v.isValid('bigdfdsfsdf')).resolves.toBe(false), 178 | expect(v.isValid('no')).resolves.toBe(true), 179 | 180 | expect(v.isValid(null)).resolves.toBe(false), 181 | 182 | expect(v.nullable().isValid(null)).resolves.toBe(true), 183 | 184 | expect(obj.isValid({ len: 3, name: 'john' })).resolves.toBe(false), 185 | ]); 186 | }); 187 | 188 | it('should check LENGTH correctly', function () { 189 | let v = string().length(5); 190 | let obj = object({ 191 | len: number(), 192 | name: string().length(ref('len')), 193 | }); 194 | 195 | return Promise.all([ 196 | expect(v.isValid('exact')).resolves.toBe(true), 197 | expect(v.isValid('sml')).resolves.toBe(false), 198 | expect(v.isValid('biiiig')).resolves.toBe(false), 199 | 200 | expect(v.isValid(null)).resolves.toBe(false), 201 | expect(v.nullable().isValid(null)).resolves.toBe(true), 202 | 203 | expect(obj.isValid({ len: 5, name: 'foo' })).resolves.toBe(false), 204 | ]); 205 | }); 206 | 207 | it('should check url correctly', function () { 208 | let v = string().url(); 209 | 210 | return Promise.all([ 211 | expect(v.isValid('//www.github.com/')).resolves.toBe(true), 212 | expect(v.isValid('https://www.github.com/')).resolves.toBe(true), 213 | expect(v.isValid('this is not a url')).resolves.toBe(false), 214 | ]); 215 | }); 216 | 217 | it('should check UUID correctly', function () { 218 | let v = string().uuid(); 219 | 220 | return Promise.all([ 221 | expect(v.isValid('0c40428c-d88d-4ff0-a5dc-a6755cb4f4d1')).resolves.toBe( 222 | true, 223 | ), 224 | expect(v.isValid('42c4a747-3e3e-42be-af30-469cfb9c1913')).resolves.toBe( 225 | true, 226 | ), 227 | expect(v.isValid('42c4a747-3e3e-zzzz-af30-469cfb9c1913')).resolves.toBe( 228 | false, 229 | ), 230 | expect(v.isValid('this is not a uuid')).resolves.toBe(false), 231 | expect(v.isValid('')).resolves.toBe(false), 232 | ]); 233 | }); 234 | 235 | describe('DATETIME', function () { 236 | it('should check DATETIME correctly', function () { 237 | let v = string().datetime(); 238 | 239 | return Promise.all([ 240 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true), 241 | expect(v.isValid('1977-00-28T12:34:56.0Z')).resolves.toBe(true), 242 | expect(v.isValid('1900-10-29T12:34:56.00Z')).resolves.toBe(true), 243 | expect(v.isValid('1000-11-30T12:34:56.000Z')).resolves.toBe(true), 244 | expect(v.isValid('4444-12-31T12:34:56.0000Z')).resolves.toBe(true), 245 | 246 | // Should not allow time zone offset by default 247 | expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(false), 248 | expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(false), 249 | expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(false), 250 | 251 | expect(v.isValid('this is not a datetime')).resolves.toBe(false), 252 | expect(v.isValid('2023-08-16T12:34:56')).resolves.toBe(false), 253 | expect(v.isValid('2023-08-1612:34:56Z')).resolves.toBe(false), 254 | expect(v.isValid('1970-01-01 00:00:00Z')).resolves.toBe(false), 255 | expect(v.isValid('1970-01-01T00:00:00,000Z')).resolves.toBe(false), 256 | expect(v.isValid('1970-01-01T0000')).resolves.toBe(false), 257 | expect(v.isValid('1970-01-01T00:00.000')).resolves.toBe(false), 258 | expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false), 259 | expect(v.isValid('2023-08-16')).resolves.toBe(false), 260 | expect(v.isValid('1970-as-df')).resolves.toBe(false), 261 | expect(v.isValid('19700101')).resolves.toBe(false), 262 | expect(v.isValid('197001')).resolves.toBe(false), 263 | ]); 264 | }); 265 | 266 | it('should support DATETIME allowOffset option', function () { 267 | let v = string().datetime({ allowOffset: true }); 268 | 269 | return Promise.all([ 270 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(true), 271 | expect(v.isValid('2010-04-10T14:06:14+00:00')).resolves.toBe(true), 272 | expect(v.isValid('2000-07-11T21:06:14+07:00')).resolves.toBe(true), 273 | expect(v.isValid('1999-08-16T07:06:14-07:00')).resolves.toBe(true), 274 | expect(v.isValid('1970-01-01T00:00:00+0630')).resolves.toBe(true), 275 | ]); 276 | }); 277 | 278 | it('should support DATETIME precision option', function () { 279 | let v = string().datetime({ precision: 4 }); 280 | 281 | return Promise.all([ 282 | expect(v.isValid('2023-01-09T12:34:56.0000Z')).resolves.toBe(true), 283 | expect(v.isValid('2023-01-09T12:34:56.00000Z')).resolves.toBe(false), 284 | expect(v.isValid('2023-01-09T12:34:56.000Z')).resolves.toBe(false), 285 | expect(v.isValid('2023-01-09T12:34:56.00Z')).resolves.toBe(false), 286 | expect(v.isValid('2023-01-09T12:34:56.0Z')).resolves.toBe(false), 287 | expect(v.isValid('2023-01-09T12:34:56.Z')).resolves.toBe(false), 288 | expect(v.isValid('2023-01-09T12:34:56Z')).resolves.toBe(false), 289 | expect(v.isValid('2010-04-10T14:06:14.0000+00:00')).resolves.toBe( 290 | false, 291 | ), 292 | ]); 293 | }); 294 | 295 | describe('DATETIME error strings', function () { 296 | function getErrorString(schema: AnySchema, value: string) { 297 | try { 298 | schema.validateSync(value); 299 | fail('should have thrown validation error'); 300 | } catch (e) { 301 | const err = e as ValidationError; 302 | return err.errors[0]; 303 | } 304 | } 305 | 306 | it('should use the default locale string on error', function () { 307 | let v = string().datetime(); 308 | expect(getErrorString(v, 'asdf')).toBe( 309 | 'this must be a valid ISO date-time', 310 | ); 311 | }); 312 | 313 | it('should use the allowOffset locale string on error when offset caused error', function () { 314 | let v = string().datetime(); 315 | expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe( 316 | 'this must be a valid ISO date-time with UTC "Z" timezone', 317 | ); 318 | }); 319 | 320 | it('should use the precision locale string on error when precision caused error', function () { 321 | let v = string().datetime({ precision: 2 }); 322 | expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe( 323 | 'this must be a valid ISO date-time with a sub-second precision of exactly 2 digits', 324 | ); 325 | }); 326 | 327 | it('should prefer options.message over all default error messages', function () { 328 | let msg = 'hello'; 329 | let v = string().datetime({ message: msg }); 330 | expect(getErrorString(v, 'asdf')).toBe(msg); 331 | expect(getErrorString(v, '2010-04-10T14:06:14+00:00')).toBe(msg); 332 | 333 | v = string().datetime({ message: msg, precision: 2 }); 334 | expect(getErrorString(v, '2023-01-09T12:34:56Z')).toBe(msg); 335 | }); 336 | }); 337 | }); 338 | 339 | xit('should check allowed values at the end', () => { 340 | return Promise.all([ 341 | expect( 342 | string() 343 | .required('Required') 344 | .notOneOf([ref('$someKey')]) 345 | .validate('', { context: { someKey: '' } }), 346 | ).rejects.toEqual( 347 | TestHelpers.validationErrorWithMessages( 348 | expect.stringContaining('Ref($someKey)'), 349 | ), 350 | ), 351 | expect( 352 | object({ 353 | email: string().required('Email Required'), 354 | password: string() 355 | .required('Password Required') 356 | .notOneOf([ref('email')]), 357 | }) 358 | .validate({ email: '', password: '' }, { abortEarly: false }) 359 | .catch(console.log), 360 | ).rejects.toEqual( 361 | TestHelpers.validationErrorWithMessages( 362 | expect.stringContaining('Email Required'), 363 | expect.stringContaining('Password Required'), 364 | ), 365 | ), 366 | ]); 367 | }); 368 | 369 | it('should validate transforms', function () { 370 | return Promise.all([ 371 | expect(string().trim().isValid(' 3 ')).resolves.toBe(true), 372 | 373 | expect(string().lowercase().isValid('HellO JohN')).resolves.toBe(true), 374 | 375 | expect(string().uppercase().isValid('HellO JohN')).resolves.toBe(true), 376 | 377 | expect(string().trim().isValid(' 3 ', { strict: true })).resolves.toBe( 378 | false, 379 | ), 380 | 381 | expect( 382 | string().lowercase().isValid('HellO JohN', { strict: true }), 383 | ).resolves.toBe(false), 384 | 385 | expect( 386 | string().uppercase().isValid('HellO JohN', { strict: true }), 387 | ).resolves.toBe(false), 388 | ]); 389 | }); 390 | }); 391 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "noImplicitAny": true, 6 | "types": ["node", "jest"], 7 | "rootDir": "../" 8 | }, 9 | "include": ["../src", "."] 10 | } 11 | -------------------------------------------------------------------------------- /test/tuple.ts: -------------------------------------------------------------------------------- 1 | import { string, number, object, tuple, mixed } from '../src'; 2 | 3 | describe('Array types', () => { 4 | describe('casting', () => { 5 | it('should failed casts return input', () => { 6 | expect( 7 | tuple([number(), number()]).cast('asfasf', { assert: false }), 8 | ).toEqual('asfasf'); 9 | }); 10 | 11 | it('should recursively cast fields', () => { 12 | expect(tuple([number(), number()]).cast(['4', '5'])).toEqual([4, 5]); 13 | 14 | expect( 15 | tuple([string(), string(), string()]).cast(['4', 5, false]), 16 | ).toEqual(['4', '5', 'false']); 17 | }); 18 | }); 19 | 20 | it('should handle DEFAULT', () => { 21 | expect(tuple([number(), number(), number()]).getDefault()).toBeUndefined(); 22 | 23 | expect( 24 | tuple([number(), number(), number()]) 25 | .default(() => [1, 2, 3]) 26 | .getDefault(), 27 | ).toEqual([1, 2, 3]); 28 | }); 29 | 30 | it('should type check', () => { 31 | let inst = tuple([number()]); 32 | 33 | expect(inst.isType([1])).toBe(true); 34 | expect(inst.isType({})).toBe(false); 35 | expect(inst.isType('true')).toBe(false); 36 | expect(inst.isType(NaN)).toBe(false); 37 | expect(inst.isType(34545)).toBe(false); 38 | 39 | expect(inst.isType(null)).toBe(false); 40 | 41 | expect(inst.nullable().isType(null)).toBe(true); 42 | }); 43 | 44 | it('should pass options to children', () => { 45 | expect( 46 | tuple([object({ name: string() })]).cast([{ id: 1, name: 'john' }], { 47 | stripUnknown: true, 48 | }), 49 | ).toEqual([{ name: 'john' }]); 50 | }); 51 | 52 | describe('validation', () => { 53 | test.each([ 54 | ['required', undefined, tuple([mixed()]).required()], 55 | ['required', null, tuple([mixed()]).required()], 56 | ['null', null, tuple([mixed()])], 57 | ])('Basic validations fail: %s %p', async (_, value, schema) => { 58 | expect(await schema.isValid(value)).toBe(false); 59 | }); 60 | 61 | test.each([ 62 | ['required', ['any'], tuple([mixed()]).required()], 63 | ['nullable', null, tuple([mixed()]).nullable()], 64 | ])('Basic validations pass: %s %p', async (_, value, schema) => { 65 | expect(await schema.isValid(value)).toBe(true); 66 | }); 67 | 68 | it('should allow undefined', async () => { 69 | await expect( 70 | tuple([number().defined()]).isValid(undefined), 71 | ).resolves.toBe(true); 72 | }); 73 | 74 | it('should respect subtype validations', async () => { 75 | let inst = tuple([number().max(5), string()]); 76 | 77 | await expect(inst.isValid(['gg', 'any'])).resolves.toBe(false); 78 | await expect(inst.isValid([7, 3])).resolves.toBe(false); 79 | 80 | expect(await inst.validate(['4', 3])).toEqual([4, '3']); 81 | }); 82 | 83 | it('should use labels', async () => { 84 | let schema = tuple([ 85 | string().label('name'), 86 | number().positive().integer().label('age'), 87 | ]); 88 | 89 | await expect(schema.validate(['James', -24.55])).rejects.toThrow( 90 | 'age must be a positive number', 91 | ); 92 | }); 93 | 94 | it('should throw useful type error for length', async () => { 95 | let schema = tuple([string().label('name'), number().label('age')]); 96 | 97 | // expect(() => schema.cast(['James'])).toThrowError( 98 | // 'this tuple value has too few items, expected a length of 2 but got 1 for value', 99 | // ); 100 | await expect(schema.validate(['James'])).rejects.toThrowError( 101 | 'this tuple value has too few items, expected a length of 2 but got 1 for value', 102 | ); 103 | 104 | await expect(schema.validate(['James', 2, 4])).rejects.toThrowError( 105 | 'this tuple value has too many items, expected a length of 2 but got 3 for value', 106 | ); 107 | // expect(() => schema.validate(['James', 2, 4])).rejects.toThrowError( 108 | // 'this tuple value has too many items, expected a length of 2 but got 3 for value', 109 | // ); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 { parseIsoDate } from '../../src/util/parseIsoDate'; 10 | 11 | const sixHours = 6 * 60 * 60 * 1000; 12 | const sixHoursThirty = sixHours + 30 * 60 * 1000; 13 | const epochLocalTime = new Date(1970, 0, 1, 0, 0, 0, 0).valueOf(); 14 | 15 | describe('plain date (no time)', () => { 16 | describe('valid dates', () => { 17 | test('Unix epoch', () => { 18 | const result = parseIsoDate('1970-01-01'); 19 | expect(result).toBe(epochLocalTime); 20 | }); 21 | test('2001', () => { 22 | const result = parseIsoDate('2001'); 23 | const expected = new Date(2001, 0, 1, 0, 0, 0, 0).valueOf(); 24 | expect(result).toBe(expected); 25 | }); 26 | test('2001-02', () => { 27 | const result = parseIsoDate('2001-02'); 28 | const expected = new Date(2001, 1, 1, 0, 0, 0, 0).valueOf(); 29 | expect(result).toBe(expected); 30 | }); 31 | test('2001-02-03', () => { 32 | const result = parseIsoDate('2001-02-03'); 33 | const expected = new Date(2001, 1, 3, 0, 0, 0, 0).valueOf(); 34 | expect(result).toBe(expected); 35 | }); 36 | test('-002001', () => { 37 | const result = parseIsoDate('-002001'); 38 | const expected = new Date(-2001, 0, 1, 0, 0, 0, 0).valueOf(); 39 | expect(result).toBe(expected); 40 | }); 41 | test('-002001-02', () => { 42 | const result = parseIsoDate('-002001-02'); 43 | const expected = new Date(-2001, 1, 1, 0, 0, 0, 0).valueOf(); 44 | expect(result).toBe(expected); 45 | }); 46 | test('-002001-02-03', () => { 47 | const result = parseIsoDate('-002001-02-03'); 48 | const expected = new Date(-2001, 1, 3, 0, 0, 0, 0).valueOf(); 49 | expect(result).toBe(expected); 50 | }); 51 | test('+010000-02', () => { 52 | const result = parseIsoDate('+010000-02'); 53 | const expected = new Date(10000, 1, 1, 0, 0, 0, 0).valueOf(); 54 | expect(result).toBe(expected); 55 | }); 56 | test('+010000-02-03', () => { 57 | const result = parseIsoDate('+010000-02-03'); 58 | const expected = new Date(10000, 1, 3, 0, 0, 0, 0).valueOf(); 59 | expect(result).toBe(expected); 60 | }); 61 | test('-010000-02', () => { 62 | const result = parseIsoDate('-010000-02'); 63 | const expected = new Date(-10000, 1, 1, 0, 0, 0, 0).valueOf(); 64 | expect(result).toBe(expected); 65 | }); 66 | test('-010000-02-03', () => { 67 | const result = parseIsoDate('-010000-02-03'); 68 | const expected = new Date(-10000, 1, 3, 0, 0, 0, 0).valueOf(); 69 | expect(result).toBe(expected); 70 | }); 71 | }); 72 | 73 | describe('invalid dates', () => { 74 | test('invalid YYYY (non-digits)', () => { 75 | expect(parseIsoDate('asdf')).toBeNaN(); 76 | }); 77 | test('invalid YYYY-MM-DD (non-digits)', () => { 78 | expect(parseIsoDate('1970-as-df')).toBeNaN(); 79 | }); 80 | test('invalid YYYY-MM- (extra hyphen)', () => { 81 | expect(parseIsoDate('1970-01-')).toBe(epochLocalTime); 82 | }); 83 | test('invalid YYYY-MM-DD (missing hyphens)', () => { 84 | expect(parseIsoDate('19700101')).toBe(epochLocalTime); 85 | }); 86 | test('ambiguous YYYY-MM/YYYYYY (missing plus/minus or hyphen)', () => { 87 | expect(parseIsoDate('197001')).toBe(epochLocalTime); 88 | }); 89 | }); 90 | }); 91 | 92 | describe('date-time', () => { 93 | describe('no time zone', () => { 94 | test('2001-02-03T04:05', () => { 95 | const result = parseIsoDate('2001-02-03T04:05'); 96 | const expected = new Date(2001, 1, 3, 4, 5, 0, 0).valueOf(); 97 | expect(result).toBe(expected); 98 | }); 99 | test('2001-02-03T04:05:06', () => { 100 | const result = parseIsoDate('2001-02-03T04:05:06'); 101 | const expected = new Date(2001, 1, 3, 4, 5, 6, 0).valueOf(); 102 | expect(result).toBe(expected); 103 | }); 104 | test('2001-02-03T04:05:06.007', () => { 105 | const result = parseIsoDate('2001-02-03T04:05:06.007'); 106 | const expected = new Date(2001, 1, 3, 4, 5, 6, 7).valueOf(); 107 | expect(result).toBe(expected); 108 | }); 109 | }); 110 | 111 | describe('Z time zone', () => { 112 | test('2001-02-03T04:05Z', () => { 113 | const result = parseIsoDate('2001-02-03T04:05Z'); 114 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 115 | expect(result).toBe(expected); 116 | }); 117 | test('2001-02-03T04:05:06Z', () => { 118 | const result = parseIsoDate('2001-02-03T04:05:06Z'); 119 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 120 | expect(result).toBe(expected); 121 | }); 122 | test('2001-02-03T04:05:06.007Z', () => { 123 | const result = parseIsoDate('2001-02-03T04:05:06.007Z'); 124 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 125 | expect(result).toBe(expected); 126 | }); 127 | }); 128 | 129 | describe('offset time zone', () => { 130 | test('2001-02-03T04:05-00:00', () => { 131 | const result = parseIsoDate('2001-02-03T04:05-00:00'); 132 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 133 | expect(result).toBe(expected); 134 | }); 135 | test('2001-02-03T04:05:06-00:00', () => { 136 | const result = parseIsoDate('2001-02-03T04:05:06-00:00'); 137 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 138 | expect(result).toBe(expected); 139 | }); 140 | test('2001-02-03T04:05:06.007-00:00', () => { 141 | const result = parseIsoDate('2001-02-03T04:05:06.007-00:00'); 142 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 143 | expect(result).toBe(expected); 144 | }); 145 | 146 | test('2001-02-03T04:05+00:00', () => { 147 | const result = parseIsoDate('2001-02-03T04:05+00:00'); 148 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0); 149 | expect(result).toBe(expected); 150 | }); 151 | test('2001-02-03T04:05:06+00:00', () => { 152 | const result = parseIsoDate('2001-02-03T04:05:06+00:00'); 153 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0); 154 | expect(result).toBe(expected); 155 | }); 156 | test('2001-02-03T04:05:06.007+00:00', () => { 157 | const result = parseIsoDate('2001-02-03T04:05:06.007+00:00'); 158 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7); 159 | expect(result).toBe(expected); 160 | }); 161 | 162 | test('2001-02-03T04:05-06:30', () => { 163 | const result = parseIsoDate('2001-02-03T04:05-06:30'); 164 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) + sixHoursThirty; 165 | expect(result).toBe(expected); 166 | }); 167 | test('2001-02-03T04:05:06-06:30', () => { 168 | const result = parseIsoDate('2001-02-03T04:05:06-06:30'); 169 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) + sixHoursThirty; 170 | expect(result).toBe(expected); 171 | }); 172 | test('2001-02-03T04:05:06.007-06:30', () => { 173 | const result = parseIsoDate('2001-02-03T04:05:06.007-06:30'); 174 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) + sixHoursThirty; 175 | expect(result).toBe(expected); 176 | }); 177 | 178 | test('2001-02-03T04:05+06:30', () => { 179 | const result = parseIsoDate('2001-02-03T04:05+06:30'); 180 | const expected = Date.UTC(2001, 1, 3, 4, 5, 0, 0) - sixHoursThirty; 181 | expect(result).toBe(expected); 182 | }); 183 | test('2001-02-03T04:05:06+06:30', () => { 184 | const result = parseIsoDate('2001-02-03T04:05:06+06:30'); 185 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 0) - sixHoursThirty; 186 | expect(result).toBe(expected); 187 | }); 188 | test('2001-02-03T04:05:06.007+06:30', () => { 189 | const result = parseIsoDate('2001-02-03T04:05:06.007+06:30'); 190 | const expected = Date.UTC(2001, 1, 3, 4, 5, 6, 7) - sixHoursThirty; 191 | expect(result).toBe(expected); 192 | }); 193 | }); 194 | 195 | describe('incomplete dates', () => { 196 | test('2001T04:05:06.007', () => { 197 | const result = parseIsoDate('2001T04:05:06.007'); 198 | const expected = new Date(2001, 0, 1, 4, 5, 6, 7).valueOf(); 199 | expect(result).toBe(expected); 200 | }); 201 | test('2001-02T04:05:06.007', () => { 202 | const result = parseIsoDate('2001-02T04:05:06.007'); 203 | const expected = new Date(2001, 1, 1, 4, 5, 6, 7).valueOf(); 204 | expect(result).toBe(expected); 205 | }); 206 | 207 | test('-010000T04:05', () => { 208 | const result = parseIsoDate('-010000T04:05'); 209 | const expected = new Date(-10000, 0, 1, 4, 5, 0, 0).valueOf(); 210 | expect(result).toBe(expected); 211 | }); 212 | test('-010000-02T04:05', () => { 213 | const result = parseIsoDate('-010000-02T04:05'); 214 | const expected = new Date(-10000, 1, 1, 4, 5, 0, 0).valueOf(); 215 | expect(result).toBe(expected); 216 | }); 217 | test('-010000-02-03T04:05', () => { 218 | const result = parseIsoDate('-010000-02-03T04:05'); 219 | const expected = new Date(-10000, 1, 3, 4, 5, 0, 0).valueOf(); 220 | expect(result).toBe(expected); 221 | }); 222 | }); 223 | 224 | describe('invalid date-times', () => { 225 | test('missing T', () => { 226 | expect(parseIsoDate('1970-01-01 00:00:00')).toBe(epochLocalTime); 227 | }); 228 | test('too many characters in millisecond part', () => { 229 | expect(parseIsoDate('1970-01-01T00:00:00.000000')).toBe(epochLocalTime); 230 | }); 231 | test('comma instead of dot', () => { 232 | expect(parseIsoDate('1970-01-01T00:00:00,000')).toBe(epochLocalTime); 233 | }); 234 | test('missing colon in timezone part', () => { 235 | const subject = '1970-01-01T00:00:00+0630'; 236 | expect(parseIsoDate(subject)).toBe(Date.parse(subject)); 237 | }); 238 | test('missing colon in time part', () => { 239 | expect(parseIsoDate('1970-01-01T0000')).toBe(epochLocalTime); 240 | }); 241 | test('msec with missing seconds', () => { 242 | expect(parseIsoDate('1970-01-01T00:00.000')).toBeNaN(); 243 | }); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /test/yup.js: -------------------------------------------------------------------------------- 1 | import reach, { getIn } from '../src/util/reach'; 2 | 3 | import { 4 | addMethod, 5 | object, 6 | array, 7 | string, 8 | lazy, 9 | number, 10 | boolean, 11 | date, 12 | Schema, 13 | ObjectSchema, 14 | ArraySchema, 15 | StringSchema, 16 | NumberSchema, 17 | BooleanSchema, 18 | DateSchema, 19 | mixed, 20 | MixedSchema, 21 | tuple, 22 | } from '../src'; 23 | 24 | describe('Yup', function () { 25 | it('cast should not assert on undefined', () => { 26 | expect(() => string().cast(undefined)).not.toThrowError(); 27 | }); 28 | 29 | it('cast should assert on undefined cast results', () => { 30 | expect(() => 31 | string() 32 | .defined() 33 | .transform(() => undefined) 34 | .cast('foo'), 35 | ).toThrowError( 36 | 'The value of field could not be cast to a value that satisfies the schema type: "string".', 37 | ); 38 | }); 39 | 40 | it('cast should respect assert option', () => { 41 | expect(() => string().cast(null)).toThrowError(); 42 | 43 | expect(() => string().cast(null, { assert: false })).not.toThrowError(); 44 | }); 45 | 46 | it('should getIn correctly', async () => { 47 | let num = number(); 48 | let shape = object({ 'num-1': num }); 49 | let inst = object({ 50 | num: number().max(4), 51 | 52 | nested: object({ 53 | arr: array().of(shape), 54 | }), 55 | }); 56 | 57 | const value = { nested: { arr: [{}, { 'num-1': 2 }] } }; 58 | let { schema, parent, parentPath } = getIn( 59 | inst, 60 | 'nested.arr[1].num-1', 61 | value, 62 | ); 63 | 64 | expect(schema).toBe(num); 65 | expect(parentPath).toBe('num-1'); 66 | expect(parent).toBe(value.nested.arr[1]); 67 | }); 68 | 69 | it('should getIn array correctly', async () => { 70 | let num = number(); 71 | let shape = object({ 'num-1': num }); 72 | let inst = object({ 73 | num: number().max(4), 74 | 75 | nested: object({ 76 | arr: array().of(shape), 77 | }), 78 | }); 79 | 80 | const value = { 81 | nested: { 82 | arr: [{}, { 'num-1': 2 }], 83 | }, 84 | }; 85 | 86 | const { schema, parent, parentPath } = getIn(inst, 'nested.arr[1]', value); 87 | 88 | expect(schema).toBe(shape); 89 | expect(parentPath).toBe('1'); 90 | expect(parent).toBe(value.nested.arr); 91 | }); 92 | 93 | it('should REACH correctly', async () => { 94 | let num = number(); 95 | let shape = object({ num }); 96 | 97 | let inst = object({ 98 | num: number().max(4), 99 | 100 | nested: tuple([ 101 | string(), 102 | object({ 103 | arr: array().of(shape), 104 | }), 105 | ]), 106 | }); 107 | 108 | expect(reach(inst, '')).toBe(inst); 109 | 110 | expect(reach(inst, 'nested[1].arr[0].num')).toBe(num); 111 | expect(reach(inst, 'nested[1].arr[].num')).toBe(num); 112 | expect(reach(inst, 'nested[1].arr.num')).toBe(num); 113 | expect(reach(inst, 'nested[1].arr[1].num')).toBe(num); 114 | expect(reach(inst, 'nested[1].arr[1]')).toBe(shape); 115 | 116 | expect(() => reach(inst, 'nested.arr[1].num')).toThrowError( 117 | '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]"', 118 | ); 119 | 120 | expect(reach(inst, 'nested[1].arr[0].num').isValid(5)).resolves.toBe(true); 121 | }); 122 | 123 | it('should REACH conditionally correctly', async function () { 124 | let num = number().oneOf([4]), 125 | inst = object().shape({ 126 | num: number().max(4), 127 | nested: object().shape({ 128 | arr: array().when('$bar', function ([bar]) { 129 | return bar !== 3 130 | ? array().of(number()) 131 | : array().of( 132 | object().shape({ 133 | foo: number(), 134 | num: number().when('foo', ([foo]) => { 135 | if (foo === 5) return num; 136 | }), 137 | }), 138 | ); 139 | }), 140 | }), 141 | }); 142 | 143 | let context = { bar: 3 }; 144 | let value = { 145 | bar: 3, 146 | nested: { 147 | arr: [{ foo: 5 }, { foo: 3 }], 148 | }, 149 | }; 150 | 151 | let options = {}; 152 | options.parent = value.nested.arr[0]; 153 | options.value = options.parent.num; 154 | expect(reach(inst, 'nested.arr.num', value).resolve(options)).toBe(num); 155 | expect(reach(inst, 'nested.arr[].num', value).resolve(options)).toBe(num); 156 | 157 | options.context = context; 158 | expect(reach(inst, 'nested.arr.num', value, context).resolve(options)).toBe( 159 | num, 160 | ); 161 | expect( 162 | reach(inst, 'nested.arr[].num', value, context).resolve(options), 163 | ).toBe(num); 164 | expect( 165 | reach(inst, 'nested.arr[0].num', value, context).resolve(options), 166 | ).toBe(num); 167 | 168 | // // should fail b/c item[1] is used to resolve the schema 169 | options.parent = value.nested.arr[1]; 170 | options.value = options.parent.num; 171 | expect( 172 | reach(inst, 'nested["arr"][1].num', value, context).resolve(options), 173 | ).not.toBe(num); 174 | 175 | let reached = reach(inst, 'nested.arr[].num', value, context); 176 | 177 | await expect( 178 | reached.validate(5, { context, parent: { foo: 4 } }), 179 | ).resolves.toBeDefined(); 180 | 181 | await expect( 182 | reached.validate(5, { context, parent: { foo: 5 } }), 183 | ).rejects.toThrowError(/one of the following/); 184 | }); 185 | 186 | it('should reach through lazy', async () => { 187 | let types = { 188 | 1: object({ foo: string() }), 189 | 2: object({ foo: number() }), 190 | }; 191 | 192 | await expect( 193 | object({ 194 | x: array(lazy((val) => types[val.type])), 195 | }) 196 | .strict() 197 | .validate({ 198 | x: [ 199 | { type: 1, foo: '4' }, 200 | { type: 2, foo: '5' }, 201 | ], 202 | }), 203 | ).rejects.toThrowError(/must be a `number` type/); 204 | }); 205 | 206 | describe('addMethod', () => { 207 | it('extending Schema should make method accessible everywhere', () => { 208 | addMethod(Schema, 'foo', () => 'here'); 209 | 210 | expect(string().foo()).toBe('here'); 211 | }); 212 | 213 | test.each([ 214 | ['mixed', mixed], 215 | ['object', object], 216 | ['array', array], 217 | ['string', string], 218 | ['number', number], 219 | ['boolean', boolean], 220 | ['date', date], 221 | ])('should work with factories: %s', (_msg, factory) => { 222 | addMethod(factory, 'foo', () => 'here'); 223 | 224 | expect(factory().foo()).toBe('here'); 225 | }); 226 | 227 | test.each([ 228 | ['mixed', MixedSchema], 229 | ['object', ObjectSchema], 230 | ['array', ArraySchema], 231 | ['string', StringSchema], 232 | ['number', NumberSchema], 233 | ['boolean', BooleanSchema], 234 | ['date', DateSchema], 235 | ])('should work with classes: %s', (_msg, ctor) => { 236 | addMethod(ctor, 'foo', () => 'here'); 237 | 238 | expect(new ctor().foo()).toBe('here'); 239 | }); 240 | }); 241 | }); 242 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "strictFunctionTypes": true 6 | }, 7 | "include": ["src/**/*.ts"] 8 | } 9 | --------------------------------------------------------------------------------