├── .prettierignore ├── .gitignore ├── tsconfig.json ├── test ├── testCompilationErrors.js ├── shouldNotCompile.ts ├── mocha.d.ts ├── expect.d.ts └── test.ts ├── package.json ├── yarn.lock ├── README.md └── src └── validation.ts /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | commonjs 5 | es 6 | test/test.js 7 | src/validation.js 8 | *.log 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es5", "es6", "es2015.core", "dom", "ES2016", "ES2017"], 4 | "target": "ES5", 5 | "strict": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "noUncheckedIndexedAccess": true, 8 | "pretty": true, 9 | "noErrorTruncation": true, 10 | "noEmit": true 11 | }, 12 | "compileOnSave": false, 13 | "include": ["src/**/*.ts", "test/**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /test/testCompilationErrors.js: -------------------------------------------------------------------------------- 1 | const ts = require('typescript') 2 | const chalk = require('chalk') 3 | const fs = require('fs') 4 | 5 | const expectedErrorCount = ( 6 | fs 7 | .readFileSync('test/shouldNotCompile.ts', 'utf8') 8 | .match(/@shouldNotCompile/g) || [] 9 | ).length 10 | 11 | const tsOptions = { 12 | noImplicitAny: true, 13 | noEmit: true, 14 | strictNullChecks: true, 15 | lib: ['lib.es6.d.ts'] // Map support 16 | } 17 | const program = ts.createProgram(['test/shouldNotCompile'], tsOptions) 18 | const diagnostics = ts.getPreEmitDiagnostics(program) 19 | 20 | if (diagnostics.length === expectedErrorCount) { 21 | console.log(chalk.green('All the expected compilation errors were found')) 22 | } else { 23 | const lines = errors(diagnostics) 24 | .map(d => d.line) 25 | .join(', ') 26 | 27 | console.log( 28 | chalk.red( 29 | `${expectedErrorCount} errors were expected but ${diagnostics.length} errors were found at these lines: ${lines}` 30 | ) 31 | ) 32 | } 33 | 34 | function errors(arr) { 35 | return arr.map(diag => ({ 36 | line: diag.file?.getLineAndCharacterOfPosition(diag.start).line + 1 37 | })) 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "idonttrustlikethat", 3 | "version": "2.1.2", 4 | "sideEffects": false, 5 | "description": "Validation for TypeScript", 6 | "license": "MIT", 7 | "main": "commonjs/validation.js", 8 | "module": "es/validation.js", 9 | "typings": "commonjs/validation.d.ts", 10 | "devDependencies": { 11 | "chalk": "1.1.1", 12 | "cross-env": "5.2.0", 13 | "expect": "1.8.0", 14 | "mocha": "2.2.5", 15 | "typescript": "4.7.3", 16 | "space-lift": "1.0.0" 17 | }, 18 | "scripts": { 19 | "build": "npm run build-es && npm run build-commonjs", 20 | "build-es": "tsc src/validation.ts --outDir es --strict --noUnusedParameters --declaration --lib dom,es5,es6 --module es6 --target es6 --moduleResolution node", 21 | "build-commonjs": "tsc src/validation.ts --outDir commonjs --strict --noUnusedParameters --declaration --lib dom,es5,es6 --target es5", 22 | "pretest": "npm run build && tsc test/mocha.d.ts test/expect.d.ts test/test.ts --lib dom,es5,es6 --strict --noUncheckedIndexedAccess", 23 | "test": "mocha --recursive && node test/testCompilationErrors.js", 24 | "locale-test": "cross-env LANG=tr_TR npm run test" 25 | }, 26 | "files": [ 27 | "commonjs", 28 | "es" 29 | ], 30 | "keywords": [ 31 | "validation", 32 | "io", 33 | "typescript", 34 | "type derivation" 35 | ], 36 | "author": "AlexGalays", 37 | "homepage": "https://github.com/AlexGalays/validation.ts", 38 | "prettier": { 39 | "tabWidth": 2, 40 | "semi": false, 41 | "singleQuote": true, 42 | "arrowParens": "avoid", 43 | "trailingComma": "none" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/shouldNotCompile.ts: -------------------------------------------------------------------------------- 1 | import * as v from '../commonjs/validation' 2 | 3 | // type derivation to null but assigned to a number @shouldNotCompile 4 | const a: typeof v.null.T = 33 5 | 6 | // type derivation to string but assigned to null @shouldNotCompile 7 | const b: typeof v.string.T = null 8 | 9 | const person = v.object({ 10 | id: v.number, 11 | name: v.string, 12 | friends: v.array( 13 | v.object({ 14 | name: v.string 15 | }) 16 | ) 17 | }) 18 | 19 | type Person = typeof person.T 20 | 21 | // Deriving the type and then assigning to a completely wrong type @shouldNotCompile 22 | const c: typeof person.T = undefined 23 | 24 | // Deriving the type and then assigning to a completely wrong type @shouldNotCompile 25 | const d: typeof person.T = {} 26 | 27 | // Deriving the type and then assigning to a wrong type @shouldNotCompile 28 | const e: typeof person.T = { 29 | id: 123, 30 | name: '111', 31 | friends: [{ name: 111 }] 32 | } 33 | 34 | // Creating an union with completely wrong members @shouldNotCompile 35 | v.union(new Date(), new Date()) 36 | 37 | // Deriving from an union type and assigning to an unrelated type @shouldNotCompile 38 | const helloOrObj = v.union(v.string, v.object({ name: v.string })) 39 | type HelloOrObj = typeof helloOrObj.T 40 | const hello: HelloOrObj = {} 41 | 42 | // Deriving from an intersection type and assigning to an unrelated type @shouldNotCompile 43 | const fooAndBar = v.intersection( 44 | v.object({ foo: v.number }), 45 | v.object({ bar: v.string }) 46 | ) 47 | type FooAndBar = typeof fooAndBar.T 48 | const foo: FooAndBar = { foo: 10 } 49 | 50 | // Assigning to the wrong literal @shouldNotCompile 51 | const aaa = v.literal('AAA') 52 | type OnlyAAA = typeof aaa.T 53 | const bbb: OnlyAAA = 'bbb' 54 | 55 | // tagged() called on a non Primitive validator (string | number) @shouldNotCompile 56 | const validator = v.object({}).tagged() 57 | 58 | // validateAs with an incompatible type param @shouldNotCompile 1 59 | v.validateAs(v.number, {}) 60 | 61 | // validateAs with an incompatible type param @shouldNotCompile 2 62 | v.validateAs<{ id: string; prefs?: { lang: string } }>( 63 | v.object({ id: v.string, prefs: v.object({}).optional() }), 64 | {} 65 | ) 66 | 67 | // discriminatedUnion where the type key is not found in all the members @shouldNotCompile 68 | v.discriminatedUnion( 69 | 'type', 70 | v.object({ type: v.literal('A') }), 71 | v.object({ name: v.string }) 72 | ) 73 | 74 | // discriminatedUnion where the type key is not found in all the members (2) @shouldNotCompile 75 | v.discriminatedUnion('type', v.object({ type: v.literal('A') }), v.string) 76 | 77 | // discriminatedUnion where the type value is not a literal validator in all the members (3) @shouldNotCompile 78 | v.discriminatedUnion( 79 | 'type', 80 | v.object({ type: v.literal('A') }), 81 | v.object({ type: v.string }) 82 | ) 83 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-regex@^2.0.0: 6 | version "2.1.1" 7 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" 8 | 9 | ansi-styles@^2.1.0: 10 | version "2.2.1" 11 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" 12 | 13 | assert@^1.3.0: 14 | version "1.4.1" 15 | resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" 16 | dependencies: 17 | util "0.10.3" 18 | 19 | chalk@1.1.1: 20 | version "1.1.1" 21 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.1.tgz#509afb67066e7499f7eb3535c77445772ae2d019" 22 | dependencies: 23 | ansi-styles "^2.1.0" 24 | escape-string-regexp "^1.0.2" 25 | has-ansi "^2.0.0" 26 | strip-ansi "^3.0.0" 27 | supports-color "^2.0.0" 28 | 29 | commander@0.6.1: 30 | version "0.6.1" 31 | resolved "https://registry.yarnpkg.com/commander/-/commander-0.6.1.tgz#fa68a14f6a945d54dbbe50d8cdb3320e9e3b1a06" 32 | 33 | commander@2.3.0: 34 | version "2.3.0" 35 | resolved "https://registry.yarnpkg.com/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873" 36 | 37 | cross-env@5.2.0: 38 | version "5.2.0" 39 | resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2" 40 | dependencies: 41 | cross-spawn "^6.0.5" 42 | is-windows "^1.0.0" 43 | 44 | cross-spawn@^6.0.5: 45 | version "6.0.5" 46 | resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" 47 | dependencies: 48 | nice-try "^1.0.4" 49 | path-key "^2.0.1" 50 | semver "^5.5.0" 51 | shebang-command "^1.2.0" 52 | which "^1.2.9" 53 | 54 | debug@2.0.0: 55 | version "2.0.0" 56 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.0.0.tgz#89bd9df6732b51256bc6705342bba02ed12131ef" 57 | dependencies: 58 | ms "0.6.2" 59 | 60 | diff@1.4.0: 61 | version "1.4.0" 62 | resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" 63 | 64 | escape-string-regexp@1.0.2: 65 | version "1.0.2" 66 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz#4dbc2fe674e71949caf3fb2695ce7f2dc1d9a8d1" 67 | 68 | escape-string-regexp@^1.0.2: 69 | version "1.0.5" 70 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 71 | 72 | expect@1.8.0: 73 | version "1.8.0" 74 | resolved "https://registry.yarnpkg.com/expect/-/expect-1.8.0.tgz#018866ccbf40a8eaa1f19325e17f18152c7effdb" 75 | dependencies: 76 | assert "^1.3.0" 77 | 78 | glob@3.2.3: 79 | version "3.2.3" 80 | resolved "https://registry.yarnpkg.com/glob/-/glob-3.2.3.tgz#e313eeb249c7affaa5c475286b0e115b59839467" 81 | dependencies: 82 | graceful-fs "~2.0.0" 83 | inherits "2" 84 | minimatch "~0.2.11" 85 | 86 | graceful-fs@~2.0.0: 87 | version "2.0.3" 88 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-2.0.3.tgz#7cd2cdb228a4a3f36e95efa6cc142de7d1a136d0" 89 | 90 | growl@1.8.1: 91 | version "1.8.1" 92 | resolved "https://registry.yarnpkg.com/growl/-/growl-1.8.1.tgz#4b2dec8d907e93db336624dcec0183502f8c9428" 93 | 94 | has-ansi@^2.0.0: 95 | version "2.0.0" 96 | resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" 97 | dependencies: 98 | ansi-regex "^2.0.0" 99 | 100 | inherits@2: 101 | version "2.0.3" 102 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 103 | 104 | inherits@2.0.1: 105 | version "2.0.1" 106 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" 107 | 108 | is-windows@^1.0.0: 109 | version "1.0.2" 110 | resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" 111 | 112 | isexe@^2.0.0: 113 | version "2.0.0" 114 | resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 115 | 116 | jade@0.26.3: 117 | version "0.26.3" 118 | resolved "https://registry.yarnpkg.com/jade/-/jade-0.26.3.tgz#8f10d7977d8d79f2f6ff862a81b0513ccb25686c" 119 | dependencies: 120 | commander "0.6.1" 121 | mkdirp "0.3.0" 122 | 123 | lru-cache@2: 124 | version "2.7.3" 125 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" 126 | 127 | minimatch@~0.2.11: 128 | version "0.2.14" 129 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a" 130 | dependencies: 131 | lru-cache "2" 132 | sigmund "~1.0.0" 133 | 134 | minimist@0.0.8: 135 | version "0.0.8" 136 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" 137 | 138 | mkdirp@0.3.0: 139 | version "0.3.0" 140 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e" 141 | 142 | mkdirp@0.5.0: 143 | version "0.5.0" 144 | resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.0.tgz#1d73076a6df986cd9344e15e71fcc05a4c9abf12" 145 | dependencies: 146 | minimist "0.0.8" 147 | 148 | mocha@2.2.5: 149 | version "2.2.5" 150 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-2.2.5.tgz#d3b72a4fe49ec9439353f1ac893dbc430d993140" 151 | dependencies: 152 | commander "2.3.0" 153 | debug "2.0.0" 154 | diff "1.4.0" 155 | escape-string-regexp "1.0.2" 156 | glob "3.2.3" 157 | growl "1.8.1" 158 | jade "0.26.3" 159 | mkdirp "0.5.0" 160 | supports-color "~1.2.0" 161 | 162 | ms@0.6.2: 163 | version "0.6.2" 164 | resolved "https://registry.yarnpkg.com/ms/-/ms-0.6.2.tgz#d89c2124c6fdc1353d65a8b77bf1aac4b193708c" 165 | 166 | nice-try@^1.0.4: 167 | version "1.0.5" 168 | resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" 169 | 170 | path-key@^2.0.1: 171 | version "2.0.1" 172 | resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" 173 | 174 | semver@^5.5.0: 175 | version "5.6.0" 176 | resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" 177 | 178 | shebang-command@^1.2.0: 179 | version "1.2.0" 180 | resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" 181 | dependencies: 182 | shebang-regex "^1.0.0" 183 | 184 | shebang-regex@^1.0.0: 185 | version "1.0.0" 186 | resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" 187 | 188 | sigmund@~1.0.0: 189 | version "1.0.1" 190 | resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" 191 | 192 | space-lift@1.0.0: 193 | version "1.0.0" 194 | resolved "https://registry.yarnpkg.com/space-lift/-/space-lift-1.0.0.tgz#2652a02898ccc90b8f3c7858e15ad784c1167ba4" 195 | integrity sha512-vf4a9yeXE+f0hY5JzhOJG7qYl9qV2bC1ITCPIKtTfBt3pnpaCKwTEuyRfi1HQj6RQwCd/PLOCA+ygRVQJGQE4Q== 196 | 197 | strip-ansi@^3.0.0: 198 | version "3.0.1" 199 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" 200 | dependencies: 201 | ansi-regex "^2.0.0" 202 | 203 | supports-color@^2.0.0: 204 | version "2.0.0" 205 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" 206 | 207 | supports-color@~1.2.0: 208 | version "1.2.1" 209 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-1.2.1.tgz#12ee21507086cd98c1058d9ec0f4ac476b7af3b2" 210 | 211 | typescript@4.7.3: 212 | version "4.7.3" 213 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d" 214 | integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA== 215 | 216 | util@0.10.3: 217 | version "0.10.3" 218 | resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" 219 | dependencies: 220 | inherits "2.0.1" 221 | 222 | which@^1.2.9: 223 | version "1.3.1" 224 | resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" 225 | dependencies: 226 | isexe "^2.0.0" 227 | -------------------------------------------------------------------------------- /test/mocha.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for mocha 2.2.5 2 | // Project: http://mochajs.org/ 3 | // Definitions by: Kazi Manzur Rashid , otiai10 , jt000 , Vadim Macagon 4 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 5 | 6 | interface MochaSetupOptions { 7 | //milliseconds to wait before considering a test slow 8 | slow?: number; 9 | 10 | // timeout in milliseconds 11 | timeout?: number; 12 | 13 | // ui name "bdd", "tdd", "exports" etc 14 | ui?: string; 15 | 16 | //array of accepted globals 17 | globals?: any[]; 18 | 19 | // reporter instance (function or string), defaults to `mocha.reporters.Spec` 20 | reporter?: any; 21 | 22 | // bail on the first test failure 23 | bail?: boolean; 24 | 25 | // ignore global leaks 26 | ignoreLeaks?: boolean; 27 | 28 | // grep string or regexp to filter tests with 29 | grep?: any; 30 | } 31 | 32 | declare var mocha: Mocha; 33 | declare var describe: Mocha.IContextDefinition; 34 | declare var xdescribe: Mocha.IContextDefinition; 35 | // alias for `describe` 36 | declare var context: Mocha.IContextDefinition; 37 | // alias for `describe` 38 | declare var suite: Mocha.IContextDefinition; 39 | declare var it: Mocha.ITestDefinition; 40 | declare var xit: Mocha.ITestDefinition; 41 | // alias for `it` 42 | declare var test: Mocha.ITestDefinition; 43 | declare var specify: Mocha.ITestDefinition; 44 | 45 | // Used with the --delay flag; see https://mochajs.org/#hooks 46 | declare function run(): void; 47 | 48 | interface MochaDone { 49 | (error?: any): any; 50 | } 51 | 52 | declare function setup(callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 53 | declare function teardown(callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 54 | declare function suiteSetup(callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 55 | declare function suiteTeardown(callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 56 | declare function before(callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 57 | declare function before(description: string, callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 58 | declare function after(callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 59 | declare function after(description: string, callback: (this: Mocha.IHookCallbackContext, done: MochaDone) => any): void; 60 | declare function beforeEach(callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 61 | declare function beforeEach(description: string, callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 62 | declare function afterEach(callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 63 | declare function afterEach(description: string, callback: (this: Mocha.IBeforeAndAfterContext, done: MochaDone) => any): void; 64 | 65 | declare class Mocha { 66 | currentTest: Mocha.ITestDefinition; 67 | constructor(options?: { 68 | grep?: RegExp; 69 | ui?: string; 70 | reporter?: string; 71 | timeout?: number; 72 | bail?: boolean; 73 | }); 74 | 75 | /** Setup mocha with the given options. */ 76 | setup(options: MochaSetupOptions): Mocha; 77 | bail(value?: boolean): Mocha; 78 | addFile(file: string): Mocha; 79 | /** Sets reporter by name, defaults to "spec". */ 80 | reporter(name: string): Mocha; 81 | /** Sets reporter constructor, defaults to mocha.reporters.Spec. */ 82 | reporter(reporter: (runner: Mocha.IRunner, options: any) => any): Mocha; 83 | ui(value: string): Mocha; 84 | grep(value: string): Mocha; 85 | grep(value: RegExp): Mocha; 86 | invert(): Mocha; 87 | ignoreLeaks(value: boolean): Mocha; 88 | checkLeaks(): Mocha; 89 | /** 90 | * Function to allow assertion libraries to throw errors directly into mocha. 91 | * This is useful when running tests in a browser because window.onerror will 92 | * only receive the 'message' attribute of the Error. 93 | */ 94 | throwError(error: Error): void; 95 | /** Enables growl support. */ 96 | growl(): Mocha; 97 | globals(value: string): Mocha; 98 | globals(values: string[]): Mocha; 99 | useColors(value: boolean): Mocha; 100 | useInlineDiffs(value: boolean): Mocha; 101 | timeout(value: number): Mocha; 102 | slow(value: number): Mocha; 103 | enableTimeouts(value: boolean): Mocha; 104 | asyncOnly(value: boolean): Mocha; 105 | noHighlighting(value: boolean): Mocha; 106 | /** Runs tests and invokes `onComplete()` when finished. */ 107 | run(onComplete?: (failures: number) => void): Mocha.IRunner; 108 | } 109 | 110 | // merge the Mocha class declaration with a module 111 | declare namespace Mocha { 112 | interface ISuiteCallbackContext { 113 | timeout(ms: number): void; 114 | retries(n: number): void; 115 | slow(ms: number): void; 116 | } 117 | 118 | interface IHookCallbackContext { 119 | skip(): void; 120 | timeout(ms: number): void; 121 | } 122 | 123 | 124 | interface ITestCallbackContext { 125 | skip(): void; 126 | timeout(ms: number): void; 127 | retries(n: number): void; 128 | slow(ms: number): void; 129 | } 130 | 131 | /** Partial interface for Mocha's `Runnable` class. */ 132 | interface IRunnable { 133 | title: string; 134 | fn: Function; 135 | async: boolean; 136 | sync: boolean; 137 | timedOut: boolean; 138 | } 139 | 140 | /** Partial interface for Mocha's `Suite` class. */ 141 | interface ISuite { 142 | parent: ISuite; 143 | title: string; 144 | 145 | fullTitle(): string; 146 | } 147 | 148 | /** Partial interface for Mocha's `Test` class. */ 149 | interface ITest extends IRunnable { 150 | parent: ISuite; 151 | pending: boolean; 152 | state: 'failed'|'passed'|undefined; 153 | 154 | fullTitle(): string; 155 | } 156 | 157 | interface IBeforeAndAfterContext extends IHookCallbackContext { 158 | currentTest: ITest; 159 | } 160 | 161 | 162 | /** Partial interface for Mocha's `Runner` class. */ 163 | interface IRunner { } 164 | 165 | interface IContextDefinition { 166 | (description: string, callback: (this: ISuiteCallbackContext) => void): ISuite; 167 | only(description: string, callback: (this: ISuiteCallbackContext) => void): ISuite; 168 | skip(description: string, callback: (this: ISuiteCallbackContext) => void): void; 169 | timeout(ms: number): void; 170 | } 171 | 172 | interface ITestDefinition { 173 | (expectation: string, callback?: (this: ITestCallbackContext, done: MochaDone) => any): ITest; 174 | only(expectation: string, callback?: (this: ITestCallbackContext, done: MochaDone) => any): ITest; 175 | skip(expectation: string, callback?: (this: ITestCallbackContext, done: MochaDone) => any): void; 176 | timeout(ms: number): void; 177 | state: "failed" | "passed"; 178 | } 179 | 180 | export module reporters { 181 | export class Base { 182 | stats: { 183 | suites: number; 184 | tests: number; 185 | passes: number; 186 | pending: number; 187 | failures: number; 188 | }; 189 | 190 | constructor(runner: IRunner); 191 | } 192 | 193 | export class Doc extends Base { } 194 | export class Dot extends Base { } 195 | export class HTML extends Base { } 196 | export class HTMLCov extends Base { } 197 | export class JSON extends Base { } 198 | export class JSONCov extends Base { } 199 | export class JSONStream extends Base { } 200 | export class Landing extends Base { } 201 | export class List extends Base { } 202 | export class Markdown extends Base { } 203 | export class Min extends Base { } 204 | export class Nyan extends Base { } 205 | export class Progress extends Base { 206 | /** 207 | * @param options.open String used to indicate the start of the progress bar. 208 | * @param options.complete String used to indicate a complete test on the progress bar. 209 | * @param options.incomplete String used to indicate an incomplete test on the progress bar. 210 | * @param options.close String used to indicate the end of the progress bar. 211 | */ 212 | constructor(runner: IRunner, options?: { 213 | open?: string; 214 | complete?: string; 215 | incomplete?: string; 216 | close?: string; 217 | }); 218 | } 219 | export class Spec extends Base { } 220 | export class TAP extends Base { } 221 | export class XUnit extends Base { 222 | constructor(runner: IRunner, options?: any); 223 | } 224 | } 225 | } 226 | 227 | declare module "mocha" { 228 | export = Mocha; 229 | } 230 | -------------------------------------------------------------------------------- /test/expect.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module expect { 3 | 4 | export interface IExpectation { 5 | /** 6 | * Asserts the given object is truthy. 7 | */ 8 | toExist(message?: string): this; 9 | 10 | /** 11 | * Asserts the given object is falsy. 12 | */ 13 | toNotExist(message?: string): this; 14 | 15 | /** 16 | * Asserts that object is strictly equal to value using ===. 17 | */ 18 | toBe(value: TExpected, message?: string): this; 19 | 20 | /** 21 | * Asserts that object is strictly not equal to value using !==. 22 | */ 23 | toNotBe(value: TExpected, message?: string): this; 24 | 25 | /** 26 | * Asserts that the given object equals value using is-equal. 27 | */ 28 | toEqual(value: TExpected, message?: string): this; 29 | 30 | /** 31 | * Asserts that the given object is not equal to value using is-equal. 32 | */ 33 | toNotEqual(value: TExpected, message?: string): this; 34 | 35 | 36 | } 37 | 38 | export interface IObjectExpectation extends IExpectation { 39 | /** 40 | * Asserts the given object is an instanceof constructor. 41 | * or 42 | * Asserts the typeof the given object is string. 43 | */ 44 | toBeA(constructor: Function | string, message?: string): this; 45 | 46 | /** 47 | * Asserts the given object is not an instanceof constructor. 48 | * or 49 | * Asserts the typeof the given object not the string. 50 | */ 51 | toNotBeA(constructor: Function | string, message?: string): this; 52 | 53 | /** 54 | * Asserts the given object is an instanceof constructor. 55 | * or 56 | * Asserts the typeof the given object is string. 57 | */ 58 | toBeAn(constructor: Function | string, message?: string): this; 59 | 60 | 61 | /** 62 | * Asserts the given object is an instanceof constructor. 63 | * or 64 | * Asserts the typeof the given object is string. 65 | */ 66 | toNotBeAn(constructor: Function | string, message?: string): this; 67 | } 68 | 69 | export interface IFunctionExpectation extends IExpectation { 70 | /** 71 | * Asserts that the given block throws an error. The error argument may be a constructor (to test using instanceof), or a string/RegExp to test against error.message. 72 | */ 73 | toThrow(error?: string | RegExp | Function, message?: string): this; 74 | 75 | /** 76 | * Asserts that the given block throws an error when called with args. The error argument may be a constructor (to test using instanceof), or a string/RegExp to test against error.message. 77 | */ 78 | withArgs(...args: any[]): this; 79 | 80 | /** 81 | * Asserts that the given block throws an error when called in the given context. The error argument may be a constructor (to test using instanceof), or a string/RegExp to test against error.message. 82 | */ 83 | withContext(context: any): this; 84 | 85 | /** 86 | * Asserts that the given block does not throw. 87 | */ 88 | toNotThrow(message?: string): this; 89 | } 90 | 91 | export interface IStringExpectation extends IExpectation { 92 | /** 93 | * Asserts the given string matches pattern, which must be a RegExp. 94 | */ 95 | toMatch(pattern: RegExp, message?: string): this; 96 | 97 | /** 98 | * Asserts the given string contains value. 99 | */ 100 | toInclude(value: string, message?: string): this; 101 | 102 | /** 103 | * Asserts the given string contains value. 104 | */ 105 | toContain(value: string, message?: string): this; 106 | 107 | /** 108 | * Asserts the given string does not contain value. 109 | */ 110 | toExclude(value: string, message?: string): this; 111 | 112 | /** 113 | * Asserts the given string does not contain value. 114 | */ 115 | toNotContain(value: string, message?: string): this; 116 | } 117 | 118 | export interface INumberExpectation extends IExpectation { 119 | /** 120 | * Asserts the given number is less than value. 121 | */ 122 | toBeLessThan(value: number, message?: string): this; 123 | 124 | /** 125 | * Asserts the given number is greater than value. 126 | */ 127 | toBeGreaterThan(value: number, message?: string): this; 128 | 129 | } 130 | 131 | export interface IArrayExpectation extends IExpectation { 132 | /** 133 | * Asserts the given array contains value. The comparator function, if given, should compare two objects and either return false or throw if they are not equal. It defaults to assert.deepEqual. 134 | * */ 135 | toInclude(value: TElement, comparator?: IComparator, message?: string): this; 136 | 137 | /** 138 | * Asserts the given array contains value. The comparator function, if given, should compare two objects and either return false or throw if they are not equal. It defaults to assert.deepEqual. 139 | * */ 140 | toContain(value: TElement, comparator?: IComparator, message?: string): this; 141 | 142 | /** 143 | * Asserts the given array contains value. The comparator function, if given, should compare two objects and either return false or throw if they are not equal. It defaults to assert.deepEqual. 144 | * */ 145 | toExclude(value: TElement, comparator?: IComparator, message?: string): this; 146 | 147 | /** 148 | * Asserts the given array contains value. The comparator function, if given, should compare two objects and either return false or throw if they are not equal. It defaults to assert.deepEqual. 149 | * */ 150 | toNotContain(value: TElement, comparator?: IComparator, message?: string): this; 151 | } 152 | 153 | export interface IComparator { 154 | (comparer: TElement, comparee: TElement): boolean; 155 | } 156 | 157 | export interface ISpyExpectation extends IExpectation { 158 | /** 159 | * Has the spy been called? 160 | */ 161 | toHaveBeenCalled(message?: string): this; 162 | 163 | toNotHaveBeenCalled(message?: string): this; 164 | 165 | /** 166 | * Has the spy been called with these arguments. 167 | */ 168 | toHaveBeenCalledWith(...args: any[]): this; 169 | } 170 | 171 | export interface ISpy { 172 | calls: ICall[]; 173 | 174 | /** 175 | * Restores a spy originally created with expect.spyOn() 176 | */ 177 | restore: () => void; 178 | 179 | /** 180 | * Makes the spy invoke a function fn when called. 181 | */ 182 | andCall(fn: Function): this; 183 | 184 | /** 185 | * Makes the spy call the original function it's spying on. 186 | */ 187 | andCallThrough(): this; 188 | 189 | /** 190 | * Makes the spy return a value; 191 | */ 192 | andReturn(object: any): this; 193 | 194 | /** 195 | * Makes the spy throw an error when called. 196 | */ 197 | andThrow(error: Error): this; 198 | } 199 | 200 | export interface ICall { 201 | context: any; 202 | 203 | arguments: any[]; 204 | } 205 | 206 | /** 207 | * This is my best attempt at emulating the typing required for expect extend. 208 | * Unfortunately you'll still have to extend with IExpect interface or one of the 209 | * Expectation interfaces to 210 | */ 211 | export interface IExtension { 212 | [assertionMethod: string]: Function; 213 | } 214 | 215 | export interface IExpect { 216 | (compare: number): INumberExpectation; 217 | (compare: string): IStringExpectation; 218 | (spy: ISpy): ISpyExpectation; 219 | (block: TExpected): IFunctionExpectation; 220 | (object: TExpected): IObjectExpectation; 221 | (compare: TExpected): IExpectation; 222 | 223 | /** 224 | * Creates a spy function. 225 | */ 226 | //Probably could do more by typings the ISpy object with the generic type TFunc 227 | createSpy(): ISpy & TFunc; 228 | 229 | /** 230 | * Replaces the method in target with a spy. 231 | */ 232 | spyOn(target: any, method: string): ISpy; 233 | 234 | /** 235 | * Restores all spies created with expect.spyOn(). This is the same as calling spy.restore() on all spies created. 236 | */ 237 | restoreSpies():void; 238 | 239 | /** 240 | * Determins if the object is a spy. 241 | */ 242 | isSpy(object: any): boolean; 243 | 244 | /** 245 | * Does an assertion 246 | */ 247 | assert(passed: boolean, message: string, actual: any):void; 248 | 249 | /** 250 | * You can add your own assertions using expect.extend and expect.assert 251 | * A note here is that you'll have to extend the IExpect interface or one of the IExpectation interfaces have a look at 252 | * typings-expect-element lib for an example 253 | * @example 254 | * expect.extend({ 255 | * toBeAColor() { 256 | * expect.assert( 257 | * this.actual.match(/^#[a-fA-F0-9]{6}$/), 258 | * 'expected %s to be an HTML color', 259 | * this.actual 260 | * ) 261 | * } 262 | * }) 263 | * expect('#ff00ff').toBeAColor() 264 | */ 265 | extend(extension: IExtension | Object) : any; 266 | } 267 | } 268 | 269 | declare module "expect"{ 270 | let expect: expect.IExpect; 271 | export = expect; 272 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # idonttrustlikethat 2 | 3 | This module helps validating incoming JSON, Form values, url params, localStorage values, server Environment objects, etc in a concise and type safe manner. 4 | The focus of the lib is on small size and an easy API to add new validations. 5 | 6 | Note: This module uses very precise Typescript types. Thus, it is mandatory to at least have the following `tsconfig` / `tsc`'s compiler options flag: `strict`: `true`. 7 | 8 | - [How to](#how-to) 9 | - [Create a new validation](#create-a-new-validation) 10 | - [Deriving the typescript type from the validator type](#deriving-the-typescript-type-from-the-validator-type) 11 | - [Customize error messages](#customize-error-messages) 12 | - [Perform async checks](#perform-async-checks) 13 | 14 | - [Exports](#exports) 15 | - [API](#api) 16 | - [validate](#validate) 17 | - [primitives](#primitives) 18 | - [tagged string/number](#tagged-stringnumber) 19 | - [literal](#literal) 20 | - [object](#object) 21 | - [array](#array) 22 | - [tuple](#tuple) 23 | - [union](#union) 24 | - [intersection](#intersection) 25 | - [optional, nullable](#optional-nullable) 26 | - [default](#default) 27 | - [withError](#witherror) 28 | - [dictionary](#dictionary) 29 | - [map, filter](#map-filter) 30 | - [and](#and) 31 | - [then](#then) 32 | - [recursion](#recursion) 33 | - [minSize](#minSize) 34 | - [isoDate](#isoDate) 35 | - [url](#url) 36 | - [booleanFromString](#booleanFromString) 37 | - [numberFromString](#numberFromString) 38 | - [intFromString](#intFromString) 39 | 40 | ## How to 41 | 42 | ### Create a new validation 43 | 44 | This library exposes a validator for all [primitive](#primitives) and object types so you should usually start from one of these then compose it with extra validations. 45 | 46 | Here's how `isoDate` is defined internally: 47 | 48 | ```ts 49 | import { string, Err, Ok } from 'idonttrustlikethat' 50 | 51 | const isoDate = string.and(str => { 52 | const date = new Date(str) 53 | return isNaN(date.getTime()) 54 | ? Err(`Expected ISO date, got: ${pretty(str)}`) 55 | : Ok(date) 56 | }) 57 | 58 | isoDate.validate('2011-10-05T14:48:00.000Z').ok // true 59 | ``` 60 | 61 | This creates a new Validator that reads a string then tries to create a Date out of it. 62 | 63 | You can also create an optional validation step that wouldn't make sense on its own: 64 | 65 | ```ts 66 | import { string, Err, Ok, array, string } from 'idonttrustlikethat' 67 | 68 | // This is essentially a basic filter() but with a nicer, custom error message. 69 | const minSize = (size: number) => (array: T[]) => 70 | array.length >= size 71 | ? Ok(array) 72 | : Err(`Expected an array with at least ${size} items`) 73 | 74 | const bigArray = array(string).and(minSize(100)) 75 | bigArray.validate(['1', '2']).ok // false 76 | ``` 77 | 78 | Note: the `minSize` validator does exactly that, but for more input types. 79 | 80 | If you need to start from any value, you can use the `unknown` validator that always succeeds. 81 | 82 | ### Deriving the typescript type from the validator type 83 | 84 | This can be used with any combination of validators except ones using `recursion`. 85 | 86 | You can get the exact type of a validator's value easily: 87 | 88 | ```ts 89 | import { object, string, number } from 'idonttrustlikethat' 90 | 91 | const person = object({ 92 | name: string, 93 | age: number, 94 | }) 95 | 96 | type Person = typeof person.T 97 | 98 | const person: Person = { 99 | name: 'Jon', 100 | age: 80 101 | } 102 | ``` 103 | 104 | ### Customize error messages 105 | 106 | If you say, use this library to validate a Form data, it's best to assign your error messages directly in the validator so that the proper error messages get accumulated, ready for you to display them. 107 | 108 | ```ts 109 | import { object, string } from 'idonttrustlikethat' 110 | 111 | const mandatoryFieldError = 'This field is mandatory' 112 | const mandatoryString = string.withError(_ => mandatoryFieldError) 113 | 114 | const formValidator = object({ 115 | name: mandatoryString, 116 | }) 117 | 118 | // {ok: false, errors: [{path: 'name', message: 'This field is mandatory'}]} 119 | const result = formValidator.validate({}) 120 | ``` 121 | 122 | ### Perform async checks 123 | 124 | You don't! Some "similar" libraries offer this functionality but it's a pretty bad idea. It accumulates concerns inside your validation layer (you now have to pass DB connections, API tokens, etc to what should be dumb validators) and polutes the API signatures (once you go async for a tiny bit, everything now has to be async) 125 | 126 | For instance, instead of trying to make a call to the DB to check some unicity constraint inside your validator, instead prepare the call's result before hand then pass that to a function that creates a new validator using that result, for instance: 127 | 128 | ```ts 129 | import {string, object} from 'idonttrustlikethat' 130 | 131 | function makeUserValidator(params: {isEmailKnown: boolean}) { 132 | const {isEmailKnown} = params 133 | 134 | return object({ 135 | name: string, 136 | email: string 137 | .withError(_ => 'The email is mandatory') 138 | .filter(_ => !isEmailKnown) 139 | .withError(_ => 'This email is already in use') 140 | }) 141 | } 142 | 143 | const isEmailKnown = await db.user.checkIfEmailIsKnown(...) 144 | 145 | const validatedUser = makeUserValidator({isEmailKnown}).validate(body) 146 | ``` 147 | 148 | ## Exports 149 | 150 | Here are all the values this library exposes: 151 | 152 | ```ts 153 | import { 154 | Err, 155 | Ok, 156 | array, 157 | dictionary, 158 | errorDebugString, 159 | intersection, 160 | union, 161 | is, 162 | literal, 163 | unknown, 164 | null as vnull, 165 | number, 166 | object, 167 | string, 168 | boolean, 169 | tuple, 170 | undefined, 171 | } from 'idonttrustlikethat' 172 | ``` 173 | 174 | ```ts 175 | import { 176 | isoDate, 177 | recursion, 178 | snakeCaseTransformation, 179 | relativeUrl, 180 | absoluteUrl, 181 | url, 182 | booleanFromString, 183 | numberFromString, 184 | intFromString, 185 | minSize, 186 | nonEmpty 187 | } from 'idonttrustlikethat' 188 | ``` 189 | 190 | And all the types: 191 | 192 | ```ts 193 | import { 194 | Result, 195 | Err, 196 | Ok, 197 | Validation, 198 | Validator, 199 | Configuration, 200 | } from 'idonttrustlikethat' 201 | ``` 202 | 203 | ## API 204 | 205 | ### validate 206 | 207 | Every validator has a `validate` function which returns a Result (either a `{ok: true, value}` or a `{ok: false, errors}`) 208 | Errors are accumulated. 209 | 210 | ```ts 211 | import { object, errorDebugString } from 'idonttrustlikethat' 212 | 213 | const myValidator = object({}) 214 | const result = myValidator.validate(myJson) 215 | 216 | if (result.ok) { 217 | console.log(result.value) 218 | } else { 219 | console.error(errorDebugString(result.errors)) 220 | } 221 | ``` 222 | 223 | In case of errors, `errors` contains an Array of `{ message: string, path: string }` where `message` is a debug error message for developers and `path` is the path where the error occured (e.g `people.0.name`) 224 | 225 | `errorDebugString` will give you a complete debug string of all errors, e.g. 226 | 227 | ``` 228 | At [root / c] Error validating the key. "c" is not a key of { 229 | "a": true, 230 | "b": true 231 | } 232 | At [root / c] Error validating the value. Type error: expected number but got string 233 | ``` 234 | 235 | ### primitives 236 | 237 | ```ts 238 | import * as v from 'idonttrustlikethat' 239 | 240 | v.unknown 241 | v.string 242 | v.number 243 | v.boolean 244 | v.null 245 | v.undefined 246 | 247 | v.string.validate(12).ok // false 248 | ``` 249 | 250 | ### tagged string/number 251 | 252 | Sometimes, a `string` or a `number` is not just any string or number but carries extra meaning, e.g: `email`, `uuid`, `UserId`, `KiloGram`, etc. 253 | Tagging such a primitive as soon as it's being validated can help make the downstream code more robust and better documented. 254 | 255 | ```ts 256 | import { string, object } from 'idonttrustlikethat' 257 | 258 | type UserId = string & { __tag: 'UserId' } // Note: You can use any naming convention for the tag. 259 | 260 | const userId = string.tagged() 261 | 262 | const user = object({ 263 | id: userId 264 | }) 265 | 266 | ``` 267 | 268 | If you don't use tagged types, it can lead to situations like: 269 | 270 | ```ts 271 | const user = object({ 272 | id: string, 273 | companyId: string 274 | }) 275 | 276 | const user = { 277 | id: '12345678', 278 | companyId: '7cd3821a-553f-4d26-84f9-88776005612b' 279 | } 280 | 281 | function fetchCompanyDetails(companyId: string) {} 282 | 283 | // Nothing prevents you from passing the wrong ID "type" 284 | fetchCompanyDetails(user.id) 285 | ``` 286 | 287 | Using tagged types fixes all these problems while also retaining that type's usefulness as a basic `string`/`number`. 288 | 289 | ### literal 290 | 291 | ```ts 292 | import { literal } from 'idonttrustlikethat' 293 | 294 | // The only value that can ever pass this validation is the 'X' string literal 295 | const validator = literal('X') 296 | ``` 297 | 298 | ### object 299 | 300 | ```ts 301 | import { string, object, union } from 'idonttrustlikethat' 302 | 303 | const person = object({ 304 | id: string, 305 | prefs: object({ 306 | csvSeparator: union(',', ';', '|').optional(), 307 | }), 308 | }) 309 | 310 | validator.validate({ 311 | id: '123', 312 | prefs: {}, 313 | }).ok // true 314 | ``` 315 | 316 | Note that if you validate an input object with extra properties compared to what the validator know, these will be dropped from the output. 317 | This helps keeping a clean object and let us avoid dangerous situations such as: 318 | 319 | ```ts 320 | import { string, object } from 'idonttrustlikethat' 321 | 322 | const configValidator = object({ 323 | clusterId: string, 324 | version: string 325 | }) 326 | 327 | const config = { 328 | clusterId: '123', 329 | version: 'v191', 330 | extraStuffFromTheServer: 100, 331 | _metadata: true 332 | } 333 | 334 | // Let's imagine what could happen if this kept all non declared properties in the output. 335 | const result = configValidator.validate(config) 336 | 337 | if (result.ok) { 338 | // As far as typescript is concerned, all values are string in the validated object, which let us manipulate it as such, perhaps to pass it some generic utility: 339 | const configDictionary: Record = result.value 340 | 341 | // But it's a lie, some properties are still found in the object that aren't strings. 342 | // This will throw an exception when the entire point of validating is to avoid that. 343 | Object.values(configDictionary).forEach(str => str.padStart(2)) 344 | } 345 | ``` 346 | 347 | ### array 348 | 349 | ```ts 350 | import { array, string } from 'idonttrustlikethat' 351 | 352 | const validator = array(string) 353 | 354 | validator.validate(['a', 'b']).ok // true 355 | ``` 356 | 357 | ### tuple 358 | 359 | ```ts 360 | import { tuple, string, number } from 'idonttrustlikethat' 361 | 362 | const validator = tuple(string, number) 363 | 364 | validator.validate(['a', 1]).ok // true 365 | ``` 366 | 367 | ### union 368 | 369 | ```ts 370 | import { union, string, number } from 'idonttrustlikethat' 371 | 372 | const stringOrNumber = union(string, number) 373 | 374 | validator.validate(10).ok // true 375 | ``` 376 | 377 | Unions of literal values do not have to use `literal()` but can be passed the values directly: 378 | 379 | ```ts 380 | import {union} from 'idonttrustlikethat' 381 | 382 | const bag = union(null, 'hello', true, 33) 383 | ``` 384 | 385 | ### discriminatedUnion 386 | 387 | Although you could also use `union` for your discriminated unions, `discriminatedUnion` is faster and has better error messages for that special case. It will also catch common typos at the type level. 388 | Note that `discriminatedUnion` only works with `object` and `intersection` (of objects) validators. Also, the discriminating property must be either a `literal` or `union` of primitives. 389 | 390 | ```ts 391 | import {discriminatedUnion, literal, string} from 'idonttrustlikethat' 392 | 393 | const userSending = object({ 394 | type: literal('sending') 395 | }) 396 | 397 | const userEditing = object({ 398 | type: literal('editing'), 399 | currentText: string 400 | }) 401 | 402 | const userChatAction = discriminatedUnion('type', userSending, userEditing) 403 | ``` 404 | 405 | ### intersection 406 | 407 | ```ts 408 | import { intersection, object, string, number } from 'idonttrustlikethat' 409 | 410 | const object1 = object({ id: string }) 411 | const object2 = object({ age: number }) 412 | const validator = intersection(object1, object2) 413 | 414 | validator.validate({ id: '123', age: 80 }).ok // true 415 | ``` 416 | 417 | ### optional, nullable 418 | 419 | `optional()` transforms a validator to allow `undefined` values. 420 | 421 | `nullable()` transforms a validator to allow `undefined` and `null` values, akin to the std lib `NonNullable` type. 422 | 423 | If you must validate a `T | null` that shouldn't possibly be `undefined`, you can use `union()` 424 | 425 | ```ts 426 | import { string } from 'idonttrustlikethat' 427 | 428 | const validator = string.nullable() 429 | 430 | const result = validator.validate(undefined) 431 | 432 | result.ok && result.value // undefined 433 | ``` 434 | 435 | 436 | ### default 437 | 438 | Returns a default value if the validated value was either null or undefined. 439 | 440 | ```ts 441 | import { string } from 'idonttrustlikethat' 442 | 443 | const validator = string.default(':(') 444 | 445 | const result = validator.validate(undefined) 446 | 447 | result.ok && result.value // :( 448 | ``` 449 | 450 | ### withError 451 | 452 | Sets a custom error message onto the validator. 453 | The validator have decent error messages by default for developers but you will sometimes want to customize these. 454 | Note that the first `withError` encountering an error wins but a single `withError` will apply to **any** error encountered in the chain. 455 | 456 | ```ts 457 | import {object, string} from 'idonttrustlikethat' 458 | 459 | const validator = object({ 460 | id: string 461 | .withError(i => `Expected a string, got ${i}`) // This will activate if the input is not a string or is missing. 462 | .and(nonEmpty()) 463 | .withError(_ => `The id cannot be the empty string`) // This will activate only if the id is a string but is empty. 464 | }) 465 | ``` 466 | 467 | 468 | 469 | ### dictionary 470 | 471 | A dictionary is an object where all keys and all values share a common type. 472 | 473 | ```ts 474 | import { dictionary, string, number } from 'idonttrustlikethat' 475 | 476 | const validator = dictionary(string, number) 477 | 478 | validator.validate({ 479 | a: 1, 480 | b: 2, 481 | }).ok // true 482 | ``` 483 | 484 | If you need a partial dictionary, simply type your values as optional: 485 | 486 | ```ts 487 | import { dictionary, string, number, union } from 'idonttrustlikethat' 488 | 489 | const validator = dictionary(union('a', 'b', 'c'), number.optional()) 490 | 491 | validator.validate({ 492 | b: 1 493 | }).ok // true 494 | ``` 495 | 496 | ### map, filter 497 | 498 | ```ts 499 | import { string } from 'idonttrustlikethat' 500 | 501 | const validator = string.filter(str => str.length > 3).map(str => `${str}...`) 502 | 503 | const result = validator.validate('1234') 504 | result.ok // true 505 | result.value // 1234... 506 | ``` 507 | 508 | ### and 509 | 510 | Unlike `map` which deals with a validated value and returns a new value, `and` can return either a validated value or an error. 511 | 512 | ```ts 513 | import { string, Ok, Err } from 'idonttrustlikethat' 514 | 515 | const validator = string.and(str => 516 | str.length > 3 ? Ok(str) : Err(`No, that just won't do`) 517 | ) 518 | ``` 519 | 520 | ### then 521 | 522 | `then` allows the chaining of Validators. It can be used instead of `and` if you already have the Validators ready to be reused. 523 | 524 | ```ts 525 | // Validate that a string is a valid number (e.g, query string param) 526 | const stringToInt = v.string.and(str => { 527 | const result = Number.parseInt(str, 10) 528 | if (Number.isFinite(result)) return Ok(result) 529 | return Err('Expected an integer-like string, got: ' + str) 530 | }) 531 | 532 | // unix time -> Date 533 | const timestamp = v.number.and(n => { 534 | const date = new Date(n) 535 | if (isNaN(date.getTime())) return Err('Not a valid date') 536 | return Ok(date) 537 | }) 538 | 539 | const timeStampFromQueryString = stringToInt.then(timestamp) 540 | 541 | timeStampFromQueryString.validate('1604341882') // {ok: true, value: Date(...)} 542 | ``` 543 | 544 | ### recursion 545 | 546 | ```ts 547 | import { recursion, string, array, object } from 'idonttrustlikethat' 548 | 549 | type Category = { name: string; categories: Category[] } 550 | 551 | const category = recursion(self => 552 | object({ 553 | name: string, 554 | categories: array(self), 555 | }) 556 | ) 557 | ``` 558 | 559 | ### minSize 560 | 561 | Ensures an Array, Object, string, Map or Set has a minimum size. You can also use `nonEmpty`. 562 | 563 | ```ts 564 | import {dictionary, string} from 'idonttrustlikethat' 565 | import {minSize} from 'idonttrustlikethat' 566 | 567 | const dictionaryWithAtLeast10Items = dictionary(string, string).and(minSize(10)) 568 | ``` 569 | 570 | ### isoDate 571 | 572 | ```ts 573 | import { isoDate } from 'idonttrustlikethat' 574 | 575 | isoDate.validate('2011-10-05T14:48:00.000Z').ok // true 576 | ``` 577 | 578 | ### url 579 | 580 | Validates that a string is a valid URL, and returns that string. 581 | 582 | ```ts 583 | import { url, absoluteUrl, relativeUrl } from 'idonttrustlikethat' 584 | 585 | absoluteUrl.validate('https://ebay.com').ok // true 586 | ``` 587 | 588 | ### booleanFromString 589 | 590 | Validates that a string encodes a boolean and returns the boolean. 591 | 592 | ```ts 593 | import { booleanFromString } from 'idonttrustlikethat' 594 | 595 | booleanFromString.validate('true').ok // true 596 | ``` 597 | 598 | ### numberFromString 599 | 600 | Validates that a string encodes a number (float or integer) and returns the number. 601 | 602 | ```ts 603 | import { numberFromString } from 'idonttrustlikethat' 604 | 605 | numberFromString.validate('123.4').ok // true 606 | ``` 607 | 608 | ### intFromString 609 | 610 | Validates that a string encodes an integer and returns the number. 611 | 612 | ```ts 613 | import { intFromString } from 'idonttrustlikethat' 614 | 615 | intFromString.validate('123').ok // true 616 | ``` 617 | 618 | ## Configuration 619 | 620 | A Configuration object can be passed to modify the default behavior of the validators: 621 | 622 | **Configuration.transformObjectKeys** 623 | 624 | Transforms every keys of every objects before validating. 625 | 626 | ```ts 627 | import {snakeCaseTransformation} from 'idonttrustlikethat' 628 | 629 | const burger = v.object({ 630 | options: v.object({ 631 | doubleBacon: v.boolean, 632 | }), 633 | }) 634 | 635 | const ok = burger.validate( 636 | { 637 | options: { 638 | double_bacon: true, 639 | }, 640 | }, 641 | { transformObjectKeys: snakeCaseTransformation } 642 | ) 643 | ``` 644 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | //-------------------------------------- 2 | // Setup 3 | //-------------------------------------- 4 | 5 | export class Validator { 6 | constructor( 7 | private validationFunction: ( 8 | value: unknown, 9 | context: Context, 10 | path: Path 11 | ) => Validation 12 | ) { 13 | this.meta = {} 14 | } 15 | 16 | // Phantom type 17 | T: T = undefined as any as T 18 | 19 | // Opaque metadata 20 | meta: any = undefined 21 | 22 | /** 23 | * Validate any value. 24 | */ 25 | validate(value: unknown, context?: Context, path?: Path): Validation { 26 | return this.validationFunction( 27 | value, 28 | context || { ...defaultContext }, 29 | path || rootPath 30 | ) 31 | } 32 | 33 | /** 34 | * Maps the validated value or do nothing if this validator returned an error. 35 | */ 36 | map(fn: (value: T) => B): Validator { 37 | return withSameMeta( 38 | this, 39 | this.and(v => Ok(fn(v))) 40 | ) 41 | } 42 | 43 | /** 44 | * Filter this validated value or do nothing if this validator returned an error. 45 | */ 46 | filter(fn: (value: T) => boolean): Validator { 47 | return withSameMeta( 48 | this, 49 | this.and(v => (fn(v) ? Ok(v) : Err(`filter error: ${prettifyJson(v)}"`))) 50 | ) 51 | } 52 | 53 | /** 54 | * Chains this validator with another one, in series. 55 | * The resulting value of the first validator will be the input of the second. 56 | * 57 | * ```ts 58 | * declare const stringToInt: Validator 59 | * declare const intToDate: Validator 60 | * const stringToDate = stringToInt.then(intToDate) 61 | * ``` 62 | */ 63 | then(validator: Validator): Validator { 64 | const self = this 65 | 66 | return withSameMeta( 67 | this, 68 | new Validator((v, context, p) => { 69 | const validated = self.validate(v, context, p) 70 | if (!validated.ok) return validated 71 | return validator.validate(validated.value, context, p) 72 | }) 73 | ) 74 | } 75 | 76 | /** 77 | * Further refines this validator's output. 78 | */ 79 | and(fn: (value: T) => Result): Validator { 80 | return withSameMeta( 81 | this, 82 | transform(this, r => (r.ok ? fn(r.value) : r)) 83 | ) 84 | } 85 | 86 | /** 87 | * Swaps the default error string with a custom one. 88 | */ 89 | withError(errorFunction: (value: unknown) => string) { 90 | return withSameMeta( 91 | this, 92 | transform(this, (result, value, _p, context) => { 93 | if (result.ok || '_hadCustomError' in context) return result 94 | ;(context as any)._hadCustomError = true 95 | return Err(errorFunction(value)) 96 | }) 97 | ) 98 | } 99 | 100 | /** 101 | * Maps the produced errors to new ones. This is the more advanced counterpart of withError. 102 | */ 103 | mapErrors(errorFunction: (errors: ValidationError[]) => ValidationError[]) { 104 | return withSameMeta( 105 | this, 106 | transform(this, (result, _value, _p, context) => { 107 | if (result.ok || '_hadCustomError' in context) return result 108 | ;(context as any)._hadCustomError = true 109 | return Err(errorFunction(result.errors)) 110 | }) 111 | ) 112 | } 113 | 114 | /** 115 | * Refines this string to make it more strongly typed. 116 | */ 117 | tagged(this: Validator): Validator 118 | 119 | /** 120 | * Refines this number to make it more strongly typed. 121 | */ 122 | tagged(this: Validator): Validator 123 | tagged(): Validator { 124 | return this as {} as Validator 125 | } 126 | 127 | /** 128 | * Returns a new validator where undefined and null are also valid inputs. 129 | */ 130 | nullable(): UnionValidator<[T, null, undefined]> { 131 | const n = withSameMeta(this, union(this, nullValidator, undefinedValidator)) 132 | 133 | n.meta.nullable = true 134 | 135 | return n 136 | } 137 | 138 | /** 139 | * Returns a new validator where undefined is also a valid input. 140 | */ 141 | optional(): UnionValidator<[T, undefined]> { 142 | const u = withSameMeta(this, union(this, undefinedValidator)) 143 | 144 | u.meta.optional = true 145 | 146 | return u 147 | } 148 | 149 | /** 150 | * Fallbacks to a default value if the previous validator returned null or undefined. 151 | */ 152 | default(defaultValue: D): Validator | D> 153 | default(defaultValue: D): Validator { 154 | const opt = withSameMeta( 155 | this, 156 | this.nullable().map(v => 157 | v === null || v === undefined ? defaultValue : v 158 | ) 159 | ) 160 | 161 | opt.meta.default = defaultValue 162 | 163 | return opt 164 | } 165 | } 166 | 167 | export type Ok = { ok: true; value: VALUE } 168 | export type Err = { ok: false; errors: ERROR } 169 | export type Result = Err | Ok 170 | 171 | export function Ok(value: VALUE) { 172 | return { ok: true, value } as const 173 | } 174 | 175 | export function Err(errors: ERROR) { 176 | return { ok: false, errors } as const 177 | } 178 | 179 | type AnyValidator = Validator 180 | 181 | export interface ValidationError { 182 | readonly message: string 183 | readonly path: Path 184 | } 185 | 186 | type Value = unknown 187 | type Path = string & { __tag: 'path' } 188 | 189 | type Context = { 190 | transformObjectKeys?: (key: string) => string 191 | } 192 | 193 | export type Validation = Result 194 | 195 | function failure(path: Path, message: string): Validation { 196 | return Err([{ path, message }]) 197 | } 198 | 199 | function valueType(value: any) { 200 | if (Array.isArray(value)) return 'array' 201 | if (value === null) return 'null' 202 | return typeof value 203 | } 204 | 205 | function typeFailureMessage(expectedType: string, value: any) { 206 | return `Expected ${expectedType}, got ${valueType(value)}` 207 | } 208 | 209 | function typeFailure(value: any, path: Path, expectedType: string) { 210 | const message = typeFailureMessage(expectedType, value) 211 | return Err([{ path, message }]) 212 | } 213 | 214 | export function getPath(name: string, parent?: string): Path { 215 | return (parent ? `${parent}.${name}` : name) as Path 216 | } 217 | 218 | const rootPath = getPath('') 219 | 220 | const defaultContext: Context = {} 221 | 222 | export function is(value: Value, validator: Validator): value is T { 223 | return validator.validate(value).ok 224 | } 225 | 226 | function withSameMeta( 227 | source: A, 228 | target: B 229 | ): B { 230 | target.meta = { ...source.meta } 231 | 232 | return target 233 | } 234 | 235 | //-------------------------------------- 236 | // Primitives 237 | //-------------------------------------- 238 | 239 | function primitive( 240 | name: string, 241 | validationFunction: ( 242 | value: unknown, 243 | context: Context, 244 | path: Path 245 | ) => Validation 246 | ): Validator { 247 | const v = new Validator(validationFunction) 248 | v.meta.tag = name 249 | return v 250 | } 251 | 252 | const nullValidator: Validator = primitive('null', (v, _c, p) => 253 | v === null ? Ok(v) : typeFailure(v, p, 'null') 254 | ) 255 | 256 | const undefinedValidator: Validator = primitive( 257 | 'undefined', 258 | (v, _c, p) => { 259 | return v === void 0 ? Ok(v) : typeFailure(v, p, 'undefined') 260 | } 261 | ) 262 | 263 | export const string: Validator = primitive( 264 | 'string', 265 | (v, _c, p) => { 266 | return typeof v === 'string' ? Ok(v) : typeFailure(v, p, 'string') 267 | } 268 | ) 269 | 270 | export const number: Validator = primitive( 271 | 'number', 272 | (v, _c, p) => { 273 | return typeof v === 'number' ? Ok(v) : typeFailure(v, p, 'number') 274 | } 275 | ) 276 | 277 | export const boolean: Validator = primitive( 278 | 'boolean', 279 | (v, _c, p) => { 280 | return typeof v === 'boolean' ? Ok(v) : typeFailure(v, p, 'boolean') 281 | } 282 | ) 283 | 284 | export const unknown: Validator = primitive('unknown', Ok) 285 | 286 | //-------------------------------------- 287 | // array 288 | //-------------------------------------- 289 | 290 | export function array(validator: Validator): Validator { 291 | const arrayValidator = new Validator((v, context, p) => { 292 | if (!Array.isArray(v)) return typeFailure(v, p, 'array') 293 | 294 | const validatedArray: A[] = [] 295 | const errors: ValidationError[] = [] 296 | 297 | for (let i = 0; i < v.length; i++) { 298 | const item = v[i] 299 | const validation = validator.validate( 300 | item, 301 | { ...context }, 302 | getPath(String(i), p) 303 | ) 304 | 305 | if (validation.ok) { 306 | validatedArray.push(validation.value) 307 | } else { 308 | pushAll(errors, validation.errors) 309 | } 310 | } 311 | 312 | return errors.length ? Err(errors) : Ok(validatedArray) 313 | }) 314 | 315 | arrayValidator.meta.tag = 'array' 316 | arrayValidator.meta.value = validator 317 | 318 | return arrayValidator 319 | } 320 | 321 | export function readonlyArray(validator: Validator): Validator { 322 | return array(validator).map(arr => arr as ReadonlyArray) 323 | } 324 | 325 | //-------------------------------------- 326 | // set 327 | //-------------------------------------- 328 | 329 | export function arrayAsSet(validator: Validator, allowDuplicate: boolean = false): Validator> { 330 | const setValidator = new Validator((v, context, p) => { 331 | if (!Array.isArray(v)) return typeFailure(v, p, 'array') 332 | 333 | const validatedSet: Set = new Set([]) 334 | const errors: ValidationError[] = [] 335 | 336 | for (let i = 0; i < v.length; i++) { 337 | const item = v[i] 338 | const path = getPath(String(i), p) 339 | 340 | const validation = validator.validate( 341 | item, 342 | { ...context }, 343 | path 344 | ) 345 | 346 | if (validation.ok) { 347 | if (!allowDuplicate && validatedSet.has(validation.value)) { 348 | errors.push({ 349 | message: `Duplicate value in set: ${validation.value}`, 350 | path 351 | }) 352 | } else { 353 | validatedSet.add(validation.value) 354 | } 355 | } else { 356 | pushAll(errors, validation.errors) 357 | } 358 | } 359 | 360 | return errors.length ? Err(errors) : Ok(validatedSet) 361 | }) 362 | 363 | setValidator.meta.tag = 'set' 364 | setValidator.meta.value = validator 365 | 366 | return setValidator 367 | } 368 | 369 | //-------------------------------------- 370 | // tuple 371 | //-------------------------------------- 372 | 373 | export function tuple( 374 | ...vs: VS 375 | ): Validator<{ 376 | [NUM in keyof VS]: VS[NUM] extends AnyValidator ? VS[NUM]['T'] : never 377 | }> 378 | 379 | export function tuple(...validators: any[]): any { 380 | const validator = new Validator((v, context, p) => { 381 | if (!Array.isArray(v)) return typeFailure(v, p, 'Tuple') 382 | if (v.length !== validators.length) 383 | return failure( 384 | p, 385 | `Expected Tuple${validators.length}, got Tuple${v.length}` 386 | ) 387 | 388 | const validatedArray: any[] = [] 389 | const errors: ValidationError[] = [] 390 | 391 | for (let i = 0; i < v.length; i++) { 392 | const item = v[i] 393 | const validation = validators[i].validate( 394 | item, 395 | { ...context }, 396 | getPath(String(i), p) 397 | ) 398 | 399 | if (validation.ok) { 400 | validatedArray.push(validation.value) 401 | } else { 402 | pushAll(errors, validation.errors) 403 | } 404 | } 405 | 406 | return errors.length ? Err(errors) : Ok(validatedArray) 407 | }) 408 | 409 | validator.meta.tag = 'tuple' 410 | 411 | return validator 412 | } 413 | 414 | //-------------------------------------- 415 | // object 416 | //-------------------------------------- 417 | 418 | type Props = Record 419 | 420 | // Unpack helps TS inference. 421 | type Unpack

