├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── search.js │ └── style.css ├── classes │ ├── AbstractStandardValidator.html │ ├── AbstractTypedUnionValidator.html │ ├── AbstractValidator.html │ ├── CompilingDiscriminatedUnionValidator.html │ ├── CompilingHeterogeneousUnionValidator.html │ ├── CompilingStandardValidator.html │ ├── DiscriminatedUnionValidator.html │ ├── HeterogeneousUnionValidator.html │ ├── StandardValidator.html │ └── ValidationException.html ├── functions │ └── TypeIdentifyingKey.html ├── index.html └── variables │ └── DEFAULT_DISCRIMINANT_KEY.html ├── jest.config.chrome.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── abstract │ ├── abstract-compiling-typed-union-validator.ts │ ├── abstract-standard-validator.ts │ ├── abstract-typed-union-validator.ts │ └── abstract-validator.ts ├── discriminated │ ├── compiling-discriminated-union-validator.ts │ ├── discriminated-union-validator.ts │ └── index.ts ├── heterogeneous │ ├── compiling-heterogeneous-union-validator.ts │ ├── heterogeneous-union-validator.ts │ ├── index.ts │ ├── type-identifying-key-index.ts │ └── type-identifying-key.ts ├── index.ts ├── lib │ ├── error-utils.ts │ └── validation-exception.ts ├── standard │ ├── compiling-standard-validator.ts │ ├── index.ts │ └── standard-validator.ts └── test │ ├── discriminated-union-validators-invalid.test.ts │ ├── discriminated-union-validators-valid.test.ts │ ├── heterogeneous-union-validators-invalid.test.ts │ ├── heterogeneous-union-validators-valid.test.ts │ ├── key-iteration-performance.ts │ ├── maxItems-maxLength.test.ts │ ├── standard-validators-invalid.test.ts │ ├── standard-validators-valid.test.ts │ ├── test-invalid-specs.ts │ ├── test-utils.ts │ └── test-valid-specs.ts ├── tsconfig.json └── typedoc.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Joseph T. Lapp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typebox-validators 2 | 3 | TypeBox validators with lazy compilation, custom error messages, safe error handling, discriminated and heterogeneous unions 4 | 5 | [API Reference](https://jtlapp.github.io/typebox-validators/) 6 | 7 | ## Overview 8 | 9 | **PLEASE DO NOT USE THIS LIBRARY. I have been unable to keep up with the breaking changes in TypeBox. The library no longer works with TypeBox.** 10 | 11 | The [TypeBox](https://github.com/sinclairzx81/typebox) JSON Schema validator may be the [fastest JSON validator](https://moltar.github.io/typescript-runtime-type-benchmarks/) for JavaScript/TypeScript not requiring a development-time code generation step. TypeBox provides the ability to both construct and validate JSON, but it is strictly standards compliant and does not offer commonly needed additional functionality. 12 | 13 | This library provides JSON Schema validators having this additional functionality. It wraps TypeBox so you can get TypeBox validation performance and limit your use of TypeBox to just JSON Schema specification. 14 | 15 | The library provides the following abilities, each of which is optional: 16 | 17 | 1. Replace TypeBox's validation error messages with your own error messages. 18 | 2. Fail validation at the first encountered validation error, reporting just this error. This minimizes server resources consumed by faulty or malevolent clients. 19 | 3. Collect all validation errors, such as for feedback on form user input in a browser. 20 | 4. Remove unrecognized properties from validated objects, which is important for Internet APIs. 21 | 5. Validate discriminated unions, yielding only errors for the matching member schema. 22 | 6. Validate heterogeneous unions of objects that need not have any properties in common, yielding only errors for the matching member schema. Useful for branded types. 23 | 7. Compile a TypeBox schema on its first use, subsequently using the cached compilation (lazy compilation). 24 | 8. Report all validation errors within a single string, such as for debugging purposes. 25 | 26 | Tested for Node.js and Chrome. 27 | 28 | ## Updates 29 | 30 | - **v0.3.4** - Now requires that TypeBox be installed as a peer dependency. 31 | - **v0.3.2** - Replaced `eval()` with `new Function()` to eliminate bundler safety warnings. 32 | - **v0.3.1** - Exported `AbstractStandardValidator` and `AbstractTypedUnionValidator`. 33 | - **v0.3.0** - Upgraded to latest version of TypeBox, requiring TypeBox >= 0.30.0. 34 | 35 | ## Installation 36 | 37 | Install with your preferred dependency manager: 38 | 39 | ``` 40 | npm install typebox typebox-validators 41 | 42 | yarn add typebox typebox-validators 43 | 44 | pnpm add typebox typebox-validators 45 | ``` 46 | 47 | ## Usage 48 | 49 | Select the validator or validators you want to use. The validators are split into different import files to prevent applications from including classes they don't need. 50 | 51 | Imported from `typebox-validators`: 52 | 53 | - `AbstractValidator` — Abstract base class of all validators, should you need to abstractly represent a validator. 54 | - `AbstractStandardValidator` — Abstract base class for standard validators, not supporting discriminated or heterogeneous unions. 55 | - `AbstractTypedUnionValidator` — Abstract base class for discriminated union and heterogeneous union validators. 56 | - `ValidationException` — Exception reporting validation failure, for methods that throw an exception on failure. Does not includes a stack trace. 57 | 58 | Imported from `typebox-validators/standard`: 59 | 60 | - `StandardValidator` — Non-compiling validator that validates TypeBox schemas using TypeBox validation behavior. 61 | - `CompilingStandardValiator` — Compiling validator that validates TypeBox schemas using TypeBox validation behavior. This validator compiles the schema on the first validation, caches the compilation, and thereafter uses the cached compilation. 62 | 63 | Imported from `typebox-validators/discriminated`: 64 | 65 | - `DiscriminatedUnionValidator` — Non-compiling validator that validates a union of object types, each of which has a discriminant key whose value identifies the object type. This validator validates objects against the schema for the object's type, yielding only errors relevant to that type. 66 | - `CompilingDiscriminatedUnionValidator` — Compiling validator that validates a union of object types, each of which has a discriminant key whose value identifies the object type. This validator validates objects against the schema for the object's type, yielding only errors relevant to that type. It compiles the schema for an object type on the first validaton of that type, caches the compilation, and thereafter uses the cached compilation for objects of that type. 67 | 68 | Imported from `typebox-validators/heterogeneous`: 69 | 70 | - `HeterogeneousUnionValidator` — Non-compiling validator that validates a union of object types, each of which has at least one type identifying key. This key is the name of a required property that is unique among all object types of the union, whose schema includes `typeIdentifyingKey: true`. This validator validates objects against the schema for the object's type, yielding only errors relevant to that type. 71 | - `CompilingHeterogeneousUnionValidator` — Compiling validator that validates a union of object types, each of which has at least one type identifying key. This key is the name of a required property that is unique among all object types of the union, whose schema includes `typeIdentifyingKey: true`. This validator validates objects against the schema for the object's type, yielding only errors relevant to that type. It compiles the schema for an object type on the first validaton of that type, caches the compilation, and thereafter uses the cached compilation for objects of that type. 72 | - `TypeIdentifyingKey` — Convenience class that wraps a property's schema to set `typeIdentifyingKey` to `true` for the schema. 73 | 74 | Create a validator for a particular schema and use that validator to validate a value against its schema: 75 | 76 | ```ts 77 | import { Type } from '@sinclaim/typebox'; 78 | import { StandardValidator } from 'typebox-validators/standard'; 79 | 80 | const schema = Type.Object({ 81 | handle: Type.String({ 82 | minLength: 5, 83 | maxLength: 10, 84 | pattern: '^[a-zA-Z]+$', 85 | errorMessage: 'must be a string of 5 to 10 letters', 86 | }), 87 | count: Type.Integer({ minimum: 0 }), 88 | }); 89 | 90 | const validator = new StandardValidator(schema); 91 | const value = { handle: '1234', count: -1 }; 92 | 93 | // returns false indicating an error: 94 | validator.test(value); 95 | 96 | // after calling `test`, returns an iterable providing the two errors: 97 | validator.testReturningErrors(); 98 | 99 | // after calling `test`, returns the first error: 100 | validator.testReturningFirstError(); 101 | 102 | // throws with error 'must be a string of 5 to 10 letters': 103 | validator.assert(value); 104 | 105 | // throws with error 'must be a string of 5 to 10 letters' and TypeBox's 106 | // default error message for an integer being less than the minimum: 107 | validator.validate(value); 108 | 109 | // returns an iterable providing the two errors: 110 | validator.errors(value); 111 | 112 | // returns the first error: 113 | validator.firstError(value); 114 | ``` 115 | 116 | `assert` and `validate` methods throw a [`ValidationException`](https://github.com/jtlapp/typebox-validators/blob/main/src/lib/validation-exception.ts) error when validation fails, reporting only the first error for `assert` methods and reporting all errors for `validate` methods. `assert` methods are safer to use on the server because TypeBox ensures that the `maxLength` and `maxItems` contraints are tested before testing regular expressions. `test` is faster than `assert`, which is faster than `validate` when a least one error occurs. 117 | 118 | The union validators only work on schemas of type `TUnion`. Discriminated union validators assume the discriminant key is named `kind`, unless you provide a `discriminantKey` option indicating otherwise. The type identifying key of each heterogeneous union member can be assigned either by giving the key's schema a `typeIdentifyingKey: true` option or by wrapping the key's schema in a `TypeIdentifyinKeys(schema)` call (which assigns this option). The discriminated and heterogeneous union validators both report an error when the value is not one of the types in the union. To override the default message for this error, specify your message in an `errorMessage` option on the union's schema. 119 | 120 | Any schema can provide an `errorMessage` option to indicate what error message should be used when a value doesn't satify the constraints of that particular schema. If provided, this message causes all errors reported for that schema to be collapsed into a single error having the message. The error message does not apply if the failed constraints are actually on a further nested schema. 121 | 122 | The validators all offer the same methods: 123 | 124 | 125 | | Method | Description | 126 | | --- | --- | 127 | | `test`(value) | Fast test of whether the value satisfies the schema. Returns a boolean, with `true` meaning valid. | 128 | | `testReturningErrors`(value) | Fast test of whether the value satisfies the schema, returning `null` if there are no errors, otherwise returning an iterable that yields all validation errors as instances of [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99). | 129 | | `testReturningFirstError`(value) | Fast test of whether the value satisfies the schema, returning `null` if there are no errors, otherwise returning the first [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99). | 130 | | `assert`(value, msg?) | Checks for at most one error and throws [`ValidationException`](https://github.com/jtlapp/typebox-validators/blob/main/src/lib/validation-exception.ts) to report the error. If `msg` is provided, this becomes the `message` of the exception, except that the substring `{error}` (if present) is replaced with the specific error message. The exception's `details` property provides the details of the error. | 131 | | `assertAndClean`(value, msg?) | Same as `assert`, except that when valid, the method also removes unrecognized properties from the value, if the value is an object. | 132 | | `assertAndCleanCopy`(value, msg?) | Same as `assert`, except that when valid, the method returns a copy of the value with unrecognized properties removed. | 133 | | `validate`(value, msg?) | Checks for all errors and throws [`ValidationException`](https://github.com/jtlapp/typebox-validators/blob/main/src/lib/validation-exception.ts) to report them. If `msg` is provided, this becomes the `message` of the exception. The exception's `details` property provides the details of the errors. | 134 | | `validateAndClean`(value, msg?) | Same as `validate`, except that when valid, the method also removes unrecognized properties from the value, if the value is an object. | 135 | | `validateAndCleanCopy`(value, msg?) | Same as `validate`, except that when valid, the method returns a copy of the value with unrecognized properties removed. | 136 | | `errors`(value) | Returns an iterable that yields all validation errors as instances of [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99). When there are no errors, the iterable yields no values. Call `test` first for better performance. | 137 | | `firstError`(value) | Returns the first [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99), if there is a validation error, and `null` otherwise. Call `test` first for better performance. | 138 | 139 | If you want validation to fail when an object has properties not given by the schema, use the [`additionalProperties`](https://json-schema.org/understanding-json-schema/reference/object.html#additional-properties) option in the object's schema. In this case, there would be no need to use the various "clean" methods. 140 | 141 | The `details` property of a [`ValidationException`](https://github.com/jtlapp/typebox-validators/blob/main/src/lib/validation-exception.ts) contains an array of [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99) instances, one for each detected error. Call `toString()` on the exception to get a single string that describes all of the errors found in `details`. 142 | 143 | ## ValidationException 144 | 145 | When an `assert` or `validate` method fails validation, it throws a [`ValidationException`](https://github.com/jtlapp/typebox-validators/blob/main/src/lib/validation-exception.ts). The inputs to the constructor are a mandatory error message and an optional array of [`ValueError`](https://github.com/sinclairzx81/typebox/blob/master/src/errors/errors.ts#L99)s. These are available via the `message` and `details` properties. 146 | 147 | ```ts 148 | constructor(readonly message: string, readonly details: ValueError[] = []) 149 | ``` 150 | 151 | Call the `toString()` method to get a string represenation that includes the error message and a bulleted list of all the detailed errors. Each bullet provides the object path to the erroring field and the error message for the field. 152 | 153 | `ValidationException` does not subclass JavaScript's `Error` class. This prevents a stack trace from being generated for each exception, improving performance and saving memory. However, this means that if you validate for purposes of verifying program correctness, you'll need the error message to include enough information to identify the particular code that errored. 154 | 155 | Also, not subclassing `Error` has implications for testing in Jest and Chai. Asynchronous exceptions require special treatment, as `toThrow()` (Jest) and `rejectedWith()` (Chai + [chai-as-promised](https://www.chaijs.com/plugins/chai-as-promised/)) will not detect the exception. Test for asynchronous validation exceptions as follows instead: 156 | 157 | ```ts 158 | import { ValidationException } from 'typebox-validators'; 159 | 160 | const wait = () => 161 | new Promise((_resolve, reject) => 162 | setTimeout(() => reject(new ValidationException('Invalid')), 100) 163 | ); 164 | 165 | // Jest 166 | await expect(wait()).rejects.toBeInstanceOf(ValidationException); 167 | // Chai 168 | await chai 169 | .expect(wait()) 170 | .to.eventually.be.rejected.and.be.an.instanceOf(ValidationException); 171 | ``` 172 | 173 | Synchronous exceptions can be detected normally, as with the following code: 174 | 175 | ```ts 176 | const fail = () => { 177 | throw new ValidationException('Invalid'); 178 | }; 179 | 180 | // Jest 181 | expect(fail).toThrow(ValidationException); 182 | // Chai 183 | chai.expect(fail).to.throw().and.be.an.instanceOf(ValidationException); 184 | ``` 185 | 186 | ## Discriminated Union Examples 187 | 188 | ```ts 189 | import { Type } from '@sinclaim/typebox'; 190 | import { DiscriminatedUnionValidator } from 'typebox-validators/discriminated'; 191 | 192 | const schema1 = Type.Union([ 193 | Type.Object({ 194 | kind: Type.Literal('string'), 195 | val: Type.String(), 196 | }), 197 | Type.Object({ 198 | kind: Type.Literal('integer'), 199 | val: Type.Integer(), 200 | units: Type.Optional(Type.String()), 201 | }), 202 | ]); 203 | 204 | const validator1 = new DiscriminatedUnionValidator(schema1); 205 | 206 | // throws exception with message "Invalid value" and the single error 207 | // "Object type not recognized" for path "": 208 | validator1.assert({ kind: 'float', val: 1.5 }); 209 | 210 | // throws exception with message "Oopsie! val - Expected integer" 211 | // and the single error "Expected integer" for path "/val": 212 | validator1.assert({ kind: 'integer', val: 1.5 }, 'Oopsie! {error}'); 213 | ``` 214 | 215 | ```ts 216 | const schema2 = Type.Union( 217 | [ 218 | Type.Object({ 219 | __type: Type.Literal('string'), 220 | val: Type.String({ errorMessage: 'Must be a string' }), 221 | }), 222 | Type.Object({ 223 | __type: Type.Literal('integer'), 224 | val: Type.Integer({ errorMessage: 'Must be an integer' }), 225 | units: Type.Optional(Type.String()), 226 | }), 227 | ], 228 | { discriminantKey: '__type', errorMessage: 'Unknown type' } 229 | ); 230 | 231 | const validator2 = new DiscriminatedUnionValidator(schema2); 232 | 233 | // throws exception with message "Invalid value" and the single error 234 | // "Unknown type" for path "": 235 | validator2.assert({ __type: 'float', val: 1.5 }); 236 | 237 | // throws exception with message "Oopsie! val - Must be an integer" 238 | // and the single error "Must be an integer" for path "/val": 239 | validator2.assert({ __type: 'integer', val: 1.5 }, 'Oopsie! {error}'); 240 | ``` 241 | 242 | ## Heterogeneous Union Examples 243 | 244 | ```ts 245 | import { Type } from '@sinclaim/typebox'; 246 | import { 247 | TypeIdentifyingKey, 248 | HeterogeneousUnionValidator 249 | } from 'typebox-validators/heterogeneous'; 250 | 251 | const schema3 = Type.Union([ 252 | Type.Object({ 253 | summaryBrand: TypeIdentifyingKey(Type.String()), 254 | name: Type.String(), 255 | address: Type.String() 256 | zipCode: Type.String() 257 | }), 258 | Type.Object({ 259 | detailedBrand: TypeIdentifyingKey(Type.String()), 260 | firstName: Type.String(), 261 | lastName: Type.String(), 262 | streetAddress: Type.String(), 263 | city: Type.String(), 264 | state: Type.String(), 265 | zipCode: Type.String() 266 | }), 267 | ]); 268 | 269 | const validator3 = new HeterogeneousUnionValidator(schema3); 270 | 271 | // throws exception with message "Bad info" and the single error 272 | // "Object type not recognized" for path "": 273 | validator3.assert({ name: 'Jane Doe', zipcode: 12345 }, "Bad info"); 274 | 275 | // throws exception with message "Bad info: address - Expected string" 276 | // and the single error "Expected string" for path "/address": 277 | validator3.assert({ summaryBrand: '', name: 'Jane Doe' }, 'Bad info: {error}'); 278 | ``` 279 | 280 | ```ts 281 | const schema4 = Type.Union([ 282 | Type.Object({ 283 | name: TypeIdentifyingKey(Type.String()), 284 | address: Type.String({ errorMessage: 'Required string' }) 285 | zipCode: Type.String() 286 | }), 287 | Type.Object({ 288 | firstName: TypeIdentifyingKey(Type.String()), 289 | lastName: Type.String({ errorMessage: 'Required string' }), 290 | streetAddress: Type.String(), 291 | city: Type.String(), 292 | state: Type.String(), 293 | zipCode: Type.String() 294 | }), 295 | ]); 296 | 297 | const validator4 = new HeterogeneousUnionValidator(schema4); 298 | 299 | // throws exception with message "Bad info" and the single error 300 | // "Required string" for path "/address": 301 | validator4.assert({ name: 'Jane Doe', zipcode: 12345 }, "Bad info"); 302 | 303 | // throws exception with message "Bad info: lastName - Required string" 304 | // and the single error "Required string" for path "/lastName": 305 | validator4.assert({ firstName: 'Jane', zipcode: 12345 }, 'Bad info: {error}'); 306 | 307 | // throws exception with message "Invalid value" and the single error 308 | // "Object type not recognized" for path "": 309 | validator1.assert({ address: "123 Some Str, etc.", zipcode: 12345 }); 310 | 311 | ``` 312 | 313 | ## License 314 | 315 | MIT License. Copyright © 2023 Joseph T. Lapp 316 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-code-background: #FFFFFF; 3 | --dark-code-background: #1E1E1E; 4 | } 5 | 6 | @media (prefers-color-scheme: light) { :root { 7 | --code-background: var(--light-code-background); 8 | } } 9 | 10 | @media (prefers-color-scheme: dark) { :root { 11 | --code-background: var(--dark-code-background); 12 | } } 13 | 14 | :root[data-theme='light'] { 15 | --code-background: var(--light-code-background); 16 | } 17 | 18 | :root[data-theme='dark'] { 19 | --code-background: var(--dark-code-background); 20 | } 21 | 22 | pre, code { background: var(--code-background); } 23 | -------------------------------------------------------------------------------- /docs/classes/ValidationException.html: -------------------------------------------------------------------------------- 1 | ValidationException | typebox-validators
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Class ValidationException

18 |
19 |

Exception reporting the occurrence of one or more validation errors.

20 |
21 |
22 |

Hierarchy

23 |
    24 |
  • ValidationException
27 |
28 |
29 |
30 | 31 |
32 |
33 |

Constructors

34 |
36 |
37 |

Properties

38 |
details 39 | message 40 |
41 |
42 |

Methods

43 |
46 |
47 |

Constructors

48 |
49 | 50 |
67 |
68 |

Properties

69 |
70 | 71 |
details: readonly ValueError[] = []
72 |

The individual validation errors

73 |
76 |
77 | 78 |
message: string
79 |

Overall error message

80 |
83 |
84 |

Methods

85 |
86 | 87 |
    88 | 89 |
  • 90 |

    Returns a string representation of the error. Provides the overall 91 | error message, followed by the specific error messages, one per line.

    92 |
    93 |

    Returns string

    a string representation of the error.

    94 |
97 |
98 | 99 |
    100 | 101 |
  • 102 |

    Returns a string representation of a validation error, which precedes 103 | the error with its reference path if it occurs in an object.

    104 |
    105 |
    106 |

    Parameters

    107 |
      108 |
    • 109 |
      error: ValueError
    110 |

    Returns string

113 |
114 | 135 |
150 |
151 |

Generated using TypeDoc

152 |
-------------------------------------------------------------------------------- /docs/functions/TypeIdentifyingKey.html: -------------------------------------------------------------------------------- 1 | TypeIdentifyingKey | typebox-validators
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Function TypeIdentifyingKey

18 |
19 |
    20 | 21 |
  • 22 |

    Marks an occurrence of a schema as being the property of a key that 23 | uniquely identifies its containing object within a heterogeneous union.

    24 |
    25 |
    26 |

    Parameters

    27 |
      28 |
    • 29 |
      schema: Readonly<TSchema>
    30 |

    Returns TSchema

33 |
34 | 46 |
61 |
62 |

Generated using TypeDoc

63 |
-------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | typebox-validators
2 |
3 | 10 |
11 |
12 | 39 |
40 | 52 |
67 |
68 |

Generated using TypeDoc

69 |
-------------------------------------------------------------------------------- /docs/variables/DEFAULT_DISCRIMINANT_KEY.html: -------------------------------------------------------------------------------- 1 | DEFAULT_DISCRIMINANT_KEY | typebox-validators
2 |
3 | 10 |
11 |
12 |
13 |
14 | 17 |

Variable DEFAULT_DISCRIMINANT_KEYConst

18 |
DEFAULT_DISCRIMINANT_KEY: "kind" = 'kind'
19 |

The key providing the object type in discriminated unions, if not 20 | specified in the schema's discriminantKey option.

21 |
24 |
25 | 37 |
52 |
53 |

Generated using TypeDoc

54 |
-------------------------------------------------------------------------------- /jest.config.chrome.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer', 3 | testTimeout: 8000, 4 | roots: [''], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | moduleFileExtensions: ['ts', 'js', 'json'], 9 | modulePathIgnorePatterns: [ 10 | '/test/__fixtures__', 11 | '/node_modules', 12 | '/dist', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [""], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest", 5 | }, 6 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 7 | modulePathIgnorePatterns: [ 8 | "/test/__fixtures__", 9 | "/node_modules", 10 | "/dist", 11 | ], 12 | preset: "ts-jest", 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typebox-validators", 3 | "version": "0.3.5", 4 | "author": "Joseph T. Lapp ", 5 | "license": "MIT", 6 | "description": "TypeBox validators with lazy compilation, custom error messages, safe error handling, discriminated and heterogeneous unions", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jtlapp/typebox-validators" 10 | }, 11 | "keywords": [ 12 | "typebox", 13 | "validation", 14 | "errors", 15 | "error handling", 16 | "lazy compilation", 17 | "safe", 18 | "secure", 19 | "discriminated unions", 20 | "branded types" 21 | ], 22 | "scripts": { 23 | "clean": "rm -rf node_modules && rm -rf dist", 24 | "build": "rm -rdf dist && tsc && cp package.json dist && cp README.md dist && cp LICENSE dist", 25 | "build-docs": "typedoc --options ./typedoc.js ./src/index.ts", 26 | "build-all": "npm run build && npm run build-docs", 27 | "test": "npm run test-node && npm run test-chrome", 28 | "test-node": "jest", 29 | "test-chrome": "cross-env PUPPETEER_DISABLE_HEADLESS_WARNING=true jest --config jest.config.chrome.js", 30 | "test-perf": "ts-node src/test/key-iteration-performance.ts", 31 | "build-and-publish": "npm run build && (cd dist && npm publish)" 32 | }, 33 | "peerDependencies": { 34 | "@sinclair/typebox": "^0.30.4" 35 | }, 36 | "devDependencies": { 37 | "@sinclair/typebox": "^0.30.2", 38 | "@types/jest": "^29.4.0", 39 | "cross-env": "^7.0.3", 40 | "jest": "^29.4.3", 41 | "jest-puppeteer": "^9.0.0", 42 | "puppeteer": "^20.7.3", 43 | "ts-jest": "^29.0.5", 44 | "ts-node": "^10.9.1", 45 | "typedoc": "^0.24.5", 46 | "typescript": "^4.9.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/abstract/abstract-compiling-typed-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TUnion } from '@sinclair/typebox'; 2 | import { Value, ValueError } from '@sinclair/typebox/value'; 3 | import { TypeCompiler } from '@sinclair/typebox/compiler'; 4 | 5 | import { AbstractTypedUnionValidator } from './abstract-typed-union-validator'; 6 | import { 7 | createErrorsIterable, 8 | createUnionTypeError, 9 | createUnionTypeErrorIterable, 10 | throwInvalidAssert, 11 | throwInvalidValidate, 12 | } from '../lib/error-utils'; 13 | 14 | export type FindSchemaMemberIndex = (value: unknown) => number | null; 15 | export type SchemaMemberTest = (value: object) => boolean; 16 | 17 | /** 18 | * Abstract validatory for typed unions, performing lazy compilation. 19 | */ 20 | export abstract class AbstractCompilingTypedUnionValidator< 21 | S extends TUnion 22 | > extends AbstractTypedUnionValidator { 23 | #compiledSchemaMemberTests: (SchemaMemberTest | undefined)[]; 24 | 25 | /** @inheritdoc */ 26 | constructor(schema: Readonly) { 27 | super(schema); 28 | this.#compiledSchemaMemberTests = new Array(schema.anyOf.length); 29 | } 30 | 31 | /** @inheritdoc */ 32 | override test(value: Readonly): boolean { 33 | const memberIndex = this.compiledFindSchemaMemberIndex(value); 34 | return this.compiledSchemaMemberTest(memberIndex, value); 35 | } 36 | 37 | /** @inheritdoc */ 38 | override errors(value: Readonly): Iterable { 39 | const indexOrError = this.compiledFindSchemaMemberIndexOrError(value); 40 | if (typeof indexOrError !== 'number') { 41 | return createUnionTypeErrorIterable(indexOrError); 42 | } 43 | return createErrorsIterable( 44 | Value.Errors(this.schema.anyOf[indexOrError], value) 45 | ); 46 | } 47 | 48 | protected override assertReturningSchema( 49 | value: Readonly, 50 | overallError?: string 51 | ): TObject { 52 | const indexOrError = this.compiledFindSchemaMemberIndexOrError(value); 53 | if (typeof indexOrError !== 'number') { 54 | throwInvalidAssert(overallError, indexOrError); 55 | } 56 | const memberSchema = this.schema.anyOf[indexOrError]; 57 | if (!this.compiledSchemaMemberTest(indexOrError, value)) { 58 | throwInvalidAssert( 59 | overallError, 60 | Value.Errors(memberSchema, value).First()! 61 | ); 62 | } 63 | return memberSchema; 64 | } 65 | 66 | protected override validateReturningSchema( 67 | value: Readonly, 68 | overallError?: string 69 | ): TObject { 70 | const indexOrError = this.compiledFindSchemaMemberIndexOrError(value); 71 | if (typeof indexOrError !== 'number') { 72 | throwInvalidValidate(overallError, indexOrError); 73 | } 74 | const memberSchema = this.schema.anyOf[indexOrError]; 75 | if (!this.compiledSchemaMemberTest(indexOrError, value)) { 76 | throwInvalidValidate(overallError, Value.Errors(memberSchema, value)); 77 | } 78 | return memberSchema; 79 | } 80 | 81 | protected compiledFindSchemaMemberIndexOrError( 82 | value: Readonly 83 | ): number | ValueError { 84 | const memberIndex = this.compiledFindSchemaMemberIndex(value); 85 | if (memberIndex === null) { 86 | return createUnionTypeError(this.schema, value); 87 | } 88 | return memberIndex; 89 | } 90 | 91 | protected abstract compiledFindSchemaMemberIndex( 92 | value: Readonly 93 | ): number | null; 94 | 95 | private compiledSchemaMemberTest( 96 | memberIndex: number | null, 97 | value: Readonly 98 | ): boolean { 99 | if (memberIndex === null) { 100 | return false; 101 | } 102 | if (this.#compiledSchemaMemberTests[memberIndex] === undefined) { 103 | let code = TypeCompiler.Compile(this.schema.anyOf[memberIndex]).Code(); 104 | code = code.replace( 105 | `(typeof value === 'object' && value !== null && !Array.isArray(value)) &&`, 106 | '' 107 | ); 108 | // provide some resilience to change in TypeBox compiled code formatting 109 | const startOfFunction = code.indexOf('function'); 110 | const startOfReturn = code.indexOf('return', startOfFunction); 111 | code = 112 | 'return ' + 113 | code.substring(code.indexOf('(', startOfReturn), code.length - 1); 114 | this.#compiledSchemaMemberTests[memberIndex] = new Function( 115 | 'value', 116 | code 117 | ) as SchemaMemberTest; 118 | } 119 | return this.#compiledSchemaMemberTests[memberIndex]!(value); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/abstract/abstract-standard-validator.ts: -------------------------------------------------------------------------------- 1 | import type { Static, TSchema } from '@sinclair/typebox'; 2 | 3 | import { AbstractValidator } from './abstract-validator'; 4 | 5 | /** 6 | * Abstract validator for standard TypeBox values. 7 | */ 8 | export abstract class AbstractStandardValidator< 9 | S extends TSchema 10 | > extends AbstractValidator { 11 | /** @inheritdoc */ 12 | constructor(schema: Readonly) { 13 | super(schema); 14 | } 15 | 16 | /** @inheritdoc */ 17 | override assertAndClean(value: unknown, overallError?: string): void { 18 | this.assert(value as any, overallError); 19 | this.cleanValue(this.schema, value); 20 | } 21 | 22 | /** @inheritdoc */ 23 | override assertAndCleanCopy( 24 | value: Readonly, 25 | overallError?: string 26 | ): Static { 27 | this.assert(value, overallError); 28 | return this.cleanCopyOfValue(this.schema, value); 29 | } 30 | 31 | /** @inheritdoc */ 32 | override validateAndClean(value: unknown, overallError?: string): void { 33 | this.validate(value as any, overallError); 34 | this.cleanValue(this.schema, value); 35 | } 36 | 37 | /** @inheritdoc */ 38 | override validateAndCleanCopy( 39 | value: Readonly, 40 | overallError?: string 41 | ): Static { 42 | this.validate(value, overallError); 43 | return this.cleanCopyOfValue(this.schema, value); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/abstract/abstract-typed-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { Static, TObject, TUnion } from '@sinclair/typebox'; 2 | 3 | import { AbstractValidator } from './abstract-validator'; 4 | 5 | /** 6 | * The key providing the object type in discriminated unions, if not 7 | * specified in the schema's `discriminantKey` option. 8 | */ 9 | export const DEFAULT_DISCRIMINANT_KEY = 'kind'; 10 | 11 | /** 12 | * Abstract validator for values that are typed member unions of objects. 13 | */ 14 | export abstract class AbstractTypedUnionValidator< 15 | S extends TUnion 16 | > extends AbstractValidator { 17 | constructor(schema: S) { 18 | super(schema); 19 | } 20 | 21 | /** @inheritdoc */ 22 | override assert(value: Readonly, overallError?: string): void { 23 | this.assertReturningSchema(value, overallError); 24 | } 25 | 26 | /** @inheritdoc */ 27 | override assertAndClean(value: unknown, overallError?: string): void { 28 | const schema = this.assertReturningSchema(value as any, overallError); 29 | this.cleanValue(schema, value); 30 | } 31 | 32 | /** @inheritdoc */ 33 | override assertAndCleanCopy( 34 | value: Readonly, 35 | overallError?: string 36 | ): Static { 37 | const schema = this.assertReturningSchema(value, overallError); 38 | return this.cleanCopyOfValue(schema, value); 39 | } 40 | 41 | /** @inheritdoc */ 42 | override validate(value: Readonly, overallError?: string): void { 43 | this.validateReturningSchema(value, overallError); 44 | } 45 | 46 | /** @inheritdoc */ 47 | override validateAndClean(value: unknown, overallError?: string): void { 48 | const schema = this.validateReturningSchema(value as any, overallError); 49 | this.cleanValue(schema, value); 50 | } 51 | 52 | /** @inheritdoc */ 53 | override validateAndCleanCopy( 54 | value: Readonly, 55 | overallError?: string 56 | ): Static { 57 | const schema = this.validateReturningSchema(value, overallError); 58 | return this.cleanCopyOfValue(schema, value); 59 | } 60 | 61 | protected abstract assertReturningSchema( 62 | value: Readonly, 63 | overallError?: string 64 | ): TObject; 65 | 66 | protected abstract validateReturningSchema( 67 | value: Readonly, 68 | overallError?: string 69 | ): TObject; 70 | 71 | protected toValueKeyDereference(key: string): string { 72 | return /^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(key) 73 | ? `value.${key}` 74 | : `value['${key.replace(/'/g, "\\'")}']`; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/abstract/abstract-validator.ts: -------------------------------------------------------------------------------- 1 | import type { TSchema, Static } from '@sinclair/typebox'; 2 | import { Value, ValueError } from '@sinclair/typebox/value'; 3 | 4 | import { throwInvalidAssert, throwInvalidValidate } from '../lib/error-utils'; 5 | 6 | /** 7 | * Abstract base class for validators, providing validation services for a 8 | * JSON schema, offering both short-circuting and full validation, supporting 9 | * custom error messages, and optionally removing unrecognized properties. 10 | * 11 | * As of TypeBox version 0.28.13, when validating any given value, TypeBox 12 | * checks the value's `maxItems` and `maxLength` constraints before testing 13 | * any other constraints. An API endpoint can therefore protect itself from 14 | * faulty and malicious clients by short-circuting validation after the 15 | * first validation error. The `test` method does this, but it does not 16 | * return any errors. If you want to short-circuit validation and return the 17 | * first error, use one of the `assert` methods. These methods protect the 18 | * server from running regex checks on excessively long strings and from 19 | * running regex checks on all the items of excessively long arrays. 20 | * 21 | * @typeParam S Type for a JSON schema, expressed as a TypeBox type. 22 | */ 23 | export abstract class AbstractValidator { 24 | /** 25 | * @param schema JSON schema against which to validate values. When a schema 26 | * provides an `errorMessage` string option, all errors occurring for that 27 | * schema (but not for nested schemas) collapse into a single error having 28 | * this message. The `errorMessage` option allows you to provide a custom 29 | * error message for a schema. For example, an `errorMessage` on a schema 30 | * for a property of an object replaces TypeBox's built-in error messages 31 | * for errors that occur on that property. 32 | */ 33 | constructor(readonly schema: Readonly) {} 34 | 35 | /** 36 | * Tests whether a value conforms to the schema. For performance reasons, it 37 | * is best to call this method before calling `errors()` or `firstError()`, 38 | * should you also need to information about the errors. This method does not 39 | * throw `ValidationException` and does not clean values of unrecognized 40 | * properties. 41 | * 42 | * @param value Value to validate against the schema. 43 | * @returns `true` when the value conforms to the schema, `false` otherwise. 44 | */ 45 | abstract test(value: Readonly): boolean; 46 | 47 | /** 48 | * Tests whether a value conforms to the schema, returning an iterable whose 49 | * iterator yields the validation errors, or returning `null` if there are no 50 | * validation errors. This method is equivalent to calling `test()` and then 51 | * `errors()` and exists only for convenience. The method does not throw 52 | * `ValidationException` and does not clean values of unrecognized properties. 53 | * 54 | * @param value Value to validate against the schema. 55 | * @returns An iteratable yielding all validation errors, if any, otherwise 56 | * `null`. Upon detecting one or more errors for a particular schema 57 | * (possibly a nested schema), if the schema provides an `errorMessage` 58 | * property, only a single error is reported for the schema, and the 59 | * `message` property of this error is set to `errorMessage`'s value. Also, 60 | * the TypeBox error "Expected required property" is dropped when at least 61 | * one other error is reported for the property. Consequently, only the 62 | * `Type.Any` and `Type.Unknown` schemas can yield "Expected required 63 | * property" errors. 64 | */ 65 | testReturningErrors(value: Readonly): Iterable | null { 66 | return this.test(value) ? null : this.errors(value); 67 | } 68 | 69 | /** 70 | * Tests whether a value conforms to the schema, returning the first error, 71 | * or returning `null` if there is no error. This method is equivalent to 72 | * calling `test()` and then `firstError()` and exists only for convenience. 73 | * The method does not throw `ValidationException` and does not clean values 74 | * of unrecognized properties. 75 | * 76 | * @param value Value to validate against the schema. 77 | * @returns The first validation error, if there is a validation error, 78 | * otherwise `null`. 79 | */ 80 | testReturningFirstError(value: Readonly): ValueError | null { 81 | const iterable = this.testReturningErrors(value); 82 | if (iterable === null) { 83 | return null; 84 | } 85 | const result = iterable[Symbol.iterator]().next(); 86 | return result.done ? null : result.value; 87 | } 88 | 89 | /** 90 | * Validates a value against the schema, halting validation at the first 91 | * validation error with a `ValidationException`. Does not clean values of 92 | * unrecognized properties. 93 | * 94 | * @param value Value to validate against the schema. 95 | * @param errorMessage Overall eror message to report in the exception. 96 | * The substring `{error}`, if present, will be replaced with a string 97 | * representation of the error. Defaults to "Invalid value". 98 | * @throws ValidationException when the value is invalid, reporting only 99 | * the first validation error in the `details` property. The `errorMessage` 100 | * parameter provides the exception's overall error message. 101 | */ 102 | abstract assert(value: Readonly, errorMessage?: string): void; 103 | 104 | /** 105 | * Validates a value against the schema, halting validation at the first 106 | * validation error with a `ValidationException`. On successful validation, 107 | * removes unrecognized properties from the provided value. 108 | * 109 | * @param value Value to validate against the schema. 110 | * @param errorMessage Overall eror message to report in the exception. 111 | * The substring `{error}`, if present, will be replaced with a string 112 | * representation of the error. Defaults to "Invalid value". 113 | * @throws ValidationException when the value is invalid, reporting only 114 | * the first validation error in the `details` property. The `errorMessage` 115 | * parameter provides the exception's overall error message. 116 | */ 117 | abstract assertAndClean(value: unknown, errorMessage?: string): void; 118 | 119 | /** 120 | * Validates a value against the schema, halting validation at the first 121 | * validation error with a `ValidationException`. On successful validation, 122 | * returns a copy of the value with unrecognized properties removed, but 123 | * returns the original value if there are no unrecognized properties. 124 | * 125 | * @param value Value to validate against the schema. 126 | * @param errorMessage Overall eror message to report in the exception. 127 | * The substring `{error}`, if present, will be replaced with a string 128 | * representation of the error. Defaults to "Invalid value". 129 | * @returns The provided value itself if the value is not an object or if 130 | * the value is an object having no unrecognized properties. If the value 131 | * is an object having at least one unrecognized property, returns a copy 132 | * of the value with unrecognized properties removed. 133 | * @throws ValidationException when the value is invalid, reporting only 134 | * the first validation error in the `details` property. The `errorMessage` 135 | * parameter provides the exception's overall error message. 136 | */ 137 | abstract assertAndCleanCopy( 138 | value: Readonly, 139 | errorMessage?: string 140 | ): Static; 141 | 142 | /** 143 | * Validates a value against the schema, detecting all validation errors 144 | * and reporting them by throwing `ValidationException`. Does not clean 145 | * values of unrecognized properties. 146 | * 147 | * @param value Value to validate against the schema. 148 | * @param errorMessage Overall error message to report in the exception. 149 | * @throws ValidationException when the value is invalid, reporting all 150 | * validation errors in the `details` property. The `errorMessage` 151 | * parameter provides the exception's overall error message. 152 | */ 153 | abstract validate(value: Readonly, errorMessage?: string): void; 154 | 155 | /** 156 | * Validates a value against the schema, detecting all validation errors 157 | * and reporting them by throwing `ValidationException`. On successful 158 | * validation, removes unrecognized properties from the provided value. 159 | * 160 | * @param value Value to validate against the schema. 161 | * @param errorMessage Overall error message to report in the exception.= 162 | * @throws ValidationException when the value is invalid, reporting all 163 | * validation errors in the `details` property. The `errorMessage` 164 | * parameter provides the exception's overall error message. 165 | */ 166 | abstract validateAndClean(value: unknown, errorMessage?: string): void; 167 | 168 | /** 169 | * Validates a value against the schema, detecting all validation errors 170 | * and reporting them with a `ValidationException`. On successful validation, 171 | * returns a copy of the value with unrecognized properties removed, but 172 | * returns the original value if there are no unrecognized properties. 173 | * 174 | * @param value Value to validate against the schema. 175 | * @param errorMessage Overall error message to report in the exception. 176 | * @returns The provided value itself if the value is not an object or if 177 | * the value is an object having no unrecognized properties. If the value 178 | * is an object having at least one unrecognized property, returns a copy 179 | * of the value with unrecognized properties removed. 180 | * @throws ValidationException when the value is invalid, reporting all 181 | * validation errors in the `details` property. The `errorMessage` 182 | * parameter provides the exception's overall error message. 183 | */ 184 | abstract validateAndCleanCopy( 185 | value: Readonly, 186 | errorMessage?: string 187 | ): Static; 188 | 189 | /** 190 | * Validates a value against the schema and returns an iteratable whose 191 | * iterator yields the validation errors. The iterator tests the value for the 192 | * next error on each call to `next()`, returning a `ValueError` for the error 193 | * until done. It does not evaluate errors in advance of their being 194 | * requested, allowing you to short-circuit validation by stopping iteration 195 | * early. For performance reasons, it is best to call `test()` before calling 196 | * this method. This method does not throw `ValidationException` and does not 197 | * clean values of unrecognized properties. 198 | * 199 | * @param value Value to validate against the schema. 200 | * @returns An iteratable yielding all validation errors. Upon detecting one 201 | * or more errors for a particular schema (possibly a nested schema), if the 202 | * schema provides an `errorMessage` property, only a single error is 203 | * reported for the schema, and the `message` property of this error is set 204 | * to `errorMessage`'s value. Also, the TypeBox error "Expected required 205 | * property" is dropped when at least one other error is reported for the 206 | * property. Consequently, only the `Type.Any` and `Type.Unknown` schemas can 207 | * yield "Expected required property" errors. 208 | */ 209 | abstract errors(value: Readonly): Iterable; 210 | 211 | /** 212 | * Validates a value against the schema and returns the first error, 213 | * returning `null` if there is no error. No validation is performed beyond 214 | * the first error, allowing you to protect the server from wasting time and 215 | * memory validating excessively long strings. It is equivalent to calling 216 | * `next()` exactly once on the iterator returned by `errors()`, serving 217 | * only as a convenience method. For performance reasons, it is best to call 218 | * `test()` before calling this method. This method does not throw 219 | * `ValidationException` and does not clean values of unrecognized properties. 220 | * 221 | * @param value Value to validate against the schema. 222 | * @returns The first validation error, if there is a validation error, 223 | * otherwise `null`. 224 | */ 225 | firstError(value: Readonly): ValueError | null { 226 | const iterator = this.errors(value)[Symbol.iterator](); 227 | const result = iterator.next(); 228 | return result.done ? null : result.value; 229 | } 230 | 231 | protected cleanCopyOfValue( 232 | schema: Readonly, 233 | value: Readonly 234 | ): Static { 235 | if (schema.type === 'object' && typeof value === 'object') { 236 | const cleanedValue: Record = {}; 237 | Object.keys(schema.properties).forEach((key) => { 238 | cleanedValue[key] = (value as Record)[key]; 239 | }); 240 | return cleanedValue; 241 | } 242 | return value; 243 | } 244 | 245 | protected cleanValue( 246 | schema: Readonly, 247 | value: unknown 248 | ): void { 249 | if (schema.type === 'object' && typeof value === 'object') { 250 | const schemaKeys = Object.keys(schema.properties); 251 | Object.getOwnPropertyNames(value).forEach((key) => { 252 | if (!schemaKeys.includes(key)) { 253 | delete (value as Record)[key]; 254 | } 255 | }); 256 | } 257 | } 258 | 259 | protected uncompiledAssert( 260 | schema: Readonly, 261 | value: Readonly, 262 | overallError?: string 263 | ): void { 264 | if (!Value.Check(schema, value)) { 265 | throwInvalidAssert(overallError, Value.Errors(schema, value).First()!); 266 | } 267 | } 268 | 269 | protected uncompiledValidate( 270 | schema: Readonly, 271 | value: Readonly, 272 | overallError?: string 273 | ): void { 274 | if (!Value.Check(schema, value)) { 275 | throwInvalidValidate(overallError, Value.Errors(schema, value)); 276 | } 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/discriminated/compiling-discriminated-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TUnion } from '@sinclair/typebox'; 2 | 3 | import { DEFAULT_DISCRIMINANT_KEY } from '../abstract/abstract-typed-union-validator'; 4 | import { 5 | AbstractCompilingTypedUnionValidator, 6 | FindSchemaMemberIndex, 7 | } from '../abstract/abstract-compiling-typed-union-validator'; 8 | 9 | /** 10 | * Lazily compiled validator for discriminated-union unions. To improve 11 | * performance, list the more frequently used types earlier in the union, 12 | * and list each object's discriminant key first in its properties. 13 | */ 14 | export class CompilingDiscriminatedUnionValidator< 15 | S extends TUnion 16 | > extends AbstractCompilingTypedUnionValidator { 17 | #discriminantKey: string; 18 | #compiledFindSchemaMemberIndex?: FindSchemaMemberIndex; 19 | 20 | /** @inheritdoc */ 21 | constructor(schema: Readonly) { 22 | super(schema); 23 | this.#discriminantKey = 24 | this.schema.discriminantKey ?? DEFAULT_DISCRIMINANT_KEY; 25 | } 26 | 27 | protected override compiledFindSchemaMemberIndex( 28 | value: Readonly 29 | ): number | null { 30 | if (this.#compiledFindSchemaMemberIndex === undefined) { 31 | const codeParts: string[] = [ 32 | `if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; 33 | switch (${this.toValueKeyDereference(this.#discriminantKey)}) {\n`, 34 | ]; 35 | for (let i = 0; i < this.schema.anyOf.length; ++i) { 36 | const discriminantSchema = 37 | this.schema.anyOf[i].properties[this.#discriminantKey]; 38 | if (discriminantSchema === undefined) { 39 | throw Error( 40 | `Discriminant key '${ 41 | this.#discriminantKey 42 | }' not present in all members of discriminated union` 43 | ); 44 | } 45 | const literal = discriminantSchema.const; 46 | if (typeof literal === 'string') { 47 | codeParts.push( 48 | `case '${literal.replace(/'/g, "\\'")}': return ${i};\n` 49 | ); 50 | } else { 51 | codeParts.push(`case ${literal}: return ${i};\n`); 52 | } 53 | } 54 | const code = codeParts.join('') + 'default: return null; }'; 55 | this.#compiledFindSchemaMemberIndex = new Function( 56 | 'value', 57 | code 58 | ) as FindSchemaMemberIndex; 59 | } 60 | return this.#compiledFindSchemaMemberIndex(value); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/discriminated/discriminated-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TUnion } from '@sinclair/typebox'; 2 | import { Value, ValueError } from '@sinclair/typebox/value'; 3 | 4 | import { 5 | AbstractTypedUnionValidator, 6 | DEFAULT_DISCRIMINANT_KEY, 7 | } from '../abstract/abstract-typed-union-validator'; 8 | import { 9 | createErrorsIterable, 10 | createUnionTypeError, 11 | createUnionTypeErrorIterable, 12 | throwInvalidAssert, 13 | throwInvalidValidate, 14 | } from '../lib/error-utils'; 15 | 16 | /** 17 | * Non-compiling validator for discriminated unions. To improve performance, 18 | * list the more frequently used types earlier in the union, and list each 19 | * object's discriminant key first in its properties. 20 | */ 21 | export class DiscriminatedUnionValidator< 22 | S extends TUnion 23 | > extends AbstractTypedUnionValidator { 24 | discriminantKey: string; 25 | #unionIsWellformed: boolean = false; 26 | 27 | /** @inheritdoc */ 28 | constructor(schema: S) { 29 | super(schema); 30 | this.discriminantKey = 31 | this.schema.discriminantKey ?? DEFAULT_DISCRIMINANT_KEY; 32 | } 33 | 34 | /** @inheritdoc */ 35 | override test(value: Readonly): boolean { 36 | const indexOrError = this.findSchemaMemberIndex(value); 37 | if (typeof indexOrError !== 'number') { 38 | return false; 39 | } 40 | return Value.Check(this.schema.anyOf[indexOrError], value); 41 | } 42 | 43 | /** @inheritdoc */ 44 | override errors(value: Readonly): Iterable { 45 | const indexOrError = this.findSchemaMemberIndex(value); 46 | if (typeof indexOrError !== 'number') { 47 | return createUnionTypeErrorIterable(indexOrError); 48 | } 49 | const schema = this.schema.anyOf[indexOrError] as TObject; 50 | return createErrorsIterable(Value.Errors(schema, value)); 51 | } 52 | 53 | override assertReturningSchema( 54 | value: Readonly, 55 | overallError?: string 56 | ): TObject { 57 | const indexOrError = this.findSchemaMemberIndex(value); 58 | if (typeof indexOrError !== 'number') { 59 | throwInvalidAssert(overallError, indexOrError); 60 | } 61 | const schema = this.schema.anyOf[indexOrError] as TObject; 62 | this.uncompiledAssert(schema, value, overallError); 63 | return schema; 64 | } 65 | 66 | override validateReturningSchema( 67 | value: Readonly, 68 | overallError?: string 69 | ): TObject { 70 | const indexOrError = this.findSchemaMemberIndex(value); 71 | if (typeof indexOrError !== 'number') { 72 | throwInvalidValidate(overallError, indexOrError); 73 | } 74 | const schema = this.schema.anyOf[indexOrError] as TObject; 75 | this.uncompiledValidate(schema, value, overallError); 76 | return schema; 77 | } 78 | 79 | private findSchemaMemberIndex(subject: Readonly): number | ValueError { 80 | if (!this.#unionIsWellformed) { 81 | // only incur cost if validator is actually used 82 | for (const memberSchema of this.schema.anyOf) { 83 | if (memberSchema.properties[this.discriminantKey] === undefined) { 84 | throw Error( 85 | `Discriminant key '${this.discriminantKey}' not present in all members of discriminated union` 86 | ); 87 | } 88 | } 89 | this.#unionIsWellformed = true; 90 | } 91 | 92 | if (typeof subject === 'object' && subject !== null) { 93 | const subjectKind = subject[this.discriminantKey]; 94 | if (subjectKind !== undefined) { 95 | for (let i = 0; i < this.schema.anyOf.length; ++i) { 96 | const memberKind = 97 | this.schema.anyOf[i].properties[this.discriminantKey]; 98 | if (memberKind !== undefined && memberKind.const === subjectKind) { 99 | return i; 100 | } 101 | } 102 | } 103 | } 104 | return createUnionTypeError(this.schema, subject); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/discriminated/index.ts: -------------------------------------------------------------------------------- 1 | export * from './discriminated-union-validator'; 2 | export * from './compiling-discriminated-union-validator'; 3 | -------------------------------------------------------------------------------- /src/heterogeneous/compiling-heterogeneous-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TUnion } from '@sinclair/typebox'; 2 | 3 | import { 4 | AbstractCompilingTypedUnionValidator, 5 | FindSchemaMemberIndex, 6 | } from '../abstract/abstract-compiling-typed-union-validator'; 7 | import { TypeIdentifyingKeyIndex } from './type-identifying-key-index'; 8 | 9 | /** 10 | * Lazily compiled validator for heterogeneous unions of objects. To improve 11 | * performance, list the more frequently used types earlier in the union, and 12 | * list each object's unique key first in its properties. 13 | */ 14 | export class CompilingHeterogeneousUnionValidator< 15 | S extends TUnion 16 | > extends AbstractCompilingTypedUnionValidator { 17 | #typeIdentifyingKeyIndex: TypeIdentifyingKeyIndex; 18 | #compiledFindSchemaMemberIndex?: FindSchemaMemberIndex; 19 | 20 | /** @inheritdoc */ 21 | constructor(schema: Readonly) { 22 | super(schema); 23 | this.#typeIdentifyingKeyIndex = new TypeIdentifyingKeyIndex(schema); 24 | } 25 | 26 | protected override compiledFindSchemaMemberIndex( 27 | value: Readonly 28 | ): number | null { 29 | if (this.#compiledFindSchemaMemberIndex === undefined) { 30 | this.#typeIdentifyingKeyIndex.cacheKeys(); 31 | const codeParts: string[] = [ 32 | `return ((typeof value !== 'object' || value === null || Array.isArray(value)) ? null : `, 33 | ]; 34 | for (let i = 0; i < this.schema.anyOf.length; ++i) { 35 | const uniqueKey = this.#typeIdentifyingKeyIndex.keyByMemberIndex![i]; 36 | codeParts.push( 37 | `${this.toValueKeyDereference(uniqueKey)} !== undefined ? ${i} : ` 38 | ); 39 | } 40 | this.#compiledFindSchemaMemberIndex = new Function( 41 | 'value', 42 | codeParts.join('') + 'null)' 43 | ) as FindSchemaMemberIndex; 44 | } 45 | return this.#compiledFindSchemaMemberIndex(value); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/heterogeneous/heterogeneous-union-validator.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TUnion } from '@sinclair/typebox'; 2 | import { Value, ValueError } from '@sinclair/typebox/value'; 3 | 4 | import { AbstractTypedUnionValidator } from '../abstract/abstract-typed-union-validator'; 5 | import { 6 | createErrorsIterable, 7 | createUnionTypeError, 8 | createUnionTypeErrorIterable, 9 | throwInvalidAssert, 10 | throwInvalidValidate, 11 | } from '../lib/error-utils'; 12 | import { TypeIdentifyingKeyIndex } from './type-identifying-key-index'; 13 | 14 | /** 15 | * Non-compiling validator for heterogeneous unions of objects. To improve 16 | * performance, list the more frequently used types earlier in the union, and 17 | * list each object's unique key first in its properties. 18 | */ 19 | export class HeterogeneousUnionValidator< 20 | S extends TUnion 21 | > extends AbstractTypedUnionValidator { 22 | #typeIdentifyingKeyIndex: TypeIdentifyingKeyIndex; 23 | 24 | /** @inheritdoc */ 25 | constructor(schema: S) { 26 | super(schema); 27 | this.#typeIdentifyingKeyIndex = new TypeIdentifyingKeyIndex(schema); 28 | } 29 | 30 | /** @inheritdoc */ 31 | override test(value: Readonly): boolean { 32 | const indexOrError = this.findSchemaMemberIndex(value); 33 | if (typeof indexOrError !== 'number') { 34 | return false; 35 | } 36 | return Value.Check(this.schema.anyOf[indexOrError], value); 37 | } 38 | 39 | /** @inheritdoc */ 40 | override errors(value: Readonly): Iterable { 41 | const indexOrError = this.findSchemaMemberIndex(value); 42 | if (typeof indexOrError !== 'number') { 43 | return createUnionTypeErrorIterable(indexOrError); 44 | } 45 | const schema = this.schema.anyOf[indexOrError] as TObject; 46 | return createErrorsIterable(Value.Errors(schema, value)); 47 | } 48 | 49 | override assertReturningSchema( 50 | value: Readonly, 51 | overallError?: string 52 | ): TObject { 53 | const indexOrError = this.findSchemaMemberIndex(value); 54 | if (typeof indexOrError !== 'number') { 55 | throwInvalidAssert(overallError, indexOrError); 56 | } 57 | const schema = this.schema.anyOf[indexOrError] as TObject; 58 | this.uncompiledAssert(schema, value, overallError); 59 | return schema; 60 | } 61 | 62 | override validateReturningSchema( 63 | value: Readonly, 64 | overallError?: string 65 | ): TObject { 66 | const indexOrError = this.findSchemaMemberIndex(value); 67 | if (typeof indexOrError !== 'number') { 68 | throwInvalidValidate(overallError, indexOrError); 69 | } 70 | const schema = this.schema.anyOf[indexOrError] as TObject; 71 | this.uncompiledValidate(schema, value, overallError); 72 | return schema; 73 | } 74 | 75 | private findSchemaMemberIndex(value: Readonly): number | ValueError { 76 | if (this.#typeIdentifyingKeyIndex.keyByMemberIndex === undefined) { 77 | // only incur cost if validator is actually used 78 | this.#typeIdentifyingKeyIndex.cacheKeys(); 79 | } 80 | 81 | if (typeof value === 'object' && value !== null) { 82 | for (let i = 0; i < this.schema.anyOf.length; ++i) { 83 | const uniqueKey = this.#typeIdentifyingKeyIndex.keyByMemberIndex![i]; 84 | if (value[uniqueKey] !== undefined) { 85 | return i; 86 | } 87 | } 88 | } 89 | return createUnionTypeError(this.schema, value); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/heterogeneous/index.ts: -------------------------------------------------------------------------------- 1 | export * from './heterogeneous-union-validator'; 2 | export * from './compiling-heterogeneous-union-validator'; 3 | export * from './type-identifying-key'; 4 | -------------------------------------------------------------------------------- /src/heterogeneous/type-identifying-key-index.ts: -------------------------------------------------------------------------------- 1 | import { Optional, TObject, TUnion } from '@sinclair/typebox'; 2 | 3 | export const MESSAGE_OPTIONAL_TYPE_ID_KEY = 4 | 'Type identifying key cannot be optional'; 5 | export const MESSAGE_MEMBER_WITH_MULTIPLE_KEYS = 6 | 'Union has member with multiple identifying keys'; 7 | export const MESSAGE_MULTIPLE_MEMBERS_WITH_SAME_KEY = 8 | 'Union has multiple members with same identifying key'; 9 | export const MESSAGE_MEMBERS_MISSING_KEY = 10 | 'Union has members missing identifying keys'; 11 | 12 | // Note: The type identifying keys of heterogeneous unions are assigned via 13 | // the typeIdentifyingKey schema option instead of being inferred, because 14 | // inferrence requires a lot of computation, especially because multiple 15 | // keys might be unique within a given member schema. 16 | 17 | export class TypeIdentifyingKeyIndex { 18 | keyByMemberIndex?: string[]; 19 | 20 | constructor(readonly schema: Readonly>) {} 21 | 22 | cacheKeys(): void { 23 | const unionSize = this.schema.anyOf.length; 24 | const takenKeys = new Set(); 25 | this.keyByMemberIndex = new Array(unionSize); 26 | 27 | for (let i = 0; i < unionSize; ++i) { 28 | const memberSchema = this.schema.anyOf[i]; 29 | for (const [key, schema] of Object.entries(memberSchema.properties)) { 30 | if (schema.typeIdentifyingKey) { 31 | if (schema[Optional] == 'Optional') { 32 | throw Error(MESSAGE_OPTIONAL_TYPE_ID_KEY); 33 | } 34 | if (this.keyByMemberIndex[i] !== undefined) { 35 | throw Error(MESSAGE_MEMBER_WITH_MULTIPLE_KEYS); 36 | } 37 | if (takenKeys.has(key)) { 38 | throw Error(MESSAGE_MULTIPLE_MEMBERS_WITH_SAME_KEY); 39 | } 40 | this.keyByMemberIndex[i] = key; 41 | takenKeys.add(key); 42 | } 43 | } 44 | } 45 | 46 | if (takenKeys.size < unionSize) { 47 | this.keyByMemberIndex = undefined; // reset for next attempt 48 | throw Error(MESSAGE_MEMBERS_MISSING_KEY); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/heterogeneous/type-identifying-key.ts: -------------------------------------------------------------------------------- 1 | import { TSchema } from '@sinclair/typebox'; 2 | 3 | /** 4 | * Marks an occurrence of a schema as being the property of a key that 5 | * uniquely identifies its containing object within a heterogeneous union. 6 | */ 7 | export function TypeIdentifyingKey(schema: Readonly): TSchema { 8 | return { 9 | ...schema, 10 | typeIdentifyingKey: true, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './abstract/abstract-validator'; 2 | export * from './abstract/abstract-standard-validator'; 3 | export * from './abstract/abstract-typed-union-validator'; 4 | export * from './standard/index'; 5 | export * from './heterogeneous/index'; 6 | export * from './discriminated/index'; 7 | export * from './lib/validation-exception'; 8 | 9 | export { ValueError } from '@sinclair/typebox/value'; 10 | -------------------------------------------------------------------------------- /src/lib/error-utils.ts: -------------------------------------------------------------------------------- 1 | import { Kind, TObject, TUnion } from '@sinclair/typebox'; 2 | import { 3 | ValueError, 4 | ValueErrorIterator, 5 | ValueErrorType, 6 | } from '@sinclair/typebox/errors'; 7 | 8 | import { ValidationException } from './validation-exception'; 9 | 10 | export const DEFAULT_OVERALL_MESSAGE = 'Invalid value'; 11 | export const DEFAULT_UNKNOWN_TYPE_MESSAGE = 'Object type not recognized'; 12 | 13 | const TYPEBOX_REQUIRED_ERROR_MESSAGE = 'Expected required property'; 14 | 15 | export function adjustErrorMessage(error: ValueError): ValueError { 16 | if (error.schema.errorMessage !== undefined) { 17 | error.message = error.schema.errorMessage; 18 | } 19 | return error; 20 | } 21 | 22 | export function createErrorsIterable( 23 | typeboxErrorIterator: ValueErrorIterator 24 | ): Iterable { 25 | return { 26 | [Symbol.iterator]: function* () { 27 | const errors = typeboxErrorIterator[Symbol.iterator](); 28 | let result = errors.next(); 29 | let customErrorPath = '???'; // signals no prior path ('' can be root path) 30 | while (result.value !== undefined) { 31 | const error = result.value; 32 | const standardMessage = error.message; 33 | if (error.path !== customErrorPath) { 34 | adjustErrorMessage(error); 35 | if (error.message != standardMessage) { 36 | customErrorPath = error.path; 37 | yield error; 38 | } else if ( 39 | // drop 'required' errors for values that have constraints 40 | error.message != TYPEBOX_REQUIRED_ERROR_MESSAGE || 41 | ['Any', 'Unknown'].includes(error.schema[Kind]) 42 | ) { 43 | yield error; 44 | } 45 | } 46 | result = errors.next(); 47 | } 48 | }, 49 | }; 50 | } 51 | 52 | export function createUnionTypeError( 53 | unionSchema: Readonly>, 54 | value: Readonly 55 | ): ValueError { 56 | return { 57 | type: ValueErrorType.Union, 58 | path: '', 59 | schema: unionSchema, 60 | value, 61 | message: unionSchema.errorMessage ?? DEFAULT_UNKNOWN_TYPE_MESSAGE, 62 | }; 63 | } 64 | 65 | export function createUnionTypeErrorIterable( 66 | typeError: ValueError 67 | ): Iterable { 68 | return { 69 | [Symbol.iterator]: function* () { 70 | yield typeError; 71 | }, 72 | }; 73 | } 74 | 75 | export function throwInvalidAssert( 76 | overallError: string | undefined, 77 | firstError: ValueError 78 | ): never { 79 | adjustErrorMessage(firstError); 80 | throw new ValidationException( 81 | overallError === undefined 82 | ? DEFAULT_OVERALL_MESSAGE 83 | : overallError.replace( 84 | '{error}', 85 | ValidationException.errorToString(firstError) 86 | ), 87 | [firstError] 88 | ); 89 | } 90 | 91 | export function throwInvalidValidate( 92 | overallError: string | undefined, 93 | errorOrErrors: ValueError | ValueErrorIterator 94 | ): never { 95 | throw new ValidationException( 96 | overallError ?? DEFAULT_OVERALL_MESSAGE, 97 | errorOrErrors instanceof ValueErrorIterator 98 | ? [...createErrorsIterable(errorOrErrors)] 99 | : [errorOrErrors] 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/lib/validation-exception.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Classes representing the validation errors of a single value. 3 | */ 4 | 5 | import { ValueError } from '@sinclair/typebox/value'; 6 | 7 | /** 8 | * Exception reporting the occurrence of one or more validation errors. 9 | */ 10 | export class ValidationException { 11 | /** 12 | * @param message Overall error message 13 | * @param details The individual validation errors 14 | */ 15 | constructor( 16 | readonly message: string, 17 | readonly details: Readonly = [] 18 | ) {} 19 | 20 | /** 21 | * Returns a string representation of the error. Provides the overall 22 | * error message, followed by the specific error messages, one per line. 23 | * @returns a string representation of the error. 24 | */ 25 | toString(): string { 26 | let message = this.message; 27 | if (this.details.length > 0) { 28 | if (!message.endsWith(':')) { 29 | message += ':'; 30 | } 31 | for (const detail of this.details) { 32 | message += '\n * ' + ValidationException.errorToString(detail); 33 | } 34 | } 35 | return message; 36 | } 37 | 38 | /** 39 | * Returns a string representation of a validation error, which precedes 40 | * the error with its reference path if it occurs in an object. 41 | */ 42 | static errorToString(error: ValueError): string { 43 | return error.path != '' 44 | ? `${error.path.substring(1)} - ${error.message}` 45 | : error.message; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/standard/compiling-standard-validator.ts: -------------------------------------------------------------------------------- 1 | import type { TSchema } from '@sinclair/typebox'; 2 | import { 3 | TypeCheck, 4 | TypeCompiler, 5 | ValueError, 6 | } from '@sinclair/typebox/compiler'; 7 | 8 | import { AbstractStandardValidator } from '../abstract/abstract-standard-validator'; 9 | import { 10 | createErrorsIterable, 11 | throwInvalidAssert, 12 | throwInvalidValidate, 13 | } from '../lib/error-utils'; 14 | 15 | /** 16 | * Lazily compiled validator for standard TypeBox values. 17 | */ 18 | export class CompilingStandardValidator< 19 | S extends TSchema 20 | > extends AbstractStandardValidator { 21 | #compiledType?: TypeCheck; 22 | 23 | /** @inheritdoc */ 24 | constructor(schema: Readonly) { 25 | super(schema); 26 | } 27 | 28 | /** @inheritdoc */ 29 | override test(value: Readonly): boolean { 30 | const compiledType = this.getCompiledType(); 31 | return compiledType.Check(value); 32 | } 33 | 34 | /** @inheritdoc */ 35 | override assert(value: Readonly, overallError?: string): void { 36 | const compiledType = this.getCompiledType(); 37 | if (!compiledType.Check(value)) { 38 | throwInvalidAssert(overallError, compiledType.Errors(value).First()!); 39 | } 40 | } 41 | 42 | /** @inheritdoc */ 43 | override validate(value: Readonly, overallError?: string): void { 44 | const compiledType = this.getCompiledType(); 45 | if (!compiledType.Check(value)) { 46 | throwInvalidValidate(overallError, compiledType.Errors(value)); 47 | } 48 | } 49 | 50 | /** @inheritdoc */ 51 | override errors(value: Readonly): Iterable { 52 | const compiledType = this.getCompiledType(); 53 | return createErrorsIterable(compiledType.Errors(value)); 54 | } 55 | 56 | private getCompiledType(): TypeCheck { 57 | if (this.#compiledType === undefined) { 58 | this.#compiledType = TypeCompiler.Compile(this.schema); 59 | } 60 | return this.#compiledType; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/standard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './standard-validator'; 2 | export * from './compiling-standard-validator'; 3 | -------------------------------------------------------------------------------- /src/standard/standard-validator.ts: -------------------------------------------------------------------------------- 1 | import type { TSchema } from '@sinclair/typebox'; 2 | 3 | import { AbstractStandardValidator } from '../abstract/abstract-standard-validator'; 4 | import { ValueError } from '@sinclair/typebox/errors'; 5 | import { Value } from '@sinclair/typebox/value'; 6 | import { createErrorsIterable } from '../lib/error-utils'; 7 | 8 | /** 9 | * Non-compiling validator for standard TypeBox values. 10 | */ 11 | export class StandardValidator< 12 | S extends TSchema 13 | > extends AbstractStandardValidator { 14 | /** @inheritdoc */ 15 | constructor(schema: S) { 16 | super(schema); 17 | } 18 | 19 | /** @inheritdoc */ 20 | override test(value: Readonly): boolean { 21 | return Value.Check(this.schema, value); 22 | } 23 | 24 | /** @inheritdoc */ 25 | override assert(value: Readonly, overallError?: string): void { 26 | this.uncompiledAssert(this.schema, value, overallError); 27 | } 28 | 29 | /** @inheritdoc */ 30 | override validate(value: Readonly, overallError?: string): void { 31 | this.uncompiledValidate(this.schema, value, overallError); 32 | } 33 | 34 | /** @inheritdoc */ 35 | override errors(value: Readonly): Iterable { 36 | return createErrorsIterable(Value.Errors(this.schema, value)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/discriminated-union-validators-invalid.test.ts: -------------------------------------------------------------------------------- 1 | import { TSchema, TObject, Type, TUnion } from '@sinclair/typebox'; 2 | 3 | import { AbstractTypedUnionValidator } from '../abstract/abstract-typed-union-validator'; 4 | import { DiscriminatedUnionValidator } from '../discriminated/discriminated-union-validator'; 5 | import { CompilingDiscriminatedUnionValidator } from '../discriminated/compiling-discriminated-union-validator'; 6 | import { 7 | DEFAULT_OVERALL_MESSAGE, 8 | DEFAULT_UNKNOWN_TYPE_MESSAGE, 9 | } from '../lib/error-utils'; 10 | import { ValidatorKind, MethodKind, ValidatorCache } from './test-utils'; 11 | import { testInvalidSpecs } from './test-invalid-specs'; 12 | 13 | const onlyRunValidator = ValidatorKind.All; 14 | const onlyRunMethod = MethodKind.All; 15 | 16 | const wellFormedUnion1 = Type.Union([ 17 | Type.Object({ 18 | kind: Type.Literal('s'), 19 | str1: Type.String(), 20 | str2: Type.Optional(Type.String()), 21 | }), 22 | Type.Object({ 23 | kind: Type.Literal('i'), 24 | int1: Type.Integer(), 25 | int2: Type.Optional( 26 | Type.Integer({ 27 | errorMessage: 'must be an int', 28 | }) 29 | ), 30 | }), 31 | ]); 32 | 33 | const wellFormedUnion2 = Type.Union( 34 | [ 35 | Type.Object({ 36 | str1: Type.String(), 37 | t: Type.Literal('s'), 38 | str2: Type.Optional(Type.String()), 39 | }), 40 | Type.Object({ 41 | int1: Type.Integer(), 42 | int2: Type.Optional(Type.Integer()), 43 | t: Type.Literal('i'), 44 | }), 45 | ], 46 | { discriminantKey: 't', errorMessage: 'Unknown type' } 47 | ); 48 | 49 | const illFormedUnion = Type.Union( 50 | [ 51 | Type.Object({ 52 | t: Type.Literal('s'), 53 | str1: Type.String(), 54 | str2: Type.Optional(Type.String()), 55 | }), 56 | Type.Object({ 57 | kind: Type.Literal('i'), 58 | int1: Type.Integer(), 59 | int2: Type.Optional(Type.Integer()), 60 | }), 61 | ], 62 | { discriminantKey: 't' } 63 | ); 64 | 65 | const validatorCache = new ValidatorCache(); 66 | 67 | describe('discriminated union validators - invalid values', () => { 68 | if (runThisValidator(ValidatorKind.NonCompiling)) { 69 | describe('DiscriminatedUnionValidator', () => { 70 | testValidator( 71 | (schema: TSchema) => 72 | validatorCache.getNonCompiling( 73 | schema, 74 | () => new DiscriminatedUnionValidator(schema as TUnion) 75 | ) as AbstractTypedUnionValidator> 76 | ); 77 | }); 78 | } 79 | if (runThisValidator(ValidatorKind.Compiling)) { 80 | describe('CompilingDiscriminatedUnionValidator', () => { 81 | testValidator( 82 | (schema: TSchema) => 83 | validatorCache.getCompiling( 84 | schema, 85 | () => 86 | new CompilingDiscriminatedUnionValidator( 87 | schema as TUnion 88 | ) 89 | ) as AbstractTypedUnionValidator> 90 | ); 91 | }); 92 | } 93 | }); 94 | 95 | function testValidator( 96 | createValidator: ( 97 | schema: TSchema 98 | ) => AbstractTypedUnionValidator> 99 | ) { 100 | const defaultString = `${DEFAULT_OVERALL_MESSAGE}:\n * ${DEFAULT_UNKNOWN_TYPE_MESSAGE}`; 101 | testInvalidSpecs(runThisTest, createValidator, [ 102 | { 103 | description: 'selects 1st union member, single error', 104 | onlySpec: false, 105 | schema: wellFormedUnion1, 106 | value: { kind: 's', int1: 1 }, 107 | assertMessage: DEFAULT_OVERALL_MESSAGE, 108 | errors: [{ path: '/str1', message: 'Expected string' }], 109 | assertString: 'Invalid value:\n * str1 - Expected string', 110 | validateString: 'Invalid value:\n * str1 - Expected string', 111 | }, 112 | { 113 | description: 'selects 2nd union member, multiple errors', 114 | onlySpec: false, 115 | schema: wellFormedUnion1, 116 | value: { kind: 'i', int1: '1', int2: 'hello' }, 117 | assertMessage: DEFAULT_OVERALL_MESSAGE, 118 | errors: [ 119 | { path: '/int1', message: 'Expected integer' }, 120 | { path: '/int2', message: 'must be an int' }, 121 | ], 122 | assertString: 'Invalid value:\n * int1 - Expected integer', 123 | validateString: 124 | 'Invalid value:\n' + 125 | ' * int1 - Expected integer\n' + 126 | ' * int2 - must be an int', 127 | }, 128 | { 129 | description: 'discriminant key value not selecting any union member', 130 | onlySpec: false, 131 | schema: wellFormedUnion1, 132 | value: { kind: 'not-there', str1: 'hello' }, 133 | assertMessage: DEFAULT_OVERALL_MESSAGE, 134 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 135 | assertString: defaultString, 136 | validateString: defaultString, 137 | }, 138 | { 139 | description: 'value lacking discriminant key', 140 | onlySpec: false, 141 | schema: wellFormedUnion1, 142 | value: { str1: 'hello', int1: 1 }, 143 | assertMessage: DEFAULT_OVERALL_MESSAGE, 144 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 145 | assertString: defaultString, 146 | validateString: defaultString, 147 | }, 148 | { 149 | description: 'empty object value', 150 | onlySpec: false, 151 | schema: wellFormedUnion1, 152 | value: {}, 153 | assertMessage: DEFAULT_OVERALL_MESSAGE, 154 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 155 | assertString: defaultString, 156 | validateString: defaultString, 157 | }, 158 | { 159 | description: 'undefined value', 160 | onlySpec: false, 161 | schema: wellFormedUnion1, 162 | value: undefined, 163 | assertMessage: DEFAULT_OVERALL_MESSAGE, 164 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 165 | assertString: defaultString, 166 | validateString: defaultString, 167 | }, 168 | { 169 | description: 'null value', 170 | onlySpec: false, 171 | schema: wellFormedUnion1, 172 | value: null, 173 | assertMessage: DEFAULT_OVERALL_MESSAGE, 174 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 175 | assertString: defaultString, 176 | validateString: defaultString, 177 | }, 178 | { 179 | description: 'simple literal value', 180 | onlySpec: false, 181 | schema: wellFormedUnion1, 182 | value: 'hello', 183 | assertMessage: DEFAULT_OVERALL_MESSAGE, 184 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 185 | assertString: defaultString, 186 | validateString: defaultString, 187 | }, 188 | { 189 | description: 190 | 'selects 1st union member, non-1st discriminant, invalid, with overall message', 191 | onlySpec: false, 192 | schema: wellFormedUnion2, 193 | value: { t: 's', str1: 32 }, 194 | overallMessage: 'Custom message', 195 | assertMessage: 'Custom message', 196 | errors: [{ path: '/str1', message: 'Expected string' }], 197 | assertString: 'Custom message:\n * str1 - Expected string', 198 | validateString: 'Custom message:\n * str1 - Expected string', 199 | }, 200 | { 201 | description: 'not selecting any union member, with custom type error', 202 | onlySpec: false, 203 | schema: wellFormedUnion2, 204 | value: { t: 'not-there', str1: 'hello' }, 205 | assertMessage: DEFAULT_OVERALL_MESSAGE, 206 | errors: [{ path: '', message: 'Unknown type' }], 207 | assertString: 'Invalid value:\n * Unknown type', 208 | validateString: 'Invalid value:\n * Unknown type', 209 | }, 210 | { 211 | description: 212 | 'not selecting any union member, with custom overall and type errors', 213 | onlySpec: false, 214 | schema: wellFormedUnion2, 215 | value: { int1: 32 }, 216 | overallMessage: 'Oopsie: {error}', 217 | assertMessage: 'Oopsie: Unknown type', 218 | errors: [{ path: '', message: 'Unknown type' }], 219 | assertString: 'Oopsie: Unknown type:\n * Unknown type', 220 | validateString: 'Oopsie:\n * Unknown type', 221 | }, 222 | ]); 223 | 224 | if ([MethodKind.All, MethodKind.InvalidSchema].includes(onlyRunMethod)) { 225 | const errorMessage = `Discriminant key 't' not present in all members of discriminated union`; 226 | 227 | describe('errors on invalid union schemas', () => { 228 | it('union having members lacking the discriminant key, valid value', () => { 229 | const validator = createValidator(illFormedUnion); 230 | const validObject = { t: 's', str1: 'hello' }; 231 | 232 | expect(() => validator.test(validObject)).toThrow(errorMessage); 233 | expect(() => validator.assert(validObject)).toThrow(errorMessage); 234 | expect(() => validator.assertAndClean(validObject)).toThrow( 235 | errorMessage 236 | ); 237 | expect(() => validator.assertAndCleanCopy(validObject)).toThrow( 238 | errorMessage 239 | ); 240 | expect(() => validator.validate(validObject)).toThrow(errorMessage); 241 | expect(() => validator.validateAndClean(validObject)).toThrow( 242 | errorMessage 243 | ); 244 | expect(() => validator.validateAndCleanCopy(validObject)).toThrow( 245 | errorMessage 246 | ); 247 | expect(() => validator.errors(validObject)).toThrow(errorMessage); 248 | }); 249 | 250 | it('union having members lacking the discriminant key, invalid value', () => { 251 | const validator = createValidator(illFormedUnion); 252 | const invalidObject = { s: 'hello', str1: 32 }; 253 | 254 | expect(() => validator.test(invalidObject)).toThrow(errorMessage); 255 | expect(() => validator.assert(invalidObject)).toThrow(errorMessage); 256 | expect(() => validator.assertAndClean(invalidObject)).toThrow( 257 | errorMessage 258 | ); 259 | expect(() => validator.assertAndCleanCopy(invalidObject)).toThrow( 260 | errorMessage 261 | ); 262 | expect(() => validator.validate(invalidObject)).toThrow(errorMessage); 263 | expect(() => validator.validateAndClean(invalidObject)).toThrow( 264 | errorMessage 265 | ); 266 | expect(() => validator.validateAndCleanCopy(invalidObject)).toThrow( 267 | errorMessage 268 | ); 269 | expect(() => validator.errors(invalidObject)).toThrow(errorMessage); 270 | }); 271 | }); 272 | } 273 | } 274 | 275 | function runThisValidator(validatorKind: ValidatorKind): boolean { 276 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 277 | } 278 | 279 | function runThisTest(methodKind: MethodKind): boolean { 280 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 281 | } 282 | -------------------------------------------------------------------------------- /src/test/discriminated-union-validators-valid.test.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TSchema, TUnion, Type } from '@sinclair/typebox'; 2 | 3 | import { AbstractTypedUnionValidator } from '../abstract/abstract-typed-union-validator'; 4 | import { DiscriminatedUnionValidator } from '../discriminated/discriminated-union-validator'; 5 | import { CompilingDiscriminatedUnionValidator } from '../discriminated/compiling-discriminated-union-validator'; 6 | import { 7 | ValidUnionTestSpec, 8 | ValidatorKind, 9 | MethodKind, 10 | ValidatorCache, 11 | ValidTestSpec, 12 | } from './test-utils'; 13 | import { testValidSpecs } from './test-valid-specs'; 14 | import { AbstractValidator } from '../abstract/abstract-validator'; 15 | 16 | const onlyRunValidator = ValidatorKind.All; 17 | const onlyRunMethod = MethodKind.All; 18 | 19 | const wellFormedUnion1 = Type.Union([ 20 | Type.Object({ 21 | kind: Type.Literal('s'), 22 | str1: Type.String(), 23 | str2: Type.Optional(Type.String()), 24 | }), 25 | Type.Object({ 26 | kind: Type.Literal('i'), 27 | int1: Type.Integer(), 28 | int2: Type.Optional(Type.Integer()), 29 | }), 30 | ]); 31 | 32 | const wellFormedUnion2 = Type.Union( 33 | [ 34 | Type.Object({ 35 | str1: Type.String(), 36 | t: Type.Literal('s'), 37 | str2: Type.Optional(Type.String()), 38 | }), 39 | Type.Object({ 40 | int1: Type.Integer(), 41 | int2: Type.Optional(Type.Integer()), 42 | t: Type.Literal('i'), 43 | }), 44 | ], 45 | { discriminantKey: 't', errorMessage: 'Unknown type' } 46 | ); 47 | 48 | const wellFormedUnion3 = Type.Union( 49 | [ 50 | Type.Object({ 51 | "s'quote": Type.Literal(100), 52 | str1: Type.String(), 53 | }), 54 | Type.Object({ 55 | "s'quote": Type.Literal(200), 56 | int1: Type.Integer(), 57 | }), 58 | ], 59 | { discriminantKey: "s'quote" } 60 | ); 61 | 62 | const validatorCache = new ValidatorCache(); 63 | 64 | describe('discriminated union validators - valid values', () => { 65 | if (runThisValidator(ValidatorKind.NonCompiling)) { 66 | describe('DiscriminatedUnionValidator', () => { 67 | testValidator( 68 | (schema: TSchema) => 69 | validatorCache.getNonCompiling( 70 | schema, 71 | () => new DiscriminatedUnionValidator(schema as TUnion) 72 | ) as AbstractTypedUnionValidator> 73 | ); 74 | }); 75 | } 76 | if (runThisValidator(ValidatorKind.Compiling)) { 77 | describe('CompilingDiscriminatedUnionValidator', () => { 78 | testValidator((schema: TSchema) => 79 | validatorCache.getCompiling( 80 | schema, 81 | () => 82 | new CompilingDiscriminatedUnionValidator( 83 | schema as TUnion 84 | ) 85 | ) 86 | ); 87 | }); 88 | } 89 | }); 90 | 91 | function testValidator( 92 | createValidator: (schema: TSchema) => AbstractValidator 93 | ) { 94 | testValidSpecs(runThisTest, createValidator, verifyCleaning, [ 95 | { 96 | description: 'valid discrim union 1, no unrecognized fields', 97 | onlySpec: false, 98 | schema: wellFormedUnion1, 99 | value: { kind: 's', str1: 'hello' }, 100 | selectedIndex: 0, 101 | }, 102 | { 103 | description: 'valid discrim union 2, no unrecognized fields', 104 | onlySpec: false, 105 | schema: wellFormedUnion1, 106 | value: { kind: 'i', int1: 1 }, 107 | selectedIndex: 1, 108 | }, 109 | { 110 | description: 'valid discrim union 3, with unrecognized fields', 111 | onlySpec: false, 112 | schema: wellFormedUnion1, 113 | value: { 114 | kind: 's', 115 | str1: 'hello', 116 | int1: 1, 117 | unrecognized1: 1, 118 | unrecognized2: 'abc', 119 | }, 120 | selectedIndex: 0, 121 | }, 122 | { 123 | description: 'valid discrim union 4, with unrecognized fields', 124 | onlySpec: false, 125 | schema: wellFormedUnion1, 126 | value: { 127 | kind: 'i', 128 | str1: 'hello', 129 | int1: 1, 130 | unrecognized1: 1, 131 | unrecognized2: 'abc', 132 | }, 133 | selectedIndex: 1, 134 | }, 135 | { 136 | description: 'valid discrim union 5, different discriminant key', 137 | onlySpec: false, 138 | schema: wellFormedUnion2, 139 | value: { t: 's', str1: 'hello' }, 140 | selectedIndex: 0, 141 | }, 142 | { 143 | description: 'valid discrim union 6, different discriminant key', 144 | onlySpec: false, 145 | schema: wellFormedUnion2, 146 | value: { t: 'i', str1: 'hello', int1: 1 }, 147 | selectedIndex: 1, 148 | }, 149 | { 150 | description: 'valid discrim union 7, quoted discriminant key', 151 | onlySpec: false, 152 | schema: wellFormedUnion3, 153 | value: { "s'quote": 200, int1: 1 }, 154 | selectedIndex: 1, 155 | }, 156 | ]); 157 | } 158 | 159 | function verifyCleaning(spec: ValidTestSpec, value: any): void { 160 | const unionSpec = spec as ValidUnionTestSpec; 161 | const validProperties = Object.keys( 162 | unionSpec.schema.anyOf[unionSpec.selectedIndex].properties 163 | ); 164 | for (const key in value) { 165 | expect(validProperties.includes(key)).toBe(true); 166 | } 167 | expect(Object.keys(value).length).toBeLessThanOrEqual(validProperties.length); 168 | } 169 | 170 | function runThisValidator(validatorKind: ValidatorKind): boolean { 171 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 172 | } 173 | 174 | function runThisTest(methodKind: MethodKind): boolean { 175 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 176 | } 177 | -------------------------------------------------------------------------------- /src/test/heterogeneous-union-validators-invalid.test.ts: -------------------------------------------------------------------------------- 1 | import { TSchema, TObject, Type, TUnion } from '@sinclair/typebox'; 2 | 3 | import { AbstractTypedUnionValidator } from '../abstract/abstract-typed-union-validator'; 4 | import { HeterogeneousUnionValidator } from '../heterogeneous/heterogeneous-union-validator'; 5 | import { CompilingHeterogeneousUnionValidator } from '../heterogeneous/compiling-heterogeneous-union-validator'; 6 | import { TypeIdentifyingKey } from '../heterogeneous//type-identifying-key'; 7 | import { 8 | DEFAULT_OVERALL_MESSAGE, 9 | DEFAULT_UNKNOWN_TYPE_MESSAGE, 10 | } from '../lib/error-utils'; 11 | import { ValidatorKind, MethodKind, ValidatorCache } from './test-utils'; 12 | import { testInvalidSpecs } from './test-invalid-specs'; 13 | import { 14 | MESSAGE_MEMBERS_MISSING_KEY, 15 | MESSAGE_MULTIPLE_MEMBERS_WITH_SAME_KEY, 16 | MESSAGE_MEMBER_WITH_MULTIPLE_KEYS, 17 | MESSAGE_OPTIONAL_TYPE_ID_KEY, 18 | } from '../heterogeneous/type-identifying-key-index'; 19 | 20 | const onlyRunValidator = ValidatorKind.NonCompiling; 21 | const onlyRunMethod = MethodKind.InvalidSchema; 22 | 23 | const wellFormedUnion1 = Type.Union([ 24 | Type.Object({ 25 | unique1: TypeIdentifyingKey(Type.String()), 26 | str1: Type.String(), 27 | str2: Type.Optional(Type.String()), 28 | }), 29 | Type.Object({ 30 | unique2: TypeIdentifyingKey(Type.Integer()), 31 | int1: Type.Integer(), 32 | int2: Type.Optional( 33 | Type.Integer({ 34 | errorMessage: 'must be an int', 35 | }) 36 | ), 37 | }), 38 | ]); 39 | 40 | const wellFormedUnion2 = Type.Union( 41 | [ 42 | Type.Object({ 43 | str1: Type.String(), 44 | str2: Type.String(), 45 | unique3: Type.String(), 46 | unique1: TypeIdentifyingKey(Type.String()), 47 | opt: Type.Optional(Type.String()), 48 | }), 49 | Type.Object({ 50 | str1: Type.String(), 51 | unique2: TypeIdentifyingKey(Type.String()), 52 | str2: Type.String(), 53 | opt: Type.Optional(Type.Integer()), 54 | }), 55 | ], 56 | { errorMessage: 'Unknown type' } 57 | ); 58 | 59 | const unionDupTypeKeysMultipleMembers = Type.Union([ 60 | Type.Object({ 61 | s: TypeIdentifyingKey(Type.String()), 62 | str1: Type.Optional(Type.String()), 63 | }), 64 | Type.Object({ 65 | s: TypeIdentifyingKey(Type.Integer()), 66 | int1: Type.Optional(Type.Integer()), 67 | }), 68 | ]); 69 | 70 | const unionDupTypeKeysSingleMembers = Type.Union([ 71 | Type.Object({ 72 | s1: TypeIdentifyingKey(Type.String()), 73 | s3: TypeIdentifyingKey(Type.String()), 74 | str1: Type.Optional(Type.String()), 75 | }), 76 | Type.Object({ 77 | s2: TypeIdentifyingKey(Type.Integer()), 78 | int1: Type.Optional(Type.Integer()), 79 | }), 80 | ]); 81 | 82 | const unionMissingTypeKeys = Type.Union([ 83 | Type.Object({ 84 | s1: TypeIdentifyingKey(Type.String()), 85 | str1: Type.Optional(Type.String()), 86 | }), 87 | Type.Object({ 88 | s2: Type.String(), 89 | int1: Type.Optional(Type.Integer()), 90 | }), 91 | ]); 92 | 93 | const unionOptionalTypeKey = Type.Union([ 94 | Type.Object({ 95 | s1: TypeIdentifyingKey(Type.String()), 96 | str1: Type.Optional(Type.String()), 97 | }), 98 | Type.Object({ 99 | s2: TypeIdentifyingKey(Type.Optional(Type.Integer())), 100 | int1: Type.Optional(Type.Integer()), 101 | }), 102 | ]); 103 | 104 | const validatorCache = new ValidatorCache(); 105 | 106 | describe('heterogenous union validators - invalid values', () => { 107 | if (runThisValidator(ValidatorKind.NonCompiling)) { 108 | describe('HeterogeneousUnionValidator', () => { 109 | testValidator( 110 | (schema: TSchema) => 111 | validatorCache.getNonCompiling( 112 | schema, 113 | () => new HeterogeneousUnionValidator(schema as TUnion) 114 | ) as AbstractTypedUnionValidator> 115 | ); 116 | }); 117 | } 118 | if (runThisValidator(ValidatorKind.Compiling)) { 119 | describe('CompilingHeterogeneousUnionValidator', () => { 120 | testValidator( 121 | (schema: TSchema) => 122 | validatorCache.getCompiling( 123 | schema, 124 | () => 125 | new CompilingHeterogeneousUnionValidator( 126 | schema as TUnion 127 | ) 128 | ) as AbstractTypedUnionValidator> 129 | ); 130 | }); 131 | } 132 | }); 133 | 134 | function testValidator( 135 | createValidator: ( 136 | schema: TSchema 137 | ) => AbstractTypedUnionValidator> 138 | ) { 139 | const defaultString = `${DEFAULT_OVERALL_MESSAGE}:\n * ${DEFAULT_UNKNOWN_TYPE_MESSAGE}`; 140 | testInvalidSpecs(runThisTest, createValidator, [ 141 | { 142 | description: 'selects 1st union member, 1st key unique, single error', 143 | onlySpec: false, 144 | schema: wellFormedUnion1, 145 | value: { unique1: 'hello', int1: 1 }, 146 | assertMessage: DEFAULT_OVERALL_MESSAGE, 147 | errors: [{ path: '/str1', message: 'Expected string' }], 148 | assertString: 'Invalid value:\n * str1 - Expected string', 149 | validateString: 'Invalid value:\n * str1 - Expected string', 150 | }, 151 | { 152 | description: 'selects 2nd union member, 1st key unique, multiple errors', 153 | onlySpec: false, 154 | schema: wellFormedUnion1, 155 | value: { unique2: 1, str1: 'hello', int2: 'hello' }, 156 | assertMessage: DEFAULT_OVERALL_MESSAGE, 157 | errors: [ 158 | { path: '/int1', message: 'Expected integer' }, 159 | { path: '/int2', message: 'must be an int' }, 160 | ], 161 | assertString: 'Invalid value:\n * int1 - Expected integer', 162 | validateString: 163 | 'Invalid value:\n' + 164 | ' * int1 - Expected integer\n' + 165 | ' * int2 - must be an int', 166 | }, 167 | { 168 | description: 'unique field not selecting any union member', 169 | onlySpec: false, 170 | schema: wellFormedUnion1, 171 | value: { x: 'hello', str1: 'hello', int1: 1 }, 172 | assertMessage: DEFAULT_OVERALL_MESSAGE, 173 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 174 | assertString: defaultString, 175 | validateString: defaultString, 176 | }, 177 | { 178 | description: 'unique fields selecting multiple union members', 179 | onlySpec: false, 180 | schema: wellFormedUnion1, 181 | value: { str1: 'hello', int1: 1 }, 182 | assertMessage: DEFAULT_OVERALL_MESSAGE, 183 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 184 | assertString: defaultString, 185 | validateString: defaultString, 186 | }, 187 | { 188 | description: 'empty object value', 189 | onlySpec: false, 190 | schema: wellFormedUnion1, 191 | value: {}, 192 | assertMessage: DEFAULT_OVERALL_MESSAGE, 193 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 194 | assertString: defaultString, 195 | validateString: defaultString, 196 | }, 197 | { 198 | description: 'undefined value', 199 | onlySpec: false, 200 | schema: wellFormedUnion1, 201 | value: undefined, 202 | assertMessage: DEFAULT_OVERALL_MESSAGE, 203 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 204 | assertString: defaultString, 205 | validateString: defaultString, 206 | }, 207 | { 208 | description: 'null value', 209 | onlySpec: false, 210 | schema: wellFormedUnion1, 211 | value: null, 212 | assertMessage: DEFAULT_OVERALL_MESSAGE, 213 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 214 | assertString: defaultString, 215 | validateString: defaultString, 216 | }, 217 | { 218 | description: 'simple literal value', 219 | onlySpec: false, 220 | schema: wellFormedUnion1, 221 | value: 'hello', 222 | assertMessage: DEFAULT_OVERALL_MESSAGE, 223 | errors: [{ path: '', message: DEFAULT_UNKNOWN_TYPE_MESSAGE }], 224 | assertString: defaultString, 225 | validateString: defaultString, 226 | }, 227 | { 228 | description: 229 | 'selects 1st union member, non-1st key unique, invalid, with overall message', 230 | onlySpec: false, 231 | schema: wellFormedUnion2, 232 | value: { 233 | str1: 'a', 234 | str2: 'b', 235 | unique1: 'c', 236 | unique3: 'd', 237 | opt: 32, 238 | }, 239 | overallMessage: 'Custom message', 240 | assertMessage: 'Custom message', 241 | errors: [{ path: '/opt', message: 'Expected string' }], 242 | assertString: 'Custom message:\n * opt - Expected string', 243 | validateString: 'Custom message:\n * opt - Expected string', 244 | }, 245 | { 246 | description: 247 | 'selects 2nd union member, non-1st key unique, invalid key value', 248 | onlySpec: false, 249 | schema: wellFormedUnion2, 250 | value: { str1: 'a', unique2: 1, str2: 'c', opt: 32 }, 251 | assertMessage: DEFAULT_OVERALL_MESSAGE, 252 | errors: [{ path: '/unique2', message: 'Expected string' }], 253 | assertString: 'Invalid value:\n * unique2 - Expected string', 254 | validateString: 'Invalid value:\n * unique2 - Expected string', 255 | }, 256 | { 257 | description: 'not selecting any union member, with custom type error', 258 | onlySpec: false, 259 | schema: wellFormedUnion2, 260 | value: { x: 'not-there' }, 261 | assertMessage: DEFAULT_OVERALL_MESSAGE, 262 | errors: [{ path: '', message: 'Unknown type' }], 263 | assertString: 'Invalid value:\n * Unknown type', 264 | validateString: 'Invalid value:\n * Unknown type', 265 | }, 266 | { 267 | description: 268 | 'not selecting any union member, with custom overall and type errors', 269 | onlySpec: false, 270 | schema: wellFormedUnion2, 271 | value: { opt: 32 }, 272 | overallMessage: 'Oopsie: {error}', 273 | assertMessage: 'Oopsie: Unknown type', 274 | errors: [{ path: '', message: 'Unknown type' }], 275 | assertString: 'Oopsie: Unknown type:\n * Unknown type', 276 | validateString: 'Oopsie:\n * Unknown type', 277 | }, 278 | { 279 | description: 'not selecting any union member, but having a unique key', 280 | onlySpec: false, 281 | schema: wellFormedUnion2, 282 | value: { unique3: 'hello' }, 283 | overallMessage: 'Oopsie: {error}', 284 | assertMessage: 'Oopsie: Unknown type', 285 | errors: [{ path: '', message: 'Unknown type' }], 286 | assertString: 'Oopsie: Unknown type:\n * Unknown type', 287 | validateString: 'Oopsie:\n * Unknown type', 288 | }, 289 | ]); 290 | 291 | if ([MethodKind.All, MethodKind.InvalidSchema].includes(onlyRunMethod)) { 292 | describe('errors on invalid union schemas', () => { 293 | it('union with members missing keys', () => { 294 | const validator = createValidator(unionMissingTypeKeys); 295 | expect(() => validator.test({})).toThrow(MESSAGE_MEMBERS_MISSING_KEY); 296 | }); 297 | 298 | it('union with multiple members having same key', () => { 299 | const validator = createValidator(unionDupTypeKeysMultipleMembers); 300 | expect(() => validator.assert({})).toThrow( 301 | MESSAGE_MULTIPLE_MEMBERS_WITH_SAME_KEY 302 | ); 303 | }); 304 | 305 | it('union with single member having multiple keys', () => { 306 | const validator = createValidator(unionDupTypeKeysSingleMembers); 307 | expect(() => validator.validate({})).toThrow( 308 | MESSAGE_MEMBER_WITH_MULTIPLE_KEYS 309 | ); 310 | }); 311 | 312 | it('union with optional type identifying key', () => { 313 | const validator = createValidator(unionOptionalTypeKey); 314 | expect(() => validator.errors({})).toThrow( 315 | MESSAGE_OPTIONAL_TYPE_ID_KEY 316 | ); 317 | }); 318 | }); 319 | } 320 | } 321 | 322 | function runThisValidator(validatorKind: ValidatorKind): boolean { 323 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 324 | } 325 | 326 | function runThisTest(methodKind: MethodKind): boolean { 327 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 328 | } 329 | -------------------------------------------------------------------------------- /src/test/heterogeneous-union-validators-valid.test.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TSchema, TUnion, Type } from '@sinclair/typebox'; 2 | 3 | import { AbstractValidator } from '../abstract/abstract-validator'; 4 | import { AbstractTypedUnionValidator } from '../abstract/abstract-typed-union-validator'; 5 | import { HeterogeneousUnionValidator } from '../heterogeneous/heterogeneous-union-validator'; 6 | import { CompilingHeterogeneousUnionValidator } from '../heterogeneous/compiling-heterogeneous-union-validator'; 7 | import { TypeIdentifyingKey } from '../heterogeneous/type-identifying-key'; 8 | import { 9 | ValidUnionTestSpec, 10 | ValidatorKind, 11 | MethodKind, 12 | ValidatorCache, 13 | ValidTestSpec, 14 | } from './test-utils'; 15 | import { testValidSpecs } from './test-valid-specs'; 16 | 17 | const onlyRunValidator = ValidatorKind.All; 18 | const onlyRunMethod = MethodKind.All; 19 | 20 | const wellFormedUnion1 = Type.Union([ 21 | Type.Object({ 22 | unique1: TypeIdentifyingKey(Type.String()), 23 | str1: Type.String(), 24 | str2: Type.Optional(Type.String()), 25 | }), 26 | Type.Object({ 27 | unique2: TypeIdentifyingKey(Type.Integer()), 28 | int1: Type.Integer(), 29 | int2: Type.Optional(Type.Integer()), 30 | }), 31 | Type.Object({ 32 | "s'quote": TypeIdentifyingKey(Type.String()), 33 | str1: Type.String(), 34 | }), 35 | ]); 36 | 37 | const wellFormedUnion2 = Type.Union( 38 | [ 39 | Type.Object({ 40 | str1: Type.String(), 41 | str2: Type.String(), 42 | unique3: Type.String(), 43 | unique1: TypeIdentifyingKey(Type.String()), 44 | opt: Type.Optional(Type.String()), 45 | }), 46 | Type.Object({ 47 | str1: Type.String(), 48 | unique2: TypeIdentifyingKey(Type.String()), 49 | str2: Type.String(), 50 | opt: Type.Optional(Type.Integer()), 51 | }), 52 | ], 53 | { errorMessage: 'Unknown type' } 54 | ); 55 | 56 | const validatorCache = new ValidatorCache(); 57 | 58 | describe('heterogenous union validators - valid values', () => { 59 | if (runThisValidator(ValidatorKind.NonCompiling)) { 60 | describe('HeterogeneousUnionValidator', () => { 61 | testValidator( 62 | (schema: TSchema) => 63 | validatorCache.getNonCompiling( 64 | schema, 65 | () => new HeterogeneousUnionValidator(schema as TUnion) 66 | ) as AbstractTypedUnionValidator> 67 | ); 68 | }); 69 | } 70 | if (runThisValidator(ValidatorKind.Compiling)) { 71 | describe('CompilingHeterogeneousUnionValidator', () => { 72 | testValidator((schema: TSchema) => 73 | validatorCache.getCompiling( 74 | schema, 75 | () => 76 | new CompilingHeterogeneousUnionValidator( 77 | schema as TUnion 78 | ) 79 | ) 80 | ); 81 | }); 82 | } 83 | }); 84 | 85 | function testValidator( 86 | createValidator: (schema: TSchema) => AbstractValidator 87 | ) { 88 | testValidSpecs(runThisTest, createValidator, verifyCleaning, [ 89 | { 90 | description: 'valid hetero union 1, no unrecognized fields', 91 | onlySpec: false, 92 | schema: wellFormedUnion1, 93 | value: { unique1: 'hello', str1: 'hello' }, 94 | selectedIndex: 0, 95 | }, 96 | { 97 | description: 'valid hetero union 2, no unrecognized fields', 98 | onlySpec: false, 99 | schema: wellFormedUnion1, 100 | value: { unique2: 1, int1: 1 }, 101 | selectedIndex: 1, 102 | }, 103 | { 104 | description: 'valid hetero union 3, with unrecognized fields', 105 | onlySpec: false, 106 | schema: wellFormedUnion1, 107 | value: { 108 | unique1: 'hello', 109 | str1: 'hello', 110 | int1: 1, 111 | unrecognized1: 1, 112 | unrecognized2: 'abc', 113 | }, 114 | selectedIndex: 0, 115 | }, 116 | { 117 | description: 'valid hetero union 4, with unrecognized fields', 118 | onlySpec: false, 119 | schema: wellFormedUnion1, 120 | value: { 121 | unique2: 1, 122 | str1: 'hello', 123 | int1: 1, 124 | unrecognized1: 1, 125 | unrecognized2: 'abc', 126 | }, 127 | selectedIndex: 1, 128 | }, 129 | { 130 | description: 'valid hetero union 5, selecting by member keys', 131 | onlySpec: false, 132 | schema: wellFormedUnion2, 133 | value: { str1: 'a', str2: 'b', unique1: 'c', unique3: 'd', opt: 'e' }, 134 | selectedIndex: 0, 135 | }, 136 | { 137 | description: 'valid hetero union 6, selecting by member keys', 138 | onlySpec: false, 139 | schema: wellFormedUnion2, 140 | value: { 141 | str1: 'a', 142 | unique2: 'b', 143 | str2: 'c', 144 | opt: 32, 145 | }, 146 | selectedIndex: 1, 147 | }, 148 | { 149 | description: 'valid hetero union 7, selecting quoted key', 150 | onlySpec: false, 151 | schema: wellFormedUnion1, 152 | value: { "s'quote": 'a', str1: 'b' }, 153 | selectedIndex: 2, 154 | }, 155 | ]); 156 | } 157 | 158 | function verifyCleaning(spec: ValidTestSpec, value: any): void { 159 | const unionSpec = spec as ValidUnionTestSpec; 160 | const validProperties = Object.keys( 161 | unionSpec.schema.anyOf[unionSpec.selectedIndex].properties 162 | ); 163 | for (const key in value) { 164 | expect(validProperties.includes(key)).toBe(true); 165 | } 166 | expect(Object.keys(value).length).toBeLessThanOrEqual(validProperties.length); 167 | } 168 | 169 | function runThisValidator(validatorKind: ValidatorKind): boolean { 170 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 171 | } 172 | 173 | function runThisTest(methodKind: MethodKind): boolean { 174 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 175 | } 176 | -------------------------------------------------------------------------------- /src/test/key-iteration-performance.ts: -------------------------------------------------------------------------------- 1 | import { TSchema, Type } from '@sinclair/typebox'; 2 | 3 | const iterations = [1000, 10000] as const; 4 | 5 | const schema = Type.Object({ 6 | key1: Type.String(), 7 | key2: Type.String(), 8 | key3: Type.String(), 9 | key4: Type.String(), 10 | key5: Type.String(), 11 | key6: Type.String(), 12 | key7: Type.String(), 13 | key8: Type.String(), 14 | key9: Type.String(), 15 | key10: Type.String(), 16 | }); 17 | 18 | console.log('key iteration performance'); 19 | { 20 | console.log(' key iteration speed\n'); 21 | let forInTime = 0; 22 | let objectKeysTime = 0; 23 | let objectKeysForEachTime = 0; 24 | let objectGetOwnPropertyNamesTime = 0; 25 | let objectGetOwnPropertyNamesForEachTime = 0; 26 | 27 | // Mix the two tests to equally apply system slowdowns across them. 28 | for (let i = 0; i < iterations[0]; ++i) { 29 | forInTime += timeFunction(() => { 30 | let count = 0; 31 | for (const _ in schema.properties) { 32 | count++; 33 | } 34 | return count; 35 | }); 36 | objectKeysTime += timeFunction(() => { 37 | let count = 0; 38 | for (const _ of Object.keys(schema.properties)) { 39 | count++; 40 | } 41 | return count; 42 | }); 43 | objectKeysForEachTime += timeFunction(() => { 44 | let count = 0; 45 | Object.keys(schema.properties).forEach(() => { 46 | count++; 47 | }); 48 | return count; 49 | }); 50 | objectGetOwnPropertyNamesTime += timeFunction(() => { 51 | let count = 0; 52 | for (const _ of Object.getOwnPropertyNames(schema.properties)) { 53 | count++; 54 | } 55 | return count; 56 | }); 57 | objectGetOwnPropertyNamesForEachTime += timeFunction(() => { 58 | let count = 0; 59 | Object.getOwnPropertyNames(schema.properties).forEach(() => { 60 | count++; 61 | }); 62 | return count; 63 | }); 64 | } 65 | 66 | const results: [string, number][] = []; 67 | results.push(['for in', forInTime]); 68 | results.push(['Object.keys of', objectKeysTime]); 69 | results.push(['Object.keys.forEach', objectKeysForEachTime]); 70 | results.push([ 71 | 'Object.getOwnPropertyNames of', 72 | objectGetOwnPropertyNamesTime, 73 | ]); 74 | results.push([ 75 | 'Object.getOwnPropertyNames.forEach', 76 | objectGetOwnPropertyNamesForEachTime, 77 | ]); 78 | showResults(results); 79 | 80 | function timeFunction(func: () => number): number { 81 | let start = performance.now(); 82 | for (let i = 0; i < iterations[1]; ++i) { 83 | func(); 84 | } 85 | return performance.now() - start; 86 | } 87 | } 88 | 89 | { 90 | console.log(' key lookup speed\n'); 91 | let inTime = 0; 92 | let objectKeysIncludesTime = 0; 93 | let objectGetOwnPropertyIncludesTime = 0; 94 | 95 | // Mix the two tests to equally apply system slowdowns across them. 96 | for (let i = 0; i < iterations[0]; ++i) { 97 | const lookupKeys = ['key1', 'key5', 'key10']; 98 | inTime += timeFunction((schema) => { 99 | let count = 0; 100 | for (const key of lookupKeys) { 101 | if (key in schema.properties) { 102 | count++; 103 | } 104 | } 105 | return count; 106 | }); 107 | objectKeysIncludesTime += timeFunction((schema) => { 108 | let count = 0; 109 | for (const key of lookupKeys) { 110 | if (Object.keys(schema.properties).includes(key)) { 111 | count++; 112 | } 113 | } 114 | return count; 115 | }); 116 | objectGetOwnPropertyIncludesTime += timeFunction((schema) => { 117 | let count = 0; 118 | for (const key of lookupKeys) { 119 | if (Object.getOwnPropertyNames(schema.properties).includes(key)) { 120 | count++; 121 | } 122 | } 123 | return count; 124 | }); 125 | } 126 | 127 | const results: [string, number][] = []; 128 | results.push(['in', inTime]); 129 | results.push(['Object.keys.includes', objectKeysIncludesTime]); 130 | results.push([ 131 | 'Object.getOwnPropertyNames.includes', 132 | objectGetOwnPropertyIncludesTime, 133 | ]); 134 | showResults(results); 135 | 136 | function timeFunction(func: (schema: TSchema) => number): number { 137 | let start = performance.now(); 138 | for (let i = 0; i < iterations[1]; ++i) { 139 | func(schema); 140 | } 141 | return performance.now() - start; 142 | } 143 | } 144 | 145 | function showResults(results: [string, number][]) { 146 | results.sort((a, b) => b[1] - a[1]); 147 | for (const [name, time] of results) { 148 | console.log( 149 | `${ 150 | Math.round((time / results[results.length - 1][1]) * 100) / 100 151 | }x - ${name} (${time}ms)` 152 | ); 153 | } 154 | console.log(); 155 | } 156 | -------------------------------------------------------------------------------- /src/test/maxItems-maxLength.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * As of Typebox version 0.28.13,`maxLength` and `maxItems` are checked before 3 | * checking other constraints, but the TypeBox docs say nothing about this, so 4 | * this feature may not be part of the public contract. These tests confirm 5 | * that this remains the case, as this feature is important for securing APIs. 6 | */ 7 | 8 | import { TSchema, Type } from '@sinclair/typebox'; 9 | import { Value, ValueErrorIterator } from '@sinclair/typebox/value'; 10 | import { TypeCheck, TypeCompiler } from '@sinclair/typebox/compiler'; 11 | //import { performance } from 'node:perf_hooks'; 12 | 13 | const MAX_SIZE = 4; 14 | const ERROR_SUBSTRING = `less or equal to ${MAX_SIZE}`; 15 | const PATTERN = '^[a-z]+$'; 16 | 17 | // const uncompiledIterations = [400, 10000] as const; 18 | // const compiledIterations = [400, 20000] as const; 19 | 20 | describe('TypeBox value size checks', () => { 21 | describe('maxLength checks', () => { 22 | const schema = Type.Object({ 23 | lengthFirstStr: Type.String({ maxLength: 4, pattern: PATTERN }), 24 | lengthLastStr: Type.String({ pattern: PATTERN, maxLength: MAX_SIZE }), 25 | }); 26 | const badSizeValue = { 27 | lengthFirstStr: '1'.repeat(MAX_SIZE + 1), 28 | lengthLastStr: '1'.repeat(MAX_SIZE + 1), 29 | }; 30 | // const badRegexValue = { 31 | // lengthFirstStr: '1', 32 | // lengthLastStr: '1', 33 | // }; 34 | 35 | const compiledType = TypeCompiler.Compile(schema); 36 | 37 | // it('should have uncompiled Check() test maxLength first', () => { 38 | // const check = Value.Check.bind(Value, schema); 39 | // testCheckViaTiming( 40 | // check, 41 | // badSizeValue, 42 | // badRegexValue, 43 | // uncompiledIterations 44 | // ); 45 | // }); 46 | 47 | it('should have compiled Check() test maxLength first', () => { 48 | testCheckViaCode(compiledType); 49 | // const check = compiledType.Check.bind(compiledType); 50 | // testCheckViaTiming( 51 | // check, 52 | // badSizeValue, 53 | // badRegexValue, 54 | // compiledIterations 55 | // ); 56 | }); 57 | 58 | it('should have First() return a maxLength error', () => { 59 | let error = Value.Errors(schema, badSizeValue).First(); 60 | expect(error?.message).toContain(ERROR_SUBSTRING); 61 | 62 | error = compiledType.Errors(badSizeValue).First(); 63 | expect(error?.message).toContain(ERROR_SUBSTRING); 64 | }); 65 | 66 | it('should have Errors() return a maxLength error first', () => { 67 | const verifyErrors = (errors: ValueErrorIterator) => { 68 | let priorPath = ''; 69 | for (const error of errors) { 70 | if (error.path !== priorPath) { 71 | expect(error.message).toContain(ERROR_SUBSTRING); 72 | priorPath = error.path; 73 | } 74 | } 75 | }; 76 | verifyErrors(Value.Errors(schema, badSizeValue)); 77 | verifyErrors(compiledType.Errors(badSizeValue)); 78 | }); 79 | }); 80 | 81 | describe('maxItems checks', () => { 82 | const schema = Type.Object({ 83 | array: Type.Array(Type.String({ pattern: PATTERN }), { 84 | maxItems: MAX_SIZE, 85 | }), 86 | }); 87 | const badSizeValue = { 88 | array: Array.from({ length: MAX_SIZE + 1 }).fill('1'), 89 | }; 90 | // const badRegexValue = { 91 | // array: Array.from({ length: 1 }).fill('1'), 92 | // }; 93 | const compiledType = TypeCompiler.Compile(schema); 94 | 95 | // it('should have uncompiled Check() test maxItems first', () => { 96 | // const check = Value.Check.bind(Value, schema); 97 | // testCheckViaTiming( 98 | // check, 99 | // badSizeValue, 100 | // badRegexValue, 101 | // uncompiledIterations 102 | // ); 103 | // }); 104 | 105 | it('should have compiled Check() test maxItems first', () => { 106 | testCheckViaCode(compiledType); 107 | // const check = compiledType.Check.bind(compiledType); 108 | // testCheckViaTiming( 109 | // check, 110 | // badSizeValue, 111 | // badRegexValue, 112 | // compiledIterations 113 | // ); 114 | }); 115 | 116 | it('should have First() return a maxItems error', () => { 117 | let error = Value.Errors(schema, badSizeValue).First(); 118 | expect(error?.message).toContain(ERROR_SUBSTRING); 119 | 120 | error = compiledType.Errors(badSizeValue).First(); 121 | expect(error?.message).toContain(ERROR_SUBSTRING); 122 | }); 123 | 124 | it('should have Errors() return a maxItems error first', () => { 125 | for (const error of compiledType.Errors(badSizeValue)) { 126 | expect(error.message).toContain(ERROR_SUBSTRING); 127 | break; // only check first error 128 | } 129 | }); 130 | }); 131 | }); 132 | 133 | function testCheckViaCode(compiledType: TypeCheck) { 134 | // Not guaranteed to work for all versions of TypeBox, 135 | // but it's faster than doing another timing test. 136 | const code = compiledType.Code(); 137 | const maxSizeOffset = code.indexOf(`${MAX_SIZE}`); 138 | const patternOffset = code.indexOf('.test('); 139 | expect(maxSizeOffset).toBeGreaterThanOrEqual(0); 140 | expect(patternOffset).toBeGreaterThanOrEqual(0); 141 | expect(maxSizeOffset).toBeLessThan(patternOffset); 142 | } 143 | 144 | // This function works most of the time, but produces a false negative 145 | // often enough to make it too unreliable to include in the test suite. 146 | 147 | // function testCheckViaTiming( 148 | // check: (value: unknown) => boolean, 149 | // badSizeValue: unknown, 150 | // badRegexValue: unknown, 151 | // iterations: Readonly<[number, number]> 152 | // ) { 153 | // let badSizeTimes: number[] = []; 154 | // let badRegexTimes: number[] = []; 155 | 156 | // // Mix the two tests to equally apply system slowdowns across them. 157 | // for (let i = 0; i < iterations[0]; ++i) { 158 | // badSizeTimes.push(timeCheckFunction(check, badSizeValue, iterations[1])); 159 | // badRegexTimes.push(timeCheckFunction(check, badRegexValue, iterations[1])); 160 | // } 161 | // badSizeTimes.sort(); 162 | // badRegexTimes.sort(); 163 | 164 | // const sum = (times: number[]) => 165 | // times.slice(0, iterations[0] / 2).reduce((a, b) => a + b, 0); 166 | // expect(sum(badSizeTimes)).toBeLessThan(sum(badRegexTimes)); 167 | // } 168 | 169 | // function timeCheckFunction( 170 | // check: (value: unknown) => boolean, 171 | // badValue: unknown, 172 | // iterations: number 173 | // ): number { 174 | // let start = performance.now(); 175 | // for (let i = 0; i < iterations; ++i) { 176 | // check(badValue); 177 | // } 178 | // return performance.now() - start; 179 | // } 180 | -------------------------------------------------------------------------------- /src/test/standard-validators-invalid.test.ts: -------------------------------------------------------------------------------- 1 | import { TSchema, Type } from '@sinclair/typebox'; 2 | 3 | import { AbstractStandardValidator } from '../abstract/abstract-standard-validator'; 4 | import { StandardValidator } from '../standard/standard-validator'; 5 | import { CompilingStandardValidator } from '../standard/compiling-standard-validator'; 6 | import { DEFAULT_OVERALL_MESSAGE } from '../lib/error-utils'; 7 | import { ValidatorKind, MethodKind, ValidatorCache } from './test-utils'; 8 | import { testInvalidSpecs } from './test-invalid-specs'; 9 | 10 | const onlyRunValidator = ValidatorKind.All; 11 | const onlyRunMethod = MethodKind.All; 12 | 13 | const schema0 = Type.String({ 14 | minLength: 5, 15 | maxLength: 10, 16 | pattern: '^[a-zA-Z]+$', 17 | }); 18 | 19 | const schema1 = Type.String({ 20 | minLength: 5, 21 | maxLength: 10, 22 | pattern: '^[a-zA-Z]+$', 23 | errorMessage: 'name should consist of 5-10 letters', 24 | }); 25 | 26 | const schema2 = Type.Object({ 27 | delta: Type.Integer(), 28 | count: Type.Integer({ exclusiveMinimum: 0 }), 29 | name: schema1, 30 | }); 31 | 32 | const schema3 = Type.Object({ 33 | int1: Type.Integer({ errorMessage: 'must be an int' }), 34 | int2: Type.Integer({ errorMessage: 'must be an int' }), 35 | alpha: Type.String({ pattern: '^[a-zA-Z]+$', maxLength: 4 }), 36 | }); 37 | 38 | const schema4 = Type.Object({ 39 | int1: Type.Integer(), 40 | whatever: Type.Any(), 41 | }); 42 | 43 | const schema5 = Type.Object({ 44 | int1: Type.Integer(), 45 | whatever: Type.Unknown({ errorMessage: 'Missing whatever' }), 46 | }); 47 | 48 | const validatorCache = new ValidatorCache(); 49 | 50 | describe('standard validators - invalid values', () => { 51 | if (runThisValidator(ValidatorKind.NonCompiling)) { 52 | describe('StandardValidator', () => { 53 | testValidator((schema: TSchema) => 54 | validatorCache.getNonCompiling( 55 | schema, 56 | () => new StandardValidator(schema) 57 | ) 58 | ); 59 | }); 60 | } 61 | 62 | if (runThisValidator(ValidatorKind.Compiling)) { 63 | describe('CompilingStandardValidator', () => { 64 | testValidator((schema: TSchema) => 65 | validatorCache.getCompiling( 66 | schema, 67 | () => new CompilingStandardValidator(schema) 68 | ) 69 | ); 70 | }); 71 | } 72 | }); 73 | 74 | function testValidator( 75 | createValidator: (schema: TSchema) => AbstractStandardValidator 76 | ) { 77 | testInvalidSpecs(runThisTest, createValidator, [ 78 | { 79 | description: 'multiple errors for string literal', 80 | onlySpec: false, 81 | schema: schema0, 82 | value: '1', 83 | assertMessage: DEFAULT_OVERALL_MESSAGE, 84 | errors: [ 85 | { 86 | path: '', 87 | message: 'greater or equal to 5', 88 | }, 89 | { 90 | path: '', 91 | message: 'match pattern', 92 | }, 93 | ], 94 | assertString: 95 | 'Invalid value:\n * Expected string length greater or equal to 5', 96 | validateString: 97 | 'Invalid value:\n' + 98 | ' * Expected string length greater or equal to 5\n' + 99 | ' * Expected string to match pattern ^[a-zA-Z]+$', 100 | }, 101 | { 102 | description: 'custom overall and error messages for string literal', 103 | onlySpec: false, 104 | schema: schema1, 105 | value: '1', 106 | overallMessage: 'Oopsie', 107 | assertMessage: 'Oopsie', 108 | errors: [ 109 | { 110 | path: '', 111 | message: 'name should consist of 5-10 letters', 112 | }, 113 | ], 114 | assertString: 'Oopsie:\n * name should consist of 5-10 letters', 115 | validateString: 'Oopsie:\n * name should consist of 5-10 letters', 116 | }, 117 | { 118 | description: 'single invalid field with one error', 119 | onlySpec: false, 120 | schema: schema2, 121 | value: { delta: 0.5, count: 1, name: 'ABCDE' }, 122 | assertMessage: DEFAULT_OVERALL_MESSAGE, 123 | errors: [{ path: '/delta', message: 'Expected integer' }], 124 | assertString: 'Invalid value:\n * delta - Expected integer', 125 | validateString: 'Invalid value:\n * delta - Expected integer', 126 | }, 127 | { 128 | description: 129 | 'single invalid field with one error, custom overall message 1', 130 | onlySpec: false, 131 | schema: schema2, 132 | value: { delta: 0.5, count: 1, name: 'ABCDE' }, 133 | overallMessage: 'Custom message', 134 | assertMessage: 'Custom message', 135 | errors: [{ path: '/delta', message: 'Expected integer' }], 136 | assertString: 'Custom message:\n * delta - Expected integer', 137 | validateString: 'Custom message:\n * delta - Expected integer', 138 | }, 139 | { 140 | description: 141 | 'single invalid field with one error, custom overall message 2', 142 | onlySpec: false, 143 | schema: schema2, 144 | value: { delta: 0.5, count: 1, name: 'ABCDE' }, 145 | overallMessage: 'Oopsie: {error}', 146 | assertMessage: 'Oopsie: delta - Expected integer', 147 | errors: [{ path: '/delta', message: 'Expected integer' }], 148 | assertString: 149 | 'Oopsie: delta - Expected integer:\n * delta - Expected integer', 150 | validateString: 'Oopsie:\n * delta - Expected integer', 151 | }, 152 | { 153 | description: 'custom error message makes it into custom overall message', 154 | onlySpec: false, 155 | schema: schema2, 156 | value: { delta: 1, count: 1, name: '1' }, 157 | overallMessage: 'Oopsie: {error}', 158 | assertMessage: 'Oopsie: name - name should consist of 5-10 letters', 159 | errors: [ 160 | { path: '/name', message: 'name should consist of 5-10 letters' }, 161 | ], 162 | assertString: 163 | 'Oopsie: name - name should consist of 5-10 letters:\n' + 164 | ' * name - name should consist of 5-10 letters', 165 | validateString: 'Oopsie:\n * name - name should consist of 5-10 letters', 166 | }, 167 | { 168 | description: 'single invalid field with multiple errors', 169 | onlySpec: false, 170 | schema: schema3, 171 | value: { int1: 1, int2: 1, alpha: '12345' }, 172 | errors: [ 173 | { 174 | path: '/alpha', 175 | message: 'less or equal to 4', 176 | }, 177 | { 178 | path: '/alpha', 179 | message: 'match pattern', 180 | }, 181 | ], 182 | }, 183 | { 184 | description: 'multiple invalid fields with multiple errors', 185 | onlySpec: false, 186 | schema: schema3, 187 | value: { int1: 1.5, int2: 1.5, alpha: '12345' }, 188 | assertMessage: DEFAULT_OVERALL_MESSAGE, 189 | errors: [ 190 | { 191 | path: '/int1', 192 | message: 'must be an int', 193 | }, 194 | { 195 | path: '/int2', 196 | message: 'must be an int', 197 | }, 198 | { 199 | path: '/alpha', 200 | message: 'less or equal to 4', 201 | }, 202 | { 203 | path: '/alpha', 204 | message: 'match pattern', 205 | }, 206 | ], 207 | }, 208 | { 209 | description: 'one custom error message for multiple errors', 210 | onlySpec: false, 211 | schema: schema2, 212 | value: { delta: 0.5, count: 1, name: '1' }, 213 | assertMessage: DEFAULT_OVERALL_MESSAGE, 214 | errors: [ 215 | { 216 | path: '/delta', 217 | message: 'integer', 218 | }, 219 | { 220 | path: '/name', 221 | message: 'name should consist of 5-10 letters', 222 | }, 223 | ], 224 | assertString: 'Invalid value:\n * delta - Expected integer', 225 | validateString: 226 | 'Invalid value:\n' + 227 | ' * delta - Expected integer\n' + 228 | ' * name - name should consist of 5-10 letters', 229 | }, 230 | { 231 | description: 232 | 'invalid overall value with one error, custom overall message 1', 233 | onlySpec: false, 234 | schema: schema2, 235 | value: 'not an object', 236 | overallMessage: 'Oopsie: {error}', 237 | assertMessage: 'Oopsie: Expected object', 238 | errors: [{ path: '', message: 'Expected object' }], 239 | assertString: 'Oopsie: Expected object:\n * Expected object', 240 | validateString: 'Oopsie:\n * Expected object', 241 | }, 242 | { 243 | description: "reports default required message for 'any' field", 244 | onlySpec: false, 245 | schema: schema4, 246 | value: { int1: 32 }, 247 | errors: [ 248 | { 249 | path: '/whatever', 250 | message: 'Expected required property', 251 | }, 252 | ], 253 | assertString: 'Invalid value:\n * whatever - Expected required property', 254 | validateString: 255 | 'Invalid value:\n * whatever - Expected required property', 256 | }, 257 | { 258 | description: "reports custom required message for 'unknown' field", 259 | onlySpec: false, 260 | schema: schema5, 261 | value: { int1: 'not an integer' }, 262 | errors: [ 263 | { 264 | path: '/int1', 265 | message: 'Expected integer', 266 | }, 267 | { 268 | path: '/whatever', 269 | message: 'Missing whatever', 270 | }, 271 | ], 272 | assertString: 'Invalid value:\n * int1 - Expected integer', 273 | validateString: 274 | 'Invalid value:\n' + 275 | ' * int1 - Expected integer\n' + 276 | ' * whatever - Missing whatever', 277 | }, 278 | ]); 279 | } 280 | 281 | function runThisValidator(validatorKind: ValidatorKind): boolean { 282 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 283 | } 284 | 285 | function runThisTest(methodKind: MethodKind): boolean { 286 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 287 | } 288 | -------------------------------------------------------------------------------- /src/test/standard-validators-valid.test.ts: -------------------------------------------------------------------------------- 1 | import { TSchema, Type } from '@sinclair/typebox'; 2 | 3 | import { AbstractStandardValidator } from '../abstract/abstract-standard-validator'; 4 | import { StandardValidator } from '../standard/standard-validator'; 5 | import { CompilingStandardValidator } from '../standard/compiling-standard-validator'; 6 | import { 7 | ValidTestSpec, 8 | ValidatorKind, 9 | MethodKind, 10 | ValidatorCache, 11 | } from './test-utils'; 12 | import { testValidSpecs } from './test-valid-specs'; 13 | 14 | const onlyRunValidator = ValidatorKind.All; 15 | const onlyRunMethod = MethodKind.All; 16 | 17 | const schema0 = Type.String({ 18 | minLength: 5, 19 | maxLength: 10, 20 | pattern: '^[a-zA-Z]+$', 21 | }); 22 | 23 | const schema1 = Type.Object({ 24 | delta: Type.Integer(), 25 | count: Type.Integer({ exclusiveMinimum: 0 }), 26 | name: schema0, 27 | }); 28 | 29 | const validatorCache = new ValidatorCache(); 30 | 31 | describe('standard validators - valid values', () => { 32 | if (runThisValidator(ValidatorKind.NonCompiling)) { 33 | describe('StandardValidator', () => { 34 | testValidator((schema: TSchema) => 35 | validatorCache.getNonCompiling( 36 | schema, 37 | () => new StandardValidator(schema) 38 | ) 39 | ); 40 | }); 41 | } 42 | if (runThisValidator(ValidatorKind.Compiling)) { 43 | describe('CompilingStandardValidator', () => { 44 | testValidator((schema: TSchema) => 45 | validatorCache.getCompiling( 46 | schema, 47 | () => new CompilingStandardValidator(schema) 48 | ) 49 | ); 50 | }); 51 | } 52 | }); 53 | 54 | function testValidator( 55 | createValidator: (schema: TSchema) => AbstractStandardValidator 56 | ) { 57 | testValidSpecs(runThisTest, createValidator, verifyCleaning, [ 58 | { 59 | description: 'valid value 0, string literal', 60 | onlySpec: false, 61 | schema: schema0, 62 | value: 'ABCDEDEFGH', 63 | }, 64 | { 65 | description: 'valid value 1, no unrecognized fields', 66 | onlySpec: false, 67 | schema: schema1, 68 | value: { delta: 0, count: 1, name: 'ABCDE' }, 69 | }, 70 | { 71 | description: 'valid value 2, with unrecognized fields', 72 | onlySpec: false, 73 | schema: schema1, 74 | value: { 75 | delta: -5, 76 | count: 125, 77 | name: 'ABCDEDEFGH', 78 | unrecognized1: 1, 79 | unrecognized2: 'abc', 80 | }, 81 | }, 82 | ]); 83 | } 84 | 85 | function verifyCleaning(spec: ValidTestSpec, value: any): void { 86 | if (spec.schema.properties !== undefined) { 87 | for (const key in value) { 88 | expect(key in spec.schema.properties).toBe(true); 89 | } 90 | expect(Object.keys(value).length).toBeLessThanOrEqual( 91 | Object.keys(spec.schema.properties).length 92 | ); 93 | } 94 | } 95 | 96 | function runThisValidator(validatorKind: ValidatorKind): boolean { 97 | return [ValidatorKind.All, validatorKind].includes(onlyRunValidator); 98 | } 99 | 100 | function runThisTest(methodKind: MethodKind): boolean { 101 | return [MethodKind.All, methodKind].includes(onlyRunMethod); 102 | } 103 | -------------------------------------------------------------------------------- /src/test/test-invalid-specs.ts: -------------------------------------------------------------------------------- 1 | import { TSchema } from '@sinclair/typebox'; 2 | 3 | import { AbstractValidator } from '../abstract/abstract-validator'; 4 | import { 5 | InvalidTestSpec, 6 | MethodKind, 7 | ValidatorMethodOfClass, 8 | specsToRun, 9 | } from './test-utils'; 10 | import { DEFAULT_OVERALL_MESSAGE } from '../lib/error-utils'; 11 | import { ValidationException } from '../lib/validation-exception'; 12 | 13 | export function testInvalidSpecs>( 14 | runThisTest: (method: MethodKind) => boolean, 15 | createValidator: (schema: TSchema) => AbstractValidator, 16 | invalidSpecs: S[] 17 | ) { 18 | if (runThisTest(MethodKind.Test)) { 19 | describe('test() rejections', () => { 20 | specsToRun(invalidSpecs).forEach((spec) => { 21 | it('test() should reject ' + spec.description, () => { 22 | const validator = createValidator(spec.schema); 23 | expect(validator.test(spec.value)).toBe(false); 24 | }); 25 | }); 26 | }); 27 | } 28 | if (runThisTest(MethodKind.TestReturningErrors)) { 29 | describe('testReturningErrors()', () => { 30 | specsToRun(invalidSpecs).forEach((spec) => { 31 | it('testReturningErrors() for ' + spec.description, () => { 32 | const validator = createValidator(spec.schema); 33 | const result = validator.testReturningErrors(spec.value); 34 | expect(result).not.toBeNull(); 35 | const errors = [...result!]; 36 | expect(errors.length).toEqual(spec.errors.length); 37 | errors.forEach((error, i) => { 38 | expect(error.path).toEqual(spec.errors[i].path); 39 | expect(error.message).toContain(spec.errors[i].message); 40 | }); 41 | }); 42 | }); 43 | }); 44 | } 45 | if (runThisTest(MethodKind.TestReturningFirstError)) { 46 | describe('testReturningFirstError()', () => { 47 | specsToRun(invalidSpecs).forEach((spec) => { 48 | it('testReturningFirstError() for ' + spec.description, () => { 49 | const validator = createValidator(spec.schema); 50 | const firstError = validator.firstError(spec.value); 51 | expect(firstError).not.toBeNull(); 52 | expect(firstError?.path).toEqual(spec.errors[0].path); 53 | expect(firstError?.message).toContain(spec.errors[0].message); 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | if (runThisTest(MethodKind.Assert)) { 60 | testAssertMethodRejection('assert', invalidSpecs); 61 | } 62 | if (runThisTest(MethodKind.AssertAndClean)) { 63 | testAssertMethodRejection('assertAndClean', invalidSpecs); 64 | } 65 | if (runThisTest(MethodKind.AssertAndCleanCopy)) { 66 | testAssertMethodRejection('assertAndCleanCopy', invalidSpecs); 67 | } 68 | if (runThisTest(MethodKind.Validate)) { 69 | testValidateMethodRejection('validate', invalidSpecs); 70 | } 71 | if (runThisTest(MethodKind.ValidateAndClean)) { 72 | testValidateMethodRejection('validateAndClean', invalidSpecs); 73 | } 74 | if (runThisTest(MethodKind.ValidateAndCleanCopy)) { 75 | testValidateMethodRejection('validateAndCleanCopy', invalidSpecs); 76 | } 77 | 78 | if (runThisTest(MethodKind.Errors)) { 79 | describe('errors()', () => { 80 | specsToRun(invalidSpecs).forEach((spec) => { 81 | it('errors() for ' + spec.description, () => { 82 | const validator = createValidator(spec.schema); 83 | const errors = [...validator.errors(spec.value)]; 84 | expect(errors.length).toEqual(spec.errors.length); 85 | errors.forEach((error, i) => { 86 | expect(error.path).toEqual(spec.errors[i].path); 87 | expect(error.message).toContain(spec.errors[i].message); 88 | }); 89 | }); 90 | }); 91 | }); 92 | } 93 | if (runThisTest(MethodKind.FirstError)) { 94 | describe('firstError()', () => { 95 | specsToRun(invalidSpecs).forEach((spec) => { 96 | it('firstError() for ' + spec.description, () => { 97 | const validator = createValidator(spec.schema); 98 | const firstError = validator.firstError(spec.value); 99 | expect(firstError?.path).toEqual(spec.errors[0].path); 100 | expect(firstError?.message).toContain(spec.errors[0].message); 101 | }); 102 | }); 103 | }); 104 | } 105 | 106 | function testAssertMethodRejection( 107 | method: ValidatorMethodOfClass>, 108 | specs: InvalidTestSpec[] 109 | ) { 110 | describe(`${method}() rejections`, () => { 111 | specsToRun(specs).forEach((spec) => { 112 | it(`${method}() should reject ${spec.description}`, () => { 113 | const validator = createValidator(spec.schema); 114 | try { 115 | (validator[method] as any)(spec.value, spec.overallMessage); 116 | expect(false).toBe(true); 117 | } catch (e: any) { 118 | if (!(e instanceof ValidationException)) throw e; 119 | 120 | const details = e.details; 121 | const errors = spec.errors; 122 | expect(details.length).toEqual(1); 123 | expect(details[0].path).toEqual(errors[0].path); 124 | expect(details[0].message).toContain(errors[0].message); 125 | 126 | if (spec.assertMessage !== undefined) { 127 | expect(e.message).toEqual(spec.assertMessage); 128 | } 129 | if (spec.assertString !== undefined) { 130 | expect(e.toString()).toEqual(spec.assertString); 131 | } 132 | } 133 | }); 134 | }); 135 | }); 136 | } 137 | 138 | function testValidateMethodRejection( 139 | method: ValidatorMethodOfClass>, 140 | specs: InvalidTestSpec[] 141 | ) { 142 | describe(`${method}() rejections`, () => { 143 | specsToRun(specs).forEach((spec) => { 144 | it(`${method}() should reject ${spec.description}`, () => { 145 | const validator = createValidator(spec.schema); 146 | const overallMessage = spec.overallMessage 147 | ? spec.overallMessage.replace('{error}', '').trim() 148 | : undefined; 149 | try { 150 | (validator[method] as any)(spec.value, overallMessage); 151 | expect(false).toBe(true); 152 | } catch (e: any) { 153 | if (!(e instanceof ValidationException)) throw e; 154 | 155 | const details = e.details; 156 | const errors = spec.errors; 157 | expect(details.length).toEqual(errors.length); 158 | errors.forEach((error, i) => { 159 | expect(details[i]?.path).toEqual(error.path); 160 | expect(details[i]?.message).toContain(error.message); 161 | }); 162 | 163 | expect(e.message).toEqual( 164 | overallMessage ?? DEFAULT_OVERALL_MESSAGE 165 | ); 166 | if (spec.validateString !== undefined) { 167 | expect(e.toString()).toEqual(spec.validateString); 168 | } 169 | } 170 | }); 171 | }); 172 | }); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/test/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { TObject, TSchema, TUnion } from '@sinclair/typebox'; 2 | import { AbstractValidator } from '../abstract/abstract-validator'; 3 | 4 | export enum ValidatorKind { 5 | All, 6 | Compiling, 7 | NonCompiling, 8 | } 9 | 10 | export enum MethodKind { 11 | All, 12 | Test, 13 | TestReturningErrors, 14 | TestReturningFirstError, 15 | Assert, 16 | AssertAndClean, 17 | AssertAndCleanCopy, 18 | Validate, 19 | ValidateAndClean, 20 | ValidateAndCleanCopy, 21 | Errors, 22 | FirstError, 23 | InvalidSchema, 24 | } 25 | 26 | export type ValidatorMethodOfClass = { 27 | [K in keyof T]: T[K] extends (value: any, errorMessage?: string) => any 28 | ? K 29 | : never; 30 | }[keyof T]; 31 | 32 | export interface TestSpec { 33 | onlySpec: boolean; 34 | } 35 | 36 | export interface ValidTestSpec extends TestSpec { 37 | description: string; 38 | schema: S; 39 | value: any; 40 | } 41 | 42 | export interface ValidUnionTestSpec extends ValidTestSpec> { 43 | selectedIndex: number; 44 | } 45 | 46 | export interface InvalidTestSpec extends TestSpec { 47 | description: string; 48 | schema: S; 49 | value: any; 50 | overallMessage?: string; 51 | assertMessage?: string; 52 | errors: { path: string; message: string }[]; 53 | assertString?: string; 54 | validateString?: string; 55 | } 56 | 57 | export function specsToRun(specs: S[]): S[] { 58 | for (const spec of specs) { 59 | if (spec.onlySpec) { 60 | return [spec]; 61 | } 62 | } 63 | return specs; 64 | } 65 | 66 | export class ValidatorCache { 67 | compilingValidators = new Map>(); 68 | nonCompilingValidators = new Map>(); 69 | 70 | getCompiling( 71 | schema: S, 72 | createValidator: (schema: S) => AbstractValidator 73 | ): AbstractValidator { 74 | return this._getCachedValidator( 75 | this.compilingValidators, 76 | schema, 77 | createValidator 78 | ); 79 | } 80 | 81 | getNonCompiling( 82 | schema: S, 83 | createValidator: (schema: S) => AbstractValidator 84 | ): AbstractValidator { 85 | return this._getCachedValidator( 86 | this.nonCompilingValidators, 87 | schema, 88 | createValidator 89 | ); 90 | } 91 | 92 | private _getCachedValidator( 93 | cache: Map>, 94 | schema: S, 95 | createValidator: (schema: S) => AbstractValidator 96 | ): AbstractValidator { 97 | let validator = cache.get(schema); 98 | if (validator === undefined) { 99 | validator = createValidator(schema); 100 | cache.set(schema, validator); 101 | } 102 | return validator; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/test-valid-specs.ts: -------------------------------------------------------------------------------- 1 | import { TSchema } from '@sinclair/typebox'; 2 | import { MethodKind, ValidTestSpec, specsToRun } from './test-utils'; 3 | import { AbstractValidator } from '../abstract/abstract-validator'; 4 | 5 | export function testValidSpecs>( 6 | runThisTest: (method: MethodKind) => boolean, 7 | createValidator: (schema: TSchema) => AbstractValidator, 8 | verifyCleaning: (spec: S, value: any) => void, 9 | validSpecs: S[] 10 | ) { 11 | specsToRun(validSpecs).forEach((spec) => { 12 | if (runThisTest(MethodKind.Test)) { 13 | describe('test()', () => { 14 | it(`test() should accept ${spec.description}`, () => { 15 | const validator = createValidator(spec.schema); 16 | expect(validator.test(spec.value)).toBe(true); 17 | }); 18 | }); 19 | } 20 | if (runThisTest(MethodKind.TestReturningErrors)) { 21 | describe('testReturningErrors()', () => { 22 | specsToRun(validSpecs).forEach((spec) => { 23 | it('testReturningErrors() for ' + spec.description, () => { 24 | const validator = createValidator(spec.schema); 25 | const errors = validator.testReturningErrors(spec.value); 26 | expect(errors).toBeNull(); 27 | }); 28 | }); 29 | }); 30 | } 31 | if (runThisTest(MethodKind.TestReturningFirstError)) { 32 | describe('testReturningFirstError()', () => { 33 | specsToRun(validSpecs).forEach((spec) => { 34 | it('testReturningFirstError() for ' + spec.description, () => { 35 | const validator = createValidator(spec.schema); 36 | const firstError = validator.testReturningFirstError(spec.value); 37 | expect(firstError).toBeNull(); 38 | }); 39 | }); 40 | }); 41 | } 42 | 43 | describe('no cleaning', () => { 44 | if (runThisTest(MethodKind.Assert)) { 45 | it(`assert() should accept ${spec.description}`, () => { 46 | const validator = createValidator(spec.schema); 47 | expect(() => validator.assert(spec.value)).not.toThrow(); 48 | }); 49 | } 50 | 51 | if (runThisTest(MethodKind.Validate)) { 52 | it(`validate() should accept ${spec.description}`, () => { 53 | const validator = createValidator(spec.schema); 54 | expect(() => validator.validate(spec.value)).not.toThrow(); 55 | }); 56 | } 57 | }); 58 | 59 | describe('cleaning provided value', () => { 60 | if (runThisTest(MethodKind.AssertAndClean)) { 61 | it(`assertAndClean() should clean provided ${spec.description}`, () => { 62 | const validator = createValidator(spec.schema); 63 | const value = 64 | typeof spec.value == 'object' ? { ...spec.value } : spec.value; 65 | expect(() => validator.assertAndClean(value)).not.toThrow(); 66 | verifyCleaning(spec, value); 67 | }); 68 | } 69 | 70 | if (runThisTest(MethodKind.ValidateAndClean)) { 71 | it(`validateAndClean() should clean provided ${spec.description}`, () => { 72 | const validator = createValidator(spec.schema); 73 | const value = 74 | typeof spec.value == 'object' ? { ...spec.value } : spec.value; 75 | expect(() => validator.validateAndClean(value)).not.toThrow(); 76 | verifyCleaning(spec, value); 77 | }); 78 | } 79 | }); 80 | 81 | describe('cleaning copy of value', () => { 82 | if (runThisTest(MethodKind.AssertAndCleanCopy)) { 83 | it(`assertAndCleanCopy() should clean copy of ${spec.description}`, () => { 84 | const validator = createValidator(spec.schema); 85 | const value = validator.assertAndCleanCopy(spec.value) as object; 86 | verifyCleaning(spec, value); 87 | }); 88 | } 89 | 90 | if (runThisTest(MethodKind.ValidateAndCleanCopy)) { 91 | it(`validateAndCleanCopy() should clean copy of ${spec.description}`, () => { 92 | const validator = createValidator(spec.schema); 93 | const value = validator.validateAndCleanCopy(spec.value) as object; 94 | verifyCleaning(spec, value); 95 | }); 96 | } 97 | }); 98 | 99 | if (runThisTest(MethodKind.Errors)) { 100 | describe('errors()', () => { 101 | specsToRun(validSpecs).forEach((spec) => { 102 | it('errors() for ' + spec.description, () => { 103 | const validator = createValidator(spec.schema); 104 | const errors = [...validator.errors(spec.value)]; 105 | expect(errors.length).toEqual(0); 106 | }); 107 | }); 108 | }); 109 | } 110 | if (runThisTest(MethodKind.FirstError)) { 111 | describe('firstError()', () => { 112 | specsToRun(validSpecs).forEach((spec) => { 113 | it('firstError() for ' + spec.description, () => { 114 | const validator = createValidator(spec.schema); 115 | const firstError = validator.firstError(spec.value); 116 | expect(firstError).toBeNull(); 117 | }); 118 | }); 119 | }); 120 | } 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES6", 5 | "module": "CommonJS", 6 | "sourceMap": true, 7 | "composite": false, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "inlineSources": false, 13 | "moduleResolution": "node", 14 | "preserveWatchOutput": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "experimentalDecorators": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noImplicitOverride": true, 23 | "outDir": "./dist", 24 | "rootDir": "./src" 25 | }, 26 | "include": ["*.d.ts", "**/*.ts", "**/*.tsx"], 27 | "exclude": ["src/test", "dist", "build", "node_modules", "comparison"] 28 | } 29 | -------------------------------------------------------------------------------- /typedoc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | out: './docs/', 3 | 4 | exclude: ['tests/**/*'], 5 | readme: 'none', 6 | 7 | excludeExternals: true, 8 | excludePrivate: true, 9 | }; 10 | --------------------------------------------------------------------------------