├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── constants.ts ├── createErrors.ts ├── createSchema.ts ├── helpers.ts ├── index.ts ├── type-utils.ts ├── utils.ts └── validatorTypes.ts ├── test ├── index.test.ts ├── tsconfig.json └── validators.test.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | eqeqeq: 'off', 4 | 'no-redeclare': 'off', 5 | '@typescript-eslint/no-redeclare': ['error', { ignoreDeclarationMerge: true }], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | coverage 6 | todo -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [5.0.3](https://github.com/5alidz/tiny-schema-validator/compare/v5.0.2...v5.0.3) (2021-08-31) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * remove unused import ([8c1247a](https://github.com/5alidz/tiny-schema-validator/commit/8c1247a5be26a40209e584c7ea856db85a9896fd)) 11 | * report unknown keys found in data on validation ([#2](https://github.com/5alidz/tiny-schema-validator/issues/2)) ([08a4e5c](https://github.com/5alidz/tiny-schema-validator/commit/08a4e5cda8a01710d8964db9ab199fc94d6e23e1)) 12 | * test and fix unknown keys ([aff85e1](https://github.com/5alidz/tiny-schema-validator/commit/aff85e1d3177a2db7a9c1c5947820db0a3521329)) 13 | 14 | ### [5.0.2](https://github.com/5alidz/tiny-schema-validator/compare/v5.0.1...v5.0.2) (2021-08-28) 15 | 16 | ### [5.0.1](https://github.com/5alidz/tiny-schema-validator/compare/v5.0.0...v5.0.1) (2021-08-28) 17 | 18 | ## [5.0.0](https://github.com/5alidz/tiny-schema-validator/compare/v4.0.0...v5.0.0) (2021-08-23) 19 | 20 | 21 | ### ⚠ BREAKING CHANGES 22 | 23 | * - replace "traverse" with "source" so you can parse the schema however you want, 24 | also to add "atomic" validations 25 | 26 | * remove traverse, and replace it with direct validation ([513c682](https://github.com/5alidz/tiny-schema-validator/commit/513c682acd3d1845acbc73c44f52ed766e5039a5)) 27 | 28 | ## [4.0.0](https://github.com/5alidz/tiny-schema-validator/compare/v3.1.0...v4.0.0) (2021-08-21) 29 | 30 | 31 | ### ⚠ BREAKING CHANGES 32 | 33 | * when traversing the schema and the returned value is null, the result will contain 34 | 'invalid-type' message 35 | 36 | ### Features 37 | 38 | * add constant and union validators ([7859392](https://github.com/5alidz/tiny-schema-validator/commit/7859392dffdda3bc7adeaa6fc5f6df6085b2d5a1)) 39 | * include the schema source on the schema api ([14c1ecf](https://github.com/5alidz/tiny-schema-validator/commit/14c1ecf2e8bf0e7e9429bb34c6c58e88199bb4c2)) 40 | 41 | ## [3.1.0](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.5...v3.1.0) (2021-07-18) 42 | 43 | 44 | ### Features 45 | 46 | * add latest version of typescript ([2e8a339](https://github.com/5alidz/tiny-schema-validator/commit/2e8a339d392bb229d52f79ded5f563e1953e2dc6)) 47 | 48 | ### [3.0.5](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.4...v3.0.5) (2021-04-05) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * narrow down types in case of {} ([5cf59cf](https://github.com/5alidz/tiny-schema-validator/commit/5cf59cfaf5b6b851e0adbce055dd6fb364ccc8c6)) 54 | 55 | ### [3.0.4](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.3...v3.0.4) (2021-04-05) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * fix shape validators recursion even when optional ([994cef6](https://github.com/5alidz/tiny-schema-validator/commit/994cef6229b2accc49c46972e94ef5b41fe8d275)) 61 | * move data checking outside the loop ([2d40ec1](https://github.com/5alidz/tiny-schema-validator/commit/2d40ec1a10633d5a20c738e1f44f2da9acbcd7a9)) 62 | * optimize types for strict traverse ([4fd3d26](https://github.com/5alidz/tiny-schema-validator/commit/4fd3d26f7e6380a2d9a23ffcbb9804a1d166075b)) 63 | * throw TypeError on produce invalid-data ([0462755](https://github.com/5alidz/tiny-schema-validator/commit/04627558e7007ac39622aa085b3380f54997a750)) 64 | 65 | ### [3.0.3](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.1...v3.0.3) (2021-04-02) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * **types:** fix infered schema.embed ([ade04e0](https://github.com/5alidz/tiny-schema-validator/commit/ade04e0684dd0d4cdd1887a32c74ce6542913e90)) 71 | 72 | ### [3.0.2](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.1...v3.0.2) (2021-04-02) 73 | 74 | 75 | ### Bug Fixes 76 | 77 | * **types:** fix infered schema.embed ([ade04e0](https://github.com/5alidz/tiny-schema-validator/commit/ade04e0684dd0d4cdd1887a32c74ce6542913e90)) 78 | 79 | ### [3.0.1](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.0...v3.0.1) (2021-03-28) 80 | 81 | ## [3.0.0](https://github.com/5alidz/tiny-schema-validator/compare/v3.0.0-alpha.0...v3.0.0) (2021-03-26) 82 | 83 | 84 | ### Bug Fixes 85 | 86 | * helper better type support & insure primitives is required ([ab37494](https://github.com/5alidz/tiny-schema-validator/commit/ab374945dd1b439701161f38b183b7c1d4fecd7a)) 87 | 88 | ## [3.0.0-alpha.0](https://github.com/5alidz/tiny-schema-validator/compare/v2.1.0-alpha.3...v3.0.0-alpha.0) (2021-03-26) 89 | 90 | 91 | ### ⚠ BREAKING CHANGES 92 | 93 | * infers data type automatically for both JS & TS 94 | 95 | ### Features 96 | 97 | * implement better type inference | less work for the user ([a827591](https://github.com/5alidz/tiny-schema-validator/commit/a827591a8ce525b8f32d08e99ffdb8f8f9657485)) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * fix validator circular refernce ([f922048](https://github.com/5alidz/tiny-schema-validator/commit/f922048af6faca4389e7d0abfd5c35097946e916)) 103 | 104 | ## [2.1.0-alpha.3](https://github.com/5alidz/tiny-schema-validator/compare/v2.1.0-alpha.2...v2.1.0-alpha.3) (2021-03-19) 105 | 106 | ## [2.1.0-alpha.2](https://github.com/5alidz/tiny-schema-validator/compare/v2.1.0-alpha.1...v2.1.0-alpha.2) (2021-03-18) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * expose validatorTypes with index.d.ts ([796990d](https://github.com/5alidz/tiny-schema-validator/commit/796990d543de176332973ef198b33e5d8a48ea1d)) 112 | 113 | ## [2.1.0-alpha.1](https://github.com/5alidz/tiny-schema-validator/compare/v2.1.0-alpha.0...v2.1.0-alpha.1) (2021-03-11) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * expose path as array of strings ([f304909](https://github.com/5alidz/tiny-schema-validator/commit/f304909c9d06bf118cf9d33bc0bfa2043f8ff424)) 119 | * remove repeated parent path ([ecd7efa](https://github.com/5alidz/tiny-schema-validator/commit/ecd7efa427156c5e56c5a225975451bf467699cc)) 120 | 121 | ## [2.1.0-alpha.0](https://github.com/5alidz/tiny-schema-validator/compare/v2.0.1-alpha.3...v2.1.0-alpha.0) (2021-03-10) 122 | 123 | 124 | ### Features 125 | 126 | * expose correct path ([2fa69ae](https://github.com/5alidz/tiny-schema-validator/commit/2fa69ae08c6c95ee76afe60c07da1c060a726208)) 127 | 128 | ### [2.0.1-alpha.3](https://github.com/5alidz/tiny-schema-validator/compare/v2.0.1-alpha.2...v2.0.1-alpha.3) (2021-03-10) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * expose parentkey ([d6d4302](https://github.com/5alidz/tiny-schema-validator/commit/d6d43028f858983b4f74cfeb2908693c56465ded)) 134 | 135 | ### [2.0.1-alpha.2](https://github.com/5alidz/tiny-schema-validator/compare/v2.0.1-alpha.1...v2.0.1-alpha.2) (2021-03-10) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * prettier not supporting export * as ([0bc2a29](https://github.com/5alidz/tiny-schema-validator/commit/0bc2a2960cdee7c135a1fd57245c1892e2b7293d)) 141 | * recordof traverse using index instead of key ([9606738](https://github.com/5alidz/tiny-schema-validator/commit/96067381a3115d087f2633dfa7291d238eb01243)) 142 | 143 | ### [2.0.1-alpha.1](https://github.com/5alidz/tiny-schema-validator/compare/v2.0.1-alpha.0...v2.0.1-alpha.1) (2021-03-06) 144 | 145 | 146 | ### Bug Fixes 147 | 148 | * better type inference ([a233541](https://github.com/5alidz/tiny-schema-validator/commit/a233541bd1337fc289427046bac02d5b804e15a8)) 149 | 150 | ### [2.0.1-alpha.0](https://github.com/5alidz/tiny-schema-validator/compare/v2.0.0...v2.0.1-alpha.0) (2021-03-06) 151 | 152 | 153 | ### Bug Fixes 154 | 155 | * infer errors types ([6ea7d1f](https://github.com/5alidz/tiny-schema-validator/commit/6ea7d1f9ac62aabff78e627d1133c947f10e0d95)) 156 | 157 | ## [2.0.0](https://github.com/5alidz/tiny-schema-validator/compare/v1.0.5...v2.0.0) (2021-03-04) 158 | 159 | 160 | ### ⚠ BREAKING CHANGES 161 | 162 | * renamed recordOf -> recordof 163 | 164 | * change helpers names to match docs ([65a1f29](https://github.com/5alidz/tiny-schema-validator/commit/65a1f298323d397d7399933252b2022bdacc784a)) 165 | 166 | ### [1.0.5](https://github.com/5alidz/tiny-schema-validator/compare/v1.0.4...v1.0.5) (2021-03-02) 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 khaled 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiny Schema Validator 2 | 3 | JSON schema validator with excellent type inference for JavaScript and TypeScript. 4 | 5 | [![GitHub license](https://img.shields.io/github/license/5alidz/tiny-schema-validator)](https://github.com/5alidz/tiny-schema-validator/blob/master/LICENSE) ![Minzipped size](https://img.shields.io/bundlephobia/minzip/tiny-schema-validator.svg) 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm install tiny-schema-validator 11 | # or 12 | yarn add tiny-schema-validator 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Creating a schema 18 | 19 | ```js 20 | import { createSchema, _ } from 'tiny-schema-validator'; 21 | 22 | export const User = createSchema({ 23 | metadata: _.record({ 24 | date_created: _.number(), 25 | id: _.string(), 26 | }), 27 | profile: _.record({ 28 | name: _.string({ 29 | maxLength: [100, 'too-long'], 30 | minLength: [2, 'too-short'], 31 | }), 32 | age: _.number({ 33 | max: [150, 'too-old'], 34 | min: [13, 'too-young'], 35 | }), 36 | email: _.string({ 37 | pattern: [/^[^@]+@[^@]+\.[^@]+$/, 'invalid-email'], 38 | }), 39 | }), 40 | payment_status: _.union( 41 | _.constant('pending'), 42 | _.constant('failed'), 43 | _.constant('success'), 44 | _.constant('canceled') 45 | ), 46 | }); 47 | ``` 48 | 49 | and in TypeScript, everything is the same, but to get the data type inferred from the schema, you can do this: 50 | 51 | ```ts 52 | /* 53 | UserType { 54 | metadata: { 55 | date_created: number; 56 | id: string; 57 | }; 58 | profile: { 59 | name: string; 60 | age: number; 61 | email: string; 62 | }; 63 | payment_status: 'pending' | 'failed' | 'success' | 'canceled'; 64 | } 65 | */ 66 | export type UserType = ReturnType; 67 | ``` 68 | 69 | ### Using the schema 70 | 71 | When you create a schema, you will get a nice API to handle multiple use-cases in the client and the server. 72 | 73 | - `is(data: any): boolean` check if the data is valid (eager evaluation) 74 | - `validate(data: any): Errors` errors returned has the same shape as the schema you defined (does not throw) 75 | - `produce(data: any): data` throws an error when the data is invalid. otherwise, it returns data 76 | - `embed(config?: { optional: boolean })` embeds the schema in other schemas 77 | - `source` the schema itself in a parsable format 78 | 79 | example usage: 80 | 81 | ```js 82 | const Person = createSchema({ 83 | name: _.string(), 84 | age: _.number(), 85 | email: _.string(), 86 | }); 87 | 88 | const john = { name: 'john', age: 42, email: 'john@gmail.com' }; 89 | Person.is({}); // false 90 | Person.is(john); // true 91 | 92 | Person.validate({}); // { name: 'invalid-type', age: 'invalid-type', email: 'invalid-type' } 93 | Person.validate(john); // null 94 | 95 | try { 96 | Person.produce(undefined); 97 | } catch (e) { 98 | console.log(e instanceof TypeError); // true 99 | console.log(e.message); // "invalid-data" 100 | } 101 | 102 | // embedding the person schema 103 | const GroupOfPeople = createSchema({ 104 | // ... 105 | people: _.listof(Person.embed()), 106 | // ... 107 | }); 108 | ``` 109 | 110 | ## Validators 111 | 112 | All validators are required by default. 113 | All validators are accessible with the `_` (underscore) namespace; The reason for using `_` instead of a good name like `validators` is developer experience, and you can alias it to whatever you want. 114 | 115 | ```js 116 | import { _ as validators } from 'tiny-schema-validator'; 117 | ``` 118 | 119 | Example of all validators and corresponding Typescript types: 120 | 121 | 122 | ```js 123 | import { _ } from 'tiny-schema-validator'; 124 | 125 | // NOTE: when you call a validator you just create an object 126 | // containing { type: '', ...options } 127 | // this is just a shorthand for that. 128 | 129 | // simple validators. 130 | _.string(); // string 131 | _.number(); // number 132 | _.boolean(); // boolean 133 | _.constant(42); // 42 134 | 135 | // complex validators (types that accepts other types as paramater) 136 | _.union( 137 | _.record({ id: _.string() }), 138 | _.constant(1), 139 | _.constant(2), 140 | _.constant(3) 141 | ); // { id: string; } | 1 | 2 | 3 142 | 143 | _.list([ 144 | _.number(), 145 | _.string(), 146 | ]); // [number, number] 147 | _.record({ 148 | timestamp: _.number(), 149 | id: _.string(), 150 | }); // { timestamp: number; id: string; } 151 | 152 | _.listof(_.string()); // string[] 153 | _.recordof(_.string()); // Record 154 | ``` 155 | 156 | Check out the full validators API below: 157 | 158 | | validator | signature | props | 159 | | :-------- | ------------------------------- | :------------------------------------------------------------- | 160 | | | | | 161 | | constant | `constant(value)` | value: `string \| number \| boolean` | 162 | | | | | 163 | | string | `string(options?)` | options (optional): Object | 164 | | | | - `optional : boolean` defaults to false | 165 | | | | - `maxLength: [length: number, error: string]` | 166 | | | | - `minLength: [length: number, error: string]` | 167 | | | | - `pattern : [pattern: RegExp, error: string]` | 168 | | | | | 169 | | number | `number(options?)` | options(optional): Object | 170 | | | | - `optional: boolean` default to false | 171 | | | | - `min: [number, error: string]` | 172 | | | | - `max: [number, error: string]` | 173 | | | | - `is : ['integer' \| 'float', error: string]` default is both | 174 | | | | | 175 | | boolean | `boolean(options?)` | options(optional): Object | 176 | | | | - `optional: boolean` default to false | 177 | | | | | 178 | | union | `union(...validators)` | validators: Array of validators as paramaters | 179 | | | | | 180 | | list | `list(validators[], options?)` | validators: Array of validators | 181 | | | | options(optional): Object | 182 | | | | - `optional: boolean` default to false | 183 | | | | | 184 | | listof | `listof(validator, options?)` | validator: Validator | 185 | | | | options(optional): Object | 186 | | | | - `optional: boolean` default to false | 187 | | | | | 188 | | record | `record(shape, options?)` | shape: `Object { [key: string]: Validator }` | 189 | | | | options(optional): Object | 190 | | | | - `optional: boolean` default to false | 191 | | | | | 192 | | recordof | `recordof(validator, options?)` | validator: `Validator` | 193 | | | | options(optional): Object | 194 | | | | - `optional: boolean` default to false | 195 | 196 | ### Custom validators 197 | 198 | To create custom validators that do not break type inference: 199 | 200 | - use validators from `_` as building blocks for your custom validator. 201 | - your custom validator should define `optional` and `required` functions. 202 | 203 | Example of creating custom validators: 204 | 205 | ```js 206 | const alphaNumeric = (() => { 207 | const config = { 208 | pattern: [/^[a-zA-Z0-9]*$/, 'only-letters-and-number'], 209 | }; 210 | return { 211 | required: additional => _.string({ ...additional, ...config, optional: false }), // inferred as Required 212 | optional: additional => _.string({ ...additional, ...config, optional: true }), // inferred as Optional 213 | }; 214 | })(); 215 | 216 | const Person = createSchema({ 217 | // ... 218 | username: alphaNumeric.required({ maxLength: [20, 'username-too-long'] }), 219 | // ... 220 | }); 221 | ``` 222 | 223 | ## Built-in Errors 224 | 225 | ```js 226 | // when typeof value does not match the validator infered type 227 | const TYPEERR = 'invalid-type'; 228 | 229 | // when "schema" in createSchema(schema) is not plain object 230 | const SCHEMAERR = 'invalid-schema'; 231 | 232 | // when produce(data) and "data" failed to match the schema 233 | // always accompanied be TypeError, so make sure to catch it 234 | const DATAERR = 'invalid-data'; 235 | 236 | // when an unknown key is found in data while using record | list 237 | // and any keys that exists on data and not present in the schema 238 | const UNKOWN_KEY_ERR = 'unknown-key'; 239 | ``` 240 | 241 | ## Caveats 242 | 243 | - When using the `recordof | listof | list` validators, the optional property of the validator is ignored, example: 244 | 245 | ```js 246 | _.recordof(_.string({ optional: true /* THIS IS IGNORED */ })); 247 | _.list([_.number({ optional: true /* THIS IS IGNORED */ }), _.number()]); 248 | ``` 249 | 250 | - You might expect errors returned from a `list | listof` validators to be an array but it is actually an object, example: 251 | 252 | ```js 253 | const list = createSchema({ list: _.listof(_.string()) }); 254 | list.validate({ list: ['string', 42, 'string'] }); // { list: { 1: 'invalid-type' } } 255 | ``` 256 | 257 | ## Recursive types 258 | 259 | Currently, there's no easy way to create recursive types. if you think you could help, PRs are welcome 260 | 261 | ## Errors while wrapping schemas 262 | 263 | if you try to wrap your schema, you will encounter this error (Type instantiation is excessively deep and possibly infinite) 264 | to fix it, you should unwrap your schema and re-create it inside your abstraction. 265 | let's take the following example: 266 | 267 | ```ts 268 | const User = createSchema({ 269 | name: _.string(), 270 | age: _.number(), 271 | }); 272 | 273 | // your abstraction 274 | function schemaWrapper(schema: T) { 275 | //... 276 | } 277 | 278 | const wrappedUser = schemaWrapper(User); // ERROR: Type instantiation is excessively deep and possibly infinite 279 | ``` 280 | 281 | The fix: 282 | 283 | ```ts 284 | import { Schema, R, RecordOptions } from 'tiny-schema-validator'; 285 | /* 286 | optionally, to infer data from the embedded schema, you do DataFrom 287 | 288 | import { DataFrom } from 'tiny-schema-validator/dist/type-utils'; 289 | */ 290 | 291 | // extract schema with Schema.embed() 292 | function schemaWrapper(schema: R>) { 293 | const newSchema = createSchema(schema.shape); // you can add/remove/modify passed schema here 294 | // ... 295 | } 296 | 297 | const wrappedUser = schemaWrapper(User.embed()); // all good no errors 298 | ``` 299 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.0.3", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why", 21 | "release": "standard-version && git push --follow-tags origin master", 22 | "release:alpha": "yarn release -- --prerelease alpha" 23 | }, 24 | "peerDependencies": {}, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 100, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "tiny-schema-validator", 37 | "author": "khaled", 38 | "repository": { 39 | "url": "https://github.com/5alidz/tiny-schema-validator" 40 | }, 41 | "module": "dist/tiny-schema-validator.esm.js", 42 | "size-limit": [ 43 | { 44 | "path": "dist/tiny-schema-validator.cjs.production.min.js", 45 | "limit": "10 KB" 46 | }, 47 | { 48 | "path": "dist/tiny-schema-validator.esm.js", 49 | "limit": "10 KB" 50 | } 51 | ], 52 | "resolutions": { 53 | "**/typescript": "^4.0.5", 54 | "**/@typescript-eslint/eslint-plugin": "^4.6.1", 55 | "**/@typescript-eslint/parser": "^4.6.1" 56 | }, 57 | "jest": { 58 | "coverageReporters": [ 59 | "json-summary", 60 | "text", 61 | "lcov" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@size-limit/preset-small-lib": "^4.9.0", 66 | "@typescript-eslint/eslint-plugin": "^4.8.2", 67 | "@typescript-eslint/parser": "^4.8.2", 68 | "cz-conventional-changelog": "3.3.0", 69 | "husky": "^4.3.0", 70 | "size-limit": "^4.9.0", 71 | "standard-version": "^9.1.1", 72 | "tsdx": "^0.14.1", 73 | "tslib": "^2.3.1", 74 | "typescript": "^4.3.5" 75 | }, 76 | "config": { 77 | "commitizen": { 78 | "path": "./node_modules/cz-conventional-changelog" 79 | } 80 | }, 81 | "dependencies": { 82 | "tiny-invariant": "^1.1.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const $string = 'string'; 2 | export const $number = 'number'; 3 | export const $boolean = 'boolean'; 4 | export const $list = 'list'; 5 | export const $listof = 'listof'; 6 | export const $record = 'record'; 7 | export const $recordof = 'recordof'; 8 | export const $constant = 'constant'; 9 | export const $union = 'union'; 10 | 11 | export const TYPEERR = 'invalid-type'; 12 | export const SCHEMAERR = 'invalid-schema'; 13 | export const DATAERR = 'invalid-data'; 14 | export const UNKOWN_KEY_ERR = 'unknown-key'; 15 | -------------------------------------------------------------------------------- /src/createErrors.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject, isNumber, isString, isBool, ObjectKeys, toObj, isArray } from './utils'; 2 | import { 3 | BooleanValidator, 4 | ConstantValidator, 5 | ListofValidator, 6 | ListValidator, 7 | NumberValidator, 8 | RecordofValidator, 9 | RecordValidator, 10 | Schema, 11 | StringValidator, 12 | UnionValidator, 13 | Validator, 14 | } from './validatorTypes'; 15 | import { InferResult, InferCallbackResult } from './type-utils'; 16 | import { TYPEERR, UNKOWN_KEY_ERR } from './constants'; 17 | import invariant from 'tiny-invariant'; 18 | 19 | function shouldAddToResult(res: unknown) { 20 | if ( 21 | res == null || 22 | (isPlainObject(res) && ObjectKeys(res).length < 1) || 23 | (Array.isArray(res) && res.length < 1) 24 | ) { 25 | return false; 26 | } 27 | return true; 28 | } 29 | 30 | function shouldSkipValidation(value: unknown, validator: Validator) { 31 | return value == null && Boolean(validator.optional); 32 | } 33 | 34 | function normalizeResult>(result: T) { 35 | return ObjectKeys(result).length <= 0 ? null : result; 36 | } 37 | 38 | function enterNode(validator: Validator, value: unknown, eager = false) { 39 | const fn = validators[validator.type] as any; 40 | invariant(typeof fn == 'function', 'invalid-validator-type'); 41 | return fn(validator, value, eager); 42 | } 43 | 44 | function parseShapeValidator( 45 | validator: RecordValidator | ListValidator, 46 | value: unknown, 47 | eager = false 48 | ) { 49 | const shape = toObj(validator).shape; 50 | const keys = ObjectKeys(shape); 51 | const values = toObj(value); 52 | const result: Record = {}; 53 | const dataKeys = ObjectKeys(values); 54 | 55 | for (let i = 0; i < dataKeys.length; i++) { 56 | const key = dataKeys[i]; 57 | if (!keys.includes(key)) { 58 | // we gonna sneak this one in the result without typescript knowning about it. 59 | // can be fixed if we require "data" to be also infered 60 | // (instead of using any in e.g. schema.validate(data)) 61 | result[key] = UNKOWN_KEY_ERR as any; 62 | if (eager) return result; 63 | } 64 | } 65 | 66 | for (let i = 0; i < keys.length; i++) { 67 | const currentResult = enterNode(shape[keys[i]], values[keys[i]]); 68 | if (shouldAddToResult(currentResult)) { 69 | result[keys[i]] = currentResult; 70 | if (eager) return result; 71 | } 72 | } 73 | return normalizeResult(result); 74 | } 75 | 76 | function parseOfValidator( 77 | validator: RecordofValidator | ListofValidator, 78 | value: unknown, 79 | eager = false 80 | ) { 81 | const values = toObj(value); 82 | const keys = ObjectKeys(values); 83 | const result: Record = {}; 84 | for (let i = 0; i < keys.length; i++) { 85 | const currentResult = enterNode(validator.of, values[keys[i]]); 86 | if (shouldAddToResult(currentResult)) { 87 | result[keys[i]] = currentResult; 88 | if (eager) return result; 89 | } 90 | } 91 | return normalizeResult(result); 92 | } 93 | 94 | const validators = { 95 | string(validator: StringValidator, value: unknown) { 96 | if (shouldSkipValidation(value, validator)) return null; 97 | if (!isString(value)) return TYPEERR; 98 | 99 | const [minLength, minLengthErrMsg] = validator.minLength ? validator.minLength : []; 100 | if (minLength && minLengthErrMsg && isNumber(minLength) && value.length < minLength) 101 | return minLengthErrMsg; 102 | 103 | const [maxLength, maxLengthErrMsg] = validator.maxLength ? validator.maxLength : []; 104 | if (maxLength && maxLengthErrMsg && isNumber(maxLength) && value.length > maxLength) 105 | return maxLengthErrMsg; 106 | 107 | const [pattern, patterErrMsg] = validator.pattern ? validator.pattern : []; 108 | if (pattern && patterErrMsg && pattern.test(value) == false) return patterErrMsg; 109 | 110 | return null; 111 | }, 112 | number(validator: NumberValidator, value: unknown) { 113 | if (shouldSkipValidation(value, validator)) return null; 114 | 115 | if (!isNumber(value)) return TYPEERR; 116 | 117 | const [min, minErrMsg] = validator.min ? validator.min : []; 118 | if (isNumber(min) && value < min && minErrMsg) return minErrMsg; 119 | 120 | const [max, maxErrMsg] = validator.max ? validator.max : []; 121 | if (isNumber(max) && value > max && maxErrMsg) return maxErrMsg; 122 | 123 | const [is, isErrMsg] = validator.is ? validator.is : []; 124 | if (isString(is) && isErrMsg) { 125 | const isInt = Number.isInteger(value); 126 | if ((isInt && is == 'float') || (!isInt && is == 'integer')) return isErrMsg; 127 | } 128 | 129 | return null; 130 | }, 131 | boolean(validator: BooleanValidator, value: unknown) { 132 | if (shouldSkipValidation(value, validator)) return null; 133 | if (!isBool(value)) return TYPEERR; 134 | return null; 135 | }, 136 | constant(validator: ConstantValidator, value: unknown) { 137 | if (shouldSkipValidation(value, validator)) return null; 138 | if (value === validator.value) return null; 139 | return TYPEERR; 140 | }, 141 | union(validator: UnionValidator, value: unknown) { 142 | if (shouldSkipValidation(value, validator)) return null; 143 | const unionTypes = validator.of; 144 | let currentResult = null; 145 | for (let i = 0; i < unionTypes.length; i++) { 146 | currentResult = enterNode(unionTypes[i], value); 147 | if (currentResult == null) return null; 148 | } 149 | return TYPEERR; 150 | }, 151 | list(validator: ListValidator, value: unknown, eager = false) { 152 | if (shouldSkipValidation(value, validator)) return null; 153 | if (!isArray(value)) return TYPEERR; 154 | return parseShapeValidator(validator, value, eager); 155 | }, 156 | listof(validator: ListofValidator, value: unknown, eager = false) { 157 | if (shouldSkipValidation(value, validator)) return null; 158 | if (!isArray(value)) return TYPEERR; 159 | return parseOfValidator(validator, value, eager); 160 | }, 161 | record(validator: RecordValidator, value: unknown, eager = false) { 162 | if (shouldSkipValidation(value, validator)) return null; 163 | if (!isPlainObject(value)) return TYPEERR; 164 | return parseShapeValidator(validator, value, eager); 165 | }, 166 | recordof(validator: RecordofValidator, value: unknown, eager = false) { 167 | if (shouldSkipValidation(value, validator)) return null; 168 | if (!isPlainObject(value)) return TYPEERR; 169 | return parseOfValidator(validator, value, eager); 170 | }, 171 | }; 172 | 173 | export function createErrors( 174 | schema: T, 175 | _data: any, 176 | eager = false 177 | ): null | InferResult { 178 | const data = isPlainObject(_data) ? _data : {}; 179 | const result: InferResult = {}; 180 | const schemaKeys = ObjectKeys(schema) as (keyof T)[]; 181 | const dataKeys = ObjectKeys(data); 182 | 183 | // get unknown keys 184 | for (let i = 0; i < dataKeys.length; i++) { 185 | const key = dataKeys[i]; 186 | if (!schemaKeys.includes(key)) { 187 | // we gonna sneak this one in the result without typescript knowning about it. 188 | // can be fixed if we require "data" to be also infered 189 | // (instead of using any in e.g. schema.validate(data: any)) 190 | result[key as keyof T] = UNKOWN_KEY_ERR as any; 191 | if (eager) return result; 192 | } 193 | } 194 | 195 | for (let i = 0; i < schemaKeys.length; i++) { 196 | const schemaKey = schemaKeys[i]; 197 | const validator = schema[schemaKey]; 198 | const value = data[schemaKey as string]; 199 | let _result = enterNode(validator, value, eager); 200 | if (shouldAddToResult(_result)) { 201 | result[schemaKey] = _result as InferCallbackResult; 202 | if (eager) return result; 203 | } 204 | } 205 | return normalizeResult(result); 206 | } 207 | -------------------------------------------------------------------------------- /src/createSchema.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from './utils'; 2 | import { createErrors } from './createErrors'; 3 | import { DATAERR, $record, SCHEMAERR } from './constants'; 4 | import invariant from 'tiny-invariant'; 5 | import { RecordValidator, Schema, R, O, RecordOptions } from './validatorTypes'; 6 | import { DataFrom } from './type-utils'; 7 | 8 | export function createSchema(_schema: T) { 9 | invariant(isPlainObject(_schema), SCHEMAERR); 10 | 11 | type Data = DataFrom; 12 | const source = Object.freeze({ ..._schema }); 13 | 14 | function validate(data: any, eager = false) { 15 | return createErrors(source, data, eager); 16 | } 17 | 18 | function is(data: any): data is Data { 19 | if (!isPlainObject(data)) return false; 20 | return validate(data, true) == null; 21 | } 22 | 23 | function embed(): R>; 24 | function embed(config: { optional: false }): R>; 25 | function embed(config: { optional: true }): O>; 26 | function embed(config = { optional: false }): RecordValidator { 27 | return { type: $record, shape: source, ...config }; 28 | } 29 | 30 | function produce(data: any): Data { 31 | if (!is(data)) throw new TypeError(DATAERR); 32 | return data; 33 | } 34 | 35 | return { 36 | source, 37 | validate, 38 | embed, 39 | produce, 40 | is, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | O, 3 | R, 4 | BooleanValidator, 5 | ListValidator, 6 | ListofValidator, 7 | NumberValidator, 8 | RecordValidator, 9 | RecordofValidator, 10 | Schema, 11 | StringValidator, 12 | Validator, 13 | BooleanOptions, 14 | ListOptions, 15 | ListofOptions, 16 | NumberOptions, 17 | RecordOptions, 18 | RecordofOptions, 19 | StringOptions, 20 | ConstantOptions, 21 | UnionOptions, 22 | } from './validatorTypes'; 23 | import { 24 | $boolean, 25 | $constant, 26 | $list, 27 | $listof, 28 | $number, 29 | $record, 30 | $recordof, 31 | $string, 32 | $union, 33 | } from './constants'; 34 | 35 | export function string(): R; 36 | export function string(config: Omit): R; 37 | export function string(config: { optional: false } & Omit): R; 38 | export function string(config: { optional: true } & Omit): O; 39 | 40 | export function string( 41 | config?: { optional?: boolean } & Omit 42 | ): StringValidator { 43 | return { 44 | type: $string, 45 | optional: !!config?.optional, 46 | ...config, 47 | }; 48 | } 49 | 50 | export function number(): R; 51 | export function number(config: Omit): R; 52 | export function number(config: { optional: true } & Omit): O; 53 | export function number(config: { optional: false } & Omit): R; 54 | 55 | export function number( 56 | config?: { optional?: boolean } & Omit 57 | ): NumberValidator { 58 | return { 59 | type: $number, 60 | optional: !!config?.optional, 61 | ...config, 62 | }; 63 | } 64 | 65 | export function boolean(): R; 66 | export function boolean(config: { optional: true }): O; 67 | export function boolean(config: { optional: false }): R; 68 | 69 | export function boolean(config?: { optional: boolean }): BooleanValidator { 70 | return { 71 | type: $boolean, 72 | optional: !!config?.optional, 73 | }; 74 | } 75 | 76 | export function list[]>(list: T): R>; 77 | export function list[]>( 78 | list: T, 79 | config: { optional: false } 80 | ): R>; 81 | export function list[]>( 82 | list: T, 83 | config: { optional: true } 84 | ): O>; 85 | 86 | export function list[]>( 87 | list: T, 88 | config?: { optional: boolean } 89 | ): ListValidator { 90 | return { 91 | type: $list, 92 | optional: !!config?.optional, 93 | shape: list.map(v => ({ ...v, optional: false })) as T, 94 | }; 95 | } 96 | 97 | export function listof>(v: T): R>; 98 | export function listof>( 99 | v: T, 100 | config: { optional: false } 101 | ): R>; 102 | export function listof>( 103 | v: T, 104 | config: { optional: true } 105 | ): O>; 106 | 107 | export function listof>( 108 | v: T, 109 | config?: { optional: boolean } 110 | ): ListofValidator { 111 | return { 112 | type: $listof, 113 | optional: !!config?.optional, 114 | of: { ...v, optional: false }, 115 | }; 116 | } 117 | 118 | export function record(s: T): R>; 119 | export function record(s: T, config: { optional: false }): R>; 120 | export function record(s: T, config: { optional: true }): O>; 121 | 122 | export function record(s: T, config?: { optional: boolean }): RecordValidator { 123 | return { 124 | type: $record, 125 | optional: !!config?.optional, 126 | shape: s, 127 | }; 128 | } 129 | 130 | export function recordof>(v: T): R>; 131 | export function recordof>( 132 | v: T, 133 | config: { optional: false } 134 | ): R>; 135 | export function recordof>( 136 | v: T, 137 | config: { optional: true } 138 | ): O>; 139 | 140 | export function recordof>( 141 | v: T, 142 | config?: { optional: boolean } 143 | ): RecordofValidator { 144 | return { 145 | type: $recordof, 146 | of: { ...v, optional: false }, 147 | optional: !!config?.optional, 148 | }; 149 | } 150 | 151 | export function constant(v: T): R> { 152 | return { 153 | type: $constant, 154 | optional: false, 155 | value: v, 156 | }; 157 | } 158 | 159 | export function union[]>(...types: T): R> { 160 | return { 161 | type: $union, 162 | optional: false, 163 | of: types, 164 | }; 165 | } 166 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as helpers from './helpers'; 2 | 3 | export * from './validatorTypes'; 4 | export * from './createSchema'; 5 | export const _ = helpers; 6 | -------------------------------------------------------------------------------- /src/type-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | O, 3 | BooleanValidator, 4 | ListValidator, 5 | ListofValidator, 6 | NumberValidator, 7 | RecordValidator, 8 | RecordofValidator, 9 | StringValidator, 10 | Schema, 11 | ConstantValidator, 12 | UnionValidator, 13 | Validator, 14 | } from './validatorTypes'; 15 | 16 | type InferTypeWithOptional = T extends O ? U | undefined : U; 17 | 18 | type ArrayElement = T extends readonly unknown[] 19 | ? T extends readonly (infer ElementType)[] 20 | ? ElementType 21 | : never 22 | : never; 23 | 24 | type InferDataType = T extends UnionValidator 25 | ? ArrayElement }>> 26 | : T extends ConstantValidator 27 | ? U 28 | : T extends StringValidator 29 | ? InferTypeWithOptional 30 | : T extends NumberValidator 31 | ? InferTypeWithOptional 32 | : T extends BooleanValidator 33 | ? InferTypeWithOptional 34 | : T extends ListValidator 35 | ? InferTypeWithOptional }> 36 | : T extends ListofValidator 37 | ? InferTypeWithOptional[]> 38 | : T extends RecordValidator 39 | ? InferTypeWithOptional }> 40 | : T extends RecordofValidator 41 | ? InferTypeWithOptional }> 42 | : never; 43 | 44 | export type DataFrom = { 45 | [K in keyof S]: InferDataType; 46 | }; 47 | 48 | export type InferCallbackResult = V extends 49 | | StringValidator 50 | | NumberValidator 51 | | BooleanValidator 52 | | ConstantValidator 53 | | UnionValidator 54 | ? string 55 | : V extends ListValidator 56 | ? { [key in number]?: InferCallbackResult } 57 | : V extends ListofValidator 58 | ? { [key: number]: InferCallbackResult | undefined } 59 | : V extends RecordValidator 60 | ? { [key in keyof U]?: InferCallbackResult } 61 | : V extends RecordofValidator 62 | ? { [key: string]: InferCallbackResult | undefined } 63 | : never; 64 | 65 | export type InferResult = { 66 | [key in keyof S]?: InferCallbackResult; 67 | }; 68 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const ObjectKeys = Object.keys.bind(Object); 2 | export const isArray = (value: unknown): value is any[] => Array.isArray(value); 3 | export const isBool = (value: unknown): value is boolean => typeof value == 'boolean'; 4 | export const isString = (value: unknown): value is string => typeof value == 'string'; 5 | export const isNumber = (value: unknown): value is number => 6 | typeof value == 'number' && Number.isFinite(value); 7 | 8 | export function isPlainObject(maybeObject: any): maybeObject is Record { 9 | return ( 10 | typeof maybeObject == 'object' && 11 | maybeObject != null && 12 | Object.prototype.toString.call(maybeObject) == '[object Object]' 13 | ); 14 | } 15 | 16 | export function toObj(value: any) { 17 | return isArray(value) ? { ...value } : isPlainObject(value) ? value : ({} as Record); 18 | } 19 | -------------------------------------------------------------------------------- /src/validatorTypes.ts: -------------------------------------------------------------------------------- 1 | export type O = V & { optional: true }; 2 | export type R = V & { optional: false }; 3 | export type V = O | R; 4 | 5 | export interface StringOptions { 6 | type: 'string'; 7 | maxLength?: [number, string]; 8 | minLength?: [number, string]; 9 | pattern?: [RegExp, string]; 10 | } 11 | 12 | export interface NumberOptions { 13 | type: 'number'; 14 | max?: [number, string]; 15 | min?: [number, string]; 16 | is?: ['integer' | 'float', string]; 17 | } 18 | 19 | export interface BooleanOptions { 20 | type: 'boolean'; 21 | } 22 | 23 | export interface ListOptions { 24 | type: 'list'; 25 | shape: T; 26 | } 27 | 28 | export interface ListofOptions { 29 | type: 'listof'; 30 | of: T; 31 | } 32 | 33 | export interface RecordOptions { 34 | type: 'record'; 35 | shape: T; 36 | } 37 | 38 | export interface RecordofOptions { 39 | type: 'recordof'; 40 | of: T; 41 | } 42 | 43 | export interface ConstantOptions { 44 | type: 'constant'; 45 | value: T; 46 | } 47 | 48 | export interface UnionOptions { 49 | type: 'union'; 50 | of: T; 51 | } 52 | 53 | export type BooleanValidator = V; 54 | export type StringValidator = V; 55 | export type NumberValidator = V; 56 | export type ListValidator = V>; 57 | export type ListofValidator = V>; 58 | export type RecordValidator = V>; 59 | export type RecordofValidator = V>; 60 | export type ConstantValidator = V>; 61 | export type UnionValidator = V>; 62 | 63 | export type Validator = 64 | | UnionValidator 65 | | ConstantValidator 66 | | StringValidator 67 | | NumberValidator 68 | | BooleanValidator 69 | | ListofValidator 70 | | ListValidator 71 | | RecordValidator 72 | | RecordofValidator; 73 | 74 | export interface Schema { 75 | [key: string]: Validator; 76 | } 77 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createSchema, _ } from '../src/index'; 2 | import { DATAERR, TYPEERR } from '../src/constants'; 3 | 4 | describe('createSchema throws when', () => { 5 | test('passed invalid schema', () => { 6 | // @ts-expect-error 7 | expect(() => createSchema(null)).toThrow(); 8 | // @ts-expect-error 9 | expect(() => createSchema(undefined)).toThrow(); 10 | // @ts-expect-error 11 | expect(() => createSchema([])).toThrow(); 12 | }); 13 | }); 14 | 15 | const Person = createSchema({ 16 | is_premium: _.boolean({ optional: true }), 17 | is_verified: _.boolean(), 18 | name: _.string({ 19 | maxLength: [24, 'too-long'], 20 | minLength: [2, 'too-short'], 21 | pattern: [/[a-zA-Z ]/g, 'contains-symbols'], 22 | }), 23 | age: _.number({ 24 | max: [150, 'too-old'], 25 | min: [13, 'too-young'], 26 | }), 27 | email: _.string({ 28 | pattern: [/^[^@]+@[^@]+\.[^@]+$/, 'invalid-email'], 29 | }), 30 | tags: _.listof(_.string(), { optional: true }), 31 | friends: _.recordof(_.record({ name: _.string(), id: _.string() }), { optional: true }), 32 | nested_list: _.list([_.string(), _.list([_.list([_.number()])])], { optional: true }), 33 | four_tags: _.list([_.string(), _.string(), _.string(), _.string(), _.list([_.number()])], { 34 | optional: true, 35 | }), 36 | meta: _.record({ 37 | id: _.string({ minLength: [1, 'invalid-id'], maxLength: [1000, 'invalid-id'] }), 38 | created: _.number({ is: ['integer', 'timestamp-should-be-intger'] }), 39 | updated: _.number({ optional: true }), 40 | nested: _.record( 41 | { 42 | propA: _.number(), 43 | propB: _.boolean(), 44 | propC: _.string(), 45 | }, 46 | { optional: true } 47 | ), 48 | }), 49 | payment_status: _.union( 50 | _.constant('pending'), 51 | _.constant('canceled'), 52 | _.constant('processed'), 53 | _.constant('failed') 54 | ), 55 | }); 56 | 57 | // type IPerson = ReturnType; 58 | 59 | describe('validate', () => { 60 | test('ignores optional properties when not found', () => { 61 | const errors = Person.validate({ 62 | is_verified: true, 63 | name: 'abc', 64 | age: 42, 65 | email: 'abc@gmail.com', 66 | payment_status: 'pending', 67 | meta: { 68 | id: '123', 69 | created: Date.now(), 70 | }, 71 | }); 72 | 73 | expect(errors).toBe(null); 74 | }); 75 | 76 | test('validates optional properties when found', () => { 77 | const errors = Person.validate({ 78 | is_premium: 'hello world', 79 | is_verified: true, 80 | name: 'abc', 81 | age: 42, 82 | email: 'abc@gmail.com', 83 | tags: {}, 84 | meta: { 85 | id: '123', 86 | created: Date.now(), 87 | updated: new Date().toISOString(), 88 | }, 89 | payment_status: 'pending', 90 | }); 91 | expect(errors).toStrictEqual({ 92 | is_premium: 'invalid-type', 93 | tags: 'invalid-type', 94 | meta: { 95 | updated: 'invalid-type', 96 | }, 97 | }); 98 | }); 99 | 100 | test('emits correct error messages', () => { 101 | const errors = Person.validate( 102 | { 103 | is_premium: 42, 104 | }, 105 | true 106 | ); 107 | expect(errors).toStrictEqual({ is_premium: 'invalid-type' }); 108 | }); 109 | // test('handles eager validation correctly', () => { 110 | // expect(Person.validate({}, true)).toStrictEqual({ name: TYPEERR }); 111 | // }); 112 | }); 113 | 114 | describe('produce', () => { 115 | const Person = createSchema({ 116 | name: _.string(), 117 | age: _.number(), 118 | email: _.string(), 119 | }); 120 | 121 | test('throws on first error', () => { 122 | expect(() => Person.produce(null)).toThrow(new TypeError(DATAERR)); 123 | expect(() => Person.produce(undefined)).toThrow(new TypeError(DATAERR)); 124 | expect(() => Person.produce(34)).toThrow(new TypeError(DATAERR)); 125 | expect(() => Person.produce('hello world')).toThrow(new TypeError(DATAERR)); 126 | expect(() => { 127 | return Person.produce({ name: 2, age: 42, email: 'email@example.com' }); 128 | }).toThrow(new TypeError(DATAERR)); 129 | }); 130 | 131 | test('let data throw if it matches the schema', () => { 132 | const p = { name: 'john', age: 42, email: 'john@gmail.com' }; 133 | expect(Person.produce(p)).toStrictEqual(p); 134 | }); 135 | }); 136 | 137 | describe('is', () => { 138 | const s = createSchema({ 139 | a: _.record({ 140 | b: _.string({ optional: true }), 141 | c: _.record({ d: _.number(), e: _.number({ optional: true }) }), 142 | }), 143 | }); 144 | 145 | test('returns false when passed incorrect data type', () => { 146 | const s = createSchema({}); 147 | expect(s.is(undefined)).toBe(false); 148 | expect(s.is(null)).toBe(false); 149 | expect(s.is([])).toBe(false); 150 | 151 | expect(s.is({})).toBe(true); 152 | }); 153 | 154 | test('return correct boolean based on data', () => { 155 | expect(s.is({ a: { c: { d: 42 } } })).toBe(true); 156 | expect(s.is({ a: { b: 'hello', c: { e: 120, d: 42 } } })).toBe(true); 157 | 158 | expect(s.is({ a: { b: true, c: { e: 120, d: 42 } } })).toBe(false); 159 | expect(s.is({ a: { c: { d: 'hello' } } })).toBe(false); 160 | }); 161 | }); 162 | 163 | describe('eager validation', () => { 164 | const s = createSchema({ 165 | a: _.record({ 166 | b: _.string({ optional: true }), 167 | c: _.record({ d: _.number(), e: _.number({ optional: true }) }), 168 | }), 169 | }); 170 | 171 | test('test 1', () => { 172 | const errors = s.validate({ a: { b: 42, c: false } }, true); 173 | expect(errors).toStrictEqual({ a: { b: TYPEERR } }); 174 | }); 175 | }); 176 | 177 | describe('reports unknown keys', () => { 178 | describe('is', () => { 179 | const schema = createSchema({ 180 | hello: _.string(), 181 | }); 182 | test('exits when it finds an unknown-key', () => { 183 | expect(schema.is({ goodbye: 42, hello: 'im valid' })).toBe(false); 184 | }); 185 | }); 186 | 187 | describe('validate', () => { 188 | const schema = createSchema({ 189 | o: _.record({ a: _.string() }), 190 | }); 191 | 192 | test('errors contain unknown-keys error message', () => { 193 | expect(schema.validate({ o: { a: '' }, x: 'reported', y: 'reported' })).toStrictEqual({ 194 | x: 'unknown-key', 195 | y: 'unknown-key', 196 | }); 197 | const errors = schema.validate({ o: { a: '', b: 42 }, x: 'reported' }); 198 | expect(errors).toStrictEqual({ 199 | x: 'unknown-key', 200 | o: { 201 | b: 'unknown-key', 202 | }, 203 | }); 204 | }); 205 | test('handles eager validation', () => { 206 | expect(schema.validate({ o: { a: '' }, x: 'reported', y: 'reported' }, true)).toStrictEqual({ 207 | x: 'unknown-key', 208 | }); 209 | const errors = schema.validate({ o: { a: '', b: 42 }, x: 'reported' }, true); 210 | expect(errors).toStrictEqual({ 211 | x: 'unknown-key', 212 | }); 213 | }); 214 | }); 215 | 216 | test('is', () => { 217 | const schema = createSchema({ 218 | opt: _.record({}, { optional: true }), 219 | list: _.list([_.string(), _.number()], { optional: true }), 220 | metadata: _.record({ 221 | propA: _.string(), 222 | }), 223 | }); 224 | expect(schema.is({ metadata: { propA: 'hello world' }, unknownKey: 42 })).toBe(false); 225 | expect(schema.is({ metadata: { propA: 'hello world', extra: 42 } })).toBe(false); 226 | expect(schema.is({ metadata: { propA: 'hello world' }, list: ['hello', 42], opt: {} })).toBe( 227 | true 228 | ); 229 | expect( 230 | schema.is({ metadata: { propA: 'hello world' }, list: ['hello', 42], opt: { newKey: 42 } }) 231 | ).toBe(false); 232 | expect(schema.is({ metadata: { propA: 'hello world' }, list: ['hello', 42, undefined] })).toBe( 233 | false 234 | ); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /test/validators.test.ts: -------------------------------------------------------------------------------- 1 | import { createSchema, _ } from '../src/index'; 2 | import { TYPEERR } from '../src/constants'; 3 | 4 | const createString = (length: number, char?: string) => { 5 | let s = ''; 6 | for (let i = 0; i < length; i++) s += char || ' '; 7 | return s; 8 | }; 9 | 10 | describe('string validator', () => { 11 | const name = (() => { 12 | const config = { 13 | maxLength: [100, 'too-long'] as [number, string], 14 | minLength: [10, 'too-short'] as [number, string], 15 | pattern: [/[a-zA-Z]/g, 'invalid-pattern'] as [RegExp, string], 16 | }; 17 | return { 18 | optional: () => _.string({ ...config, optional: true }), 19 | required: () => _.string(config), 20 | }; 21 | })(); 22 | const Person1 = createSchema({ 23 | name: name.optional(), 24 | }); 25 | const Person2 = createSchema({ 26 | name: name.required(), 27 | }); 28 | 29 | test('tests type', () => { 30 | expect(Person1.is({ name: 0 })).toBe(false); 31 | expect(Person1.is({ name: 42 })).toBe(false); 32 | expect(Person1.is({ name: {} })).toBe(false); 33 | expect(Person1.is({ name: [] })).toBe(false); 34 | expect(Person1.is({ name: true })).toBe(false); 35 | 36 | expect(Person2.is({ name: 0 })).toBe(false); 37 | expect(Person2.is({ name: 42 })).toBe(false); 38 | expect(Person2.is({ name: {} })).toBe(false); 39 | expect(Person2.is({ name: [] })).toBe(false); 40 | expect(Person2.is({ name: true })).toBe(false); 41 | }); 42 | 43 | test('optional', () => { 44 | expect(Person1.is({ name: undefined })).toBe(true); 45 | expect(Person1.is({ name: null })).toBe(true); 46 | 47 | expect(Person1.is({ name: 0 })).toBe(false); 48 | expect(Person1.is({ name: '' })).toBe(false); 49 | 50 | expect(Person2.is({ name: undefined })).toBe(false); 51 | expect(Person2.is({ name: null })).toBe(false); 52 | 53 | expect(Person2.is({ name: 0 })).toBe(false); 54 | expect(Person2.is({ name: '' })).toBe(false); 55 | }); 56 | 57 | test('pattern', () => { 58 | expect(Person1.is({ name: '0123456789' })).toBe(false); 59 | expect(Person1.is({ name: '----------' })).toBe(false); 60 | expect(Person1.is({ name: '__________' })).toBe(false); 61 | expect(Person1.is({ name: 'abcdefghij' })).toBe(true); 62 | 63 | expect(Person2.is({ name: '0123456789' })).toBe(false); 64 | expect(Person2.is({ name: '----------' })).toBe(false); 65 | expect(Person2.is({ name: '__________' })).toBe(false); 66 | expect(Person2.is({ name: 'abcdefghij' })).toBe(true); 67 | }); 68 | 69 | test('maxLength', () => { 70 | expect(Person1.is({ name: '' })).toBe(false); 71 | expect(Person1.is({ name: createString(100, 'a') })).toBe(true); 72 | expect(Person1.is({ name: createString(101, 'a') })).toBe(false); 73 | }); 74 | 75 | test('minLength', () => { 76 | expect(Person1.is({ name: '' })).toBe(false); 77 | expect(Person1.is({ name: createString(9, 'a') })).toBe(false); 78 | expect(Person1.is({ name: createString(10, 'a') })).toBe(true); 79 | }); 80 | 81 | test('emits correct error message', () => { 82 | expect(Person1.validate({ name: '' })).toStrictEqual({ name: 'too-short' }); 83 | expect(Person1.validate({ name: createString(101, 'a') })).toStrictEqual({ name: 'too-long' }); 84 | expect(Person1.validate({ name: createString(11, '0') })).toStrictEqual({ 85 | name: 'invalid-pattern', 86 | }); 87 | }); 88 | }); 89 | 90 | describe('number validator', () => { 91 | const age = (() => { 92 | const config = { 93 | max: [10, 'too-large'] as [number, string], 94 | min: [1, 'too-small'] as [number, string], 95 | is: ['integer', 'wrong-type-of-number'] as ['integer', string], 96 | }; 97 | return { 98 | optional: () => _.number({ ...config, optional: true }), 99 | required: () => _.number(config), 100 | }; 101 | })(); 102 | 103 | const Person1 = createSchema({ 104 | age: age.optional(), 105 | n: _.number({ optional: true }), 106 | }); 107 | const Person2 = createSchema({ 108 | age: age.required(), 109 | n: _.number({ optional: true }), 110 | }); 111 | 112 | test('tests type', () => { 113 | expect(Person1.is({ age: '' })).toBe(false); 114 | expect(Person1.is({ age: {} })).toBe(false); 115 | expect(Person1.is({ age: [] })).toBe(false); 116 | expect(Person1.is({ age: true })).toBe(false); 117 | expect(Person1.is({ age: Infinity, n: Infinity })).toBe(false); 118 | expect(Person1.is({ age: -Infinity, n: -Infinity })).toBe(false); 119 | expect(Person1.is({ age: NaN, n: NaN })).toBe(false); 120 | 121 | expect(Person2.is({ age: '' })).toBe(false); 122 | expect(Person2.is({ age: {} })).toBe(false); 123 | expect(Person2.is({ age: [] })).toBe(false); 124 | expect(Person2.is({ age: true })).toBe(false); 125 | expect(Person1.is({ age: Infinity, n: Infinity })).toBe(false); 126 | expect(Person1.is({ age: -Infinity, n: -Infinity })).toBe(false); 127 | expect(Person1.is({ age: NaN, n: NaN })).toBe(false); 128 | }); 129 | 130 | test('optional', () => { 131 | expect(Person1.is({ age: undefined })).toBe(true); 132 | expect(Person1.is({ age: null })).toBe(true); 133 | expect(Person1.is({ age: false })).toBe(false); 134 | 135 | expect(Person2.is({ age: undefined })).toBe(false); 136 | expect(Person2.is({ age: null })).toBe(false); 137 | expect(Person1.is({ age: false })).toBe(false); 138 | }); 139 | 140 | test('max', () => { 141 | expect(Person1.is({ age: 10 })).toBe(true); 142 | expect(Person1.is({ age: 11 })).toBe(false); 143 | }); 144 | 145 | test('min', () => { 146 | expect(Person1.is({ age: 0 })).toBe(false); 147 | expect(Person1.is({ age: 10 })).toBe(true); 148 | }); 149 | 150 | test('is', () => { 151 | expect(Person1.is({ age: 9.4 })).toBe(false); 152 | expect(Person1.is({ age: 10 })).toBe(true); 153 | }); 154 | 155 | test('emits correct error message', () => { 156 | expect(Person1.validate({ age: 11 })).toStrictEqual({ age: 'too-large' }); 157 | expect(Person1.validate({ age: -1 })).toStrictEqual({ age: 'too-small' }); 158 | expect(Person1.validate({ age: 9.4 })).toStrictEqual({ age: 'wrong-type-of-number' }); 159 | }); 160 | }); 161 | 162 | describe('boolean validator', () => { 163 | const Person1 = createSchema({ 164 | is: _.boolean({ optional: true }), 165 | }); 166 | const Person2 = createSchema({ 167 | is: _.boolean(), 168 | }); 169 | 170 | test('tests type', () => { 171 | expect(Person1.is({ is: 0 })).toBe(false); 172 | expect(Person1.is({ is: '' })).toBe(false); 173 | expect(Person1.is({ is: {} })).toBe(false); 174 | expect(Person1.is({ is: [] })).toBe(false); 175 | 176 | expect(Person2.is({ is: 0 })).toBe(false); 177 | expect(Person2.is({ is: '' })).toBe(false); 178 | expect(Person2.is({ is: {} })).toBe(false); 179 | expect(Person2.is({ is: [] })).toBe(false); 180 | 181 | expect(Person1.is({ is: false })).toBe(true); 182 | expect(Person1.is({ is: true })).toBe(true); 183 | expect(Person2.is({ is: false })).toBe(true); 184 | expect(Person2.is({ is: true })).toBe(true); 185 | }); 186 | 187 | test('optional', () => { 188 | expect(Person1.is({ is: undefined })).toBe(true); 189 | expect(Person1.is({ is: null })).toBe(true); 190 | 191 | expect(Person2.is({ is: undefined })).toBe(false); 192 | expect(Person2.is({ is: null })).toBe(false); 193 | }); 194 | }); 195 | 196 | describe('listof validator', () => { 197 | const Person = createSchema({ 198 | friends: _.listof(_.string({ minLength: [2, 'too-short'] })), 199 | }); 200 | 201 | test('emits correct error messages', () => { 202 | expect(Person.validate({ friends: [] })).toStrictEqual(null); 203 | expect(Person.validate({ friends: {} })).toStrictEqual({ friends: TYPEERR }); 204 | expect(Person.validate({ friends: [1, 'john'] })).toStrictEqual({ friends: { 0: TYPEERR } }); 205 | }); 206 | }); 207 | 208 | describe('list validator', () => { 209 | const Person = createSchema({ 210 | friends: _.list([_.string(), _.number()]), 211 | otherList: _.list([_.string({ minLength: [4, 'lower-bound'] })], { optional: true }), 212 | }); 213 | 214 | test('handles optional property', () => { 215 | expect(Person.validate({ friends: ['John', 42] })).toBe(null); 216 | expect(Person.validate({ friends: ['John', 42], otherList: [] })).toStrictEqual({ 217 | otherList: { 0: TYPEERR }, 218 | }); 219 | expect(Person.validate({ friends: ['John', 42], otherList: ['hel'] })).toStrictEqual({ 220 | otherList: { 0: 'lower-bound' }, 221 | }); 222 | expect(Person.validate({ friends: ['John', 42], otherList: ['hell'] })).toStrictEqual(null); 223 | }); 224 | 225 | test('emits correct error messages', () => { 226 | expect(Person.validate({ friends: [] })).toStrictEqual({ friends: { 0: TYPEERR, 1: TYPEERR } }); 227 | expect(Person.validate({ friends: {} })).toStrictEqual({ friends: TYPEERR }); 228 | expect(Person.validate({ friends: [1, 'john'] })).toStrictEqual({ 229 | friends: { 0: TYPEERR, 1: TYPEERR }, 230 | }); 231 | expect(Person.validate({ friends: ['john', 0] })).toStrictEqual(null); 232 | }); 233 | }); 234 | 235 | describe('record validator', () => { 236 | const Person = createSchema({ 237 | meta: _.record({ 238 | id: _.string(), 239 | date_created: _.number(), 240 | is_verified: _.boolean(), 241 | }), 242 | tags: _.listof(_.string(), { optional: true }), 243 | }); 244 | 245 | test('emits correct error messages', () => { 246 | expect(Person.validate({})).toStrictEqual({ meta: TYPEERR }); 247 | expect(Person.validate({ meta: {} })).toStrictEqual({ 248 | meta: { 249 | id: TYPEERR, 250 | date_created: TYPEERR, 251 | is_verified: TYPEERR, 252 | }, 253 | }); 254 | expect( 255 | Person.validate({ meta: { id: 123, date_created: true, is_verified: '' } }) 256 | ).toStrictEqual({ 257 | meta: { 258 | id: TYPEERR, 259 | date_created: TYPEERR, 260 | is_verified: TYPEERR, 261 | }, 262 | }); 263 | expect( 264 | Person.validate({ meta: { id: null, date_created: 123, is_verified: false } }) 265 | ).toStrictEqual({ meta: { id: TYPEERR } }); 266 | }); 267 | 268 | test('handle recursive records', () => { 269 | const s = createSchema({ o: _.record({ o: _.record({ x: _.record({ y: _.number() }) }) }) }); 270 | 271 | expect(s.validate({ o: { o: { x: { y: 'hello' } } } })).toStrictEqual({ 272 | o: { o: { x: { y: TYPEERR } } }, 273 | }); 274 | }); 275 | }); 276 | 277 | describe('constant validator', () => { 278 | const schema = createSchema({ 279 | version: _.constant('v2'), 280 | }); 281 | 282 | test('emits correct error message', () => { 283 | expect(schema.validate({ version: 'v2' })).toStrictEqual(null); 284 | expect(schema.validate({ version: 'something else' })).toStrictEqual({ version: TYPEERR }); 285 | }); 286 | }); 287 | 288 | describe('union validator', () => { 289 | test('emits correct error message', () => { 290 | const schema = createSchema({ 291 | state: _.union(_.constant('on'), _.constant('off'), _.constant('unknown')), 292 | }); 293 | expect(schema.validate({ state: 'on' })).toStrictEqual(null); 294 | expect(schema.validate({ state: 'off' })).toStrictEqual(null); 295 | expect(schema.validate({ state: 'unknown' })).toStrictEqual(null); 296 | expect(schema.validate({ state: 'should-error' })).toStrictEqual({ state: TYPEERR }); 297 | }); 298 | 299 | test('performs deep checks', () => { 300 | const schema = createSchema({ 301 | state: _.union( 302 | _.record({ prop: _.number() }), 303 | _.record({ x: _.record({ nested: _.constant(42) }) }) 304 | ), 305 | }); 306 | expect(schema.validate({ state: { prop: 100 } })).toStrictEqual(null); 307 | expect(schema.validate({ state: { x: { nested: 42 } } })).toStrictEqual(null); 308 | expect(schema.validate({ state: { x: { nested: 33 } } })).toStrictEqual({ state: TYPEERR }); 309 | expect(schema.validate({ state: 'should-error' })).toStrictEqual({ state: TYPEERR }); 310 | }); 311 | }); 312 | 313 | describe('recordof validator', () => { 314 | const Group = createSchema({ 315 | people: _.recordof( 316 | _.record({ 317 | name: _.string(), 318 | age: _.number(), 319 | }) 320 | ), 321 | }); 322 | 323 | test('emits correct error messages', () => { 324 | expect(Group.validate({ people: { john: { name: 'john', age: 42 } } })).toStrictEqual(null); 325 | expect( 326 | Group.validate({ 327 | people: { 328 | john: { name: 'john', age: 42 }, 329 | sarah: { name: 'sarah', age: true }, 330 | }, 331 | }) 332 | ).toStrictEqual({ people: { sarah: { age: TYPEERR } } }); 333 | }); 334 | }); 335 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true 34 | } 35 | } 36 | --------------------------------------------------------------------------------