= { [K in keyof P]: P[K]['T'] } 422 | 423 | type OptionalKeys = { 424 | [K in keyof T]: undefined extends T[K] ? K : never 425 | }[keyof T] 426 | 427 | type MandatoryKeys = { 428 | [K in keyof T]: undefined extends T[K] ? never : K 429 | }[keyof T] 430 | 431 | // Intermediary mapped type so that we only Unpack once, and not for each key. 432 | type ObjectWithOptionalKeysOf

> = Id< 433 | { 434 | [K in MandatoryKeys

]: P[K] 435 | } & { [K in OptionalKeys

]?: P[K] } 436 | > 437 | 438 | export type ObjectOf

= ObjectWithOptionalKeysOf> 439 | 440 | type ObjectValidator

= Validator> & { props: P } 441 | 442 | export function object

(props: P): ObjectValidator

{ 443 | const validator = new Validator((v, context, p) => { 444 | if (v == null || typeof v !== 'object') return typeFailure(v, p, 'object') 445 | 446 | const validatedObject: any = {} 447 | const errors: ValidationError[] = [] 448 | 449 | for (let key in props) { 450 | const transformedKey = 451 | context.transformObjectKeys !== undefined 452 | ? context.transformObjectKeys(key) 453 | : key 454 | 455 | const value = (v as any)[transformedKey] 456 | const validator = props[key]! 457 | const validation = validator.validate( 458 | value, 459 | { ...context }, 460 | getPath(transformedKey, p) 461 | ) 462 | 463 | if (validation.ok) { 464 | if (validation.value !== undefined) 465 | validatedObject[key] = validation.value 466 | } else { 467 | pushAll(errors, validation.errors) 468 | } 469 | } 470 | return errors.length ? Err(errors) : Ok(validatedObject) 471 | }) 472 | 473 | validator.meta.tag = 'object' 474 | validator.meta.props = props 475 | ;(validator as any).props = props // For compatibility 476 | 477 | return validator as ObjectValidator

478 | } 479 | 480 | //-------------------------------------- 481 | // dictionary 482 | //-------------------------------------- 483 | 484 | export function dictionary( 485 | domain: Validator, 486 | codomain: Validator 487 | ): Validator>> { 488 | const validator = new Validator((v, context, p) => { 489 | if (v == null || typeof v !== 'object') return typeFailure(v, p, 'object') 490 | 491 | const validatedDict: any = {} 492 | const errors: ValidationError[] = [] 493 | 494 | for (let key in v) { 495 | const value = (v as any)[key] 496 | 497 | const path = getPath(key, p) 498 | const domainValidation = domain.validate(key, { ...context }, path) 499 | const codomainValidation = codomain.validate(value, { ...context }, path) 500 | 501 | if (domainValidation.ok) { 502 | key = domainValidation.value 503 | } else { 504 | const error = domainValidation.errors 505 | pushAll( 506 | errors, 507 | error.map(e => ({ path, message: `key error: ${e.message}` })) 508 | ) 509 | } 510 | 511 | if (codomainValidation.ok) { 512 | validatedDict[key] = codomainValidation.value 513 | } else { 514 | const error = codomainValidation.errors 515 | pushAll( 516 | errors, 517 | error.map(e => ({ 518 | path: e.path, 519 | message: `value error: ${e.message}` 520 | })) 521 | ) 522 | } 523 | } 524 | 525 | return errors.length ? Err(errors) : Ok(validatedDict) 526 | }) 527 | 528 | validator.meta.tag = 'dictionary' 529 | validator.meta.value = codomain 530 | 531 | return validator 532 | } 533 | 534 | //-------------------------------------- 535 | // literal 536 | //-------------------------------------- 537 | 538 | type Literal = string | number | boolean | null | undefined 539 | type LiteralValidator = Validator & { literal: V } 540 | 541 | export function literal(value: V): LiteralValidator { 542 | const validator = new Validator((v, _c, p) => 543 | v === value 544 | ? Ok(v as V) 545 | : failure(p, `Expected ${prettifyJson(value)}, got ${prettifyJson(v)}`) 546 | ) 547 | 548 | ;(validator as any).literal = value 549 | return validator as LiteralValidator 550 | } 551 | 552 | //-------------------------------------- 553 | // intersection 554 | //-------------------------------------- 555 | 556 | // Hack to flatten an intersection into a single type. 557 | type Id = {} & { [P in keyof T]: T[P] } 558 | 559 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( 560 | k: infer I 561 | ) => void 562 | ? I 563 | : never 564 | 565 | // Just like ObjectValidator... but with one extra step. The compiler can't make ObjectValidator work here. 566 | type IntersectionOfObjectsResult[]> = Validator< 567 | Id> 568 | > & { 569 | props: Id> 570 | } 571 | // Special signature for when all validators are object validators: we want the output to be compatible with ObjectValidator too. 572 | export function intersection[]>( 573 | ...vs: VS 574 | ): IntersectionOfObjectsResult 575 | export function intersection( 576 | ...vs: VS 577 | ): Validator>> 578 | export function intersection(...validators: any[]): any { 579 | const allObjectValidators = validators.every(v => Boolean(v.props)) 580 | 581 | const validator = new Validator((v, context, p) => { 582 | let result: any = {} 583 | const errors: ValidationError[] = [] 584 | 585 | for (let i = 0; i < validators.length; i++) { 586 | const validation = validators[i].validate(v, context, p) 587 | 588 | if (validation.ok) { 589 | result = { ...result, ...(validation.value as object) } 590 | } else { 591 | pushAll(errors, validation.errors) 592 | } 593 | } 594 | 595 | return errors.length ? Err(errors) : Ok(result) 596 | }) 597 | 598 | if (allObjectValidators) { 599 | const allProps = validators.reduce((acc, v) => { 600 | Object.assign(acc, v.meta.props) 601 | return acc 602 | }, {}) 603 | 604 | validator.meta.tag = 'object' 605 | validator.meta.props = allProps 606 | ;(validator as any).props = allProps // For compatibility 607 | } else { 608 | validator.meta.tag = 'intersection' 609 | } 610 | 611 | return validator 612 | } 613 | 614 | //-------------------------------------- 615 | // union 616 | //-------------------------------------- 617 | 618 | type ValidatorFromLiteral = T extends Literal ? LiteralValidator : never 619 | 620 | type TupleOfLiteralsToTupleOfValidators = { 621 | [Index in keyof T]: ValidatorFromLiteral 622 | } 623 | 624 | type TupleOfAnyToTupleOfValidators = { 625 | [Index in keyof T]: Validator 626 | } 627 | 628 | type UnionValidator = Validator & { 629 | union: TupleOfAnyToTupleOfValidators 630 | } 631 | 632 | type UnionValidatorOfValidators = Validator< 633 | VS[number]['T'] 634 | > & { union: VS } 635 | 636 | type UnionValidatorOfLiterals = Validator & { 637 | union: TupleOfLiteralsToTupleOfValidators 638 | } 639 | 640 | export function union( 641 | ...union: LS 642 | ): UnionValidatorOfLiterals 643 | 644 | export function union( 645 | ...union: VS 646 | ): UnionValidatorOfValidators 647 | 648 | export function union(...validators: any[]): any { 649 | const probe = validators[0] 650 | 651 | // All arguments are validators 652 | if (probe && typeof probe === 'object') { 653 | const validator = new Validator((v, context, p) => { 654 | const errors: ValidationError[][] = [] 655 | 656 | for (let i = 0; i < validators.length; i++) { 657 | const validation = validators[i].validate(v, { ...context }, p) 658 | if (validation.ok) return validation 659 | else errors.push(validation.errors) 660 | } 661 | 662 | const detailString = errors 663 | .map( 664 | (es, index) => 665 | `Union type #${index} => \n ${errorDebugString(es).replace( 666 | /\n/g, 667 | '\n ' 668 | )}` 669 | ) 670 | .join('\n') 671 | 672 | return failure( 673 | p, 674 | `The value ${prettifyJson( 675 | v 676 | )} \nis not part of the union: \n\n${detailString}` 677 | ) 678 | }) 679 | 680 | ;(validator as any).union = validators // for compatibility 681 | validator.meta.union = validators 682 | validator.meta.tag = 'union' 683 | 684 | return validator 685 | } 686 | 687 | // All arguments are primitives 688 | 689 | validators = validators.map(literal) 690 | 691 | const validator = new Validator((v, context, p) => { 692 | for (let i = 0; i < validators.length; i++) { 693 | const validator = validators[i] 694 | const validation = validator.validate(v, { ...context }, p) 695 | if (validation.ok) return validation 696 | } 697 | return failure(p, `The value ${prettifyJson(v)} is not part of the union`) 698 | }) 699 | 700 | ;(validator as any).union = validators // for compatibility 701 | validator.meta.union = validators 702 | validator.meta.tag = 'union' 703 | 704 | return validator 705 | } 706 | 707 | //-------------------------------------- 708 | // discriminatedUnion 709 | //-------------------------------------- 710 | 711 | export function discriminatedUnion< 712 | TYPEKEY extends string, 713 | VS extends ObjectValidator<{ 714 | [K in TYPEKEY]: LiteralValidator | UnionValidatorOfLiterals 715 | }>[] 716 | >(typeKey: TYPEKEY, ...vs: VS): Validator { 717 | const validatorByType = vs.reduce((map, validator) => { 718 | const v: LiteralValidator | UnionValidatorOfLiterals = 719 | validator.props[typeKey] 720 | 721 | if ('literal' in v) { 722 | map.set(v.literal, validator) 723 | } else { 724 | v.union.forEach(l => map.set(l.literal, validator)) 725 | } 726 | 727 | return map 728 | }, new Map()) 729 | 730 | const discriminated = new Validator((v, context, p) => { 731 | if (v == null) return failure(p, `union member is nullish: ${v}`) 732 | 733 | const typeValue = (v as any)[typeKey] 734 | const validator = validatorByType.get(typeValue) 735 | 736 | if (typeValue === undefined) { 737 | return failure( 738 | getPath(typeKey, p), 739 | `discriminant key ("${typeKey}") missing in: ${prettifyJson(v)}` 740 | ) 741 | } else if (!validator) { 742 | return failure( 743 | getPath(typeKey, p), 744 | `discriminant value ("${typeKey}": "${typeValue}") not part of the union` 745 | ) 746 | } 747 | 748 | return validator.validate(v, context, p) 749 | }) 750 | 751 | discriminated.meta.tag = 'discriminatedUnion' 752 | 753 | return discriminated 754 | } 755 | 756 | //-------------------------------------- 757 | // transform 758 | //-------------------------------------- 759 | 760 | function transform( 761 | validator: Validator, 762 | fn: ( 763 | result: Validation, 764 | value: Value, 765 | p: Path, 766 | context: Context 767 | ) => Result 768 | ): Validator { 769 | return new Validator((v, context, p) => { 770 | const validated = validator.validate(v, context, p) 771 | const transformed = fn(validated, v, p, context) 772 | 773 | if (transformed.ok) return transformed 774 | 775 | const error = transformed.errors 776 | if (typeof error === 'string') return failure(p, error) 777 | 778 | return transformed as Err 779 | }) 780 | } 781 | 782 | //-------------------------------------- 783 | // validateAs 784 | //-------------------------------------- 785 | 786 | type NoInfer = [T][T extends unknown ? 0 : never] 787 | 788 | export function validateAs( 789 | validator: NoInfer>, 790 | value: Value 791 | ): Validation { 792 | return validator.validate(value) 793 | } 794 | 795 | //-------------------------------------- 796 | // util 797 | //-------------------------------------- 798 | 799 | function pushAll(xs: A[], ys: A[]) { 800 | Array.prototype.push.apply(xs, ys) 801 | } 802 | 803 | export function prettifyJson(value: Value) { 804 | return JSON.stringify(value, undefined, 2) 805 | } 806 | 807 | export function errorDebugString(errors: ValidationError[]) { 808 | return errors 809 | .map(e => `At [root${(e.path && '.' + e.path) || ''}] ${e.message}`) 810 | .join('\n') 811 | } 812 | 813 | //-------------------------------------- 814 | // Export aliases 815 | //-------------------------------------- 816 | 817 | export { nullValidator as null, undefinedValidator as undefined } 818 | 819 | //-------------------------------------- 820 | // extra validators 821 | //-------------------------------------- 822 | 823 | export function recursion( 824 | definition: (self: Validator) => Validator 825 | ): Validator { 826 | const Self = new Validator((value, context, path) => 827 | Result.validate(value, context, path) 828 | ) 829 | const Result: any = definition(Self) 830 | return Result 831 | } 832 | 833 | export const isoDate = string.and(str => { 834 | const date = new Date(str) 835 | return isNaN(date.getTime()) 836 | ? Err(`Expected ISO date, got: ${prettifyJson(str)}`) 837 | : Ok(date) 838 | }) 839 | 840 | //-------------------------------------- 841 | // context 842 | //-------------------------------------- 843 | 844 | const upperThenLower = /([A-Z]+)([A-Z][a-z])/g 845 | const lowerThenUpper = /([a-z\\\\d])([A-Z])/g 846 | export const snakeCaseTransformation = (key: string): string => 847 | key 848 | .replace(upperThenLower, '$1_$2') 849 | .replace(lowerThenUpper, '$1_$2') 850 | .toLowerCase() 851 | 852 | function withLogicalType( 853 | logicalType: string, 854 | validator: Validator 855 | ): Validator { 856 | validator.meta.logicalType = logicalType 857 | return validator 858 | } 859 | 860 | //-------------------------------------- 861 | // url 862 | //-------------------------------------- 863 | 864 | export const relativeUrl = (baseUrl: string = 'http://some-domain.com') => 865 | withLogicalType( 866 | 'relativeUrl', 867 | string.and(str => { 868 | try { 869 | new URL(str, baseUrl) 870 | return Ok(str) 871 | } catch (err) { 872 | return Err(`${str} is not a relative URL for baseURL: ${baseUrl}`) 873 | } 874 | }) 875 | ) 876 | 877 | export const absoluteUrl = withLogicalType( 878 | 'absoluteUrl', 879 | string.and(str => { 880 | try { 881 | new URL(str) 882 | return Ok(str) 883 | } catch (err) { 884 | return Err(`${str} is not an absolute URL`) 885 | } 886 | }) 887 | ) 888 | 889 | export const url = union(absoluteUrl, relativeUrl()) 890 | 891 | //-------------------------------------- 892 | // parsed from string 893 | //-------------------------------------- 894 | 895 | export const booleanFromString = withLogicalType( 896 | 'boolean', 897 | union('true', 'false') 898 | .withError(v => `Expected "true" | "false", got: ${v}`) 899 | .map(str => str === 'true') 900 | ) 901 | 902 | export const numberFromString = withLogicalType( 903 | 'number', 904 | string.and(str => { 905 | const parsed = Number(str) 906 | return Number.isNaN(parsed) 907 | ? Err(`"${str}" is not a stringified number`) 908 | : Ok(parsed) 909 | }) 910 | ) 911 | 912 | export const intFromString = withLogicalType( 913 | 'integer', 914 | numberFromString.and(num => { 915 | return Number.isInteger(num) ? Ok(num) : Err(`${num} is not an int`) 916 | }) 917 | ) 918 | 919 | //-------------------------------------- 920 | // generic refinement functions 921 | //-------------------------------------- 922 | 923 | type HasSize = 924 | | object 925 | | string 926 | | Array 927 | | Map 928 | | Set 929 | 930 | export function minSize( 931 | minSize: number 932 | ): (value: T) => Result { 933 | return (value: T) => { 934 | const size = 935 | typeof value === 'string' 936 | ? value.length 937 | : Array.isArray(value) 938 | ? value.length 939 | : value instanceof Map || value instanceof Set 940 | ? value.size 941 | : Object.keys(value).length 942 | 943 | return size >= minSize 944 | ? Ok(value) 945 | : Err(`Expected a min size of ${minSize}, got ${size}`) 946 | } 947 | } 948 | 949 | // Note: this a fully fledged function so that inference on T will work. 950 | export function nonEmpty( 951 | value: T 952 | ): Result> { 953 | return minSize(1)(value) as any 954 | } 955 | 956 | type NonEmptyResult = T extends Array ? NonEmptyArray : T 957 | 958 | export type NonEmptyArray = [E, ...E[]] 959 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import * as expect from 'expect' 2 | import { lift } from 'space-lift' 3 | 4 | import * as v from '../commonjs/validation' 5 | import { Ok, Err } from '../commonjs/validation' 6 | 7 | const showErrorMessages = true 8 | 9 | describe('validation core', () => { 10 | it('can validate that a value is a null', () => { 11 | expect(v.null.validate(null).ok).toBe(true) 12 | expect(v.is(null, v.null)).toBe(true) 13 | expect(v.null.meta.tag).toEqual('null') 14 | 15 | expect(v.null.validate(undefined).ok).toBe(false) 16 | expect(v.null.validate({}).ok).toBe(false) 17 | expect(v.is({}, v.null)).toBe(false) 18 | 19 | type Null = typeof v.null.T 20 | const n: Null = null 21 | }) 22 | 23 | it('can validate that a value is a string', () => { 24 | expect(v.string.validate('hola').ok).toBe(true) 25 | expect(v.is('hola', v.string)).toBe(true) 26 | expect(v.string.meta.tag).toEqual('string') 27 | 28 | expect(v.string.validate(undefined).ok).toBe(false) 29 | expect(v.string.validate({}).ok).toBe(false) 30 | expect(v.is({}, v.string)).toBe(false) 31 | 32 | type String = typeof v.string.T 33 | const str: String = 'hola' 34 | }) 35 | 36 | it('can validate a value and map it', () => { 37 | const validator = v.number.map(x => x * 2) 38 | 39 | expect((validator.validate(10) as any).value).toBe(20) 40 | expect(v.number.meta.tag).toEqual('number') 41 | 42 | type Number = typeof validator.T 43 | const num: Number = 33 44 | }) 45 | 46 | it('can validate a value and further refine it', () => { 47 | const validator = v.number.and(x => Ok(String(x * 2))) 48 | 49 | type StringFromNumber = typeof validator.T 50 | const str: StringFromNumber = 'ok' 51 | 52 | expect((validator.validate(10) as Ok).value).toBe('20') 53 | 54 | const validator2 = v.number.and(x => (x < 1000 ? Err('hell no') : Ok(x))) 55 | 56 | type Number = typeof validator2.T 57 | const num: Number = 33 58 | 59 | const result2 = validator2.validate(10) 60 | expect(!result2.ok && result2.errors[0]!.message).toBe('hell no') 61 | 62 | const validator3 = v.number.and(x => 63 | x > 10 ? Ok(String(x).split('')) : Err('aww') 64 | ) 65 | 66 | type StrArray = typeof validator3.T 67 | const strArray: StrArray = ['1'] 68 | 69 | expect((validator3.validate(20) as Ok).value).toEqual(['2', '0']) 70 | const result3 = validator3.validate(5) 71 | expect(!result3.ok && result3.errors[0]!.message).toBe('aww') 72 | printErrorMessage(result3) 73 | }) 74 | 75 | it('can compose two validators returning a single string error', () => { 76 | const stringToInt = v.string.and(str => { 77 | const result = Number.parseInt(str, 10) 78 | if (Number.isFinite(result)) return Ok(result) 79 | return Err('Expected an integer-like string, got: ' + str) 80 | }) 81 | 82 | const timestampOk = v.number.and(n => { 83 | const date = new Date(n) 84 | if (date.getTime() === NaN) return Err('Not a valid date') 85 | return Ok(date) 86 | }) 87 | 88 | const timestampNope = v.number.and(n => { 89 | return Err('Not a valid date') 90 | }) 91 | 92 | const composedValidator1 = stringToInt.then(timestampOk) 93 | const result1 = composedValidator1.validate('01') 94 | expect(result1.ok && result1.value.getMilliseconds()).toBe(1) 95 | 96 | const result2 = composedValidator1.validate(1) 97 | expect(result2.ok).toBe(false) 98 | printErrorMessage(result2) 99 | 100 | const result3 = stringToInt.then(timestampNope).validate('01') 101 | expect(!result3.ok && result3.errors[0]!.message).toBe('Not a valid date') 102 | printErrorMessage(result3) 103 | }) 104 | 105 | it('can validate a filtered value', () => { 106 | const positiveNumber = v.number.filter(x => x >= 0) 107 | 108 | expect(positiveNumber.meta.tag).toEqual('number') 109 | 110 | function isPositiveNumber(n: number) { 111 | return n >= 0 112 | } 113 | 114 | expect((positiveNumber.validate(10) as Ok).value).toBe(10) 115 | expect(positiveNumber.validate(-1).ok).toBe(false) 116 | 117 | printErrorMessage(positiveNumber.validate(-1)) 118 | printErrorMessage(v.number.filter(isPositiveNumber).validate(-1)) 119 | 120 | type PositiveNumber = typeof positiveNumber.T 121 | const num: PositiveNumber = 33 122 | }) 123 | 124 | it('can validate an array', () => { 125 | const numArray = [1, 2, 3] 126 | const av = v.array(v.number) 127 | 128 | expect((av.validate(numArray) as Ok).value).toEqual(numArray) 129 | expect(av.meta.tag).toEqual('array') 130 | expect(av.meta.value).toBe(v.number) 131 | 132 | const badNumArray = [1, 'oops', 'fuu'] 133 | const badValidation = v.array(v.number).validate(badNumArray) 134 | 135 | printErrorMessage(badValidation) 136 | 137 | if (badValidation.ok) { 138 | throw new Error('Should be an Error') 139 | } 140 | 141 | expect(badValidation.errors.length).toBe(2) 142 | }) 143 | 144 | it('can validate a readonly array', () => { 145 | const numArray: readonly number[] = [1, 2, 3] 146 | const av = v.readonlyArray(v.number) 147 | const result = av.validate(numArray) 148 | 149 | expect((result as Ok).value).toEqual(numArray) 150 | expect(av.meta.tag).toEqual('array') 151 | expect(av.meta.value).toBe(v.number) 152 | 153 | if (!result.ok) { 154 | throw new Error('Should be a Success') 155 | } 156 | 157 | // Check type is readonly 158 | const _v: readonly number[] = result.value 159 | 160 | const badNumArray = [1, 'oops', 'fuu'] 161 | const badValidation = v.readonlyArray(v.number).validate(badNumArray) 162 | 163 | printErrorMessage(badValidation) 164 | 165 | if (badValidation.ok) { 166 | throw new Error('Should be an Error') 167 | } 168 | 169 | expect(badValidation.errors.length).toBe(2) 170 | }) 171 | 172 | it('can validate an array as set', () => { 173 | const numArray = [1, 2, 3] 174 | const av = v.arrayAsSet(v.number) 175 | 176 | expect((av.validate(numArray) as Ok).value).toEqual(new Set(numArray)) 177 | expect(av.meta.tag).toEqual('set') 178 | expect(av.meta.value).toBe(v.number) 179 | 180 | const badNumArray = [1, 'oops', 'fuu'] 181 | const badValidation = v.arrayAsSet(v.number).validate(badNumArray) 182 | 183 | printErrorMessage(badValidation) 184 | 185 | if (badValidation.ok) { 186 | throw new Error('Should be an Error') 187 | } 188 | 189 | expect(badValidation.errors.length).toBe(2) 190 | 191 | const duplicateArray = ['foo', 'bar', 'bar'] 192 | const duplicateValidation = v.arrayAsSet(v.string).validate(duplicateArray) 193 | 194 | printErrorMessage(duplicateValidation) 195 | 196 | if (duplicateValidation.ok) { 197 | throw new Error('Should be an Error') 198 | } 199 | 200 | expect(duplicateValidation.errors.map(e => e.message)).toEqual([ 201 | 'Duplicate value in set: bar' 202 | ]) 203 | }) 204 | 205 | it('can validate an object', () => { 206 | const person = v.object({ 207 | id: v.number, 208 | name: v.string, 209 | friends: v.array( 210 | v.object({ 211 | name: v.string 212 | }) 213 | ) 214 | }) 215 | 216 | const okValidation = person.validate({ 217 | id: 123, 218 | name: 'Alex', 219 | friends: [{ name: 'bob' }, { name: 'john' }], 220 | someIrrelevantKey: true 221 | }) 222 | 223 | if (!okValidation.ok) throw new Error('Should be OK') 224 | 225 | expect(okValidation.value).toEqual({ 226 | id: 123, 227 | name: 'Alex', 228 | friends: [{ name: 'bob' }, { name: 'john' }] 229 | }) 230 | 231 | const notOkValidation = person.validate({ 232 | id: '123', 233 | name: 'Alex', 234 | friends: [{ name: 'bob' }, { id: 'john' }] 235 | }) 236 | 237 | expect(!notOkValidation.ok && notOkValidation.errors.length).toBe(2) 238 | printErrorMessage(notOkValidation) 239 | 240 | type Person = typeof person.T 241 | 242 | // Tests the type derivation: it should compile 243 | const alex2: Person = { 244 | id: 123, 245 | name: 'Alex', 246 | friends: [{ name: 'bob' }, { name: 'john' }] 247 | } 248 | }) 249 | 250 | it('can expose object props', () => { 251 | const obj = { 252 | id: v.number, 253 | name: v.string, 254 | friends: v.array( 255 | v.object({ 256 | name: v.string 257 | }) 258 | ) 259 | } 260 | const person = v.object(obj) 261 | 262 | expect(person.props).toBe(obj) 263 | expect(person.meta.tag).toEqual('object') 264 | 265 | // e.g. Generate shell mapper from metadata 266 | const shellVarsToJson = Object.keys(person.props).map(k => { 267 | const v = (person.props as any)[k] 268 | const shellVar = k.toUpperCase() 269 | const tpe = v.meta.tag 270 | 271 | if (tpe == 'string') { 272 | // Quote string 273 | return `"${k}": "\$${shellVar}"` 274 | } else { 275 | return `"${k}": \$${shellVar}` 276 | } 277 | }) 278 | 279 | const shellScript = `cat > /dev/stdout << EOF 280 | { 281 | ${shellVarsToJson.join(',\n ')} 282 | } 283 | EOF` 284 | 285 | expect(shellScript).toEqual(`cat > /dev/stdout << EOF 286 | { 287 | "id": \$ID, 288 | "name": "\$NAME", 289 | "friends": \$FRIENDS 290 | } 291 | EOF`) 292 | }) 293 | 294 | it('can validate a dictionary', () => { 295 | const strNumMap = v.dictionary(v.string, v.number) 296 | 297 | expect(strNumMap.meta.tag).toEqual('dictionary') 298 | expect(strNumMap.meta.value).toBe(v.number) 299 | 300 | const okValidation = strNumMap.validate({ 301 | a: 1, 302 | b: 2, 303 | c: 3 304 | }) 305 | 306 | expect(okValidation.ok).toBe(true) 307 | 308 | const notOkValidation = v.object({ dic: strNumMap }).validate({ 309 | dic: { 310 | a: 1, 311 | b: 2, 312 | c: '3' 313 | } 314 | }) 315 | 316 | expect(notOkValidation.ok).toBe(false) 317 | printErrorMessage(notOkValidation) 318 | 319 | // domain = more precise than strings and object values 320 | const enumNumMap = v.dictionary( 321 | v.union(...(['a', 'b'] as const)), 322 | v.object({ id: v.number }) 323 | ) 324 | 325 | const okValidation2 = enumNumMap.validate({ a: { id: 1 }, b: { id: 2 } }) 326 | 327 | expect(okValidation2.ok).toBe(true) 328 | 329 | const notOkValidation2 = v.object({ dic: enumNumMap }).validate({ 330 | dic: { 331 | a: { id: 1 }, 332 | bb: { id: '2' }, 333 | c: { id: '3' } 334 | } 335 | }) 336 | 337 | expect(!notOkValidation2.ok && notOkValidation2.errors.length).toBe(4) 338 | printErrorMessage(notOkValidation2) 339 | }) 340 | 341 | it('can validate a Map-like dictionary where all values are optional', () => { 342 | const dict = v.dictionary(v.union('A', 'B'), v.string.optional()) 343 | type OptionalDict = typeof dict.T 344 | 345 | const okValidation = dict.validate({ 346 | B: 'hello' 347 | }) 348 | 349 | expect(okValidation.ok && okValidation.value).toEqual({ B: 'hello' }) 350 | 351 | // Type assertion. 352 | const _dict: OptionalDict = { A: 'hey' } 353 | const _dict2: OptionalDict = {} 354 | const _dict3: OptionalDict = { A: undefined } 355 | }) 356 | 357 | it('can validate an intersection of types', () => { 358 | const flying = v.object({ 359 | flyingDistance: v.number 360 | }) 361 | 362 | const squirrel = v.object({ 363 | family: v.literal('Sciuridae'), 364 | isCute: v.boolean.optional() 365 | }) 366 | 367 | const flyingSquirrel = v.intersection(flying, squirrel) 368 | 369 | expect(flyingSquirrel.meta.tag).toEqual('object') 370 | 371 | const vulture = { 372 | flyingDistance: 5000, 373 | family: 'Accipitridae', 374 | isCute: false 375 | } 376 | 377 | const honeyBadger = { 378 | isCute: 'ohno' 379 | } 380 | 381 | const notOkValidation = flyingSquirrel.validate(vulture) 382 | const notOkValidation2 = flyingSquirrel.validate(honeyBadger) 383 | 384 | printErrorMessage(notOkValidation) 385 | printErrorMessage(notOkValidation2) 386 | 387 | expect(notOkValidation.ok).toBe(false) 388 | 389 | // All 3 fields are missing so we should get 3 errors. 390 | expect(!notOkValidation2.ok && notOkValidation2.errors.length).toBe(3) 391 | 392 | const bob = { 393 | flyingDistance: 90, 394 | family: 'Sciuridae' as 'Sciuridae', 395 | hasAnAgenda: true 396 | } 397 | 398 | const okValidation = flyingSquirrel.validate(bob) 399 | expect(okValidation.ok && okValidation.value).toEqual({ 400 | flyingDistance: 90, 401 | family: 'Sciuridae' 402 | }) 403 | 404 | // smoke-test generated type 405 | type Squirel = typeof flyingSquirrel.T 406 | const x: Squirel = bob 407 | }) 408 | 409 | it('can validate an union of types', () => { 410 | const helloOrObj = v.union( 411 | v.string, 412 | v.object({ id: v.string, name: v.string }) 413 | ) 414 | const okValidation = helloOrObj.validate('hello') 415 | const okValidation2 = helloOrObj.validate({ id: '123', name: 'hello' }) 416 | 417 | expect(okValidation.ok).toBe(true) 418 | expect(okValidation2.ok).toBe(true) 419 | 420 | expect(helloOrObj.meta.tag).toEqual('union') 421 | 422 | const notOkValidation = helloOrObj.validate(111) 423 | const notOkValidation2 = helloOrObj.validate({ name2: 'hello' }) 424 | 425 | expect(notOkValidation.ok).toBe(false) 426 | expect(notOkValidation2.ok).toBe(false) 427 | printErrorMessage(notOkValidation) 428 | printErrorMessage(notOkValidation2) 429 | 430 | type HelloOrObj = typeof helloOrObj.T 431 | const hello: HelloOrObj = 'hello' 432 | 433 | // Union of literals - shortcut 434 | const unionsOfLiterals = v.union(null, 'hello', true, 33) 435 | const okValidation3 = unionsOfLiterals.validate('hello') 436 | const okValidation4 = unionsOfLiterals.validate(33) 437 | const okValidation5 = unionsOfLiterals.validate(null) 438 | 439 | expect(okValidation3.ok).toBe(true) 440 | expect(okValidation4.ok).toBe(true) 441 | expect(okValidation5.ok).toBe(true) 442 | 443 | const notOkValidation3 = unionsOfLiterals.validate('hello2') 444 | const notOkValidation4 = unionsOfLiterals.validate(34) 445 | 446 | expect(notOkValidation3.ok).toBe(false) 447 | expect(notOkValidation4.ok).toBe(false) 448 | printErrorMessage(notOkValidation3) 449 | }) 450 | 451 | it('can use an union validator to validate against the keys of an object', () => { 452 | const obj = { 453 | age: 10, 454 | address: '134 clapham manor street' 455 | } 456 | 457 | function keysOfValidator(object: T) { 458 | return v.union(...lift(object).keys().value()) 459 | } 460 | 461 | const validator = keysOfValidator(obj) 462 | 463 | const okValidation = validator.validate('age') 464 | const notOkValidation = validator.validate('nope') 465 | 466 | expect(okValidation.ok && okValidation.value).toEqual('age') 467 | expect(notOkValidation.ok).toBe(false) 468 | }) 469 | 470 | it('can validate a discriminated union of types', () => { 471 | const validator = v 472 | .discriminatedUnion( 473 | 'type', 474 | v.object({ type: v.literal('A'), name: v.string }), 475 | v.object({ type: v.literal('B'), data: v.number }) 476 | ) 477 | .mapErrors(errors => 478 | errors.map(error => { 479 | if (error.path === 'type') return { ...error, message: 'OHNO' } 480 | return error 481 | }) 482 | ) 483 | 484 | const okValidation = validator.validate({ 485 | type: 'A', 486 | name: 'Alfred', 487 | _meta: 10 488 | }) 489 | 490 | const okValidation2 = validator.validate({ type: 'B', data: 10 }) 491 | 492 | const notOkValidation = validator.validate({ _type: 'A', name: 'name' }) 493 | const notOkValidation2 = validator.validate({ type: 'B', name: '10' }) 494 | const notOkValidation3 = validator.validate({ type: 'C', name: '10' }) 495 | const notOkValidation4 = validator.validate(null) 496 | 497 | expect(okValidation.ok && okValidation.value).toEqual({ 498 | type: 'A', 499 | name: 'Alfred' 500 | }) 501 | expect(okValidation2.ok && okValidation2.value).toEqual({ 502 | type: 'B', 503 | data: 10 504 | }) 505 | 506 | expect( 507 | !notOkValidation.ok && 508 | notOkValidation.errors.find(e => e.path === 'type')!.message 509 | ).toBe('OHNO') 510 | expect(notOkValidation2.ok).toBe(false) 511 | expect(notOkValidation3.ok).toBe(false) 512 | expect(notOkValidation4.ok).toBe(false) 513 | 514 | printErrorMessage(notOkValidation) 515 | printErrorMessage(notOkValidation2) 516 | printErrorMessage(notOkValidation3) 517 | printErrorMessage(notOkValidation4) 518 | }) 519 | 520 | it('can validate a discriminated union made of intersection types', () => { 521 | const a = v.object({ type: v.literal('a'), a: v.number }) 522 | const b = v.object({ b: v.string }) 523 | const ab = v.intersection(a, b) 524 | 525 | const c = v.object({ type: v.union('c', 'cc'), c: v.number }) 526 | const d = v.object({ d: v.string }) 527 | const cd = v.intersection(c, d) 528 | 529 | const validator = v.discriminatedUnion('type', ab, cd) 530 | 531 | const notOKValidation = validator.validate({ type: 'c', c: 10 }) 532 | const okValidation = validator.validate({ type: 'c', c: 10, d: 'dd' }) 533 | 534 | expect(notOKValidation.ok).toBe(false) 535 | expect(okValidation.ok && okValidation.value).toEqual({ 536 | type: 'c', 537 | c: 10, 538 | d: 'dd' 539 | }) 540 | }) 541 | 542 | it('can validate a literal value', () => { 543 | const literalStr = v.literal('hello') 544 | 545 | const okValidation = literalStr.validate('hello') 546 | expect(okValidation.ok).toBe(true) 547 | 548 | const notOkValidation = literalStr.validate('boo') 549 | expect(notOkValidation.ok).toBe(false) 550 | }) 551 | 552 | it('can validate an optional value', () => { 553 | const optionalString = v.string.optional() 554 | 555 | expect(optionalString.meta.tag).toEqual('string') 556 | expect(optionalString.meta.optional).toBe(true) 557 | 558 | const okValidation = optionalString.validate('hello') 559 | expect(okValidation.ok).toBe(true) 560 | 561 | const okValidation2 = optionalString.validate(undefined) 562 | expect(okValidation2.ok).toBe(true) 563 | 564 | const notOkValidation = optionalString.validate(null) 565 | expect(notOkValidation.ok).toBe(false) 566 | 567 | const notOkValidation2 = optionalString.validate({}) 568 | expect(notOkValidation2.ok).toBe(false) 569 | 570 | printErrorMessage(notOkValidation2) 571 | }) 572 | 573 | it('can validate a primitive and tag it', () => { 574 | type UserId = string & { __tag: 'UserId' } 575 | 576 | const userIdValidator = v.string.tagged() 577 | 578 | const okValidation = userIdValidator.validate('abcd') 579 | 580 | if (okValidation.ok) { 581 | // Check assignation/type 582 | const idAsUserId: UserId = okValidation.value 583 | const idAsString: string = okValidation.value 584 | } else { 585 | throw new Error() 586 | } 587 | 588 | const notOkValidation = v.string.tagged().validate({}) 589 | 590 | expect(notOkValidation.ok).toBe(false) 591 | }) 592 | 593 | it('can validate a combination of object and union values', () => { 594 | const validator = v.object({ 595 | id: v.string, 596 | params: v.union(v.null, v.object({ id: v.string })) 597 | }) 598 | 599 | const okValidation = validator.validate({ id: '1', params: null }) 600 | const okValidation2 = validator.validate({ id: '1', params: { id: '2' } }) 601 | const notOkValidation = validator.validate({ id: '1', params: {} }) 602 | 603 | expect(okValidation.ok).toBe(true) 604 | expect(okValidation2.ok).toBe(true) 605 | expect(notOkValidation.ok).toBe(false) 606 | }) 607 | 608 | it('can validate and omit optional object values', () => { 609 | const validator = v.object({ 610 | id: v.string, 611 | query: v.string.optional(), 612 | path: v.string.optional() 613 | }) 614 | 615 | const okValidation = validator.validate({ id: '1', query: 'q' }) 616 | expect(okValidation.ok && okValidation.value).toEqual({ 617 | id: '1', 618 | query: 'q' 619 | }) 620 | }) 621 | 622 | it('can validate a combination of dictionary and union values', () => { 623 | const validator = v.dictionary( 624 | v.string, 625 | v.union(v.null, v.object({ id: v.string })) 626 | ) 627 | 628 | expect(validator.meta.tag).toEqual('dictionary') 629 | 630 | const okValidation = validator.validate({ id: null }) 631 | const okValidation2 = validator.validate({ id: { id: '2' } }) 632 | const notOkValidation = validator.validate({ id: {} }) 633 | 634 | expect(okValidation.ok).toBe(true) 635 | expect(okValidation2.ok).toBe(true) 636 | expect(notOkValidation.ok).toBe(false) 637 | }) 638 | 639 | it('can validate a tuple', () => { 640 | const tuple0 = v.tuple() 641 | const tuple1 = v.tuple(v.number) 642 | const validator = v.tuple(v.number, v.string, v.null) 643 | 644 | expect(validator.meta.tag).toEqual('tuple') 645 | 646 | const okValidation = validator.validate([10, '10', null]) 647 | 648 | // The length is strictly validated so the type doesn't lie if we map, etc 649 | const notOkValidation = validator.validate([10, '10', null, 10, 10, 10]) 650 | const notOkValidation2 = validator.validate([10, 10, null]) 651 | const notOkValidation3 = validator.validate(33) 652 | 653 | expect(tuple0.validate([]).ok).toBe(true) 654 | expect(tuple1.validate([10]).ok).toBe(true) 655 | expect(okValidation.ok).toBe(true) 656 | expect(notOkValidation.ok).toBe(false) 657 | expect(notOkValidation2.ok).toBe(false) 658 | expect(notOkValidation3.ok).toBe(false) 659 | 660 | printErrorMessage(notOkValidation) 661 | printErrorMessage(notOkValidation2) 662 | printErrorMessage(notOkValidation3) 663 | }) 664 | 665 | it('can transform snake cased inputs into camel case before validating', () => { 666 | const burger = v.object({ 667 | id: v.number, 668 | meatCooking: v.string, 669 | awesomeSidesNomNom: v.array(v.string), 670 | options: v.object({ 671 | doubleBacon: v.boolean 672 | }) 673 | }) 674 | 675 | const okSnakeCased = burger.validate( 676 | { 677 | id: 123, 678 | meat_cooking: 'rare', 679 | awesome_sides_nom_nom: ['loaded fries', 'barbecue sauce'], 680 | options: { 681 | double_bacon: true 682 | } 683 | }, 684 | { transformObjectKeys: v.snakeCaseTransformation } 685 | ) 686 | 687 | const expected = { 688 | id: 123, 689 | meatCooking: 'rare', 690 | awesomeSidesNomNom: ['loaded fries', 'barbecue sauce'], 691 | options: { 692 | doubleBacon: true 693 | } 694 | } 695 | 696 | if (!okSnakeCased.ok) throw new Error('Should be OK') 697 | 698 | expect(okSnakeCased.value).toEqual(expected) 699 | }) 700 | 701 | it('reports transformed key names to the user in case of error', () => { 702 | const burger = v.object({ 703 | id: v.number, 704 | meatCooking: v.string, 705 | awesomeSides: v.array(v.string) 706 | }) 707 | 708 | const fieldInError = burger.validate( 709 | { 710 | id: 123, 711 | meat_cooking: 42, 712 | awesome_sides: ['loaded fries', 'barbecue sauce'] 713 | }, 714 | { transformObjectKeys: v.snakeCaseTransformation } 715 | ) 716 | 717 | expect(fieldInError.ok).toBe(false) 718 | 719 | printErrorMessage(fieldInError) 720 | 721 | if (!fieldInError.ok) { 722 | const { path } = fieldInError.errors[0]! 723 | expect(path).toEqual('meat_cooking') 724 | } 725 | }) 726 | 727 | it('should be strict on input casing when using transformObjectKeys', () => { 728 | const burger = v.object({ 729 | id: v.number, 730 | meatCooking: v.string, 731 | awesomeSides: v.array(v.string) 732 | }) 733 | 734 | const errorCamelCased = burger.validate( 735 | { 736 | id: 456, 737 | meatCooking: 'blue', 738 | awesomeSides: ['potatoes', 'ketchup'] 739 | }, 740 | { transformObjectKeys: v.snakeCaseTransformation } 741 | ) 742 | 743 | expect(errorCamelCased.ok).toBe(false) 744 | }) 745 | 746 | it('default to international locale conversion and pass the turkish test', () => { 747 | const burger = v.object({ 748 | burgerId: v.number 749 | }) 750 | 751 | const expected = burger.validate( 752 | { burger_id: 456 }, 753 | { transformObjectKeys: v.snakeCaseTransformation } 754 | ) 755 | 756 | expect(expected.ok && expected.value).toEqual({ burgerId: 456 }) 757 | }) 758 | 759 | it('should allow missing keys for optional object keys when using the generated type', () => { 760 | const options = v.object({ 761 | name: v.string, 762 | age: v.number.optional() 763 | }) 764 | 765 | type Options = typeof options.T 766 | 767 | // Should compile, even if we didn't specify 'age' 768 | const a: Options = { 769 | name: 'a' 770 | } 771 | }) 772 | 773 | it('can use a default value', () => { 774 | const validator = v.string.optional().default('yes') 775 | 776 | expect(validator.meta.tag).toEqual('string') 777 | expect(validator.meta.optional).toBe(true) 778 | expect(validator.meta.default).toEqual('yes') 779 | 780 | const validated1 = validator.validate(undefined) 781 | const validated2 = v.union(v.null, v.string).default('yes').validate(null) 782 | const validated3 = validator.validate('') 783 | const validated4 = validator.validate('hey') 784 | const validated5 = v.string.default('yes').validate(undefined) 785 | 786 | expect(validated1.ok && validated1.value).toEqual('yes') 787 | expect(validated2.ok && validated2.value).toEqual('yes') 788 | expect(validated3.ok && validated3.value).toEqual('') 789 | expect(validated4.ok && validated4.value).toEqual('hey') 790 | expect(validated5.ok && validated5.value).toEqual('yes') 791 | }) 792 | 793 | it('can validate with a custom error string', () => { 794 | const validator = v.string 795 | .withError(value => `not a string (${value})`) 796 | .and(v.minSize(2)) 797 | .withError(_ => 'wrong size') 798 | .filter(value => value !== 'GOD') 799 | .withError(_ => 'God is not allowed') 800 | 801 | expect(validator.meta.tag).toEqual('string') 802 | 803 | const validator2 = v.string 804 | .withError(() => 'string is mandatory') 805 | .nullable() 806 | const validator3 = v.string 807 | .withError(() => 'string is mandatory') 808 | .optional() 809 | const validator4 = v.string 810 | .withError(() => 'string is mandatory') 811 | .default('') 812 | 813 | const result1 = validator.validate('123') 814 | const result2 = validator.validate(123) 815 | const result3 = validator.validate('1') 816 | const result4 = validator.validate('GOD') 817 | const result5 = validator2.validate(null) 818 | const result6 = validator3.validate(undefined) 819 | const result7 = validator4.validate(undefined) 820 | 821 | expect(result1.ok && result1.value).toEqual('123') 822 | expect(result5.ok && result5.value).toBe(null) 823 | expect(result6.ok && result6.value).toBe(undefined) 824 | expect(result7.ok && result7.value).toBe('') 825 | 826 | expect( 827 | !result2.ok && 828 | result2.errors.length === 1 && 829 | result2.errors[0]!.path === '' && 830 | result2.errors[0]!.message 831 | ).toBe('not a string (123)') 832 | 833 | expect(!result3.ok && result3.errors[0]!.message).toBe('wrong size') 834 | expect(!result4.ok && result4.errors[0]!.message).toBe('God is not allowed') 835 | 836 | printErrorMessage(result2) 837 | printErrorMessage(result3) 838 | }) 839 | 840 | it('can validate with a custom error string for each item of an Array or value of an Object', () => { 841 | const user = v.object({ 842 | id: v.string 843 | .withError(_ => 'user id is mandatory') 844 | .and(v.minSize(1)) 845 | .withError(_ => 'min size is 1') 846 | .filter(value => Number(value) >= 0) 847 | .withError(v => `Got a negative id (${v})`) 848 | }) 849 | 850 | const arrayValidator = v.array(user) 851 | 852 | const objectValidator = v.object({ 853 | a: user, 854 | b: user, 855 | c: user, 856 | d: user, 857 | e: user 858 | }) 859 | 860 | const result1 = arrayValidator.validate([{ id: '1' }, { id: '2' }]) 861 | const result2 = arrayValidator.validate([ 862 | { id: '1' }, 863 | { id: 3 }, 864 | { id: '' }, 865 | { id: '-5' }, 866 | {} 867 | ]) 868 | const result3 = objectValidator.validate({ 869 | a: { id: '1' }, 870 | b: { id: 3 }, 871 | c: { id: '' }, 872 | d: { id: '-5' }, 873 | e: {} 874 | }) 875 | 876 | expect(result1.ok).toBe(true) 877 | if (result2.ok) throw new Error('should be Err') 878 | if (result3.ok) throw new Error('should be Err') 879 | 880 | expect(result2.errors.length).toBe(4) 881 | expect(result2.errors[0]!.message).toBe('user id is mandatory') 882 | expect(result2.errors[1]!.message).toBe('min size is 1') 883 | expect(result2.errors[2]!.message).toBe('Got a negative id (-5)') 884 | expect(result2.errors[3]!.message).toBe('user id is mandatory') 885 | 886 | expect(result3.errors.length).toBe(4) 887 | expect(result3.errors.find(e => e.path === 'b.id')!.message).toBe( 888 | 'user id is mandatory' 889 | ) 890 | 891 | expect(result3.errors.find(e => e.path === 'c.id')!.message).toBe( 892 | 'min size is 1' 893 | ) 894 | 895 | expect(result3.errors.find(e => e.path === 'd.id')!.message).toBe( 896 | 'Got a negative id (-5)' 897 | ) 898 | 899 | expect(result3.errors.find(e => e.path === 'e.id')!.message).toBe( 900 | 'user id is mandatory' 901 | ) 902 | }) 903 | 904 | it('can assign a custom nullable validator to a validator containing null', () => { 905 | function nullable(validator: v.Validator): v.Validator { 906 | return v.union(v.null, validator) 907 | } 908 | 909 | const _validator: v.Validator = v.union( 910 | v.null, 911 | v.number 912 | ) 913 | 914 | const _validator2: v.Validator = nullable(v.number) 915 | }) 916 | 917 | it('can transform a validator into a nullable validator', () => { 918 | const validator = v 919 | .object({ 920 | a: v.number, 921 | b: v.string 922 | }) 923 | .nullable() 924 | 925 | const result1 = validator.validate(null) 926 | const result2 = validator.validate(undefined) 927 | const result3 = validator.validate({ a: 10, b: 'aa' }) 928 | const result4 = validator.validate('aa') 929 | const result5 = validator.validate({ a: 10 }) 930 | 931 | expect(result1.ok && result1.value).toEqual(null) 932 | expect(result2.ok && result2.value).toEqual(undefined) 933 | expect(result3.ok && result3.value).toEqual({ a: 10, b: 'aa' }) 934 | expect(!result4.ok) 935 | expect(!result5.ok) 936 | 937 | printErrorMessage(result4) 938 | console.log('\n\n') 939 | printErrorMessage(result5) 940 | }) 941 | 942 | it('can validate an ISO date', () => { 943 | const okValidation = v.isoDate.validate('2017-06-23T12:14:38.298Z') 944 | expect(okValidation.ok && okValidation.value.getFullYear() === 2017).toBe( 945 | true 946 | ) 947 | 948 | const notOkValidation = v.isoDate.validate('hello') 949 | expect(notOkValidation.ok).toBe(false) 950 | }) 951 | 952 | it('can validate a recursive type', () => { 953 | type Category = { name: string; categories: Category[] } 954 | 955 | const category = v.recursion(self => 956 | v.object({ 957 | name: v.string, 958 | categories: v.array(self) 959 | }) 960 | ) 961 | 962 | const okValidation = category.validate({ 963 | name: 'tools', 964 | categories: [{ name: 'piercing', categories: [] }] 965 | }) 966 | 967 | expect(okValidation.ok).toBe(true) 968 | 969 | const notOkValidation = category.validate({ 970 | name: 'tools', 971 | categories: [{ name2: 'piercing', categories: [] }] 972 | }) 973 | 974 | expect(!notOkValidation.ok && notOkValidation.errors.length).toBe(1) 975 | printErrorMessage(notOkValidation) 976 | }) 977 | 978 | //-------------------------------------- 979 | // parsed from string 980 | //-------------------------------------- 981 | 982 | it('can validate a boolean from a string', () => { 983 | const okValidation = v.booleanFromString.validate('true') 984 | const okValidation2 = v.booleanFromString.validate('false') 985 | const notOkValidation = v.booleanFromString.validate('nope') 986 | const notOkValidation2 = v.booleanFromString.validate(true) 987 | 988 | expect(okValidation.ok && okValidation.value).toBe(true) 989 | expect(okValidation2.ok && okValidation2.value).toBe(false) 990 | expect(notOkValidation.ok).toBe(false) 991 | expect(notOkValidation2.ok).toBe(false) 992 | 993 | printErrorMessage(notOkValidation) 994 | }) 995 | 996 | it('can validate a number from a string', () => { 997 | const okValidation = v.numberFromString.validate('123.4') 998 | const okValidation2 = v.numberFromString.validate('100') 999 | const notOkValidation = v.numberFromString.validate('aa123') 1000 | const notOkValidation2 = v.numberFromString.validate('123aa') 1001 | 1002 | expect(okValidation.ok && okValidation.value).toBe(123.4) 1003 | expect(okValidation2.ok && okValidation2.value).toBe(100) 1004 | expect(notOkValidation.ok).toBe(false) 1005 | expect(notOkValidation2.ok).toBe(false) 1006 | 1007 | printErrorMessage(notOkValidation) 1008 | }) 1009 | 1010 | it('can validate an int from a string', () => { 1011 | const okValidation = v.intFromString.validate('123') 1012 | const notOkValidation = v.intFromString.validate('123.4') 1013 | const notOkValidation2 = v.intFromString.validate('123aa') 1014 | const notOkValidation3 = v.intFromString.validate('aaa123') 1015 | 1016 | expect(okValidation.ok && okValidation.value).toBe(123) 1017 | expect(notOkValidation.ok).toBe(false) 1018 | expect(notOkValidation2.ok).toBe(false) 1019 | expect(notOkValidation3.ok).toBe(false) 1020 | 1021 | printErrorMessage(notOkValidation) 1022 | }) 1023 | 1024 | //-------------------------------------- 1025 | // url 1026 | //-------------------------------------- 1027 | 1028 | it('can validate a relative URL', () => { 1029 | const v1 = v.relativeUrl() 1030 | const v2 = v.relativeUrl('http://use-this-domain.com/hey') 1031 | 1032 | expect(v1.meta.tag).toEqual('string') 1033 | expect(v1.meta.logicalType).toEqual('relativeUrl') 1034 | 1035 | expect(v2.meta.tag).toEqual('string') 1036 | expect(v2.meta.logicalType).toEqual('relativeUrl') 1037 | 1038 | const okValidation = v1.validate('path') 1039 | const okValidation2 = v2.validate('path/subpath') 1040 | const notOkValidation = v 1041 | .relativeUrl('http://use-this-domain.com/hey') 1042 | .validate('////') 1043 | const notOkValidation2 = v.relativeUrl().validate(true) 1044 | 1045 | expect(okValidation.ok && okValidation.value).toBe('path') 1046 | expect(okValidation2.ok && okValidation2.value).toBe('path/subpath') 1047 | expect(notOkValidation.ok).toBe(false) 1048 | expect(notOkValidation2.ok).toBe(false) 1049 | 1050 | printErrorMessage(notOkValidation) 1051 | }) 1052 | 1053 | it('can validate an absolute URL', () => { 1054 | const okValidation = v.absoluteUrl.validate('http://hi.com') 1055 | const notOkValidation = v.absoluteUrl.validate('//aa') 1056 | const notOkValidation2 = v.absoluteUrl.validate('/hey') 1057 | 1058 | expect(v.absoluteUrl.meta.tag).toEqual('string') 1059 | expect(v.absoluteUrl.meta.logicalType).toEqual('absoluteUrl') 1060 | 1061 | expect(okValidation.ok && okValidation.value).toBe('http://hi.com') 1062 | expect(notOkValidation.ok).toBe(false) 1063 | expect(notOkValidation2.ok).toBe(false) 1064 | 1065 | printErrorMessage(notOkValidation) 1066 | }) 1067 | 1068 | it('can validate an URL', () => { 1069 | const v1 = v.url 1070 | 1071 | expect(v1.meta.tag).toEqual('union') 1072 | 1073 | const okValidation = v1.validate('http://hi.com') 1074 | const okValidation2 = v.url.validate('path/subpath') 1075 | const notOkValidation = v.url.validate('////') 1076 | 1077 | expect(okValidation.ok && okValidation.value).toBe('http://hi.com') 1078 | expect(okValidation2.ok && okValidation2.value).toBe('path/subpath') 1079 | expect(notOkValidation.ok).toBe(false) 1080 | 1081 | printErrorMessage(notOkValidation) 1082 | }) 1083 | 1084 | //-------------------------------------- 1085 | // generic constraints 1086 | //-------------------------------------- 1087 | 1088 | it('can validate that a container has a minimum size', () => { 1089 | const okValidation = v.array(v.number).and(v.minSize(2)).validate([1, 2, 3]) 1090 | const okValidation2 = v.string.and(v.minSize(3)).validate('abc') 1091 | const okValidation3 = v 1092 | .dictionary(v.string, v.string) 1093 | .and(v.minSize(1)) 1094 | .validate({ a: 'a' }) 1095 | 1096 | const notOkValidation = v.array(v.number).and(v.minSize(2)).validate([0]) 1097 | const notOkValidation2 = v.string.and(v.minSize(3)).validate('') 1098 | const notOkValidation3 = v 1099 | .dictionary(v.string, v.string) 1100 | .and(v.minSize(2)) 1101 | .validate({ a: 'a' }) 1102 | 1103 | expect(okValidation.ok && okValidation.value).toEqual([1, 2, 3]) 1104 | expect(okValidation2.ok && okValidation2.value).toEqual('abc') 1105 | expect(okValidation3.ok && okValidation3.value).toEqual({ a: 'a' }) 1106 | 1107 | expect(notOkValidation.ok).toBe(false) 1108 | expect(notOkValidation2.ok).toBe(false) 1109 | expect(notOkValidation3.ok).toBe(false) 1110 | 1111 | printErrorMessage(notOkValidation) 1112 | printErrorMessage(notOkValidation2) 1113 | printErrorMessage(notOkValidation3) 1114 | }) 1115 | 1116 | it('can validate that a container is not empty', () => { 1117 | const array = [1, 2, 3] 1118 | type ArrayType = typeof array 1119 | const okValidation = v.array(v.number).and(v.nonEmpty).validate(array) 1120 | 1121 | const obj = { a: 'a' } 1122 | type ObjType = typeof obj 1123 | const okValidation2 = v 1124 | .object({ a: v.string }) 1125 | .and(v.nonEmpty) 1126 | .validate(obj) 1127 | 1128 | const notOkValidation = v 1129 | .object({ a: v.string }) 1130 | .and(v.nonEmpty) 1131 | .validate({}) 1132 | 1133 | if (okValidation.ok && okValidation2.ok) { 1134 | // Type assertion. 1135 | const _arrayType = okValidation.value.map(_ => _) 1136 | const _nonEmpty = okValidation.value[0].toExponential() // The returned array should let you access its first item 1137 | const _objType: ObjType = okValidation2.value 1138 | } else { 1139 | throw new Error() 1140 | } 1141 | 1142 | expect(notOkValidation.ok).toBe(false) 1143 | }) 1144 | }) 1145 | 1146 | function printErrorMessage(validation: v.Validation) { 1147 | if (!showErrorMessages) return 1148 | if (!validation.ok) console.log(v.errorDebugString(validation.errors)) 1149 | } 1150 | 1151 | function immutable(obj: T): Immutable { 1152 | return obj as any 1153 | } 1154 | 1155 | // Manually test how a complex type tooltip looks like in our IDE 1156 | 1157 | type UserId = string & { __tag: 'UserIds' } 1158 | 1159 | const tooltipValidator = v.object({ 1160 | id: v.string.tagged(), 1161 | address: v 1162 | .object({ 1163 | street: v.string, 1164 | zipCode: v.string 1165 | }) 1166 | .map(address => ({ ...address, comment: 4312 })), 1167 | preferences: v.union( 1168 | v.object({ name: v.literal('name1'), data: v.string.optional() }), 1169 | v.object({ name: v.literal('name2'), data: v.number }) 1170 | ), 1171 | friends: v.array( 1172 | v.object({ 1173 | id: v.string.tagged(), 1174 | name: v.string 1175 | }) 1176 | ), 1177 | dict: v.dictionary(v.string.tagged(), v.number), 1178 | intersectionOfUnions: v.intersection( 1179 | v.union( 1180 | v.object({ prop1: v.object({ aa: v.string }) }), 1181 | v.object({ data1: v.string }) 1182 | ), 1183 | v.union( 1184 | v.object({ prop2: v.object({ bb: v.string }) }), 1185 | v.object({ data2: v.number }) 1186 | ) 1187 | ), 1188 | unionOfIntersections: v.union( 1189 | v.intersection( 1190 | v.object({ name: v.literal('aa') }), 1191 | v.object({ data: v.number }) 1192 | ), 1193 | v.intersection( 1194 | v.object({ name: v.literal('bb') }), 1195 | v.object({ data: v.string }) 1196 | ) 1197 | ), 1198 | tuple: v.tuple(v.string, v.number, v.object({ name: v.string })) 1199 | }) 1200 | 1201 | type ValidatorType = typeof tooltipValidator.T 1202 | type Dict = ValidatorType['dict'] 1203 | type IntersectionOfUnions = ValidatorType['intersectionOfUnions'] 1204 | type UnionOfIntersections = ValidatorType['unionOfIntersections'] 1205 | type Tuple = ValidatorType['tuple'] 1206 | 1207 | // Helper types 1208 | 1209 | type ImmutablePrimitive = 1210 | | undefined 1211 | | null 1212 | | boolean 1213 | | string 1214 | | number 1215 | | Function 1216 | 1217 | type Immutable = T extends ImmutablePrimitive 1218 | ? T 1219 | : T extends Array 1220 | ? ImmutableArray 1221 | : T extends Map 1222 | ? ImmutableMap 1223 | : T extends Set 1224 | ? ImmutableSet 1225 | : ImmutableObject 1226 | 1227 | type ImmutableArray = ReadonlyArray> 1228 | type ImmutableMap = ReadonlyMap, Immutable> 1229 | type ImmutableSet = ReadonlySet> 1230 | type ImmutableObject = { readonly [K in keyof T]: Immutable } 1231 | --------------------------------------------------------------------------------