├── .changeset ├── README.md └── config.json ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── pr.yaml │ └── release.yaml ├── .gitignore ├── .npmignore ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── UPCOMING.md ├── package.json ├── pnpm-lock.yaml ├── src ├── builder.ts ├── index.ts ├── schema │ └── types.ts ├── types │ ├── array.d.ts │ ├── boolean.d.ts │ ├── errors.d.ts │ ├── index.d.ts │ ├── misc.d.ts │ ├── number.d.ts │ ├── object.d.ts │ └── string.d.ts └── utils.ts ├── tests ├── array.test.ts ├── base.test.ts ├── const.test.ts ├── enum.test.ts ├── error.test.ts ├── nullable.test.ts ├── number.test.ts ├── object.test.ts ├── parse.bench.test.ts ├── string.test.ts └── union.test.ts ├── tsconfig.json ├── tsup.config.mts └── vitest.config.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "vitalics/ajv-ts" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [] 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "standard-with-typescript", 7 | "parserOptions": { 8 | "ecmaVersion": "latest", 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | open_collective: ajv-typescript 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: vitalics 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System info (please complete the following information):** 27 | 28 | - JS runtime with version(Node.js / Bun / Deno). E.g. Bun 1.0.0 29 | 30 | 31 | 32 | - package manager with version (Bun / npm / yarn / pnpm). E.g. Bun 1.0.0 33 | - Ajv-ts version 34 | - from package.json 35 | - from lock file (package-lock.json / yarn.lock / pnpm-lock.yaml) 36 | - System info 37 | 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | **Additional context** 47 | Add any other context about the problem here. 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: vitalics 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request - Build 2 | on: 3 | # Triggers the workflow on push or pull request events but only for the develop branch 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | run-name: PR. Setup, build and test on PR. 9 | 10 | jobs: 11 | node: 12 | name: Node.js+pnpm - setup, build and test 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node_version: [18, 20, 22, latest] 17 | pnpm_version: [9.10.0] 18 | steps: 19 | - name: Clone repository 20 | uses: actions/checkout@v3 21 | - uses: pnpm/action-setup@v4 22 | with: 23 | version: ${{matrix.pnpm_version}} 24 | - name: Use Node.js ${{matrix.node_version}} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node_version }} 28 | cache: "pnpm" 29 | - name: Install dependencies 30 | run: pnpm install 31 | - name: Generate build 32 | run: pnpm build 33 | - name: Run Tests 34 | run: pnpm run test 35 | 36 | bun: 37 | name: Bun.sh - setup, build and test 38 | runs-on: ubuntu-latest 39 | strategy: 40 | fail-fast: true 41 | matrix: 42 | bun_version: [1.0.0, 1.1.0, latest] 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: oven-sh/setup-bun@v1 46 | with: 47 | bun-version: ${{matrix.bun_version}} 48 | - run: bun install 49 | - run: bun run build 50 | - run: bun run test 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | packages: write 12 | 13 | # Automatically cancel in-progress actions on the same branch 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | install_build_release: 20 | name: Build & Release Packages 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | node_version: [20] 25 | pnpm_version: [9.10.0] 26 | steps: 27 | - name: Clone repository 28 | uses: actions/checkout@v3 29 | - uses: pnpm/action-setup@v4 30 | with: 31 | version: ${{matrix.pnpm_version}} 32 | - name: Use Node.js ${{matrix.node_version}} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node_version }} 36 | cache: "pnpm" 37 | - name: Install dependencies 38 | run: pnpm install 39 | - name: Generate build 40 | run: pnpm build 41 | - uses: changesets/action@v1 42 | if: ${{ github.event_name != 'pull_request' }} 43 | with: 44 | version: pnpm ci:version 45 | publish: pnpm ci:publish 46 | commit: "[ci] release" 47 | title: "[ci] release" 48 | env: 49 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | 15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 | 17 | # Runtime data 18 | 19 | pids 20 | _.pid 21 | _.seed 22 | \*.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | 30 | coverage 31 | \*.lcov 32 | 33 | # nyc test coverage 34 | 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | 39 | .grunt 40 | 41 | # Bower dependency directory (https://bower.io/) 42 | 43 | bower_components 44 | 45 | # node-waf configuration 46 | 47 | .lock-wscript 48 | 49 | # Compiled binary addons (https://nodejs.org/api/addons.html) 50 | 51 | build/Release 52 | 53 | # Dependency directories 54 | 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | 60 | web_modules/ 61 | 62 | # TypeScript cache 63 | 64 | \*.tsbuildinfo 65 | 66 | # Optional npm cache directory 67 | 68 | .npm 69 | 70 | # Optional eslint cache 71 | 72 | .eslintcache 73 | 74 | # Optional stylelint cache 75 | 76 | .stylelintcache 77 | 78 | # Microbundle cache 79 | 80 | .rpt2_cache/ 81 | .rts2_cache_cjs/ 82 | .rts2_cache_es/ 83 | .rts2_cache_umd/ 84 | 85 | # Optional REPL history 86 | 87 | .node_repl_history 88 | 89 | # Output of 'npm pack' 90 | 91 | \*.tgz 92 | 93 | # Yarn Integrity file 94 | 95 | .yarn-integrity 96 | 97 | # dotenv environment variable files 98 | 99 | .env 100 | .env.development.local 101 | .env.test.local 102 | .env.production.local 103 | .env.local 104 | 105 | # parcel-bundler cache (https://parceljs.org/) 106 | 107 | .cache 108 | .parcel-cache 109 | 110 | # Next.js build output 111 | 112 | .next 113 | out 114 | 115 | # Nuxt.js build / generate output 116 | 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | 122 | .cache/ 123 | 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | 128 | # public 129 | 130 | # vuepress build output 131 | 132 | .vuepress/dist 133 | 134 | # vuepress v2.x temp and cache directory 135 | 136 | .temp 137 | .cache 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.\* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | src 3 | tests 4 | .github 5 | .changeset 6 | .vscode 7 | 8 | # Files 9 | bun.lockb 10 | yarn.lock 11 | tsup.config.*ts 12 | tsconfig.json 13 | .eslintrc.json 14 | CONTRIBUTING.md 15 | CHANGELOG.md 16 | UPCOMING.md 17 | SECURITY.md 18 | vitest* 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "tsx", 9 | "type": "node", 10 | "request": "launch", 11 | // Debug current file in VSCode 12 | "program": "${file}", 13 | /* 14 | * Path to tsx binary 15 | * Assuming locally installed 16 | */ 17 | "runtimeExecutable": "tsx", 18 | /* 19 | * Open terminal when debugging starts (Optional) 20 | * Useful to see console.logs 21 | */ 22 | "console": "internalConsole", 23 | "internalConsoleOptions": "neverOpen", 24 | // Files to exclude from debugger (e.g. call stack) 25 | "skipFiles": [ 26 | // Node.js internal core modules 27 | "/**", 28 | // Ignore all dependencies (optional) 29 | "${workspaceFolder}/node_modules/**", 30 | ], 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ajv-ts 2 | 3 | ## 0.9.0 4 | 5 | ### Minor Changes 6 | 7 | - [#70](https://github.com/vitalics/ajv-ts/pull/70) [`1c36eca`](https://github.com/vitalics/ajv-ts/commit/1c36eca3d77da5229f21e1aa3d4ecad27813160f) Thanks [@vitalics](https://github.com/vitalics)! - Add `example` for every schema. 8 | 9 | Example: 10 | 11 | ```ts 12 | s.string().examples(["str1", "string 2"]); // OK 13 | s.number().examples(["str1", "string 2"]); // Error in typescript, but OK 14 | s.number().examples([1, 2, 3]); // OK 15 | s.number().examples(1, 2, 3); // OK 16 | ``` 17 | 18 | ### Patch Changes 19 | 20 | - [#67](https://github.com/vitalics/ajv-ts/pull/67) [`1eab44b`](https://github.com/vitalics/ajv-ts/commit/1eab44b72dcef6549db7a8f543b8008fc15cad6e) Thanks [@vitalics](https://github.com/vitalics)! - switch changeset type to github 21 | 22 | - [#68](https://github.com/vitalics/ajv-ts/pull/68) [`dda3326`](https://github.com/vitalics/ajv-ts/commit/dda3326c1657e5b48c2487cc124d6ee4b5c010b3) Thanks [@vitalics](https://github.com/vitalics)! - update pnpm minor version 23 | 24 | ## 0.8.0 25 | 26 | ### Minor Changes 27 | 28 | - 9a62d94: Make [strict numbers](#strict-numbers) 29 | 30 | ### Strict numbers 31 | 32 | We make validation for number `type`, `format`, `minValue` and `maxValue` fields. That means we handle it in our side so you get an error for invalid values. 33 | 34 | Examples: 35 | 36 | ```ts 37 | s.number().format("float").int(); // error in type! 38 | s.int().const(3.4); // error in type! 39 | s.number().int().format("float"); // error in format! 40 | s.number().int().format("double"); // error in format! 41 | 42 | // ranges are also check for possibility 43 | 44 | s.number().min(5).max(3); // error in range! 45 | s.number().min(3).max(5).const(10); // error in constant - out of range! 46 | ``` 47 | 48 | ## 🏡 Chore/Infra 49 | 50 | - add [type-fest](https://www.npmjs.com/package/type-fest) library for correct type checking 51 | - add [tsx](https://www.npmjs.com/package/tsx) package 52 | - add minified files for cjs and esm modules in `dist` folder 53 | - remove `bun-types` dependency 54 | 55 | ### Patch Changes 56 | 57 | - 37a7b1d: fix #61 58 | - 0f787a7: add example in meta object. Closes #64 59 | 60 | ## 0.7.1 61 | 62 | ### Patch Changes 63 | 64 | - 6acd6ef: This release contains next updates 65 | 66 | ## Fixes 67 | 68 | - Issue [#57](https://github.com/vitalics/ajv-ts/issues/57) - `merge` construct schema with `undefined` fields. 69 | - Extend don't update `def` property. 70 | - Advanced TS type for `.array()` call. 71 | 72 | ## Chore 73 | 74 | - remove benchmarks for now 75 | - add more detailed bug report template 76 | 77 | ## Infra 78 | 79 | - Configure vitest config file for testing and enable github actions reporter for CI 80 | - Add `tsx` package for inspection. 81 | - Update launch.json file for vscode debugger 82 | 83 | ## Tests 84 | 85 | - add [#57](https://github.com/vitalics/ajv-ts/issues/57) issue test for `object` type. 86 | 87 | ## 0.7.0 88 | 89 | ### Minor Changes 90 | 91 | - 3e6636b: This release contains new cool features. 92 | 93 | ## New features 94 | 95 | - extend `error` method for map custom messages(#54). Originally works from ajv-errors and examples from this docs are works 96 | examples: 97 | 98 | ```ts 99 | import s from "ajv-ts"; 100 | 101 | const s1 = s.string().error({ _: "any error here" }); 102 | 103 | s.parse(123); // throws "any error here" 104 | 105 | s.string().error({ _: "any error here", type: "not a string. Custom" }); 106 | 107 | s.parse(123); // throws "not a string. Custom" 108 | 109 | const Obj = s 110 | .object({ foo: s.string() }) 111 | .strict() 112 | .error({ additionalProperties: "Not expected to pass additional props" }); 113 | 114 | Obj.parse({ foo: "ok", bar: true }); // throws "Not expected to pass additional props" 115 | ``` 116 | 117 | One more example: 118 | 119 | ```ts 120 | const Schema = s 121 | .object({ 122 | foo: s.integer().minimum(2), 123 | bar: s.string().minLength(2), 124 | }) 125 | .strict() 126 | .error({ 127 | properties: { 128 | foo: "data.foo should be integer >= 2", 129 | bar: "data.bar should be string with length >= 2", 130 | }, 131 | }); 132 | Schema.parse({ foo: 1, bar: "a" }); // throws: "data.foo should be integer >= 2" 133 | ``` 134 | 135 | - check length for `string` and `array` schema. Example: 136 | 137 | ```ts 138 | import s from "ajv-ts"; 139 | s.string().minLength(3).maxLength(1); // error. MaxLength < MinLength 140 | 141 | s.array().minLength(4).maxLength(2); // error. MaxLength < MinLength 142 | 143 | s.string().length(-1); // error. Length is negative 144 | ``` 145 | 146 | - `fromJSON` can eval incoming JSON(or object) and "attach" into schema. 147 | example: 148 | 149 | ```ts 150 | import s from "ajv-ts"; 151 | const externalSchema = s.fromJSON( 152 | { someProp: "YesImCustomProp", type: "number" }, 153 | s.string() 154 | ); 155 | externalSchema.schema.someProp === "YesImCustomProp"; // true 156 | externalSchema.schema.type === "number"; // true 157 | ``` 158 | 159 | ## Fixes 160 | 161 | - issue with `merge` when old schema(before modified with `merge` function) schema was applies 162 | 163 | ## Infra 164 | 165 | - migrate from bun to pnpm package manager 166 | - update PR steps. Now we use bun@latest, pnpm@9 with node@18, node@20 and node@22 versions. 167 | 168 | ## 0.6.3 169 | 170 | ### Patch Changes 171 | 172 | - f5b5403: patch release 173 | - 3d0d56d: $schema in meta support(#49) 174 | - 84fe084: fix: homepage link, organize imports, exports (#48) 175 | - f5b5403: feature: array.element. Add tests for addItems (#47) 176 | - 4421ac8 chore: npmignore (#45) 177 | - 152ebfa add `shape` for zod compatibility (#44) 178 | - 9f98286 Zod: expose literal function (#42) 179 | - b315539 feature: expose nativeEnum to zod compatibility (#39) 180 | 181 | ## 0.6.2 182 | 183 | ### Patch Changes 184 | 185 | - 37b57f2: make object schema accept object type. 186 | 187 | ## What's new 188 | 189 | ### Object 190 | 191 | make object schema accept generic object type. 192 | 193 | ```ts 194 | import s from "ajv-ts"; 195 | type CustomObj = { 196 | name: string; 197 | age: number; 198 | }; 199 | // Before 200 | const before = s.object(); // error, accept another definition 201 | // After 202 | s.object(); // OK now 203 | ``` 204 | 205 | ### Zod compatibility 206 | 207 | `describe` - add `description` to your schema. This method for `zod` compatibility. 208 | 209 | ## 0.6.1 210 | 211 | ### Patch Changes 212 | 213 | - 1c4b78b: - fix `default` behavior. 214 | 215 | Now you can pass `undefined` or `null` and your default values will be applies. 216 | 217 | **NOTE:** `default` keyword will be applied for `items` and `properties` definition. That means `default` keyword will works only for `object()` and `array()` builders. See in [ajv](https://ajv.js.org/guide/modifying-data.html#assigning-defaults) docs 218 | 219 | Example: 220 | 221 | ```ts 222 | import s from "ajv-ts"; 223 | 224 | const Person = s.object({ 225 | age: s.number().default(18), 226 | }); 227 | 228 | // Before 229 | Person.parse({}); // throws error 230 | 231 | // Now 232 | Person.parse({}); // returns { age: 18 }, default value 233 | ``` 234 | 235 | - `parse`, `safeParse` and `validate` now can be called without arguments. 236 | 237 | ```ts 238 | import s from "ajv-ts"; 239 | 240 | // Before 241 | s.number().safeParse(); // Error, required at least 1 argument 242 | 243 | // After 244 | s.number().safeParse(); // ok now 245 | ``` 246 | 247 | - expose default `Ajv` instance. 248 | 249 | - make `object` builder optional. 250 | 251 | Example: 252 | 253 | ```ts 254 | import s from "ajv-ts"; 255 | // Before 256 | s.object(); // error, expected object 257 | // After 258 | s.object(); // OK, empty object definition 259 | ``` 260 | 261 | ## 0.6.0 262 | 263 | ### Minor Changes 264 | 265 | - 219c7b7: new method: `refine`. 266 | 267 | Inspired from `zod`. Set custom validation. Any result exept `undefined` will throws(or exposed for `safeParse` method). 268 | 269 | ```ts 270 | import s from "ajv-ts"; 271 | // example: object with only 1 "active element" 272 | const Schema = s 273 | .object({ 274 | active: s.boolean(), 275 | name: s.string(), 276 | }) 277 | .array() 278 | .refine((arr) => { 279 | const subArr = arr.filter((el) => el.active === true); 280 | if (subArr.length > 1) 281 | throw new Error('Array should contains only 1 "active" element'); 282 | }); 283 | 284 | Schema.parse([ 285 | { active: true, name: "some 1" }, 286 | { active: true, name: "some 2" }, 287 | ]); // throws Error: Array should contains only 1 "active" element 288 | ``` 289 | 290 | ## 0.5.1 291 | 292 | ### Patch Changes 293 | 294 | - 09c54ff: Add `async` keyword support 295 | 296 | example: 297 | 298 | ```ts 299 | const obj = s.object({}).async(); 300 | 301 | obj.schema; // {type: 'object', $async: true} 302 | // make sync schema 303 | obj.sync(); // {type: 'object', $async: false} 304 | 305 | // or completely remove the `$async` keyword form schema 306 | obj.sync(true); // {type: 'object'} 307 | ``` 308 | 309 | ## fixes/refactories 310 | 311 | - refactor `safeParse` logic. 312 | - update JSDoc for `default` method 313 | 314 | ## 0.5.0 315 | 316 | ### Minor Changes 317 | 318 | - cc5ef23: Minor release `0.5` is out! 319 | 320 | ## Installation/Update 321 | 322 | ```bash 323 | npm i ajv-ts@latest # npm 324 | yarn add ajv-ts@latest # yarn 325 | pnpm add ajv-ts@latest # pnpm 326 | bun add ajv-ts@latest # bun 327 | ``` 328 | 329 | ## New Features 330 | 331 | ### not, exclude 332 | 333 | Now you can mark your schema with `not` keyword! 334 | 335 | Here is a 2 differences between `not` and `exclude`. 336 | 337 | - `not` method wrap given schema with `not` 338 | - `exclude(schema)` - add `not` keyword for incoming `schema` argument 339 | 340 | Example: 341 | 342 | ```ts 343 | import s from "ajv-ts"; 344 | 345 | // not 346 | const notAString = s.string().not(); // or s.not(s.string()) 347 | 348 | notAString.valid("random string"); // false, this is a string 349 | notAString.valid(123); // true 350 | 351 | // exclude 352 | const notJohn = s.string().exclude(s.const("John")); 353 | 354 | notJohn.valid("random string"); // true 355 | notJohn.valid("John"); // false, this is John 356 | 357 | // advanced usage 358 | 359 | const str = s.string<"John" | "Mary">().exclude(s.const("John")); 360 | s.infer; // 'Mary' 361 | ``` 362 | 363 | ### keyof 364 | 365 | New function that can be used in a root. Same as `keyof T` in Typescript. 366 | 367 | **NOTE:** currenty works only with objects only, this behavior will be fixed in future releases. 368 | 369 | Example: 370 | 371 | ```ts 372 | import s from "ajv-ts"; 373 | 374 | const keys = s.keyof( 375 | s.object({ 376 | key1: s.string(), 377 | key2: s.object({}), 378 | }) 379 | ); 380 | 381 | type Result = s.infer; // 'key1' | 'key2' 382 | 383 | keys.schema; // { anyOf: [ { cosnt: 'key1' }, {const: 'key2' } ] } 384 | ``` 385 | 386 | ### Never 387 | 388 | Same as `never` type in Typescript. JSON-schema equivalent is `{not: {}}`. 389 | 390 | ## Fixes 391 | 392 | - `s.number()` - now generic! 393 | - `s.boolean()` - now generic! 394 | 395 | ### Array 396 | 397 | #### empty schema definition 398 | 399 | function can be called without schema definition 400 | 401 | ```ts 402 | import s from "ajv-ts"; 403 | // before 0.5 404 | s.array(); // error 405 | 406 | // 0.5 and later 407 | s.array(); // OK, deinition is not required anymore! 408 | ``` 409 | 410 | ### pushItems 411 | 412 | push(append) schema to array(parent) schema. 413 | 414 | Example: 415 | 416 | ```ts 417 | import s from "ajv-ts"; 418 | 419 | const empty = s.array(); 420 | const stringArr = empty.addItems(s.string()); 421 | 422 | stringArr.schema; // {type: 'array', items: [{ type: 'string' }]} 423 | ``` 424 | 425 | #### minContains/maxContains 426 | 427 | Improve typescript generics usage. Now you cannot set float or negative values. 428 | 429 | Example: 430 | 431 | ```ts 432 | import s from "ajv-ts"; 433 | 434 | // Before 0.5 435 | s.array(s.number()).minContains(-1); // Typescript was silent 436 | 437 | // After 0.5 438 | s.array(s.number()).minContains(-1); // Typescript error: `Argument of type 'number' is not assignable to parameter of type '[never, 'TypeError: "minContains" should be positive integer', "Received: '-1'"]'.` 439 | ``` 440 | 441 | ## JS Doc updates 442 | 443 | Update and add JS Doc for: 444 | 445 | - `nullable` - update 446 | - `array` - update 447 | - `validate` - update 448 | - `parse` - update 449 | - `number().const()` - update 450 | - `array().contains()` - add 451 | - `const()` - add 452 | - `any()` - update 453 | - `unknown` - update 454 | - `create` - update 455 | 456 | ## 0.4.0 457 | 458 | ### Minor Changes 459 | 460 | - 2b4dd9e: # Description 461 | 462 | Add support for custom error message for any error. 463 | 464 | Example: 465 | 466 | ```ts 467 | import s from "ajv-ts"; 468 | 469 | const num = s.number().error("cannot be not a number"); 470 | 471 | const res1 = num.safeParse("some random string"); 472 | const res2 = num.safeParse("1.2"); 473 | console.error(res1.error.message); // cannot be not a number 474 | console.error(res2.error.message); // cannot be not a number 475 | 476 | res1.error instanceof Error; // true 477 | ``` 478 | 479 | ## What's new 480 | 481 | new feature: add support custom error message 482 | 483 | ## Fixes 484 | 485 | fix: issue with typescript [#21](https://github.com/vitalics/ajv-ts/issues/21) 486 | 487 | ## Thanks 488 | 489 | [@rjvim](https://github.com/rjvim) for submitting the issue 490 | 491 | ## 0.3.1 492 | 493 | ### Patch Changes 494 | 495 | - bf9a916: Add generics parameters for number and string builders 496 | 497 | ## 0.3.0 498 | 499 | ### Minor Changes 500 | 501 | - ca3b768: add `custom` method for base builder 502 | use `Prettify` for merging objects via `passthough` method 503 | 504 | ## 0.2.2 505 | 506 | ### Patch Changes 507 | 508 | - 920dde5: remove integers 509 | 510 | ## 0.2.1 511 | 512 | ### Patch Changes 513 | 514 | - b1ff91f: ## Changes 515 | 516 | add `array` invariant for any schema 517 | fix `intersection` and `union` types generation 518 | add `and` and `or` methods for common builder 519 | fix `pattern` method usage in string builder 520 | add JSDoc for `pattern` method in string builder 521 | fix reusing formats reusability for string builder 522 | add `UUID` and `Email` string types 523 | 524 | ## 0.2.0 525 | 526 | ### Minor Changes 527 | 528 | - 52d376d: Add integer support. Add dependantRequired support for object. Add JSDoc with examples 529 | 530 | ## 0.1.4 531 | 532 | ### Patch Changes 533 | 534 | - 4e7615f: remove extra dependencies 535 | 536 | ## 0.1.3 537 | 538 | ### Patch Changes 539 | 540 | - 8f48260: add js doc 541 | 542 | ## 0.1.2 543 | 544 | ### Patch Changes 545 | 546 | - 267b36a: Reduce bundle size 547 | fix string minLength schema builder 548 | 549 | ## 0.1.1 550 | 551 | ### Patch Changes 552 | 553 | - df1c20c: reduce bundle size 554 | 555 | ## 0.1.0 556 | 557 | ### Minor Changes 558 | 559 | - b78096f: Init package 560 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Install 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | ## Test 10 | 11 | ```bash 12 | bun run test 13 | ``` 14 | 15 | ## Build 16 | 17 | ```bash 18 | bun run build 19 | ``` 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vitali Haradkou 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 | # ajv-ts 2 | 3 | ## Table of Contents 4 | 5 | - [ajv-ts](#ajv-ts) 6 | - [Table of Contents](#table-of-contents) 7 | - [Zod unsupported APIs/differences](#zod-unsupported-apisdifferences) 8 | - [Installation](#installation) 9 | - [Basic usage](#basic-usage) 10 | - [Base schema](#base-schema) 11 | - [examples](#examples) 12 | - [custom](#custom) 13 | - [meta](#meta) 14 | - [JSON schema overriding](#json-schema-overriding) 15 | - [Defaults](#defaults) 16 | - [Primitives](#primitives) 17 | - [Constant values(literals)](#constant-valuesliterals) 18 | - [String](#string) 19 | - [Typescript features](#typescript-features) 20 | - [Numbers](#numbers) 21 | - [Types](#types) 22 | - [Number](#number) 23 | - [Int](#int) 24 | - [Formats](#formats) 25 | - [int32](#int32) 26 | - [int64](#int64) 27 | - [float](#float) 28 | - [double](#double) 29 | - [Typescript features](#typescript-features-1) 30 | - [BigInts](#bigints) 31 | - [NaNs](#nans) 32 | - [Dates](#dates) 33 | - [Enums](#enums) 34 | - [Autocompletion](#autocompletion) 35 | - [Native enums](#native-enums) 36 | - [Optionals](#optionals) 37 | - [Nullables](#nullables) 38 | - [Objects](#objects) 39 | - [`.keyof`](#keyof) 40 | - [`.extend`](#extend) 41 | - [`.merge`](#merge) 42 | - [`.pick`/`.omit`](#pickomit) 43 | - [`.partial`](#partial) 44 | - [`.required`](#required) 45 | - [`.requiredFor`](#requiredfor) 46 | - [`.partialFor`](#partialfor) 47 | - [`.passthrough`](#passthrough) 48 | - [`.strict`](#strict) 49 | - [`.dependentRequired`](#dependentrequired) 50 | - [`.rest`](#rest) 51 | - [Arrays](#arrays) 52 | - [`.addItems`](#additems) 53 | - [`.element`](#element) 54 | - [`.nonempty`](#nonempty) 55 | - [`.min`/`.max`/`.length`/`.minLength`/`.maxLength`](#minmaxlengthminlengthmaxlength) 56 | - [Typescript features](#typescript-features-2) 57 | - [`.unique`](#unique) 58 | - [`.contains`/`.minContains`](#containsmincontains) 59 | - [Tuples](#tuples) 60 | - [unions/or](#unionsor) 61 | - [Intersections/and](#intersectionsand) 62 | - [Set](#set) 63 | - [Map](#map) 64 | - [`any`/`unknown`](#anyunknown) 65 | - [`never`](#never) 66 | - [`not`/`exclude`](#notexclude) 67 | - [Custom Ajv instance](#custom-ajv-instance) 68 | - [`custom` shema definition](#custom-shema-definition) 69 | - [Transformations](#transformations) 70 | - [Preprocess](#preprocess) 71 | - [Postprocess](#postprocess) 72 | - [Error handling](#error-handling) 73 | - [Error Map](#error-map) 74 | - [refine](#refine) 75 | 76 | JSON schema builder like in ZOD-like API 77 | 78 | > TypeScript schema validation with static type inference! 79 | 80 | Reasons to install `ajv-ts` instead of `zod` 81 | 82 | 1. Less code. `zod` has 4k+ lines of code 83 | 2. not JSON-schema compatibility out of box (but you can install some additional plugins) 84 | 3. we not use own parser, just `ajv`, which wild spreadable(90M week installations for `ajv` vs 5M for `zod`) 85 | 4. Same typescript types and API 86 | 5. You can inject own `ajv` instance! 87 | 88 | We inspired API from `zod`. So you just can reimport you api and that's it! 89 | 90 | ## Zod unsupported APIs/differences 91 | 92 | 1. `s.date`, `s.symbol`, `s.void`, `s.void`, `s.bigint`, `s.function` does not supported. Since JSON-schema doesn't define `Date`, `Symbol`, `void`, `function`, `Set`, `Map` as separate type. For strings you can use `s.string().format('date-time')` or other JSON-string format compatibility: https://json-schema.org/understanding-json-schema/reference/string.html 93 | 2. `s.null` === `s.undefined` - same types, but helps typescript with autocompletion 94 | 3. `z.enum` and `z.nativeEnum` it's a same as `s.enum`. We make enums fully compatible, it can be array of strings or structure defined with `enum` keyword in typescript 95 | 4. Exporting `s` isntead of `z`, since `s` - is a shorthand for `schema` 96 | 5. `z.custom` is not supported 97 | 6. `z.literal` === `s.const`. 98 | 99 | ## Installation 100 | 101 | ```bash 102 | npm install ajv-ts # npm 103 | yarn add ajv-ts # yarn 104 | bun add ajv-ts # bun 105 | pnpm add ajv-ts # pnpm 106 | ``` 107 | 108 | ## Basic usage 109 | 110 | Creating a simple string schema 111 | 112 | ```typescript 113 | import { s } from "ajv-ts"; 114 | 115 | // creating a schema for strings 116 | const mySchema = s.string(); 117 | 118 | // parsing 119 | mySchema.parse("tuna"); // => "tuna" 120 | mySchema.parse(12); // => throws Ajv Error 121 | 122 | // "safe" parsing (doesn't throw error if validation fails) 123 | mySchema.safeParse("tuna"); // => { success: true; data: "tuna" } 124 | mySchema.safeParse(12); // => { success: false; error: AjvError } 125 | ``` 126 | 127 | Creating an object schema 128 | 129 | ```ts 130 | import { s } from "ajv-ts"; 131 | 132 | const User = s.object({ 133 | username: s.string(), 134 | }); 135 | 136 | User.parse({ username: "Ludwig" }); 137 | 138 | // extract the inferred type 139 | type User = s.infer; 140 | // { username: string } 141 | ``` 142 | 143 | ## Base schema 144 | 145 | Every schema inherits these class with next methods/properties 146 | 147 | ### examples 148 | 149 | The `examples` keyword is a place to provide an array of examples that validate against the schema. This isn’t used for validation, but may help with explaining the effect and purpose of the schema to a reader. Each entry should validate against the schema in which it resides, but that isn’t strictly required. There is no need to duplicate the default value in the examples array, since default will be treated as another example. 150 | 151 | **Note**: While it is recommended that the examples validate against the subschema they are defined in, this requirement is not strictly enforced. 152 | 153 | Used to demonstrate how data should conform to the schema. 154 | examples does not affect data validation but serves as an informative annotation. 155 | 156 | ```ts 157 | s.string().examples(["str1", 'string 2']) // OK 158 | s.number().examples(["str1", 'string 2']) // Error 159 | s.number().examples([1, 2, 3]) // OK 160 | s.number().examples(1, 2, 3) // OK 161 | ``` 162 | 163 | ### custom 164 | 165 | Add custom schema key-value definition. 166 | 167 | set custom JSON-schema field. Useful if you need to declare something but no api founded for built-in solution. 168 | 169 | Example: `If-Then-Else` you cannot declare without `custom` method. 170 | 171 | ```ts 172 | const myObj = s.object({ 173 | foo: s.string(), 174 | bar: s.string() 175 | }).custom('if', { 176 | "properties": { 177 | "foo": { "const": "bar" } 178 | }, 179 | "required": ["foo"] 180 | }).custom('then', { "required": ["bar"] }) 181 | ``` 182 | 183 | ### meta 184 | 185 | Adds meta information fields in your schema, such as `deprecated`, `description`, `$id`, `title` and more! 186 | 187 | Example: 188 | 189 | ```ts 190 | const numSchema = s.number().meta({ 191 | title: 'my number schema', 192 | description: 'Some description', 193 | deprecated: true 194 | }) 195 | 196 | numSchema.schema // {type: 'number', title: 'my number schema', description: 'Some description', deprecated: true } 197 | ``` 198 | 199 | ## JSON schema overriding 200 | 201 | In case of you have alredy defined JSON-schema, you create an `any/object/number/string/boolean/null` schema and set `schema` property from your schema. 202 | 203 | Example: 204 | 205 | ```ts 206 | import s from 'ajv-ts' 207 | 208 | const SchemaFromSomewhere = { 209 | "title": "Example Schema", 210 | "type": "object", 211 | "properties": { 212 | "name": { 213 | "type": "string" 214 | }, 215 | "age": { 216 | "description": "Age in years", 217 | "type": "integer", 218 | "minimum": 0 219 | }, 220 | }, 221 | "required": ["name", "age"] 222 | } 223 | 224 | type MySchema = { 225 | name: string; 226 | age: number 227 | } 228 | 229 | const AnySchema = s.any() 230 | AnySchema.schema = SchemaFromSomewhere 231 | 232 | AnySchema.parse({name: 'hello', age: 18}) // OK, since we override JSON-schema 233 | 234 | // or using object 235 | const Obj = s.object() 236 | Obj.schema = SchemaFromSomewhere 237 | 238 | Obj.parse({name: 'hello', age: 18}) // OK 239 | 240 | ``` 241 | 242 | ## Defaults 243 | 244 | Option `default` keywords throws exception during schema compilation when used in: 245 | 246 | - not in properties or items subschemas 247 | - in schemas inside anyOf, oneOf and not ([#42](https://github.com/ajv-validator/ajv/issues/42)) 248 | - in if schema 249 | - in schemas generated by user-defined macro keywords. 250 | 251 | This means only `object()` and `array()` buidlers are supported. 252 | 253 | Example 254 | 255 | ```ts 256 | import s from 'ajv-ts' 257 | const Person = s.object({ 258 | age: s.int().default(18) 259 | }) 260 | Person.parse({}) // { age: 18 } 261 | ``` 262 | 263 | ## Primitives 264 | 265 | ```ts 266 | import { s } from "ajv-ts"; 267 | 268 | // primitive values 269 | s.string(); 270 | s.number(); 271 | s.boolean(); 272 | 273 | // empty types 274 | s.undefined(); 275 | s.null(); 276 | 277 | // allows any value 278 | s.any(); 279 | s.unknown(); 280 | ``` 281 | 282 | ## Constant values(literals) 283 | 284 | ```ts 285 | const tuna = s.const("tuna"); 286 | const twelve = s.const(12); 287 | const tru = s.const(true); 288 | 289 | // retrieve literal value 290 | tuna.value; // "tuna" 291 | ``` 292 | 293 | ## String 294 | 295 | includes a handful of string-specific validations. 296 | 297 | ```ts 298 | // validations 299 | s.string().maxLength(5); 300 | s.string().minLength(5); 301 | s.string().length(5); 302 | s.string().format('email'); 303 | s.string().format('url'); 304 | s.string().regex(regex); 305 | s.string().format('date-time'); 306 | s.string().format('ipv4'); 307 | 308 | // transformations 309 | s.string().postprocess(v => v.trim()); 310 | s.string().postprocess(v => v.toLowerCase()); 311 | s.string().postprocess(v => v.toUpperCase()); 312 | ``` 313 | 314 | ### Typescript features 315 | 316 | > from >=0.7.x 317 | 318 | Unlike zod - we make typescript validation for `minLength` and `maxLength`. That means you cannot create schema when expected length are negative number or `maxLength < minLength`. 319 | 320 | Here is few examples: 321 | 322 | ```typescript 323 | s.string().minLength(3).maxLength(1) // [never, "RangeError: MaxLength less than MinLength", "MinLength: 3", "MaxLength: 1"] 324 | 325 | s.string().length(-1) // [never, "TypeError: expected positive integer. Received: '-1'"] 326 | ``` 327 | 328 | ## Numbers 329 | 330 | includes a handful of number-specific validations. 331 | 332 | ```ts 333 | s.number().gt(5); 334 | s.number().gte(5); // alias .min(5) 335 | s.number().lt(5); 336 | s.number().lte(5); // alias .max(5) 337 | 338 | s.number().int(); // value must be an integer 339 | 340 | s.number().positive(); // > 0 341 | s.number().nonnegative(); // >= 0 342 | s.number().negative(); // < 0 343 | s.number().nonpositive(); // <= 0 344 | 345 | s.number().multipleOf(5); // Evenly divisible by 5. Alias .step(5) 346 | ``` 347 | 348 | ### Types 349 | 350 | #### Number 351 | 352 | Number - any number type 353 | 354 | ```ts 355 | s.number() 356 | // same as 357 | s.number().number() 358 | ``` 359 | 360 | #### Int 361 | 362 | Only integers values. 363 | 364 | Note: we check in runtime non-integer format (`float`, `double`) and give an error. 365 | 366 | ```ts 367 | s.number().int() 368 | // or 369 | s.number().integer() 370 | // or 371 | s.int() 372 | ``` 373 | 374 | ### Formats 375 | 376 | Defines in [ajv-formats](https://ajv.js.org/packages/ajv-formats.html#formats) package 377 | 378 | #### int32 379 | 380 | Signed 32 bits integer according to the [openApi 3.0.0 specification](https://spec.openapis.org/oas/v3.0.0#data-types) 381 | 382 | #### int64 383 | 384 | Signed 64 bits according to the [openApi 3.0.0 specification](https://spec.openapis.org/oas/v3.0.0#data-types) 385 | 386 | #### float 387 | 388 | float: float according to the [openApi 3.0.0 specification](https://spec.openapis.org/oas/v3.0.0#data-types) 389 | 390 | #### double 391 | 392 | double: double according to the [openApi 3.0.0 specification](https://spec.openapis.org/oas/v3.0.0#data-types) 393 | 394 | ### Typescript features 395 | 396 | > from >= 0.8 397 | 398 | We make validation for number `type`, `format`, `minValue` and `maxValue` fields. That means we handle it in our side so you get an error for invalid values. 399 | 400 | Examples: 401 | 402 | ```ts 403 | s.number().format('float').int() // error in type! 404 | s.int().const(3.4) // error in type! 405 | s.number().int().format('float') // error in format! 406 | s.number().int().format('double') // error in format! 407 | 408 | // ranges are also check for possibility 409 | 410 | s.number().min(5).max(3) // error in range! 411 | s.number().min(3).max(5).const(10) // error in constant! 412 | ``` 413 | 414 | ## BigInts 415 | 416 | Not supported 417 | 418 | ## NaNs 419 | 420 | Not supported 421 | 422 | ## Dates 423 | 424 | Not supported, but you can pass `parseDates` in your AJV instance. 425 | 426 | ## Enums 427 | 428 | ```ts 429 | const FishEnum = s.enum(["Salmon", "Tuna", "Trout"]); 430 | type FishEnum = s.infer; 431 | // 'Salmon' | 'Tuna' | 'Trout' 432 | ``` 433 | 434 | ```ts 435 | const VALUES = ["Salmon", "Tuna", "Trout"] as const; 436 | const FishEnum = s.enum(VALUES); 437 | ``` 438 | 439 | ### Autocompletion 440 | 441 | To get autocompletion with a enum, use the `.enum` property of your schema: 442 | 443 | ```ts 444 | FishEnum.enum.Salmon; // => autocompletes 445 | 446 | FishEnum.enum; 447 | /* 448 | => { 449 | Salmon: "Salmon", 450 | Tuna: "Tuna", 451 | Trout: "Trout", 452 | } 453 | */ 454 | ``` 455 | 456 | You can also retrieve the list of options as a tuple with the .options property: 457 | 458 | ```ts 459 | FishEnum.options; // ["Salmon", "Tuna", "Trout"]; 460 | ``` 461 | 462 | ## Native enums 463 | 464 | **Numeric enums:** 465 | 466 | ```ts 467 | enum Fruits { 468 | Apple, 469 | Banana, 470 | } 471 | 472 | const FruitEnum = s.enum(Fruits); 473 | type FruitEnum = s.infer; // Fruits 474 | 475 | FruitEnum.parse(Fruits.Apple); // passes 476 | FruitEnum.parse(Fruits.Banana); // passes 477 | FruitEnum.parse(0); // passes 478 | FruitEnum.parse(1); // passes 479 | FruitEnum.parse(3); // fails 480 | ``` 481 | 482 | **String enums:** 483 | 484 | ```ts 485 | enum Fruits { 486 | Apple = "apple", 487 | Banana = "banana", 488 | Cantaloupe, // you can mix numerical and string enums 489 | } 490 | 491 | const FruitEnum = s.enum(Fruits); 492 | type FruitEnum = s.infer; // Fruits 493 | 494 | FruitEnum.parse(Fruits.Apple); // passes 495 | FruitEnum.parse(Fruits.Cantaloupe); // passes 496 | FruitEnum.parse("apple"); // passes 497 | FruitEnum.parse("banana"); // passes 498 | FruitEnum.parse(0); // passes 499 | FruitEnum.parse("Cantaloupe"); // pass 500 | ``` 501 | 502 | **Const enums:** 503 | 504 | The `.enum()` function works for as const objects as well. ⚠️ as const requires TypeScript 3.4+! 505 | 506 | ```ts 507 | const Fruits = { 508 | Apple: "apple", 509 | Banana: "banana", 510 | Cantaloupe: 3, 511 | } as const; 512 | 513 | const FruitEnum = s.enum(Fruits); 514 | type FruitEnum = s.infer; // "apple" | "banana" | 3 515 | 516 | FruitEnum.parse("apple"); // passes 517 | FruitEnum.parse("banana"); // passes 518 | FruitEnum.parse(3); // passes 519 | FruitEnum.parse("Cantaloupe"); // fails 520 | ``` 521 | 522 | You can access the underlying object with the .enum property: 523 | 524 | ```ts 525 | FruitEnum.enum.Apple; // "apple" 526 | ``` 527 | 528 | ## Optionals 529 | 530 | You can make any schema optional with `s.optional()`. This wraps the schema in a `Optional` instance and returns the result. 531 | 532 | ```ts 533 | const schema = s.string().optional(); 534 | 535 | schema.parse(undefined); // => returns undefined 536 | type A = s.infer; // string | undefined 537 | ``` 538 | 539 | ## Nullables 540 | 541 | ```ts 542 | const nullableString = s.string().nullable(); 543 | nullableString.parse("asdf"); // => "asdf" 544 | nullableString.parse(null); // => null 545 | nullableString.parse(undefined); // throws error 546 | ``` 547 | 548 | ## Objects 549 | 550 | ```ts 551 | // all properties are required by default 552 | const Dog = s.object({ 553 | name: s.string(), 554 | age: s.number(), 555 | }); 556 | 557 | // extract the inferred type like this 558 | type Dog = s.infer; 559 | 560 | // equivalent to: 561 | type Dog = { 562 | name: string; 563 | age: number; 564 | }; 565 | ``` 566 | 567 | ### `.keyof` 568 | 569 | Use `.keyof` to create a `Enum` schema from the keys of an object schema. 570 | 571 | ```ts 572 | const keySchema = Dog.keyof(); 573 | keySchema; // Enum<["name", "age"]> 574 | ``` 575 | 576 | ### `.extend` 577 | 578 | You can add additional fields to an object schema with the `.extend` method. 579 | 580 | ```ts 581 | const DogWithBreed = Dog.extend({ 582 | breed: s.string(), 583 | }); 584 | ``` 585 | 586 | You can use `.extend` to overwrite fields! Be careful with this power! 587 | 588 | ### `.merge` 589 | 590 | Equivalent to `A.extend(B.schema)`. 591 | 592 | ```ts 593 | const BaseTeacher = s.object({ students: s.array(s.string()) }); 594 | const HasID = s.object({ id: s.string() }); 595 | 596 | const Teacher = BaseTeacher.merge(HasID); 597 | type Teacher = s.infer; // => { students: string[], id: string } 598 | ``` 599 | 600 | ### `.pick`/`.omit` 601 | 602 | Inspired by TypeScript's built-in `Pick` and `Omit` utility types, all object schemas have `.pick` and `.omit` methods that return a modified version. Consider this Recipe schema: 603 | 604 | ```ts 605 | const Recipe = s.object({ 606 | id: s.string(), 607 | name: s.string(), 608 | ingredients: s.array(s.string()), 609 | }); 610 | ``` 611 | 612 | To only keep certain keys, use .pick . 613 | 614 | ```ts 615 | const JustTheName = Recipe.pick({ name: true }); 616 | type JustTheName = s.infer; 617 | // => { name: string } 618 | ``` 619 | 620 | To remove certain keys, use `.omit` . 621 | 622 | ```ts 623 | const NoIDRecipe = Recipe.omit({ id: true }); 624 | 625 | type NoIDRecipe = s.infer; 626 | // => { name: string, ingredients: string[] } 627 | ``` 628 | 629 | ### `.partial` 630 | 631 | Inspired by the built-in TypeScript utility type `Partial`, the .partial method makes all properties optional. 632 | 633 | Starting from this object: 634 | 635 | ```ts 636 | const user = s.object({ 637 | email: s.string(), 638 | username: s.string(), 639 | }); 640 | // { email: string; username: string } 641 | We can create a partial version: 642 | 643 | const partialUser = user.partial(); 644 | // { email?: string | undefined; username?: string | undefined } 645 | You can also specify which properties to make optional: 646 | 647 | const optionalEmail = user.partial({ 648 | email: true, 649 | }); 650 | /* 651 | { 652 | email?: string | undefined; 653 | username: string 654 | } 655 | */ 656 | ``` 657 | 658 | ### `.required` 659 | 660 | Contrary to the `.partial` method, the `.required` method makes all properties required. 661 | 662 | Starting from this object: 663 | 664 | ```ts 665 | const user = z 666 | .object({ 667 | email: s.string(), 668 | username: s.string(), 669 | }) 670 | .partial(); 671 | // { email?: string | undefined; username?: string | undefined } 672 | ``` 673 | 674 | We can create a required version: 675 | 676 | ```ts 677 | const requiredUser = user.required(); 678 | // { email: string; username: string } 679 | You can also specify which properties to make required: 680 | 681 | const requiredEmail = user.required({ 682 | email: true, 683 | }); 684 | /* 685 | { 686 | email: string; 687 | username?: string | undefined; 688 | } 689 | */ 690 | ``` 691 | 692 | ### `.requiredFor` 693 | 694 | Accepts keys which are required. Set `requiredProperties` for your JSON-schema 695 | 696 | ```ts 697 | const O = s.object({ 698 | first: s.number().optional(), 699 | second: s.string().optional() 700 | }).requiredFor('first') 701 | 702 | type O = s.infer // {first: number, second?: string} 703 | ``` 704 | 705 | ### `.partialFor` 706 | 707 | Accepts keys which are partial. unset properties from `required` schema field in your JSON-schema 708 | 709 | ```ts 710 | const O = s.object({ 711 | first: s.number().optional(), 712 | second: s.string().optional() 713 | }).required().partialFor('second') 714 | 715 | type O = s.infer // {first: number, second?: string} 716 | ``` 717 | 718 | ### `.passthrough` 719 | 720 | By default object schemas strip out unrecognized keys during parsing. 721 | 722 | ```ts 723 | const person = s.object({ 724 | name: s.string(), 725 | }); 726 | 727 | person.parse({ 728 | name: "bob dylan", 729 | extraKey: 61, 730 | }); 731 | // => { name: "bob dylan" } 732 | // extraKey has been stripped 733 | ``` 734 | 735 | Instead, if you want to pass through unknown keys, use `.passthrough()` . 736 | 737 | ```ts 738 | person.passthrough().parse({ 739 | name: "bob dylan", 740 | extraKey: 61, 741 | }); 742 | // => { name: "bob dylan", extraKey: 61 } 743 | ``` 744 | 745 | ### `.strict` 746 | 747 | By default JSON object schemas allow to pass unrecognized keys during parsing. You can disallow unknown keys with `.strict()` . If there are any unknown keys in the input - will throw an error. 748 | 749 | ```ts 750 | const person = z 751 | .object({ 752 | name: s.string(), 753 | }) 754 | .strict(); 755 | 756 | person.parse({ 757 | name: "bob dylan", 758 | extraKey: 61, 759 | }); 760 | // => throws ZodError 761 | ``` 762 | 763 | ### `.dependentRequired` 764 | 765 | The `dependentRequired` keyword conditionally requires that 766 | certain properties must be present if a given property is 767 | present in an object. For example, suppose we have a schema 768 | representing a customer. If you have their "credit card number", 769 | you also want to ensure you have a "billing address". 770 | If you don't have their credit card number, a "billing address" 771 | operty 772 | on another using the `dependentRequired` keyword. 773 | The value of the `dependentRequired` keyword is an object. 774 | Each entry in the object maps from the name of a property, p, 775 | to an array of strings listing properties that are `required` 776 | if p is present. 777 | 778 | ```ts 779 | const Test1 = s.object({ 780 | name: s.string(), 781 | credit_card: s.number(), 782 | billing_address: s.string(), 783 | }).requiredFor('name').dependentRequired({ 784 | credit_card: ['billing_address'], 785 | }) 786 | 787 | /** 788 | Test1.schema === { 789 | "type": "object", 790 | "properties": { 791 | "name": { "type": "string" }, 792 | "credit_card": { "type": "number" }, 793 | "billing_address": { "type": "string" } 794 | }, 795 | "required": ["name"], 796 | "dependentRequired": { 797 | "credit_card": ["billing_address"] 798 | } 799 | } 800 | */ 801 | ``` 802 | 803 | ### `.rest` 804 | 805 | The `additionalProperties` keyword is used to control the handling of extra stuff, that is, `properties` whose names are 806 | not listed in the `properties` keyword or match any of the regular expressions in the `patternProperties` keyword. 807 | By default any additional properties are allowed. 808 | 809 | If you need to set `additionalProperties=false` use [`strict`](#strict) method 810 | 811 | ```ts 812 | const Test = s.object({ 813 | street_name: s.string(), 814 | street_type: s.enum(["Street", "Avenue", "Boulevard"]) 815 | }).rest(s.string()) 816 | 817 | Test.schema === { 818 | "type": "object", 819 | "properties": { 820 | "street_name": { "type": "string" }, 821 | "street_type": { "enum": ["Street", "Avenue", "Boulevard"] } 822 | }, 823 | "additionalProperties": { "type": "string" } 824 | } 825 | ``` 826 | 827 | ## Arrays 828 | 829 | ```typescript 830 | const stringArray = s.array(s.string()); 831 | type StringArray = s.infer // string[] 832 | ``` 833 | 834 | Or it's invariant 835 | 836 | ```ts 837 | const stringArray = s.string().array(); 838 | type StringArray = s.infer // string[] 839 | ``` 840 | 841 | Or you can pass empty schema 842 | 843 | ```ts 844 | const empty = s.array() 845 | 846 | type Empty = s.infer // unknown[] 847 | ``` 848 | 849 | ### `.addItems` 850 | 851 | push(append) schema to array(parent) schema. 852 | 853 | Example: 854 | 855 | ```ts 856 | import s from 'ajv-ts' 857 | 858 | const empty = s.array() 859 | const stringArr = empty.addItems(s.string()) 860 | 861 | stringArr.schema // {type: 'array', items: [{ type: 'string' }]} 862 | ``` 863 | 864 | ### `.element` 865 | 866 | Use `.element` to access the schema for an element of the array. 867 | 868 | ```ts 869 | stringArray.element; // => string schema, not array schema 870 | ``` 871 | 872 | ### `.nonempty` 873 | 874 | If you want to ensure that an array contains at least one element, use `.nonempty()`. 875 | 876 | ```ts 877 | const nonEmptyStrings = s.array(s.string()).nonempty(); 878 | // the inferred type is now 879 | // [string, ...string[]] 880 | 881 | nonEmptyStrings.parse([]); // throws: "Array cannot be empty" 882 | nonEmptyStrings.parse(["Ariana Grande"]); // passes 883 | ``` 884 | 885 | ### `.min`/`.max`/`.length`/`.minLength`/`.maxLength` 886 | 887 | ```ts 888 | s.string().array().min(5); // must contain 5 or more items 889 | s.string().array().max(5); // must contain 5 or fewer items 890 | s.string().array().length(5); // must contain 5 items exactly 891 | ``` 892 | 893 | Unlike `.nonempty()` these methods do not change the inferred type. 894 | 895 | ### Typescript features 896 | 897 | > from >=0.7.x 898 | 899 | Unlike zod - we make typescript validation for `minLength` and `maxLength`. That means you cannot create schema when expected length are not positive number or `maxLength < minLength`. 900 | 901 | Here is few examples: 902 | 903 | ```typescript 904 | s.string().array().minLength(3).maxLength(1) // [never, "RangeError: MaxLength less than MinLength", "MinLength: 2", "MaxLength: 1"] 905 | 906 | s.string().array().length(-1) // [never, "TypeError: expected positive integer. Received: '-2'"] 907 | ``` 908 | 909 | ### `.unique` 910 | 911 | Set the `uniqueItems` keyword to `true`. 912 | 913 | ```ts 914 | const UniqueNumbers = s.array(s.number()).unique() 915 | 916 | UniqueNumbers.parse([1,2,3,4]) // Ok 917 | UniqueNumbers.parse([1,2,3,3]) // Error 918 | ``` 919 | 920 | ### `.contains`/`.minContains` 921 | 922 | ## Tuples 923 | 924 | Unlike arrays, tuples have a fixed number of elements and each element can have a different type. 925 | 926 | ```ts 927 | const athleteSchema = s.tuple([ 928 | s.string(), // name 929 | s.number(), // jersey number 930 | s.object({ 931 | pointsScored: s.number(), 932 | }), // statistics 933 | ]); 934 | 935 | type Athlete = s.infer; 936 | // type Athlete = [string, number, { pointsScored: number }] 937 | ``` 938 | 939 | A variadic ("rest") argument can be added with the .rest method. 940 | 941 | ```ts 942 | const variadicTuple = s.tuple([s.string()]).rest(s.number()); 943 | const result = variadicTuple.parse(["hello", 1, 2, 3]); 944 | // => [string, ...number[]]; 945 | ``` 946 | 947 | ## unions/or 948 | 949 | includes a built-in s.union method for composing "OR" types. 950 | 951 | This function accepts array of schemas by spread argument. 952 | 953 | ```ts 954 | const stringOrNumber = s.union(s.string(), s.number()); 955 | 956 | stringOrNumber.parse("foo"); // passes 957 | stringOrNumber.parse(14); // passes 958 | ``` 959 | 960 | Or it's invariant - `or` function: 961 | 962 | ```ts 963 | s.number().or(s.string()) // number | string 964 | ``` 965 | 966 | ## Intersections/and 967 | 968 | Intersections are "logical AND" types. This is useful for intersecting two object types. 969 | 970 | ```ts 971 | const Person = s.object({ 972 | name: s.string(), 973 | }); 974 | 975 | const Employee = s.object({ 976 | role: s.string(), 977 | }); 978 | 979 | const EmployedPerson = s.intersection(Person, Employee); 980 | 981 | // equivalent to: 982 | const EmployedPerson = Person.and(Employee); 983 | 984 | // equivalent to: 985 | const EmployedPerson = and(Person, Employee); 986 | ``` 987 | 988 | Though in many cases, it is recommended to use `A.merge(B)` to merge two objects. The `.merge` method returns a new Object instance, whereas `A.and(B)` returns a less useful Intersection instance that lacks common object methods like `pick` and `omit`. 989 | 990 | ```ts 991 | const a = s.union(s.number(), s.string()); 992 | const b = s.union(s.number(), s.boolean()); 993 | const c = s.intersection(a, b); 994 | 995 | type c = s.infer; // => number 996 | ``` 997 | 998 | ## Set 999 | 1000 | Not supported 1001 | 1002 | ## Map 1003 | 1004 | Not supported 1005 | 1006 | ## `any`/`unknown` 1007 | 1008 | Any and unknown defines `{}` (empty object) as JSON-schema. very useful if you need to create something specific 1009 | 1010 | ## `never` 1011 | 1012 | Never defines using `{not: {}}` (empty not). Any given json schema will be fails. 1013 | 1014 | ## `not`/`exclude` 1015 | 1016 | Here is a 2 differences between `not` and `exclude`. 1017 | 1018 | - `not` method wrap given schema with `not` 1019 | - `exclude(schema)` - add `not` keyword for incoming `schema` argument 1020 | 1021 | Example: 1022 | 1023 | ```ts 1024 | import s from 'ajv-ts' 1025 | 1026 | // not 1027 | const notAString = s.string().not() // or s.not(s.string()) 1028 | 1029 | notAString.valid('random string') // false, this is a string 1030 | notAString.valid(123) // true 1031 | 1032 | // exclude 1033 | const notJohn = s.string().exclude(s.const('John')) 1034 | 1035 | notJohn.valid('random string') // true 1036 | notJohn.valid('John') // false, this is John 1037 | 1038 | // advanced usage 1039 | 1040 | const str = s.string<'John' | 'Mary'>().exclude(s.const('John')) 1041 | s.infer // 'Mary' 1042 | ``` 1043 | 1044 | ## Custom Ajv instance 1045 | 1046 | If you need to create a custom AJV Instance, you can use `create` or `new` function. 1047 | 1048 | ```ts 1049 | import addKeywords from 'ajv-keywords'; 1050 | import schemaBuilder from 'ajv-ts' 1051 | 1052 | const myAjvInstance = new Ajv({parseDate: true}) 1053 | 1054 | export const s = schemaBuilder.create(myAjvInstance) 1055 | 1056 | // later: 1057 | s.string().dateTime().parse(new Date()) // 2023-10-05T19:31:57.610Z 1058 | ``` 1059 | 1060 | ## `custom` shema definition 1061 | 1062 | If you need to append something specific to you schema, you can use `custom` method. 1063 | 1064 | ```ts 1065 | const condition = s.any() // schema: {} 1066 | 1067 | const withIf = condition.custom('if', {properties: {foo: {type: 'string'}}}) 1068 | 1069 | withIf.schema // {if: {properties: {foo: {type: 'string'}}}} 1070 | 1071 | ``` 1072 | 1073 | ## Transformations 1074 | 1075 | ### Preprocess 1076 | 1077 | function thant will be applied before calling `parse` method, It can helps you to modify incomining data 1078 | 1079 | Be careful with this information 1080 | 1081 | ```ts 1082 | const ToString = s.string().preprocess(x => { 1083 | if(x instanceof Date){ 1084 | return x.toISOString() 1085 | } 1086 | return x 1087 | }, s.string()) 1088 | 1089 | ToString.parse(12) // error: expects a string 1090 | 1091 | ToString.parse(new Date()) // 2023-09-26T13:44:46.497Z 1092 | 1093 | ``` 1094 | 1095 | ### Postprocess 1096 | 1097 | function thant will be applied after calling `parse` method. 1098 | 1099 | ```ts 1100 | const ToString = s.number().postprocess(x => String(x), s.string()) 1101 | 1102 | ToString.parse(12) // after parse we get "12" 12 => "12". 1103 | 1104 | ToString.parse({}) // error: expects number. Postprocess has not been called 1105 | ``` 1106 | 1107 | ## Error handling 1108 | 1109 | Error handling and error maps based from official package [`ajv-errors`](https://ajv.js.org/packages/ajv-errors.html#templates). You can check in out from there. 1110 | 1111 | Defines custom error message for not valid schema. 1112 | 1113 | ```ts 1114 | const S1 = s.string().error('Im fails unexpected') 1115 | S1.parse({}) // throws: Im fails unexpected 1116 | ``` 1117 | 1118 | ### Error Map 1119 | 1120 | You can define custom error map. 1121 | In most cases you can pass just a string for invalidation. 1122 | Also, you can pass error map. 1123 | 1124 | Example: 1125 | 1126 | ```ts 1127 | import s from 'ajv-ts' 1128 | 1129 | const s1 = s.string().error({ _: "any error here" }) 1130 | 1131 | s.parse(123) // throws "any error here" 1132 | 1133 | s.string().error({ _: "any error here", type: "not a string. Custom" }) 1134 | 1135 | s.parse(123) // throws "not a string. Custom" 1136 | 1137 | const Obj = s 1138 | .object({ foo: s.string(),}) 1139 | .strict() 1140 | .error({ additionalProperties: "Not expected to pass additional props" }); 1141 | 1142 | Obj.parse({foo: 'ok', bar: true}) // throws "Not expected to pass additional props" 1143 | ``` 1144 | 1145 | ```ts 1146 | const Schema = s 1147 | .object({ 1148 | foo: s.integer().minimum(2), 1149 | bar: s.string().minLength(2), 1150 | }) 1151 | .strict() 1152 | .error({ 1153 | properties: { 1154 | foo: "data.foo should be integer >= 2", 1155 | bar: "data.bar should be string with length >= 2", 1156 | }, 1157 | }); 1158 | Schema.parse({ foo: 1, bar: "a" }) // throws: "data.foo should be integer >= 2" 1159 | ``` 1160 | 1161 | ### refine 1162 | 1163 | Inspired from `zod`. Set custom validation. Any result exept `undefined` will throws(or exposed for `safeParse` method). 1164 | 1165 | ```ts 1166 | import s from 'ajv-ts' 1167 | // example: object with only 1 "active element" 1168 | const Schema = s.object({ 1169 | active: s.boolean(), 1170 | name: s.string() 1171 | }).array().refine((arr) => { 1172 | const subArr = arr.filter(el => el.active === true) 1173 | if (subArr.length > 1) throw new Error('Array should contains only 1 "active" element') 1174 | }) 1175 | 1176 | Schema.parse([{ active: true, name: 'some 1' }, { active: true, name: 'some 2' }]) // throws Error 1177 | ``` 1178 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | < 0.x | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Use this section to tell people how to report a vulnerability. 15 | 16 | Tell them where to go, how often they can expect to get an update on a 17 | reported vulnerability, what to expect if the vulnerability is accepted or 18 | declined, etc. 19 | -------------------------------------------------------------------------------- /UPCOMING.md: -------------------------------------------------------------------------------- 1 | # x.x.x 2 | 3 | ## 💥 Breaking Changes 4 | 5 | ## ✅ New Features 6 | 7 | ## 🐛 Bug Fixes 8 | 9 | ## 🏡 Chore/Infra 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ajv-ts", 3 | "description": "JSON-schema builder with typescript safety", 4 | "version": "0.9.0", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "packageManager": "pnpm@9.10.0", 9 | "scripts": { 10 | "build": "tsx ./tsup.config.mts", 11 | "test": "vitest run", 12 | "test:watch": "vitest", 13 | "ci:version": "changeset version", 14 | "ci:publish": "changeset publish" 15 | }, 16 | "author": "Vitali Haradkou ", 17 | "homepage": "https://github.com/vitalics/ajv-ts", 18 | "keywords": [ 19 | "ajv", 20 | "validation", 21 | "json-schema", 22 | "typescript", 23 | "builder" 24 | ], 25 | "bugs": { 26 | "url": "https://github.com/vitalics/ajv-ts/issues", 27 | "email": "vitalicset@yandex.ru" 28 | }, 29 | "license": "MIT", 30 | "exports": { 31 | ".": { 32 | "import": { 33 | "types": "./dist/index.d.mts", 34 | "default": "./dist/index.mjs" 35 | }, 36 | "require": { 37 | "types": "./dist/index.d.ts", 38 | "default": "./dist/index.cjs" 39 | } 40 | }, 41 | "./package.json": "./package.json" 42 | }, 43 | "devDependencies": { 44 | "@biomejs/biome": "1.8.3", 45 | "@changesets/changelog-github": "0.5.0", 46 | "@changesets/cli": "2.26.2", 47 | "@types/benchmark": "^2.1.5", 48 | "@typescript-eslint/eslint-plugin": "6.4.0", 49 | "@vitest/ui": "1.6.0", 50 | "benchmark": "2.1.4", 51 | "eslint": "8.0.1", 52 | "eslint-plugin-import": "2.25.2", 53 | "eslint-plugin-n": "15.0.0", 54 | "tsup": "8.1.0", 55 | "tsx": "4.17.0", 56 | "typescript": "5.5.3", 57 | "vitest": "1.6.0" 58 | }, 59 | "peerDependencies": { 60 | "typescript": ">=5.0.0" 61 | }, 62 | "dependencies": { 63 | "ajv": "8.16.0", 64 | "ajv-errors": "3.0.0", 65 | "ajv-formats": "3.0.1", 66 | "type-fest": "4.26.0" 67 | } 68 | } -------------------------------------------------------------------------------- /src/builder.ts: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | import ajvErrors from "ajv-errors"; 3 | import addFormats from "ajv-formats"; 4 | 5 | import type { 6 | And, 7 | GreaterThan, 8 | GreaterThanOrEqual, 9 | IsFloat, 10 | IsInteger, 11 | LessThan, 12 | LessThanOrEqual, 13 | Or, 14 | } from 'type-fest' 15 | 16 | import type { 17 | AnySchema, 18 | AnySchemaOrAnnotation, 19 | ArraySchema, 20 | BaseSchema, 21 | BooleanSchema, 22 | ConstantAnnotation, 23 | EnumAnnotation, 24 | NullSchema, 25 | NumberSchema, 26 | ObjectSchema, 27 | StringSchema, 28 | } from "./schema/types"; 29 | import type { Create, MakeReadonly, Optional } from "./types/array"; 30 | import type { 31 | // Debug, 32 | Fn, 33 | Object as ObjectTypes, 34 | UnionToIntersection, 35 | UnionToTuple, 36 | } from "./types/index"; 37 | import type { OmitByValue, OmitMany, PickMany, Prettify } from "./types/object"; 38 | import type { Email, UUID } from "./types/string"; 39 | import type { TRangeGenericError, TTypeGenericError } from './types/errors'; 40 | import type { IsPositiveInteger, NumericStringifyType } from './types/number' 41 | /** 42 | * Default Ajv instance. 43 | * 44 | * @default 45 | * ajvErrors(addFormats(new Ajv({ 46 | * allErrors: true, 47 | * useDefaults: true, 48 | * }))) 49 | */ 50 | export const DEFAULT_AJV = ajvErrors( 51 | addFormats( 52 | new Ajv({ 53 | allErrors: true, 54 | useDefaults: true, 55 | } as never) as never, 56 | ) as never, 57 | ); 58 | 59 | /** Any schema builder. */ 60 | export type AnySchemaBuilder = 61 | | SchemaBuilder 62 | | NumberSchemaBuilder 63 | | StringSchemaBuilder 64 | | BooleanSchemaBuilder 65 | | NullSchemaBuilder 66 | | ObjectSchemaBuilder 67 | | RecordSchemaBuilder 68 | | ArraySchemaBuilder 69 | | TupleSchemaBuilder 70 | | EnumSchemaBuilder 71 | | ConstantSchemaBuilder 72 | | UnionSchemaBuilder 73 | | IntersectionSchemaBuilder 74 | | UnknownSchemaBuilder 75 | | NotSchemaBuilder; 76 | 77 | export type MetaObject = PickMany< 78 | BaseSchema, 79 | ["title", "description", "deprecated", "$id", "$async", "$ref", "$schema"] 80 | > & { 81 | examples?: unknown | unknown[] 82 | }; 83 | 84 | export type SafeParseResult = 85 | | SafeParseSuccessResult 86 | | SafeParseErrorResult; 87 | 88 | export type SafeParseSuccessResult = { 89 | success: true; 90 | data: T; 91 | input: unknown; 92 | /** `undefined` for success result */ 93 | error?: Error; 94 | }; 95 | export type SafeParseErrorResult = { 96 | success: false; 97 | error: Error; 98 | input: unknown; 99 | /** `undefined` for error result */ 100 | data?: T; 101 | }; 102 | 103 | export type ErrorMessageParams = { 104 | /** Error message for not expected type. E.g. schema define string, but got number */ 105 | type?: string; 106 | /** Error message for `required` property. E.g. schema define required property, but in actial result this property missing. */ 107 | required?: T extends ObjectSchemaBuilder 108 | ? { [K in keyof Infer]?: string } | string 109 | : string; 110 | /** Error message for properties. Mostly works for object, arrays */ 111 | properties?: T extends ObjectSchemaBuilder 112 | ? { [K in keyof Infer]?: string } | string 113 | : string; 114 | /** Error message for additional properties. Mostly works for object, arrays */ 115 | additionalProperties?: string; 116 | /** Default or unmapped error message */ 117 | _?: string; 118 | }; 119 | export type SchemaBuilderOpts = { 120 | _preProcesses: Fn[] 121 | _postProcesses: Fn[] 122 | } 123 | export class SchemaBuilder< 124 | Input = unknown, 125 | Schema extends AnySchemaOrAnnotation = AnySchemaOrAnnotation, 126 | Output = Input, 127 | Opts extends SchemaBuilderOpts = { _preProcesses: [], _postProcesses: [] } 128 | > { 129 | /** 130 | * type helper. Do Not Use! 131 | */ 132 | _input!: Input; 133 | 134 | /** 135 | * type helper. Do Not Use! 136 | */ 137 | _output!: Output; 138 | private _schema: Schema; 139 | private _shape: Schema; 140 | _preProcesses!: Opts['_preProcesses'] 141 | _postProcesses!: Opts['_postProcesses'] 142 | 143 | /** 144 | * returns JSON-schema representation 145 | */ 146 | get schema() { 147 | return this._schema; 148 | } 149 | /** 150 | * returns JSON-schema representation. same as `schema` does. 151 | * @satisfies zod API 152 | */ 153 | get shape() { 154 | return this._shape; 155 | } 156 | 157 | /** 158 | * Set custom JSON-Schema representation. 159 | * Updates `shape` property too 160 | */ 161 | set schema(schema) { 162 | this._schema = schema; 163 | this._shape = schema; 164 | } 165 | 166 | /** 167 | * Set custom JSON-Schema representation. 168 | * Updates `schema` property too 169 | * @satisfies zod API 170 | */ 171 | set shape(schema) { 172 | this._schema = schema; 173 | this._shape = schema; 174 | } 175 | 176 | /** Returns your ajv instance */ 177 | get ajv() { 178 | return this._ajv; 179 | } 180 | 181 | /** 182 | * Set Ajv Instance. 183 | * @throws `TypeError` if not ajv instance comes 184 | */ 185 | set ajv(instance: Ajv) { 186 | if (!(instance instanceof Ajv)) { 187 | throw new TypeError(`Cannot set ajv variable for non-ajv instance.`, { 188 | cause: { type: typeof instance, value: instance }, 189 | }); 190 | } 191 | this._ajv = instance; 192 | } 193 | 194 | constructor( 195 | schema: Schema, 196 | private _ajv = DEFAULT_AJV, 197 | ) { 198 | this._schema = schema; 199 | this._shape = schema; 200 | } 201 | protected isNullable = false; 202 | 203 | /** 204 | * set custom JSON-schema field. Useful if you need to declare something but no api founded for built-in solution. 205 | * 206 | * Example: `If-Then-Else` you cannot declare without `custom` method. 207 | * @example 208 | * const myObj = s.object({ 209 | * foo: s.string(), 210 | * bar: s.string() 211 | * }).custom('if', { 212 | * "properties": { 213 | * "foo": { "const": "bar" } 214 | * }, 215 | * "required": ["foo"] 216 | * }).custom('then', { "required": ["bar"] }) 217 | */ 218 | custom( 219 | key: string, 220 | value: V, 221 | ): Result { 222 | (this.schema as Record)[key] = value; 223 | return this as never; 224 | } 225 | 226 | /** 227 | * # 2020-12 Draft 6 228 | * The `examples` keyword is a place to provide an array of examples that validate against the schema. 229 | * This isn’t used for validation, but may help with explaining the effect and purpose of the schema 230 | * to a reader. Each entry should validate against the schema in which it resides, 231 | * but that isn’t strictly required. There is no need to duplicate the default value in the examples array, 232 | * since default will be treated as another example. 233 | * 234 | * **Note:** While it is recommended that the examples validate against the subschema they are defined in, this requirement is not strictly enforced. 235 | * - Used to demonstrate how data should conform to the schema. 236 | * - `examples` does not affect data validation but serves as an informative annotation. 237 | * @see {@link https://www.learnjsonschema.com/2020-12/meta-data/examples JSON-schema examples definition} 238 | * @example 239 | * s.string().examples(["str1", 'string 2']) // OK 240 | * s.number().examples(["str1", 'string 2']) // Error in Typescript, schema is OK 241 | * s.number().examples([1, 2, 3]) // OK 242 | * s.number().examples(1, 2, 3) // OK 243 | */ 244 | examples(...examples: Output[]): this 245 | examples(examples: Output[]): this 246 | examples(...examples: unknown[]) { 247 | if (examples.length === 1 && Array.isArray(examples[0])) { 248 | (this.schema as Record).examples = examples[0] 249 | } else { 250 | (this.schema as Record).examples = examples 251 | } 252 | return this 253 | } 254 | 255 | /** 256 | * Marks your property as nullable (`undefined`) 257 | * 258 | * **NOTES**: json-schema not accept `undefined` type. It's just `nullable` as typescript `undefined` type. 259 | */ 260 | optional(): SchemaBuilder { 261 | return this.nullable() as never; 262 | } 263 | 264 | /** 265 | * Marks your property as nullable (`null`). 266 | * 267 | * Updates `type` property for your schema. 268 | * @example 269 | * const schemaDef = s.string().nullable() 270 | * schemaDef.schema // { type: ['string', 'null'], nullable: true } 271 | */ 272 | nullable(): SchemaBuilder { 273 | return or(this, nil()) as never 274 | } 275 | 276 | private preFns: Function[] = []; 277 | 278 | /** 279 | * pre process function for incoming result. Transform input **BEFORE** calling `parse`, `safeParse`, `validate` functions 280 | * 281 | * **NOTE:** this functions works BEFORE parsing. use it at own risk. (e.g. transform Date object into string) 282 | * @see {@link SchemaBuilder.parse parse} method 283 | * @see {@link SchemaBuilder.safeParse safe parse} method 284 | * @see {@link SchemaBuilder.validate validate} method 285 | * @example 286 | * const myString = s.string().preprocess(v => { 287 | * // if date => transform to ISO string 288 | * if(v instanceof Date) { 289 | * return Date.toISOString() 290 | * } 291 | * // do nothing if not a date 292 | * return v 293 | * }) 294 | * const res = myString.parse(new Date()) // '2023-09-23T07:10:57.881Z' 295 | * const res = myString.parse('qwe') // 'qwe' 296 | * const res = myString.parse({}) // error: not a string 297 | */ 298 | preprocess< 299 | const In, 300 | const Out, 301 | const F extends Fn, 302 | >(fn: F): this { 303 | if (typeof fn !== "function") { 304 | throw new TypeError(`Cannot use not a function for pre processing.`, { 305 | cause: { type: typeof fn, value: fn }, 306 | }); 307 | } 308 | this.preFns.push(fn); 309 | return this as never; 310 | } 311 | 312 | private postFns: { fn: Function; schema: AnySchemaBuilder }[] = []; 313 | /** 314 | * Post process. Use it when you would like to transform result after parsing is happens. 315 | * 316 | * **NOTE:** this function override your `input` variable for `safeParse` calling. 317 | * @see {@link SchemaBuilder.safeParse safeParse method} 318 | */ 319 | postprocess( 320 | fn: Fn, 321 | schema: S, 322 | ): this { 323 | if (typeof fn !== "function") { 324 | throw new TypeError(`Cannot use not a function for pre processing.`, { 325 | cause: { type: typeof fn, value: fn }, 326 | }); 327 | } 328 | this.postFns.push({ fn, schema }); 329 | return this as never; 330 | } 331 | 332 | private refineFns: Function[] = []; 333 | /** 334 | * Set custom validation. Any result exept `undefined` will throws. 335 | * @param fn function that will be called after `safeParse`. Any result will throws 336 | * @example 337 | * import s from 'ajv-ts' 338 | * // example: object with only 1 "active element" 339 | * const Schema = s.object({ 340 | * active: s.boolean(), 341 | * name: s.string() 342 | * }).array().refine((arr) => { 343 | * const subArr = arr.filter(el => el.active === true) 344 | * if (subArr.length > 1) throw new Error('Array should contains only 1 "active" element') 345 | * }) 346 | * 347 | * Schema.parse([{ active: true, name: 'some 1' }, { active: true, name: 'some 2' }]) // throws Error 348 | */ 349 | refine(fn: (output: Output) => any) { 350 | if (typeof fn !== "function") { 351 | throw new TypeError( 352 | `Cannot set for not a function for refine. Expect "function", Got: ${typeof fn}`, 353 | { cause: { fn, type: typeof fn } }, 354 | ); 355 | } 356 | this.refineFns.push(fn); 357 | return this; 358 | } 359 | 360 | /** 361 | * Meta object. Adds meta information fields in your schema, such as `deprecated`, `description`, `$id`, `title` and more! 362 | * 363 | * @see {@link https://json-schema.org/draft/2020-12/json-schema-validation#name-examples json-schema draft 2020-12} 364 | * @see {@link https://json-schema.org/understanding-json-schema/reference/annotations json-schema annotations} 365 | */ 366 | meta(obj: MetaObject) { 367 | Object.entries(obj).forEach(([key, value]) => { 368 | if (key === 'examples') { 369 | return this.examples(value as never) 370 | } 371 | return this.custom(key, value); 372 | }); 373 | return this; 374 | } 375 | 376 | /** 377 | * Option `default` keywords throws exception during schema compilation when used in: 378 | * 379 | * - not in `properties` or `items` subschemas 380 | * - in schemas inside `anyOf`, `oneOf` and `not` ({@link https://github.com/ajv-validator/ajv/issues/42 #42}) 381 | * - in `if` schema 382 | * - in schemas generated by user-defined _macro_ keywords 383 | * This means only `object()` and `array()` buidlers are supported. 384 | * @see {@link object} 385 | * @see {@link array} 386 | * @example 387 | * import s from 'ajv-ts' 388 | * const Person = s.object({ 389 | * age: s.int().default(18) 390 | * }) 391 | * Person.parse({}) // { age: 18 } 392 | */ 393 | default(value: Output) { 394 | (this.schema as AnySchema).default = value; 395 | return this; 396 | } 397 | /** 398 | * Defines custom error message for invalid schema. 399 | * 400 | * Set `schema.errorMessage = message` under the hood. 401 | * @example 402 | * // number example 403 | * const numberSchema = s.number().error('Not a number') 404 | * numberSchema.parse('qwe') // error: Not a number 405 | */ 406 | error(messageOrOptions: string | ErrorMessageParams) { 407 | (this.schema as AnySchema).errorMessage = messageOrOptions; 408 | return this; 409 | } 410 | 411 | /** 412 | * set `description` for your schema. 413 | * You can use `meta` method to provide information in more consistant way. 414 | * @see {@link SchemaBuilder.meta meta} method 415 | * @satisfies `zod` API 416 | */ 417 | describe(message: string) { 418 | return this.meta({ description: message }); 419 | } 420 | 421 | /** 422 | * set `$async=true` for your current schema. 423 | * 424 | * @see {@link https://ajv.js.org/guide/async-validation.html ajv async validation} 425 | */ 426 | async() { 427 | (this.schema as Record).$async = true; 428 | return this; 429 | } 430 | 431 | /** 432 | * set `$async=false` for your current schema. 433 | * @param [remove=false] applies `delete` operator for `schema.$async` property. 434 | */ 435 | sync(remove: boolean = false) { 436 | (this.schema as AnySchema).$async = false; 437 | if (remove) { 438 | delete (this.schema as AnySchema).$async; 439 | } 440 | return this; 441 | } 442 | 443 | /** 444 | * Construct Array schema. Same as `s.array(s.number())` 445 | * 446 | * @see {@link array} 447 | */ 448 | array>(): ArraySchemaBuilder { 449 | return array(this) as never; 450 | } 451 | 452 | /** 453 | * Same as `s.and()`. Combine current type with another. Logical "AND" 454 | * 455 | * Typescript `A & B` 456 | */ 457 | intersection: typeof this.and = this.and; 458 | /** 459 | * Same as `s.and()`. Combine current type with another. Logical "AND" 460 | * 461 | * Typescript `A & B` 462 | */ 463 | and< 464 | S extends AnySchemaBuilder[] = AnySchemaBuilder[], 465 | Arr extends AnySchemaBuilder[] = [this, ...S], 466 | // @ts-ignore - IntersectionSchemaBuilder circular return itself 2577 467 | >(...others: S): IntersectionSchemaBuilder { 468 | return and(this, ...others) as never; 469 | } 470 | /** 471 | * Same as `s.or()`. Combine current type with another type. Logical "OR" 472 | * 473 | * Typescript: `A | B` 474 | */ 475 | or( 476 | ...others: S 477 | ): UnionSchemaBuilder<[this, ...S]> { 478 | return or(this, ...others); 479 | } 480 | /** 481 | * Same as `s.or()`. Combine current type with another type. Logical "OR" 482 | * 483 | * Typescript: `A | B` 484 | */ 485 | union: typeof this.or = this.or; 486 | 487 | /** 488 | * Exclude given subschema. 489 | * 490 | * Append `not` keyword for your schema 491 | * 492 | * @see {@link not} 493 | * @see {@link SchemaBuilder.not not method} 494 | * @example 495 | * cosnt res = s 496 | * .string<'Jerry' | 'Martin'>() 497 | * .exclude(s.const('Jerry')) 498 | * .schema // {type: "string", not: {const: "Jerry"} } 499 | * type Res = s.infer // 'Martin' 500 | */ 501 | exclude< 502 | S extends SchemaBuilder = SchemaBuilder, 503 | Excl = Exclude, 504 | This = this extends StringSchemaBuilder 505 | ? StringSchemaBuilder 506 | : this extends NumberSchemaBuilder 507 | ? NumberSchemaBuilder 508 | : this extends BooleanSchemaBuilder 509 | ? BooleanSchemaBuilder 510 | : this extends ArraySchemaBuilder 511 | ? ArraySchemaBuilder> 512 | : this extends ObjectSchemaBuilder< 513 | infer Def extends ObjectDefinition 514 | > 515 | ? ObjectSchemaBuilder> 516 | : this, 517 | >(s: S): This { 518 | (this.schema as AnySchema).not = s.schema; 519 | return this as never; 520 | } 521 | 522 | /** 523 | * Exclude self schema. 524 | * 525 | * Wrap your schema with `not` keyword 526 | * 527 | * `s.not(s.string())` === `s.string().not()` 528 | * 529 | * If you need to append `not` keyword instead of wrap you might need to use {@link SchemaBuilder.exclude `exclude`} method 530 | * 531 | * @see {@link not} 532 | * @see {@link SchemaBuilder.exclude exclude method} 533 | * 534 | * @example 535 | * // not string 536 | * s 537 | * .string() 538 | * .not() 539 | * .schema // {not: { type: "string" }}, 540 | */ 541 | not(): NotSchemaBuilder { 542 | return not(this) as never; 543 | } 544 | 545 | private _transform( 546 | input?: unknown, 547 | arr: ({ fn: Function; schema: AnySchemaBuilder } | Function)[] = [], 548 | ): SafeParseResult { 549 | let output; 550 | if (Array.isArray(arr) && arr.length > 0) { 551 | try { 552 | output = arr.reduce( 553 | (prevResult, el) => { 554 | if (!prevResult.success) { 555 | throw prevResult.error; 556 | } 557 | let fnTransform; 558 | let result: SafeParseResult = { 559 | data: input, 560 | input, 561 | success: true, 562 | }; 563 | if (typeof el === "function") { 564 | fnTransform = el(prevResult.data, this); 565 | result.data = fnTransform; 566 | } else { 567 | fnTransform = el.fn(prevResult.data, this); 568 | result = el.schema.safeParse(fnTransform); 569 | } 570 | return result; 571 | }, 572 | { input, data: input, success: true } as SafeParseResult, 573 | ); 574 | } catch (e) { 575 | return { 576 | success: false, 577 | error: new Error((e as Error).message, { cause: e }), 578 | input, 579 | }; 580 | } 581 | return output as SafeParseResult; 582 | } 583 | output = input; 584 | return { data: output as Out, input, success: true }; 585 | } 586 | 587 | private _safeParseRaw(input?: unknown): SafeParseResult { 588 | let success = false; 589 | try { 590 | const validateFn = this.ajv.compile(this.schema); 591 | success = validateFn(input); 592 | if (!success) { 593 | const firstError = validateFn.errors?.at(0); 594 | return { 595 | error: new Error(firstError?.message, { 596 | cause: validateFn.errors, 597 | }), 598 | success, 599 | input, 600 | }; 601 | } 602 | } catch (e) { 603 | return { 604 | error: new Error((e as Error).message, { cause: e }), 605 | success: success as false, 606 | input, 607 | }; 608 | } 609 | return { 610 | input, 611 | data: input, 612 | success, 613 | }; 614 | } 615 | 616 | /** 617 | * Parse you input result. Used `ajv.validate` under the hood 618 | * 619 | * It also applies your `postProcess` functions if parsing was successfull 620 | */ 621 | safeParse(input?: I): SafeParseResult { 622 | // need to remove schema, or we get precompiled result. It's bad for `extend` and `merge` in object schema 623 | // TODO: investigate merge and add in ajv 624 | // this.ajv.removeSchema(this.schema); 625 | let preTransformedResult = this._transform(input, this.preFns); 626 | preTransformedResult.input = input; 627 | if (!preTransformedResult.success) { 628 | return preTransformedResult as never; 629 | } 630 | 631 | const parseResult = this._safeParseRaw(preTransformedResult.data); 632 | parseResult.input = input; 633 | if (!parseResult.success) { 634 | return parseResult as never; 635 | } 636 | 637 | const postTransformedResult = this._transform( 638 | parseResult.data, 639 | this.postFns, 640 | ); 641 | postTransformedResult.input = input; 642 | if (!postTransformedResult.success) { 643 | return postTransformedResult; 644 | } 645 | if ( 646 | this.refineFns && 647 | Array.isArray(this.refineFns) && 648 | this.refineFns.length > 0 649 | ) { 650 | for (const refine of this.refineFns) { 651 | try { 652 | const res = refine(postTransformedResult.data); 653 | if (res !== undefined) { 654 | return { 655 | success: false, 656 | error: new Error(`refine error`, { 657 | cause: { 658 | refine: res, 659 | debug: { 660 | input, 661 | preTransformedResult, 662 | parseResult, 663 | postTransformedResult, 664 | }, 665 | }, 666 | }), 667 | input: postTransformedResult.data, 668 | }; 669 | } 670 | } catch (e) { 671 | return { 672 | success: false, 673 | error: e as Error, 674 | input: postTransformedResult.data, 675 | }; 676 | } 677 | } 678 | } 679 | return postTransformedResult; 680 | } 681 | /** 682 | * Validate your schema. 683 | * 684 | * @returns {boolean} Validity of your schema 685 | */ 686 | validate(input?: unknown): input is Output { 687 | const { success } = this.safeParse(input); 688 | return success; 689 | } 690 | 691 | /** 692 | * Parse input for given schema. 693 | * 694 | * @returns {Output} parsed output result. 695 | * @throws `Error` when input not match given schema 696 | */ 697 | parse< 698 | const I, 699 | >(input?: I): Output { 700 | const result = this.safeParse(input); 701 | if (!result.success) { 702 | throw result.error; 703 | } 704 | return result.data as never; 705 | } 706 | } 707 | 708 | type NumberSchemaOpts = SchemaBuilderOpts & { 709 | const?: number, 710 | type?: 'integer' | 'number', 711 | format?: NumberSchema["format"], 712 | minValue?: number, 713 | maxValue?: number, 714 | } 715 | class NumberSchemaBuilder< 716 | const N extends number = number, 717 | Opts extends NumberSchemaOpts = { 718 | _preProcesses: [], 719 | _postProcesses: [], 720 | const: undefined, 721 | type: 'number', 722 | format: undefined, 723 | minValue: undefined, 724 | maxValue: undefined, 725 | } 726 | > extends SchemaBuilder { 727 | constructor() { 728 | super({ type: "number" }); 729 | } 730 | 731 | /** 732 | * Define schema as `any number`. 733 | * Set schema `{type: 'number'}` 734 | * 735 | * @note This is default behavior 736 | */ 737 | number(): NumberSchemaBuilder & { 738 | type: 'number', 739 | }>> { 740 | this.schema.type = "number"; 741 | return this as never; 742 | } 743 | 744 | /** 745 | * The `const` keyword is used to restrict a value to a single value. 746 | * @example 747 | * const a = s.number().const(5) 748 | * a.schema // {type: "number", const: 5} 749 | * s.infer // 5 750 | */ 751 | const< 752 | const N extends number, 753 | TypeValid = Opts['type'] extends 'integer' ? IsInteger : 754 | Or, IsInteger>, 755 | FormatValid = Opts['format'] extends 'int32' ? IsInteger : 756 | Opts['format'] extends 'int64' ? IsInteger 757 | : Or, IsInteger>, 758 | ValueValid = Opts['maxValue'] extends number ? 759 | Opts['minValue'] extends number ? 760 | And, GreaterThanOrEqual> : LessThanOrEqual : true 761 | >(value: TypeValid extends true ? 762 | FormatValid extends true ? 763 | ValueValid extends true ? 764 | N 765 | : TTypeGenericError<`Constant cannot be more than "MaxValue" and less than "MinValue"`, [`MinValue:`, Opts['minValue'], 'MaxValue:', Opts['maxValue']]> 766 | : TTypeGenericError<`Format invalid. Expected ${Opts['format']}. Got "${N}" (${NumericStringifyType})`> 767 | : TTypeGenericError<`Type invalid. Expected ${Opts['type']}. Got "${N}" (${NumericStringifyType})`> 768 | ): NumberSchemaBuilder & { 769 | const: N, 770 | }>> { 771 | this.schema.const = value; 772 | return this as never; 773 | } 774 | 775 | /** 776 | * Define schema as `any integer number`. 777 | * Set schema `{type: 'integer'}` 778 | */ 779 | integer(): NumberSchemaBuilder & { 780 | type: 'integer', 781 | }>> { 782 | this.schema.type = "integer"; 783 | return this as never; 784 | } 785 | 786 | /** 787 | * Set schema `{type: 'integer'}`. Same as `integer` method 788 | * @see {@link integer integer} method 789 | */ 790 | int = this.integer 791 | 792 | 793 | /** 794 | * Appends format for your number schema. 795 | */ 796 | format< 797 | const Format extends 'int32' | 'double' | 'int64' | 'float', 798 | FormatValid = Opts['type'] extends 'integer' ? 799 | Format extends 'int32' ? true : 800 | Format extends 'int64' ? true 801 | // type=int. Format float or doouble 802 | : false 803 | // Rest 804 | : true 805 | >(format: FormatValid extends true ? 806 | Format : 807 | TTypeGenericError<`Wrong format for given type. Expected "int32" or "int64". Given: "${Format}"`> 808 | ): NumberSchemaBuilder & { 809 | format: Format, 810 | }>> { 811 | this.schema.format = format as Format; 812 | return this as never; 813 | } 814 | 815 | /** Getter. Retuns `minimum` or `exclusiveMinimum` depends on your schema definition */ 816 | get minValue(): Opts['minValue'] extends number ? Opts['minValue'] : number { 817 | return this.schema.minimum ?? (this.schema.exclusiveMinimum as number); 818 | } 819 | 820 | /** Getter. Retuns `maximum` or `exclusiveMaximum` depends on your schema definition */ 821 | get maxValue(): Opts['maxValue'] extends number ? Opts['maxValue'] : number { 822 | return this.schema.maximum ?? (this.schema.exclusiveMaximum as number); 823 | } 824 | 825 | min = this.minimum; 826 | /** 827 | * Provides minimum value 828 | * 829 | * Set schema `minimum = value` (and add `exclusiveMinimum = true` if needed) 830 | * @example 831 | * s.number().min(2, true) // > 2 832 | * s.number().min(2) // >= 2 833 | */ 834 | minimum< 835 | const Min extends number = number, 836 | Exclusive extends boolean = false, 837 | MinLengthValid = Opts['maxValue'] extends number ? GreaterThan : true, 838 | TypeValid = Opts['type'] extends 'integer' ? IsInteger : 839 | Or, IsInteger>, 840 | FormatValid = Opts['format'] extends undefined ? true : Opts['format'] extends 'int32' ? IsInteger : 841 | Opts['format'] extends 'int64' ? IsInteger 842 | : Or, IsInteger>, 843 | >( 844 | value: MinLengthValid extends true ? 845 | TypeValid extends true ? 846 | FormatValid extends true ? 847 | Min 848 | : TTypeGenericError<`Format invalid. Expected ${Opts['format']}. Got "${Min}" (${NumericStringifyType})`> 849 | : TTypeGenericError<`Type invalid. Expected ${Opts['type']}. Got "${Min}" (${NumericStringifyType})`> 850 | : TTypeGenericError<`"MaxValue" is less than "MinValue"`, ['MaxValue:', Opts['maxValue'], "MinValue:", Min]>, 851 | exclusive = false as Exclusive 852 | ): NumberSchemaBuilder & { 853 | minValue: Min, 854 | }> 855 | > { 856 | if (exclusive) { 857 | this.schema.exclusiveMinimum = value as Min; 858 | } else { 859 | this.schema.minimum = value as Min; 860 | } 861 | return this as never; 862 | } 863 | 864 | step = this.multipleOf; 865 | 866 | /** 867 | * Numbers can be restricted to a multiple of a given number, using the `multipleOf` keyword. 868 | * It may be set to any positive number. Same as `step`. 869 | * 870 | * **NOTE**: Since JSON schema odes not allow to use `multipleOf` with negative value - we use `Math.abs` to transform negative values into positive 871 | * @see {@link step step} method 872 | * @example 873 | * const a = s.number().multipleOf(10) 874 | * 875 | * a.parse(10) // ok 876 | * a.parse(9) // error 877 | * 878 | * const b = s.number().multipleOf(-0.1) 879 | * b.parse(1.1) // ok, step is `0.1` 880 | * b.parse(1) // error, step is not `0.1` 881 | */ 882 | multipleOf(value: number) { 883 | this.schema.multipleOf = Math.abs(value); 884 | return this; 885 | } 886 | 887 | max = this.maximum; 888 | /** 889 | * marks you number maximum value 890 | */ 891 | maximum< 892 | const Max extends number = number, 893 | Exclusive extends boolean = false, 894 | FormatValid = Opts['format'] extends undefined ? true : IsInteger extends true ? Opts['format'] extends 'int32' ? true : Opts['format'] extends 'int64' ? true : false : true, 895 | TypeValid = Opts['type'] extends 'integer' ? IsInteger : 896 | Or, IsInteger>, 897 | MinLengthValid = Opts['minValue'] extends number ? LessThan : true, 898 | >( 899 | value: MinLengthValid extends true ? 900 | TypeValid extends true ? 901 | FormatValid extends true ? 902 | Max 903 | : TTypeGenericError<`Format invalid. Expected ${Opts['format']}. Got "${Max}" (${NumericStringifyType})`> 904 | : TTypeGenericError<`Type invalid. Expected ${Opts['type']}. Got "${Max}" (${NumericStringifyType})`> 905 | : TTypeGenericError<`"MinValue" greater than "MaxValue"`, ['MinValue:', Opts['minValue'], 'MaxValue:', Max]>, 906 | exclusive = false as Exclusive 907 | ): NumberSchemaBuilder< 908 | N, Prettify & { 909 | maxValue: Max, 910 | } 911 | >> { 912 | if (exclusive) { 913 | this.schema.exclusiveMaximum = value as Max; 914 | } else { 915 | this.schema.maximum = value as Max; 916 | } 917 | return this as never; 918 | } 919 | /** 920 | * Greater than 921 | * 922 | * Range: `(value; Infinity)` 923 | * @see {@link maximum} method 924 | * @see {@link gte} method 925 | */ 926 | gt(value: V): NumberSchemaBuilder & { 928 | minValue: V, 929 | }>> { 930 | return this.minimum(value as never, true); 931 | } 932 | /** 933 | * Greater than or equal 934 | * 935 | * Range: `[value; Infinity)` 936 | * @see {@link maximum maximum} 937 | * @see {@link gt gt} 938 | */ 939 | gte(value: V): NumberSchemaBuilder & { 941 | minValue: V, 942 | }>> { 943 | return this.minimum(value as never); 944 | } 945 | 946 | /** 947 | * Less than 948 | * 949 | * Range: `(value; Infinity)` 950 | * @see {@link minimum minimum} method 951 | * @see {@link lte lte} method 952 | */ 953 | lt(value: V): NumberSchemaBuilder< 954 | N, Prettify & { 955 | maxValue: V, 956 | } 957 | >> { 958 | return this.max(value as never, true); 959 | } 960 | /** 961 | * Less than or Equal 962 | * 963 | * Range: `[value; Infinity)` 964 | * @see {@link minimum} method 965 | * @see {@link lt} method 966 | */ 967 | lte(value: V): NumberSchemaBuilder & { 969 | maxValue: V, 970 | }>> { 971 | return this.max(value as never); 972 | } 973 | /** Any positive number (greater than `0`) 974 | * Range: `(0; Infinity)` 975 | */ 976 | positive() { 977 | return this.gt(0); 978 | } 979 | /** Any non negative number (greater than or equal `0`) 980 | * 981 | * Range: `[0; Inifnity)` 982 | */ 983 | nonnegative() { 984 | return this.gte(0); 985 | } 986 | /** Any negative number (less than `0`) 987 | * 988 | * Range: `(Inifinity; 0)` 989 | */ 990 | negative() { 991 | return this.lt(0); 992 | } 993 | /** Any non postive number (less than or equal `0`) 994 | * 995 | * Range: `(Inifinity; 0]` 996 | */ 997 | nonpositive() { 998 | return this.lte(0); 999 | } 1000 | /** Marks incoming number between `MAX_SAFE_INTEGER` and `MIN_SAFE_INTEGER` */ 1001 | safe() { 1002 | return this.lte(Number.MAX_SAFE_INTEGER as 9007199254740991).gte(Number.MIN_SAFE_INTEGER as -9007199254740991); 1003 | } 1004 | } 1005 | /** 1006 | * Construct `number` schema. 1007 | * 1008 | * **NOTE:** By default Ajv fails `{"type": "number"}` (or `"integer"`) 1009 | * validation for `Infinity` and `NaN`. 1010 | * 1011 | * @example 1012 | * const test1 = s.number() 1013 | * 1014 | * test1.parse(1) // ok 1015 | * test1.parse('qwe') // error 1016 | */ 1017 | function number() { 1018 | return new NumberSchemaBuilder(); 1019 | } 1020 | 1021 | /** 1022 | * construct `integer` schema. 1023 | * 1024 | * Same as `s.number().integer()` 1025 | * 1026 | * **NOTE:** By default Ajv fails `{"type": "integer"}` validation for `Infinity` and `NaN`. 1027 | */ 1028 | function integer() { 1029 | return new NumberSchemaBuilder().integer(); 1030 | } 1031 | 1032 | export type StringBuilderOpts = { 1033 | minLength?: number 1034 | maxLength?: number, 1035 | pattern?: string | RegExp 1036 | } 1037 | class StringSchemaBuilder< 1038 | const S extends string = string, 1039 | Opts extends StringBuilderOpts & SchemaBuilderOpts = { 1040 | minLength: undefined, 1041 | maxLength: undefined, 1042 | _preProcesses: [], 1043 | _postProcesses: [], 1044 | }, 1045 | > extends SchemaBuilder { 1046 | /** DO not use. This is typescript type */ 1047 | _opts!: Opts 1048 | /** 1049 | * The `pattern` use regular expressions to express constraints. 1050 | * The regular expression syntax used is from JavaScript ({@link https://www.ecma-international.org/publications-and-standards/standards/ecma-262/ ECMA 262}, specifically). 1051 | * However, that complete syntax is not widely supported, therefore it is recommended that you stick to the subset of that syntax described below. 1052 | * 1053 | * - A single unicode character (other than the special characters below) matches itself. 1054 | * - `.`: Matches any character except line break characters. (Be aware that what constitutes a line break character is somewhat dependent on your platform and language environment, but in practice this rarely matters). 1055 | * - `^`: Matches only at the beginning of the string. 1056 | * - `$`: Matches only at the end of the string. 1057 | * - `(...)`: Group a series of regular expressions into a single regular expression. 1058 | * - `|`: Matches either the regular expression preceding or following the | symbol. 1059 | * - `[abc]`: Matches any of the characters inside the square brackets. 1060 | * - `[a-z]`: Matches the range of characters. 1061 | * - `[^abc]`: Matches any character not listed. 1062 | * - `[^a-z]`: Matches any character outside of the range. 1063 | * - `+`: Matches one or more repetitions of the preceding regular expression. 1064 | * - `*`: Matches zero or more repetitions of the preceding regular expression. 1065 | * - `?`: Matches zero or one repetitions of the preceding regular expression. 1066 | * - `+?`, `*?`, `??`: The *, +, and ? qualifiers are all greedy; they match as much text as possible. Sometimes this behavior isn't desired and you want to match as few characters as possible. 1067 | * - `(?!x)`, `(?=x)`: Negative and positive lookahead. 1068 | * - `{x}`: Match exactly x occurrences of the preceding regular expression. 1069 | * - `{x,y}`: Match at least x and at most y occurrences of the preceding regular expression. 1070 | * - `{x,}`: Match x occurrences or more of the preceding regular expression. 1071 | * - `{x}?`, `{x,y}?`, `{x,}?`: Lazy versions of the above expressions. 1072 | * @example 1073 | * const phoneNumber = s.string().pattern("^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$") 1074 | * 1075 | * phoneNumber.parse("555-1212") // OK 1076 | * phoneNumber.parse("(888)555-1212") // OK 1077 | * phoneNumber.parse("(888)555-1212 ext. 532") // Error 1078 | * phoneNumber.parse("(800)FLOWERS") // Error 1079 | * // typescript custom type 1080 | * const prefixS = s.string().pattern<`S_${string}`>("^S_$") 1081 | * type S = s.infer // `S_${string}` 1082 | * const str1 = prefixS.parse("qwe") // Error 1083 | * const str2 = prefixS.parse("S_Some") // OK 1084 | */ 1085 | pattern( 1086 | pattern: In, 1087 | ): StringSchemaBuilder> { 1088 | if (typeof pattern === 'string') { 1089 | this.schema.pattern = pattern; 1090 | } else if (pattern instanceof RegExp) { 1091 | this.schema.pattern = pattern.source 1092 | } 1093 | return this as never; 1094 | } 1095 | 1096 | constructor() { 1097 | super({ type: "string" }); 1098 | } 1099 | 1100 | const(value: V): StringSchemaBuilder { 1101 | this.schema.const = value; 1102 | return this as never; 1103 | } 1104 | 1105 | /** 1106 | * Define minimum string length. 1107 | * 1108 | * Same as `min` method 1109 | * @see {@link min min} method 1110 | */ 1111 | minLength< 1112 | const L extends number, 1113 | Valid = IsPositiveInteger, 1114 | MinLengthValid = GreaterThan, 1115 | >( 1116 | value: Valid extends true 1117 | ? Opts['maxLength'] extends undefined 1118 | ? L 1119 | : MinLengthValid extends true ? L : TRangeGenericError<`MinLength are greater than MaxLength. MinLength: ${L}. MaxLength: ${Opts['maxLength']}`> 1120 | : TTypeGenericError< 1121 | `Only Positive and non floating numbers are supported. Received: '${L}'` 1122 | > 1123 | ): StringSchemaBuilder { 1124 | this.schema.minLength = value as L; 1125 | return this as never; 1126 | } 1127 | /** 1128 | * Define minimum string length. 1129 | * 1130 | * Same as `minLength` 1131 | * @see {@link StringSchemaBuilder.minLength minLength} 1132 | */ 1133 | min = this.minLength; 1134 | 1135 | /** 1136 | * Define maximum string length. 1137 | * 1138 | * Same as `max` 1139 | * @see {@link max max} method 1140 | */ 1141 | maxLength< 1142 | const L extends number, 1143 | Valid = IsPositiveInteger, 1144 | MinLengthValid = GreaterThan>( 1145 | value: Valid extends true 1146 | ? Opts['minLength'] extends undefined ? L 1147 | : MinLengthValid extends true ? L 1148 | : TRangeGenericError<`MinLength are greater than MaxLength. MinLength: ${Opts['minLength']}. MaxLength: ${L}`> 1149 | : TTypeGenericError<`Expected positive integer. Received: '${L}'`>, 1150 | ): StringSchemaBuilder { 1151 | this.schema.maxLength = value as L; 1152 | return this as never; 1153 | } 1154 | /** 1155 | * Define maximum string length. 1156 | * 1157 | * Same as `maxLength` 1158 | * @see {@link StringSchemaBuilder.maxLength maxLength} 1159 | */ 1160 | max = this.maxLength; 1161 | 1162 | /** 1163 | * Define exact string length 1164 | * 1165 | * Same as `s.string().min(v).max(v)` 1166 | * 1167 | * @see {@link StringSchemaBuilder.minLength minLength} 1168 | * @see {@link StringSchemaBuilder.maxLength maxLength} 1169 | * @example 1170 | * const exactStr = s.string().length(3) 1171 | * exactStr.parse('qwe') //Ok 1172 | * exactStr.parse('qwer') // Error 1173 | * exactStr.parse('go') // Error 1174 | */ 1175 | length< 1176 | const L extends number, 1177 | Valid = IsPositiveInteger>( 1178 | value: Valid extends true 1179 | ? L 1180 | : TTypeGenericError< 1181 | `Expected positive integer. Received: '${L}'` 1182 | >, 1183 | ): StringSchemaBuilder { 1184 | return this.maxLength(value as never).minLength(value as never) as never; 1185 | } 1186 | /** 1187 | * Define non empty string. Same as `minLength(1)` 1188 | */ 1189 | nonEmpty(): StringSchemaBuilder { 1190 | return this.minLength<1>(1 as never); 1191 | } 1192 | 1193 | /** 1194 | * A string is valid against this format if it represents a valid e-mail address format. 1195 | * 1196 | * Example: `some@gmail.com` 1197 | */ 1198 | email(): OmitMany< 1199 | StringSchemaBuilder, 1200 | [ 1201 | "format", 1202 | "ipv4", 1203 | "ipv6", 1204 | "time", 1205 | "date", 1206 | "dateTime", 1207 | "regex", 1208 | "uuid", 1209 | "email", 1210 | ] 1211 | > { 1212 | return this.format("email") as never; 1213 | } 1214 | ipv4() { 1215 | return this.format("ipv4"); 1216 | } 1217 | ipv6() { 1218 | return this.format("ipv6"); 1219 | } 1220 | /** 1221 | * A Universally Unique Identifier as defined by {@link https://datatracker.ietf.org/doc/html/rfc4122 RFC 4122}. 1222 | * 1223 | * Same as `s.string().format('uuid')` 1224 | * 1225 | * Example: `3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a` 1226 | */ 1227 | uuid(): OmitMany< 1228 | StringSchemaBuilder, 1229 | [ 1230 | "format", 1231 | "ipv4", 1232 | "ipv6", 1233 | "time", 1234 | "date", 1235 | "dateTime", 1236 | "regex", 1237 | "uuid", 1238 | "email", 1239 | ] 1240 | > { 1241 | return this.format("uuid") as never; 1242 | } 1243 | /** 1244 | * A string is valid against this format if it represents a time in the following format: `hh:mm:ss.sTZD`. 1245 | * 1246 | * Same as `s.string().format('time')` 1247 | * 1248 | * Example: `20:20:39+00:00` 1249 | */ 1250 | time() { 1251 | return this.format("time"); 1252 | } 1253 | /** 1254 | * A string is valid against this format if it represents a date in the following format: `YYYY-MM-DD`. 1255 | * 1256 | * Same as `s.string().format('date')` 1257 | * 1258 | * Example: `2023-10-10` 1259 | */ 1260 | date() { 1261 | return this.format("date"); 1262 | } 1263 | 1264 | /** 1265 | * A string is valid against this format if it represents a date-time in the following format: `YYYY:MM::DDThh:mm:ss.sTZD`. 1266 | * 1267 | * Same as `s.string().format('date-time')` 1268 | * 1269 | * Example: `2023-10-05T05:49:37.757Z` 1270 | */ 1271 | dateTime() { 1272 | return this.format("date-time"); 1273 | } 1274 | 1275 | /** 1276 | * A string is valid against this format if it represents a valid regular expression. 1277 | * 1278 | * Same as `s.string().format('regex')` 1279 | */ 1280 | regex() { 1281 | return this.format("regex"); 1282 | } 1283 | 1284 | format(formatType: StringSchema["format"]): this { 1285 | this.schema.format = formatType; 1286 | return this; 1287 | } 1288 | // override parse< 1289 | // const I extends string, 1290 | // Matches = I extends S ? true : false, 1291 | // ValidMinLength = GreaterThanOrEqual, Opts['minLength'] extends number ? Opts['minLength'] : number> 1292 | // >(input?: Matches extends true ? 1293 | // ValidMinLength extends true 1294 | // ? I 1295 | // : TRangeGenericError<`Incoming parameter not matches MinLength requirement. Got '${I}'. MinLength: ${Opts['minLength']}`> 1296 | // : TTypeGenericError<`Incoming type '${I}' not mathes to expected '${S}'`> 1297 | // ): S { 1298 | // return super.parse(input) as never 1299 | // } 1300 | } 1301 | 1302 | /** 1303 | * Construct `string` schema 1304 | */ 1305 | function string() { 1306 | return new StringSchemaBuilder(); 1307 | } 1308 | 1309 | class BooleanSchemaBuilder< 1310 | const B extends boolean = boolean, 1311 | > extends SchemaBuilder { 1312 | constructor() { 1313 | super({ type: "boolean" }); 1314 | } 1315 | } 1316 | /** 1317 | * Construct `boolean` schema 1318 | */ 1319 | function boolean() { 1320 | return new BooleanSchemaBuilder(); 1321 | } 1322 | 1323 | class NullSchemaBuilder extends SchemaBuilder { 1324 | constructor() { 1325 | super({ type: "null" }); 1326 | } 1327 | } 1328 | function nil() { 1329 | return new NullSchemaBuilder(); 1330 | } 1331 | 1332 | export type ObjectDefinition = { [key: string]: SchemaBuilder }; 1333 | type Merge = Omit & S; 1334 | class ObjectSchemaBuilder< 1335 | Definition extends ObjectDefinition = ObjectDefinition, 1336 | T = { [K in keyof Definition]: Infer }, 1337 | Out = ObjectTypes.OptionalUndefined, 1338 | > extends SchemaBuilder { 1339 | protected def: Definition = {} as Definition; 1340 | constructor(def?: Definition) { 1341 | super({ 1342 | type: "object", 1343 | properties: {}, 1344 | }); 1345 | if (def) { 1346 | Object.entries(def).forEach(([key, d]) => { 1347 | this.schema.properties![key] = d.schema; 1348 | }); 1349 | this.def = def; 1350 | } 1351 | } 1352 | 1353 | /** 1354 | * set `additionalProperties=true` for your JSON-schema. 1355 | * 1356 | * Opposite of `strict` 1357 | * @see {@link ObjectSchemaBuilder.strict strict} 1358 | */ 1359 | passthrough(): ObjectSchemaBuilder< 1360 | Definition, 1361 | T & ObjectTypes.IndexType, 1362 | ObjectTypes.IndexType 1363 | > { 1364 | this.schema.additionalProperties = true; 1365 | return this as never; 1366 | } 1367 | /** 1368 | * Makes all properties partial(not required) 1369 | */ 1370 | partial(): ObjectSchemaBuilder< 1371 | Definition, 1372 | Partial, 1373 | ObjectTypes.OptionalUndefined> 1374 | > { 1375 | this.schema.required = []; 1376 | return this as never; 1377 | } 1378 | 1379 | /** 1380 | * Makes selected properties partial(not required), rest of them are not changed. 1381 | * 1382 | * Same as for as for `requiredFor('item1').requiredFor('item2')...etc` 1383 | * 1384 | * @example 1385 | * const Test = s.object({ 1386 | * name: s.string(), 1387 | * email: s.string(), 1388 | * }) 1389 | * .required() 1390 | * .partialFor('email') 1391 | * 1392 | * Test.schema === { 1393 | * type: 'object', 1394 | * properties: { 1395 | * "name": {type: 'string'}, 1396 | * "email": {type: 'string'} 1397 | * } 1398 | * "required": ['name'] 1399 | * } 1400 | */ 1401 | partialFor( 1402 | key: Key, 1403 | ): ObjectSchemaBuilder> { 1404 | const required = this.schema.required ?? ([] as string[]); 1405 | const findedIndex = required.indexOf(key as string); 1406 | // remove element from array. e.g. "email" for ['name', 'email'] => ['name'] 1407 | // opposite of push 1408 | if (findedIndex !== -1) { 1409 | (this.schema.required as string[]).splice(findedIndex, 1); 1410 | } 1411 | return this as never; 1412 | } 1413 | 1414 | /** 1415 | * The `dependentRequired` keyword conditionally requires that 1416 | * certain properties must be present if a given property is 1417 | * present in an object. For example, suppose we have a schema 1418 | * representing a customer. If you have their credit card number, 1419 | * you also want to ensure you have a billing address. 1420 | * If you don't have their credit card number, a billing address 1421 | * would not be required. We represent this dependency of one property 1422 | * on another using the `dependentRequired` keyword. 1423 | * The value of the `dependentRequired` keyword is an object. 1424 | * Each entry in the object maps from the name of a property, p, 1425 | * to an array of strings listing properties that are required 1426 | * if p is present. 1427 | * 1428 | * In the following example,whenever a `credit_card` property is provided, 1429 | * a `billing_address` property must also be present: 1430 | * @example 1431 | * const Test1 = s.object({ 1432 | * name: s.string(), 1433 | * credit_card: s.number(), 1434 | * billing_address: s.string(), 1435 | * }).requiredFor('name').dependentRequired({ 1436 | * credit_card: ['billing_address'], 1437 | * }) 1438 | * Test1.schema === { 1439 | * "type": "object", 1440 | * "properties": { 1441 | * "name": { "type": "string" }, 1442 | * "credit_card": { "type": "number" }, 1443 | * "billing_address": { "type": "string" } 1444 | * }, 1445 | * "required": ["name"], 1446 | * "dependentRequired": { 1447 | * "credit_card": ["billing_address"] 1448 | * } 1449 | * } 1450 | */ 1451 | dependentRequired[] }>( 1452 | dependencies: Deps, 1453 | ): ObjectSchemaBuilder< 1454 | Definition, 1455 | ObjectTypes.OptionalByKey< 1456 | T, 1457 | ObjectTypes.InferKeys extends keyof T 1458 | ? ObjectTypes.InferKeys 1459 | : keyof T 1460 | > 1461 | > { 1462 | this.schema.dependentRequired = dependencies as never; 1463 | return this as never; 1464 | } 1465 | 1466 | /** 1467 | * Disallow additional properties for object schema `additionalProperties=false` 1468 | * 1469 | * If you would like to define additional properties type - use `additionalProperties` 1470 | * @see {@link ObjectSchemaBuilder.additionalProperties additionalProperties} 1471 | * 1472 | * If you would like to mark properties required - use `required` or `requiredFor` 1473 | * @see {@link ObjectSchemaBuilder.required required} 1474 | * @see {@link ObjectSchemaBuilder.requiredFor requiredFor} 1475 | */ 1476 | strict() { 1477 | this.schema.additionalProperties = false; 1478 | return this; 1479 | } 1480 | 1481 | /** 1482 | * Makes 1 property required, other keys are not required. 1483 | * 1484 | * If some properties is already marked with `requiredFor` - we append new key into `required` JSON schema 1485 | */ 1486 | requiredFor( 1487 | ...keys: Key[] 1488 | ): ObjectSchemaBuilder< 1489 | Definition, 1490 | ObjectTypes.RequiredByKeys 1491 | > { 1492 | this.schema.required = [ 1493 | ...new Set([...(this.schema.required ?? []), ...(keys as string[])]), 1494 | ]; 1495 | return this as never; 1496 | } 1497 | 1498 | /** 1499 | * Make **ALL** properties in your object required. 1500 | * 1501 | * If you need to make few required properties (1 or more, not everything fields) - use `requiredFor` 1502 | * @see {@link ObjectSchemaBuilder.requiredFor requiredFor} 1503 | */ 1504 | required(): ObjectSchemaBuilder> { 1505 | const allProperties = Object.keys(this.schema.properties!); 1506 | // keep unique only 1507 | this.schema.required = [ 1508 | ...new Set([...(this.schema.required ?? []), ...allProperties]), 1509 | ]; 1510 | return this as never; 1511 | } 1512 | 1513 | /** 1514 | * Define schema for additional properties 1515 | * 1516 | * If you need to make `additionalProperties=false` use `strict` method instead 1517 | * 1518 | * @see {@link ObjectSchemaBuilder.strict strict} 1519 | */ 1520 | rest( 1521 | def: S, 1522 | ): ObjectSchemaBuilder< 1523 | Definition & S, 1524 | T & { [K in string]: Infer }, 1525 | T & { [K in string]: Infer } 1526 | > { 1527 | this.schema.additionalProperties = def.schema; 1528 | return this as never; 1529 | } 1530 | 1531 | /** 1532 | * Merge current object with another object definition 1533 | * @example 1534 | * const a = s.object({num: s.number()}) 1535 | * const b = s.object({str: s.string()}) 1536 | * const c = a.merge(b) 1537 | * type C = s.infer // {num: number; str: string} 1538 | */ 1539 | merge< 1540 | Def extends ObjectDefinition = ObjectDefinition, 1541 | ObjSchema extends ObjectSchemaBuilder = ObjectSchemaBuilder, 1542 | >( 1543 | schema: ObjSchema, 1544 | ): ObjectSchemaBuilder< 1545 | Merge, 1546 | Merge 1547 | > { 1548 | if (schema.schema.type !== 'object') { 1549 | throw new TypeError('Cannot merge not object type with object', { 1550 | cause: { 1551 | incoming: schema.schema, 1552 | given: this.schema, 1553 | }, 1554 | }) 1555 | } 1556 | const a = object(); 1557 | a.schema = Object.assign({}, this.schema); 1558 | Object.entries(schema.def).forEach(([key, def]) => { 1559 | a.schema.properties![key] = def.schema; 1560 | }); 1561 | a.def = { ...this.def, ...schema.def } 1562 | return a as never; 1563 | } 1564 | /** 1565 | * Same as `merge`, but not accepts `s.object`. 1566 | * @example 1567 | * const a = s.object({num: s.number()}) 1568 | * const c = a.extend({str: s.string()}) 1569 | * type C = s.infer // {num: number; str: string} 1570 | */ 1571 | extend( 1572 | def: ObjDef, 1573 | ): ObjectSchemaBuilder> { 1574 | const a = object(); 1575 | a.schema = Object.assign({}, this.schema); 1576 | Object.entries(def).forEach(([key, def]) => { 1577 | a.schema.properties![key] = def.schema; 1578 | }); 1579 | a.def = { ...this.def, ...def } 1580 | return a as never; 1581 | } 1582 | 1583 | /** 1584 | * Mark object as `readOnly`. It mostly decoration for typescript. 1585 | * 1586 | * Set `schema.readOnly=true`. 1587 | * @see {@link https://json-schema.org/draft-07/json-schema-validation#rfc.section.10.3 JSON-schema - readOnly keyword} 1588 | */ 1589 | readonly(): ObjectSchemaBuilder> { 1590 | this.schema.readOnly = true; 1591 | return this as never; 1592 | } 1593 | 1594 | /** 1595 | * Inspired by TypeScript's built-in `Pick` and `Omit` utility types, 1596 | * all object schemas have `.pick` and `.omit` methods that return a modified version. 1597 | * Consider this Recipe schema: 1598 | * @example 1599 | * const Recipe = z.object({ 1600 | * id: z.string(), 1601 | * name: z.string(), 1602 | * ingredients: z.array(z.string()), 1603 | * }); 1604 | * const JustTheNameAndId = Recipe.pick('name', 'id'); 1605 | * type JustTheName = s.infer; 1606 | * // => { name: string, id: string } 1607 | */ 1608 | pick( 1609 | ...keys: Keys 1610 | ): ObjectSchemaBuilder> { 1611 | const picked: Record = {}; 1612 | Object.entries(this.def).forEach(([k, def]) => { 1613 | const finded = keys.find((key) => key === k); 1614 | if (finded) { 1615 | picked[k] = def; 1616 | } 1617 | }); 1618 | return new ObjectSchemaBuilder(picked) as never; 1619 | } 1620 | /** 1621 | * Inspired by TypeScript's built-in `Pick` and `Omit` utility types, 1622 | * all object schemas have `.pick` and `.omit` methods that return a modified version. 1623 | * Consider this Recipe schema: 1624 | * @example 1625 | * const Recipe = s.object({ 1626 | * id: s.string(), 1627 | * name: s.string(), 1628 | * ingredients: s.array(s.string()), 1629 | * }); 1630 | * const JustTheName = Recipe.omit('name'); 1631 | * type JustTheName = s.infer; 1632 | * // => { id: string; ingredients: string[] } 1633 | */ 1634 | omit( 1635 | ...keys: Keys 1636 | ): ObjectSchemaBuilder> { 1637 | keys.forEach((k) => { 1638 | delete (this.def as any)[k]; 1639 | }); 1640 | return new ObjectSchemaBuilder(this.def) as never; 1641 | } 1642 | 1643 | /** 1644 | * Use `.keyof` to create a `EnumSchema` from the keys of an object schema. 1645 | * @example 1646 | * const Dog = z.object({ 1647 | * name: z.string(), 1648 | * age: z.number(), 1649 | * }); 1650 | * const keySchema = Dog.keyof(); 1651 | * keySchema; // Enum<["name", "age"]> 1652 | */ 1653 | keyof>() { 1654 | return makeEnum>( 1655 | Object.keys(this.def) as Key[], 1656 | ); 1657 | } 1658 | } 1659 | /** 1660 | * Create `object` schema. 1661 | * 1662 | * JSON schema: `{type: 'object', properties: {}}` 1663 | * 1664 | * You can pass you object type to get typescript validation 1665 | * @example 1666 | * import s from 'ajv-ts' 1667 | * type User = { 1668 | * name: string; 1669 | * age: number; 1670 | * }; 1671 | * const UserSchema = s.object({}) // typescript error: expect type User, got {} 1672 | */ 1673 | function object< 1674 | ObjType extends { 1675 | [key: string]: 1676 | | string 1677 | | number 1678 | | boolean 1679 | | null 1680 | | undefined 1681 | | readonly unknown[] 1682 | | object; 1683 | } = {}, 1684 | Definitions extends ObjectDefinition = { 1685 | [K in keyof ObjType]: MatchTypeToBuilder extends SchemaBuilder ? MatchTypeToBuilder : never; 1686 | }, 1687 | >(def?: Definitions) { 1688 | return new ObjectSchemaBuilder(def); 1689 | } 1690 | 1691 | class RecordSchemaBuilder< 1692 | ValueDef extends AnySchemaBuilder = AnySchemaBuilder, 1693 | > extends SchemaBuilder>, ObjectSchema> { 1694 | constructor(def?: ValueDef) { 1695 | super({ 1696 | type: "object", 1697 | additionalProperties: {} as AnySchemaOrAnnotation, 1698 | }); 1699 | if (def) { 1700 | this.schema.additionalProperties = def.schema; 1701 | } 1702 | } 1703 | } 1704 | 1705 | /** 1706 | * Same as `object` but less strict for properties. 1707 | * 1708 | * Same as `object().passthrough()` 1709 | * @see {@link object} 1710 | */ 1711 | function record(valueDef?: Def) { 1712 | return new RecordSchemaBuilder(valueDef); 1713 | } 1714 | 1715 | export type ArrayShemaOpts = { 1716 | minLength?: number, 1717 | maxLength?: number, 1718 | prefix?: any[], 1719 | } 1720 | type GetArrayOrEmpty = T extends readonly unknown[] ? T : [] 1721 | class ArraySchemaBuilder< 1722 | El = undefined, 1723 | Arr extends readonly unknown[] = El[], 1724 | S extends AnySchemaBuilder = SchemaBuilder, 1725 | Opts extends ArrayShemaOpts = { prefix: [], minLength: undefined, maxLength: undefined } 1726 | > extends SchemaBuilder, ...Arr]> { 1727 | constructor(definition?: S) { 1728 | super({ type: "array", items: definition?.schema ?? {}, minItems: 0 }); 1729 | } 1730 | 1731 | /** 1732 | * Make your array `readonly`. 1733 | * 1734 | * Set in JSON schema `unevaluatedItems=false`. 1735 | */ 1736 | readonly(): ArraySchemaBuilder, S, Opts> { 1737 | this.schema.unevaluatedItems = false; 1738 | return this; 1739 | } 1740 | 1741 | /** 1742 | * set `prefixItems` in your schema. 1743 | * 1744 | * For better DX - we mark main element schema as `element`. 1745 | */ 1746 | prefix( 1747 | ...definitions: Pref 1748 | ): ArraySchemaBuilder, 1750 | maxLength: Opts['maxLength'], 1751 | minLength: Opts['minLength'] 1752 | }> { 1753 | this.schema.prefixItems = definitions.map((def) => def.schema); 1754 | return this as never; 1755 | } 1756 | 1757 | /** 1758 | * Append subschema for current array schema. 1759 | * 1760 | * If your schema contains 1 element - this method will transform to array. 1761 | * 1762 | * **NOTE:** if your schema defined with `items: false` - `boolean` value will be replaced to incoming schema. 1763 | * 1764 | * @example 1765 | * import s from 'ajv-ts' 1766 | * const arr = s 1767 | * .array(s.string()) // schema = { type: 'array', items: {type: 'string'} } 1768 | * .addItems(s.number(), s.boolean()) 1769 | * arr.schema // {type: 'array', items: [{type: 'string'}, {type: 'number'}, {type: 'boolean'}] } 1770 | */ 1771 | addItems< 1772 | Schema extends AnySchemaBuilder, 1773 | Schemas extends SchemaBuilder[] = Schema[], 1774 | >( 1775 | ...s: Schemas 1776 | ): ArraySchemaBuilder< 1777 | El | Infer, 1778 | [...Arr, ...InferArray], 1779 | S, 1780 | Opts 1781 | > { 1782 | if (Array.isArray(this.schema.items)) { 1783 | this.schema.items.push(...s.map((el) => el.schema)); 1784 | } else if (typeof this.schema.items === "object") { 1785 | const prev = this.schema.items; 1786 | const isEmptyObject = 1787 | Object.keys(prev).length === 0 && prev.constructor === Object; 1788 | if (isEmptyObject) { 1789 | this.schema.items = s.map((el) => el.schema); 1790 | } else { 1791 | this.schema.items = [prev, ...s.map((el) => el.schema)]; 1792 | } 1793 | } else { 1794 | this.schema.items = s.map((el) => el.schema); 1795 | } 1796 | return this as never; 1797 | } 1798 | 1799 | max = this.maxLength; 1800 | /** 1801 | * Must contain less items or equal than declared 1802 | * @see {@link ArraySchemaBuilder.length length} 1803 | * @see {@link ArraySchemaBuilder.minLength minLength} 1804 | * @example 1805 | * const arr = s.array(s.number()).maxLength(3) 1806 | * arr.parse([1, 2, 3]) // OK 1807 | * arr.parse([1]) // OK 1808 | * arr.parse([1, 2, 3, 4]) // Error 1809 | */ 1810 | maxLength< 1811 | const L extends number, 1812 | Valid = IsPositiveInteger, 1813 | IsValidByMaxLength = Opts['minLength'] extends number ? GreaterThan : true 1814 | >( 1815 | value: Valid extends false ? TTypeGenericError<`Only Positive and non floating numbers are supported. Received: '${L}'`> 1816 | : IsValidByMaxLength extends false ? TRangeGenericError<'MaxLength less than MinLength', [ 1817 | `MinLength: ${Opts['minLength']}`, 1818 | `MaxLength: ${L}` 1819 | ]> 1820 | : L, 1821 | ): ArraySchemaBuilder>, S, { maxLength: L, minLength: Opts['minLength'], prefix: Opts['prefix'] }> { 1822 | this.schema.maxItems = value as L; 1823 | return this as never; 1824 | } 1825 | /** 1826 | * Must contain more items or equal than declared 1827 | * 1828 | * @see {@link ArraySchemaBuilder.length length} 1829 | * @see {@link ArraySchemaBuilder.maxLength maxLength} 1830 | * @example 1831 | * const arr = s.array(s.number()).minLength(3) 1832 | * arr.parse([1, 2, 3]) // OK 1833 | * arr.parse([1]) // Error 1834 | * arr.parse([1, 2, 3, 4]) // OK 1835 | */ 1836 | minLength< 1837 | const L extends number, 1838 | IsValidByMaxLength = Opts['maxLength'] extends undefined ? true : Opts['maxLength'] extends number ? LessThan : true, 1839 | >( 1840 | value: IsPositiveInteger extends false ? TTypeGenericError< 1841 | `MinLength should be positive integer. Received: '${L}'`, [L] 1842 | > : IsValidByMaxLength extends false ? TRangeGenericError< 1843 | `MaxLength is less than minLength.`, 1844 | [ 1845 | `MinLength: ${L}`, 1846 | `MaxLength: ${Opts['maxLength']}` 1847 | ]> : L, 1848 | ): ArraySchemaBuilder, ...El[]], S, { maxLength: Opts['maxLength'], minLength: L, prefix: Opts['prefix'] }> { 1849 | if ((value as never) < 0) { 1850 | throw new TypeError( 1851 | `Only Positive and non floating numbers are supported.`, 1852 | ); 1853 | } 1854 | 1855 | this.schema.minItems = value as L; 1856 | return this as never; 1857 | } 1858 | min = this.minLength; 1859 | 1860 | /** 1861 | * Returns schema builder of the element. 1862 | * 1863 | * If element is an array - returns `ArraySchemaBuilder` instance 1864 | * 1865 | * @example 1866 | * import s from 'ajv-ts' 1867 | * const strArr = s.array(s.string()) 1868 | * const str = strArr.element // isntance of StringSchemaBuilder 1869 | * s.parse('qwe') // ok, string schema 1870 | * s.schema // {type: 'string'} 1871 | */ 1872 | get element(): El extends Array 1873 | ? ArraySchemaBuilder 1874 | : El extends 1875 | | string 1876 | | number 1877 | | boolean 1878 | | object 1879 | | unknown[] 1880 | | null 1881 | | undefined 1882 | ? MatchTypeToBuilder 1883 | : SchemaBuilder { 1884 | const elementSchema = this.schema.items; 1885 | if (Array.isArray(elementSchema)) { 1886 | const builder = array>(); 1887 | builder.schema = { type: "array", items: elementSchema }; 1888 | return builder as never; 1889 | } else { 1890 | const builder = any(); 1891 | builder.schema = elementSchema; 1892 | return builder as never; 1893 | } 1894 | } 1895 | /** 1896 | * Must contain array length exactly. Same as `minLength(v).maxLength(v)` 1897 | * @see {@link ArraySchemaBuilder.maxLength} 1898 | * @see {@link ArraySchemaBuilder.minLength} 1899 | * @example 1900 | * const arr = s.array(s.number()).length(5) 1901 | * arr.parse([1, 2, 3, 4, 5]) // OK 1902 | * arr.parse([1, 2, 3, 4, 5, 6]) // Error 1903 | * arr.parse([1, 2, 3, 4]) // Error 1904 | */ 1905 | length, 1907 | OkMinLength = Opts['minLength'] extends undefined ? true : false, 1908 | OkMaxLength = Opts['maxLength'] extends undefined ? true : false, 1909 | >( 1910 | value: OkMaxLength extends true ? 1911 | OkMinLength extends true ? 1912 | Valid extends true 1913 | ? L 1914 | : TTypeGenericError< 1915 | `expected positive integer. Received: '${L}'` 1916 | > 1917 | : TRangeGenericError<`MinLength not equal to Length. MinLength: ${Opts['minLength']}. Length: ${L}`> 1918 | : TRangeGenericError<`MaxLength not equal to Length. MaxLength: ${Opts['maxLength']}. Length: ${L}`>, 1919 | ): ArraySchemaBuilder, S, Pick & { minLength: L, maxLength: L }> { 1920 | return this.minLength(value as never).maxLength(value as never) as never; 1921 | } 1922 | 1923 | /** 1924 | * same as `s.array().minLength(1)` 1925 | * @see {@link ArraySchemaBuilder.minLength} 1926 | */ 1927 | nonEmpty() { 1928 | return this.minLength<1>(1 as never); 1929 | } 1930 | 1931 | /** 1932 | * Set the `uniqueItems` keyword to `true`. 1933 | * @example 1934 | * const items = s.array(s.number()).unique() 1935 | * 1936 | * items.parse([1, 2, 3, 4, 5]) // OK 1937 | * items.parse([1, 2, 3, 3, 3]) // Error: items are not unique 1938 | */ 1939 | unique() { 1940 | this.schema.uniqueItems = true; 1941 | return this; 1942 | } 1943 | 1944 | /** 1945 | * `contains` schema only needs to validate against one or more items in the array. 1946 | * 1947 | * JSON Schema: `{type: 'array', contains: }` 1948 | * @example 1949 | * const arr = s.array().contains(s.number()) 1950 | * arr.validate([]) // false, no numbers here 1951 | * arr.validate([true, 1, 'str']) // true 1952 | */ 1953 | contains(containItem: S) { 1954 | this.schema.contains = containItem.schema; 1955 | return this; 1956 | } 1957 | /** 1958 | * ## draft 2019-09 1959 | * `minContains` and `maxContains` can be used with contains to further specify how many times a schema matches a 1960 | * `contains` constraint. These keywords can be any non-negative number including zero. 1961 | * @example 1962 | * const schema = s.array(s.string()).contains(s.number()).minContains(3) 1963 | * schema.parse(['qwe', 1,2,3]) // OK 1964 | * schema.parse(['qwe', 1,2]) // Error, expect at least 3 numerics 1965 | */ 1966 | minContains>( 1967 | value: Valid extends true 1968 | ? N 1969 | : [ 1970 | never, 1971 | 'TypeError: "minContains" should be positive integer', 1972 | `Received: '${N}'`, 1973 | ], 1974 | ) { 1975 | this.schema.minContains = value as N; 1976 | return this; 1977 | } 1978 | /** 1979 | * ## draft 2019-09 1980 | * `minContains` and `maxContains` can be used with contains to further specify how many times a schema matches a 1981 | * `contains` constraint. These keywords can be any non-negative number including zero. 1982 | * @example 1983 | * const schema = s.array(s.string()).contains(s.number()).maxContains(3) 1984 | * schema.parse(['qwe', 1,2,3]) // OK 1985 | * schema.parse(['qwe', 1,2,3, 4]) // Error, expect max 3 numbers 1986 | */ 1987 | maxContains>( 1988 | value: Valid extends true 1989 | ? N 1990 | : [ 1991 | never, 1992 | 'TypeError: "maxContains" should be positive integer', 1993 | `Received: '${N}'`, 1994 | ], 1995 | ) { 1996 | this.schema.maxContains = value as N; 1997 | return this; 1998 | } 1999 | } 2000 | 2001 | /** 2002 | * Define schema for array of elements. Accept array of subschemas. 2003 | * @example 2004 | * import s from 'ajv-ts' 2005 | * 2006 | * const tuple = s.array(s.string(), s.number()) 2007 | * tuple.schema // {type: 'array', items: [{type: 'string'}, {type: 'number'}] } 2008 | */ 2009 | function array( 2010 | definition?: S, 2011 | ): ArraySchemaBuilder, Infer[], S, { maxLength: undefined, minLength: undefined, prefix: [] }> { 2012 | return new ArraySchemaBuilder(definition); 2013 | } 2014 | 2015 | type AssertArray = T extends any[] ? T : never; 2016 | type TupleItems = [AnySchemaBuilder, ...AnySchemaBuilder[]]; 2017 | type OutputTypeOfTuple = AssertArray<{ 2018 | [k in keyof T]: T[k] extends SchemaBuilder 2019 | ? T[k]["_output"] 2020 | : never; 2021 | }>; 2022 | 2023 | type OutputTypeOfTupleWithRest< 2024 | T extends TupleItems | [], 2025 | Rest extends SchemaBuilder | null = null, 2026 | > = Rest extends SchemaBuilder 2027 | ? [...OutputTypeOfTuple, ...Infer[]] 2028 | : OutputTypeOfTuple; 2029 | 2030 | class TupleSchemaBuilder< 2031 | Schemas extends TupleItems | [] = TupleItems, 2032 | > extends SchemaBuilder, ArraySchema> { 2033 | constructor(...defs: Schemas) { 2034 | super({ 2035 | type: "array", 2036 | prefixItems: defs.map((def) => def.schema), 2037 | additionalItems: false, 2038 | }); 2039 | } 2040 | /** set `unevaluatedItems` to `false`. That means that all properties should be evaluated */ 2041 | required() { 2042 | this.schema.unevaluatedItems = false; 2043 | return this; 2044 | } 2045 | } 2046 | 2047 | /** 2048 | * Similar to `array`, but it's tuple 2049 | * @example 2050 | * const athleteSchema = z.tuple([ 2051 | * z.string(), // name 2052 | * z.number(), // jersey number 2053 | * z.object({ 2054 | * pointsScored: z.number(), 2055 | * }), // statistics 2056 | * ]); 2057 | * type Athlete = z.infer; 2058 | * // type Athlete = [string, number, { pointsScored: number }] 2059 | */ 2060 | function tuple(defs: Defs) { 2061 | return new TupleSchemaBuilder(...defs); 2062 | } 2063 | 2064 | class EnumSchemaBuilder< 2065 | Enum extends EnumLike = EnumLike, 2066 | Tuple extends Enum[keyof Enum][] = Enum[keyof Enum][], 2067 | > extends SchemaBuilder { 2068 | private _enum: Record = {}; 2069 | 2070 | readonly options = [] as unknown as Tuple; 2071 | 2072 | constructor(values: Tuple) { 2073 | // TODO: warning about tuple appears here in strict mode. Need to declare `type` field 2074 | super({ enum: values as never }); 2075 | values.forEach((v: any) => { 2076 | this._enum[v] = v; 2077 | }); 2078 | this.options = values; 2079 | } 2080 | 2081 | /** 2082 | * returns enum as object representation 2083 | */ 2084 | get enum(): Enum { 2085 | return this._enum as never; 2086 | } 2087 | } 2088 | type EnumLike = { [k: string]: string | number;[nu: number]: string }; 2089 | 2090 | class NativeEnumSchemaBuilder extends SchemaBuilder< 2091 | T, 2092 | EnumAnnotation 2093 | > { 2094 | get enum(): T { 2095 | return this.enumValues; 2096 | } 2097 | get options(): (keyof T)[] { 2098 | return Object.values(this.enumValues); 2099 | } 2100 | constructor(private enumValues: T) { 2101 | super({ enum: Object.values(enumValues) }); 2102 | } 2103 | } 2104 | 2105 | /** 2106 | * handle `enum` typescript type to make `enum` JSON annotation 2107 | */ 2108 | function makeEnum( 2109 | enumLike: E, 2110 | ): EnumSchemaBuilder; 2111 | /** 2112 | * handle tuple(array) of possible values to make `enum` JSON annotation 2113 | */ 2114 | function makeEnum< 2115 | P extends string | number = string | number, 2116 | T extends P[] | readonly P[] = [], 2117 | U extends UnionToTuple = UnionToTuple, 2118 | >( 2119 | possibleValues: T, 2120 | ): EnumSchemaBuilder< 2121 | { [K in Extract]: K }, 2122 | Extract[] 2123 | >; 2124 | function makeEnum(tupleOrEnum: unknown) { 2125 | if ( 2126 | typeof tupleOrEnum === "object" && 2127 | tupleOrEnum !== null && 2128 | !Array.isArray(tupleOrEnum) 2129 | ) { 2130 | // enum 2131 | return new NativeEnumSchemaBuilder(tupleOrEnum as never); 2132 | } else if (Array.isArray(tupleOrEnum)) { 2133 | // tuple 2134 | return new EnumSchemaBuilder(tupleOrEnum); 2135 | } 2136 | throw new Error(`Cannot handle non tuple or non enum type.`, { 2137 | cause: { type: typeof tupleOrEnum, value: tupleOrEnum }, 2138 | }); 2139 | } 2140 | 2141 | class ConstantSchemaBuilder< 2142 | T extends number | string | boolean | null | object = never, 2143 | > extends SchemaBuilder { 2144 | constructor(readonly value: T) { 2145 | super({ const: value }); 2146 | } 2147 | } 2148 | /** 2149 | * `const` is used to restrict a value to a single value. 2150 | * 2151 | * zod differences - `Date` is supported. 2152 | * @alias literal 2153 | * @satisfies zod API. **NOTE:** `Symbol`, unserializable `object` is not supported and throws error. 2154 | * @example 2155 | * const constant = s.const("Hello World") 2156 | * constant.validate("Hello World") // true 2157 | * constant.validate("Hello World 1") // false 2158 | */ 2159 | function constant( 2160 | value: T, 2161 | ) { 2162 | return new ConstantSchemaBuilder(value); 2163 | } 2164 | 2165 | class UnionSchemaBuilder< 2166 | S extends AnySchemaBuilder[] = AnySchemaBuilder[], 2167 | > extends SchemaBuilder, AnySchemaOrAnnotation> { 2168 | constructor(...schemas: S) { 2169 | super({ 2170 | anyOf: schemas.map((s) => s.schema), 2171 | } as AnySchemaOrAnnotation); 2172 | } 2173 | } 2174 | 2175 | function or(...defs: S) { 2176 | return new UnionSchemaBuilder(...defs); 2177 | } 2178 | 2179 | class IntersectionSchemaBuilder< 2180 | S extends AnySchemaBuilder[] = SchemaBuilder[], 2181 | Elem extends AnySchemaBuilder = S[number], 2182 | Intersection extends 2183 | AnySchemaBuilder = UnionToIntersection extends SchemaBuilder 2184 | ? UnionToIntersection 2185 | : SchemaBuilder, 2186 | > extends SchemaBuilder< 2187 | Infer, 2188 | AnySchemaOrAnnotation, 2189 | Infer 2190 | > { 2191 | constructor(...schemas: S) { 2192 | super({ 2193 | allOf: schemas.map((s) => s.schema), 2194 | } as AnySchemaOrAnnotation); 2195 | } 2196 | } 2197 | 2198 | function and(...defs: S) { 2199 | return new IntersectionSchemaBuilder(...defs); 2200 | } 2201 | 2202 | type PropKey = Exclude; 2203 | 2204 | /** 2205 | * Extract keys from given schema `s.object` and set as constant for output schema 2206 | * 2207 | * TypeScript - `keyof T` type 2208 | * 2209 | * JSON schema - `{anyOf: [{const: 'key1'}, {const: 'key2'}, ...] }` 2210 | * @throws `Error` if given schema doesn't have `properties` properties. Only non-empty `object` schema has `properties` properties. 2211 | */ 2212 | function keyof< 2213 | ObjSchema extends ObjectSchemaBuilder, 2214 | Res extends PropKey = keyof ObjSchema["_output"] extends PropKey 2215 | ? keyof ObjSchema["_output"] 2216 | : never, 2217 | >(obj: ObjSchema): UnionSchemaBuilder[]> { 2218 | if (!obj.schema.properties) { 2219 | throw new Error( 2220 | `cannot get keys from not an object. Got ${obj.schema.type}`, 2221 | { cause: { schema: obj.schema, instance: obj } }, 2222 | ); 2223 | } 2224 | const schemas = Object.keys(obj.schema.properties).map((prop) => 2225 | constant(prop), 2226 | ); 2227 | return or(...schemas) as never; 2228 | } 2229 | 2230 | class UnknownSchemaBuilder extends SchemaBuilder< 2231 | T, 2232 | any 2233 | > { 2234 | constructor() { 2235 | super({} as AnySchemaOrAnnotation); 2236 | } 2237 | } 2238 | 2239 | /** 2240 | * TypeScript - `any` type 2241 | * 2242 | * JSON schema - `{}` (empty object) 2243 | */ 2244 | function any(): SchemaBuilder { 2245 | return new UnknownSchemaBuilder(); 2246 | } 2247 | 2248 | /** 2249 | * Same as {@link any} but for typescript better type quality. 2250 | * 2251 | * TypeScript - `unknown` type 2252 | * 2253 | * JSON schema - `{}` (empty object) 2254 | */ 2255 | function unknown() { 2256 | return new UnknownSchemaBuilder(); 2257 | } 2258 | 2259 | class NeverSchemaBuilder extends SchemaBuilder { 2260 | constructor() { 2261 | super({ not: {} } as AnySchemaOrAnnotation); 2262 | } 2263 | } 2264 | /** 2265 | * Typescript - `never` type. 2266 | * 2267 | * JSON Schema - `{ not: {} }` 2268 | */ 2269 | function never() { 2270 | return new NeverSchemaBuilder(); 2271 | } 2272 | 2273 | class NotSchemaBuilder< 2274 | S extends AnySchemaBuilder = SchemaBuilder, 2275 | T extends number | string | boolean | object | null | Array = 2276 | | number 2277 | | string 2278 | | boolean 2279 | | object 2280 | | null 2281 | | Array, 2282 | Out = Exclude, 2283 | > extends SchemaBuilder { 2284 | constructor(schema: S) { 2285 | super({ 2286 | not: schema.schema, 2287 | } as AnySchemaOrAnnotation); 2288 | } 2289 | } 2290 | 2291 | /** 2292 | * The `not` declares that an instance validates if it doesn't validate against the given subschema. 2293 | * 2294 | * **NOTE:** `s.not(s.string())` and `s.string().not()` is not the same! 2295 | * 2296 | * JSON Schema: `{ not: }` 2297 | * 2298 | * @see {@link https://json-schema.org/understanding-json-schema/reference/combining#not json schema `not` keyword} 2299 | * @see {@link SchemaBuilder.not not method} 2300 | * @example 2301 | * import s from 'ajv-ts' 2302 | * 2303 | * const notString = s.not(s.string()) 2304 | * // or 2305 | * const notStringAlternative = s.string().not() 2306 | * 2307 | * notString.parse(42) // OK 2308 | * notString.parse({key: 'value'}) // OK 2309 | * notString.parse('I am a string') // throws 2310 | */ 2311 | function not(def: S) { 2312 | return new NotSchemaBuilder(def); 2313 | } 2314 | 2315 | /** 2316 | * get JSON-schema from somewhere and merge with `baseSchema` 2317 | * @param externalJsonSchema external schema. E.g. from swagger 2318 | * @param [baseSchema=any() as S] schema to use. Default is `s.any()`. 2319 | * @example 2320 | * const v = s.fromJSON({someCustomProp: true}, s.string()) 2321 | * v.schema.someCustomProp === true 2322 | * v.schema.type === 'string' 2323 | */ 2324 | function fromJSON( 2325 | externalJsonSchema: T, 2326 | baseSchema: S = any() as S 2327 | ): S { 2328 | baseSchema.schema = { ...baseSchema.schema, ...externalJsonSchema }; 2329 | return baseSchema; 2330 | } 2331 | 2332 | function injectAjv( 2333 | ajv: Ajv, 2334 | schemaBuilderFn: (...args: any[]) => S, 2335 | ): (...args: unknown[]) => S { 2336 | return new Proxy(schemaBuilderFn, { 2337 | apply(target, thisArg, argArray) { 2338 | const result = Reflect.apply(target, thisArg, argArray); 2339 | result.ajv = ajv; 2340 | return result; 2341 | }, 2342 | }); 2343 | } 2344 | 2345 | /** 2346 | * Create new instance of schema definition with non default AJV instance 2347 | * 2348 | * @example 2349 | * import Ajv from 'ajv' 2350 | * import s from 'ajv-ts' 2351 | * 2352 | * const myAjv = new Ajv(/custom options/); 2353 | * const builder = s.create(myAjv) 2354 | * 2355 | * builder.number().parse(123) // OK, but use myAjv instance instead of default 2356 | */ 2357 | function create(ajv: Ajv) { 2358 | return { 2359 | number: injectAjv(ajv, number) as typeof number, 2360 | integer: injectAjv(ajv, integer) as typeof integer, 2361 | int: injectAjv(ajv, integer) as typeof integer, 2362 | string: injectAjv(ajv, string) as typeof string, 2363 | null: injectAjv(ajv, nil) as typeof nil, 2364 | enum: injectAjv(ajv, makeEnum) as typeof makeEnum, 2365 | nativeEnum: injectAjv(ajv, makeEnum) as typeof makeEnum, 2366 | boolean: injectAjv(ajv, boolean) as typeof boolean, 2367 | object: injectAjv(ajv, object) as typeof object, 2368 | keyof: injectAjv(ajv, keyof) as typeof keyof, 2369 | record: injectAjv(ajv, record) as typeof record, 2370 | array: injectAjv(ajv, array) as typeof array, 2371 | tuple: injectAjv(ajv, tuple) as typeof tuple, 2372 | const: injectAjv(ajv, constant) as typeof constant, 2373 | literal: injectAjv(ajv, constant) as typeof constant, 2374 | unknown: injectAjv(ajv, unknown) as typeof unknown, 2375 | any: injectAjv(ajv, any) as typeof any, 2376 | never: injectAjv(ajv, never) as typeof never, 2377 | or: injectAjv(ajv, or) as typeof or, 2378 | union: injectAjv(ajv, or) as typeof or, 2379 | and: injectAjv(ajv, and) as typeof and, 2380 | intersection: injectAjv(ajv, and) as typeof and, 2381 | not: injectAjv(ajv, not) as typeof not, 2382 | fromJSON, 2383 | }; 2384 | } 2385 | 2386 | export { 2387 | DEFAULT_AJV as Ajv, 2388 | and, 2389 | any, 2390 | array, 2391 | boolean, 2392 | constant as const, 2393 | create, 2394 | makeEnum as enum, 2395 | integer as int, 2396 | integer, 2397 | and as intersection, 2398 | keyof, 2399 | constant as literal, 2400 | makeEnum as nativeEnum, 2401 | never, 2402 | create as new, 2403 | not, 2404 | nil as null, 2405 | number, 2406 | object, 2407 | or, 2408 | record, 2409 | string, 2410 | tuple, 2411 | or as union, 2412 | unknown, 2413 | fromJSON, 2414 | type AnySchemaBuilder as Any, 2415 | type NumberSchemaBuilder as Number, 2416 | type StringSchemaBuilder as String, 2417 | type BooleanSchemaBuilder as Boolean, 2418 | type NullSchemaBuilder as Null, 2419 | type UnknownSchemaBuilder as Unknown, 2420 | type NeverSchemaBuilder as Never, 2421 | type ArraySchemaBuilder as Array, 2422 | type ObjectSchemaBuilder as Object, 2423 | type UnionSchemaBuilder as Or, 2424 | type IntersectionSchemaBuilder as And, 2425 | type Infer as infer, 2426 | type Input as input, 2427 | }; 2428 | 2429 | /** 2430 | * Extract schema level defenition and return it's represenation as typescript type 2431 | */ 2432 | export type Infer> = T["_output"]; 2433 | 2434 | /** Extract SchemaBuilder[] - used in array schema to build right types */ 2435 | export type InferArray< 2436 | T extends SchemaBuilder[], 2437 | Result extends unknown[] = [], 2438 | > = T extends [] 2439 | ? unknown[] 2440 | : T extends [ 2441 | infer First extends SchemaBuilder, 2442 | ...infer Rest extends SchemaBuilder[], 2443 | ] 2444 | ? Rest extends [] 2445 | ? [...Result, Infer] 2446 | : InferArray]> 2447 | : T extends Array 2448 | ? [...Result, ...Infer[]] 2449 | : Result; 2450 | 2451 | /** extract schema input type */ 2452 | export type Input> = T["_input"]; 2453 | 2454 | export type MatchTypeToBuilder = T extends boolean 2455 | ? BooleanSchemaBuilder 2456 | : T extends Array 2457 | ? ArraySchemaBuilder[]> 2458 | : T extends string 2459 | ? StringSchemaBuilder 2460 | : T extends number 2461 | ? NumberSchemaBuilder 2462 | : T extends Record< 2463 | string, 2464 | number | boolean | string | object | unknown[] | null 2465 | > 2466 | ? { [K in keyof T]: MatchTypeToBuilder } 2467 | : T extends null 2468 | ? NullSchemaBuilder 2469 | : T extends undefined 2470 | ? SchemaBuilder 2471 | : T extends unknown 2472 | ? UnknownSchemaBuilder 2473 | : T extends SchemaBuilder 2474 | ? SchemaBuilder 2475 | : SchemaBuilder; 2476 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as s from './builder' 2 | export * from './builder' 3 | export * as SchemaType from './schema/types' 4 | 5 | export * as s from './builder' 6 | export default s -------------------------------------------------------------------------------- /src/schema/types.ts: -------------------------------------------------------------------------------- 1 | import { type SchemaObject } from 'ajv' 2 | 3 | /** 4 | * Base schema with internal properties. 5 | * It also index-based type. 6 | */ 7 | export type BaseSchema = SchemaObject & { 8 | anyOf?: AnySchemaOrAnnotation[] 9 | oneOf?: AnySchemaOrAnnotation[] 10 | allOf?: AnySchemaOrAnnotation[] 11 | not?: AnySchemaOrAnnotation 12 | type?: string 13 | $ref?: string 14 | $async?: boolean; 15 | /** 16 | * ## New in draft 7 17 | * The `$comment` keyword is strictly intended for adding comments to a schema. 18 | * Its value must always be a string. Unlike the annotations {@link BaseSchema.title `title`}, 19 | * {@link BaseSchema.description `description`}, and {@link examples `examples`}, 20 | * JSON schema implementations aren’t allowed to attach any meaning or behavior to it whatsoever, 21 | * and may even strip them at any time. Therefore, they are useful for leaving notes to future editors 22 | * of a JSON schema, but should not be used to communicate to users of the schema. 23 | * @see {@link https://json-schema.org/understanding-json-schema/reference/generic.html#comments comment} 24 | */ 25 | $comment?: string 26 | /** 27 | * The `title` keyword must be string. A `title` will preferably be short. 28 | */ 29 | title?: string 30 | /** 31 | * The `description` keyword must be string. A {@link BaseSchema.title `title`} will preferably be short, 32 | * whereas a `description` will provide a more lengthy explanation about 33 | * the purpose of the data described by the schema. 34 | * 35 | */ 36 | description?: string 37 | default?: unknown 38 | /** 39 | * ## New in draft 6 40 | * The examples keyword is a place to provide an array of examples that validate against the schema. 41 | * This isn’t used for validation, but may help with explaining the effect and purpose of the schema 42 | * to a reader. Each entry should validate against the schema in which it resides, 43 | * but that isn’t strictly required. There is no need to duplicate the default value in the examples array, 44 | * since default will be treated as another example. 45 | */ 46 | examples?: unknown[] 47 | /** 48 | * ## New in draft 2019-09 49 | * The `deprecated` keyword is a boolean that indicates that the instance value the keyword applies 50 | * to should not be used and may be removed in the future. 51 | */ 52 | deprecated?: boolean 53 | /** 54 | * ## New in draft 7 55 | * The boolean keywords `readOnly` and `writeOnly` are typically used in an API context. 56 | * `readOnly` indicates that a value should not be modified. 57 | * It could be used to indicate that a PUT request that changes a value would result in 58 | * a `400 Bad Request` response. 59 | */ 60 | readOnly?: boolean 61 | /** 62 | * ## New in draft 7 63 | * The boolean keywords `readOnly` and `writeOnly` are typically used in an API context. 64 | * `writeOnly` indicates that a value may be set, but will remain hidden. 65 | * In could be used to indicate you can set a value with a `PUT` request, 66 | * but it would not be included when retrieving that record with a `GET` request. 67 | */ 68 | writeOnly?: boolean 69 | } 70 | 71 | // TODO: advanced types 72 | /** Schema string pattern */ 73 | type Pattern = string 74 | 75 | /** 76 | * There are two numeric types in JSON Schema: 77 | * {@link https://json-schema.org/understanding-json-schema/reference/numeric.html#id4 `integer`} and 78 | * {@link https://json-schema.org/understanding-json-schema/reference/numeric.html#number `number`}. 79 | * They share the same validation keywords. 80 | * 81 | * **NOTE:** JSON has no standard way to represent complex numbers, so there is no way to test for them in JSON Schema. 82 | */ 83 | export type NumberSchema = BaseSchema & { 84 | /** 85 | * The integer type is used for integral numbers. JSON does not have distinct types for integers and floating-point values. 86 | * Therefore, the presence or absence of a decimal point is not enough to distinguish between integers and non-integers. 87 | * For example, `1` and `1.0` are two ways to represent the same value in JSON. 88 | * JSON Schema considers that value an integer no matter which representation was used. 89 | * 90 | * The number type is used for any numeric type, either integers or floating point numbers. 91 | * 92 | * @example 93 | * // integer 94 | * { "type": "integer" } 95 | * 42 // good 96 | * 1.0 // good 97 | * -1 // good 98 | * 3.1415926 // bad. Floating point numbers are rejected 99 | * "14" // bad. Numbers as strings are rejected 100 | * 101 | * { "type": "number" } 102 | * 42 // good 103 | * -1 // good 104 | * 2.99792458e8 // good 105 | * "42" // bad 106 | * @type {('number' | 'integer')} 107 | */ 108 | type: 'number' | 'integer' 109 | /** 110 | * If `x` is the value being validated, the following must hold true: 111 | * - x ≥ `minimum` 112 | * - x > `exclusiveMinimum` 113 | * - x ≤ `maximum` 114 | * - x < `exclusiveMaximum` 115 | * 116 | * @type {number} 117 | */ 118 | minimum?: number 119 | /** 120 | * If `x` is the value being validated, the following must hold true: 121 | * - x ≥ `minimum` 122 | * - x > `exclusiveMinimum` 123 | * - x ≤ `maximum` 124 | * - x < `exclusiveMaximum` 125 | * 126 | * @type {number} 127 | */ 128 | maximum?: number 129 | /** 130 | * Numbers can be restricted to a multiple of a given number, using the `multipleOf` keyword. It may be set to any positive number. 131 | * @example 132 | * // good 133 | * 0 134 | * 10 135 | * 20 136 | * // Bad 137 | * 23 // Not a multiple of 10: 138 | * @type {number} 139 | */ 140 | multipleOf?: number 141 | /** 142 | * If `x` is the value being validated, the following must hold true: 143 | * - x ≥ `minimum` 144 | * - x > `exclusiveMinimum` 145 | * - x ≤ `maximum` 146 | * - x < `exclusiveMaximum` 147 | * 148 | * @type {number} 149 | */ 150 | exclusiveMaximum?: number | boolean 151 | /** 152 | * If `x` is the value being validated, the following must hold true: 153 | * - x ≥ `minimum` 154 | * - x > `exclusiveMinimum` 155 | * - x ≤ `maximum` 156 | * - x < `exclusiveMaximum` 157 | * 158 | * @type {number} 159 | */ 160 | exclusiveMinimum?: number | boolean 161 | format?: 'int32' | 'double' | 'int64' | 'float' 162 | } 163 | 164 | /** 165 | * The `string` type is used for strings of text. It may contain Unicode characters. 166 | */ 167 | export type StringSchema = BaseSchema & { 168 | type: 'string' 169 | /** 170 | * The length of a string can be constrained using the `minLength` and `maxLength` keywords. For both keywords, the value must be a non-negative number. 171 | * 172 | * @type {number} 173 | */ 174 | minLength?: number 175 | /** 176 | * The length of a string can be constrained using the `minLength` and `maxLength` keywords. For both keywords, the value must be a non-negative number. 177 | * 178 | * @type {number} 179 | */ 180 | maxLength?: number 181 | /** 182 | * Regex pattern 183 | * @example 184 | * const mySchema = { 185 | * "type": "string", 186 | * "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$" 187 | * } 188 | * 189 | * validate("555-1212", mySchema) // ok 190 | * validate("(888)555-1212", mySchema) // ok 191 | * validate("(888)555-1212 ext. 532", mySchema) // error 192 | * validate("(800)FLOWERS", mySchema) // error 193 | * 194 | * @type {Pattern} 195 | */ 196 | pattern?: Pattern 197 | format?: 198 | | 'date-time' 199 | | 'time' 200 | | 'date' 201 | | 'duration' 202 | | 'email' 203 | | 'idn-email' 204 | | 'hostname' 205 | | 'idn-hostname' 206 | | 'ipv4' 207 | | 'ipv6' 208 | | 'uuid' 209 | | 'uri' 210 | | 'uri-reference' 211 | | 'iri' 212 | | 'iri-reference' 213 | | 'uri-template' 214 | | 'json-pointer' 215 | | 'relative-json-pointer' 216 | | 'regex' 217 | } 218 | 219 | /** 220 | * The boolean type matches only two special values: `true` and `false`. 221 | * 222 | * **Note** that values that evaluate to `true` or `false`, such as `1` and `0`, are not accepted by the schema. 223 | */ 224 | export type BooleanSchema = BaseSchema & { 225 | type: 'boolean' 226 | } 227 | 228 | /** 229 | * When a schema specifies a type of null, it has only one acceptable value: null. 230 | * 231 | * **NOTE:** It’s important to remember that in JSON, null isn’t equivalent to something being absent. 232 | * See {@link https://json-schema.org/understanding-json-schema/reference/object.html#required Required Properties} for an example. 233 | */ 234 | export type NullSchema = BaseSchema & { 235 | type: 'null' 236 | } 237 | 238 | // TODO: improove types 239 | /** 240 | * Combine schema. 241 | * @example 242 | * {type: ['string', 'number']} 243 | * 123 // OK 244 | * "Some" // OK 245 | * {} //error. not matched the type 246 | */ 247 | export type CombinedSchema = BaseSchema & { type: AnySchema['type'][] } 248 | 249 | export type EnumAnnotation = { 250 | enum: (number | string | boolean | null | object)[] 251 | } 252 | 253 | export type ConstantAnnotation = { 254 | const: number | string | boolean | null | object 255 | } 256 | 257 | /** Any schema definition */ 258 | export type AnySchema = 259 | | ObjectSchema 260 | | NumberSchema 261 | | StringSchema 262 | | NullSchema 263 | | BooleanSchema 264 | | ArraySchema 265 | 266 | /** 267 | * Any Schema and any annotation 268 | * @see {@link EnumAnnotation} 269 | * @see {@link ConstantAnnotation} 270 | */ 271 | export type AnySchemaOrAnnotation = 272 | | AnySchema 273 | | EnumAnnotation 274 | | ConstantAnnotation 275 | | CombinedSchema 276 | 277 | type IsNever = T extends never ? true : false 278 | 279 | type MaybeReadonlyArray = T[] | readonly T[] 280 | 281 | /** 282 | * Objects are the mapping type in JSON. They map `keys` to `values`. In JSON, the `keys` must always be strings. Each of these pairs is conventionally referred to as a `property`. 283 | * @see {@link https://json-schema.org/understanding-json-schema/reference/object.html JSON Schema object definition} 284 | * @todo improve object schema definition for required and additional properties 285 | */ 286 | export type ObjectSchema = BaseSchema & { 287 | type: 'object' 288 | /** 289 | * The `properties` (key-value pairs) on an object are defined using the `properties` keyword. 290 | * The value of `properties` is an object, where each key is the name of a property and each value 291 | * is a schema used to validate that property. Any property that doesn’t match any of the property names 292 | * in the `properties` keyword is ignored by this keyword. 293 | * 294 | * **NOTE:** See Additional Properties and Unevaluated Properties for how to disallow properties that don’t match any of the property names in `properties`. 295 | * @see https://json-schema.org/understanding-json-schema/reference/object.html#properties 296 | * @example 297 | * { 298 | * type: "object", 299 | * properties: { 300 | * number: { "type": "number" }, 301 | * street_name: { "type": "string" }, 302 | * street_type: { "enum": ["Street", "Avenue", "Boulevard"] } 303 | * } 304 | * } 305 | * { "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue" } // good 306 | * { "number": "1600", "street_name": "Pennsylvania", "street_type": "Avenue" } // error: number in wrong format 307 | * { } // valid 308 | * { "number": 1600, "street_name": "Pennsylvania", "street_type": "Avenue", "direction": "NW" } // valid for additional properties 309 | */ 310 | properties?: Record 311 | /** 312 | * Sometimes you want to say that, given a particular kind of property name, the value should match 313 | * a particular schema. That’s where patternProperties comes in: it maps regular expressions to schemas. 314 | * If a property name matches the given regular expression, the property value must validate against the corresponding schema. 315 | * 316 | * **NOTE:** Regular expressions are not anchored. This means that when defining the regular expressions for `patternProperties`, 317 | * it’s important to note that the expression may match anywhere within the property name. 318 | * For example, the regular expression `p` will match any property name with a p in it, such as `apple`, 319 | * not just a property whose name is simply `p`. It’s therefore usually less confusing to surround the regular expression in 320 | * `^...$`, for example, `^p$`. 321 | * @example 322 | * { 323 | * "type": "object", 324 | * "patternProperties": { 325 | * "^S_": { "type": "string" }, 326 | * "^I_": { "type": "integer" } 327 | * } 328 | * } 329 | * { "S_25": "This is a string" } // good 330 | * { "I_0": 42 } // valid 331 | * { "S_0": 42 } // error: name starts with S_, it must be a string 332 | * { "I_42": "This is a string" } // error: the name starts with I_, it must be an integer 333 | */ 334 | patternProperties?: Record 335 | dependentRequired?: Record 336 | /** 337 | * The additionalProperties keyword is used to control the handling of extra stuff, that is, 338 | * properties whose names are not listed in the properties keyword or match any of 339 | * the regular expressions in the patternProperties keyword. By default any additional properties are allowed. 340 | * The value of the additionalProperties keyword is a schema that will be used to validate any properties 341 | * in the instance that are not matched by properties or patternProperties. 342 | * Setting the additionalProperties schema to false means no additional properties will be allowed. 343 | * @see {@link ObjectSchema.properties} 344 | * @see {@link https://json-schema.org/understanding-json-schema/reference/object.html} 345 | * @example 346 | * { 347 | * "type": "object", 348 | * "properties": { 349 | * "number": { "type": "number" }, 350 | * }, 351 | * "additionalProperties": false 352 | * } 353 | * { "number": 1600, } // ok 354 | * { "number": 1600,"direction": "NW" } // error. Since `additionalProperties` is false, this extra property “direction” makes the object invalid 355 | */ 356 | additionalProperties?: boolean | AnySchemaOrAnnotation 357 | optionalProperties?: Record 358 | /** 359 | * By default, the properties defined by the properties keyword are not required. 360 | * However, one can provide a list of required properties using the required keyword. 361 | * The required keyword takes an array of zero or more strings. Each of these strings must be unique. 362 | */ 363 | required?: MaybeReadonlyArray 364 | unevaluatedProperties?: boolean 365 | /** 366 | * ## New in draft 6 367 | * The names of properties can be validated against a schema, irrespective of their values. 368 | * This can be useful if you don’t want to enforce specific properties, 369 | * but you want to make sure that the names of those properties follow a specific convention. 370 | * You might, for example, want to enforce that all names are valid ASCII tokens so they can be 371 | * used as attributes in a particular programming language. 372 | * @since draft 6 373 | * @example 374 | * { 375 | * "type": "object", 376 | * "propertyNames": { 377 | * "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" 378 | * } 379 | * } 380 | * //usage 381 | * { "_a_proper_token_001": "value" } // ok 382 | * { "001 invalid": "value"} // error. not matched the pattern 383 | */ 384 | propertyNames?: { 385 | pattern: Pattern 386 | } 387 | nullable?: boolean 388 | minProperties?: number 389 | maxProperties?: number 390 | } 391 | /** 392 | * Arrays are used for ordered elements. In JSON, each element in an array may be of a different type. 393 | * @see {@link https://json-schema.org/understanding-json-schema/reference/array.html} 394 | * @todo improve complex type e.g. `(string|number)[]` 395 | */ 396 | export type ArraySchema = BaseSchema & { 397 | type: 'array' 398 | uniqueItems?: boolean 399 | items?: boolean | AnySchemaOrAnnotation | AnySchemaOrAnnotation[] 400 | minItems?: number 401 | maxItems?: number 402 | prefixItems?: AnySchemaOrAnnotation[] 403 | contains?: AnySchemaOrAnnotation 404 | minContains?: number 405 | maxContains?: number 406 | } 407 | -------------------------------------------------------------------------------- /src/types/array.d.ts: -------------------------------------------------------------------------------- 1 | import type { GreaterThan, GreaterThanOrEqual, LessThan, IsFloat, IsPositiveInteger, LessThanOrEqual } from './number'; 2 | 3 | export type Create< 4 | L extends number, 5 | T = unknown, 6 | U extends T[] = [] 7 | > = IsPositiveInteger extends true ? U['length'] extends L ? U : Create : never; 8 | 9 | /** Exclude Element from given array 10 | * @example 11 | * type Arr = [1,2,3] 12 | * type Result = ExcludeArr // [1,3] 13 | */ 14 | export type ExcludeArr = Exclude extends never 15 | ? Arr 16 | : Arr extends [infer Head, ...infer Tail] 17 | ? Head extends El 18 | ? ExcludeArr 19 | : [Head, ...ExcludeArr] 20 | : Arr; 21 | 22 | export type Length = T['length'] 23 | 24 | export type Head = T extends [infer First, ...unknown[]] ? First : never; 25 | export type Tail = T extends [infer _, ...infer Rest] ? Rest : []; 26 | 27 | export type MakeReadonly = readonly T 28 | 29 | export type Optional = T extends [infer First, ...infer Rest] ? [First?, ...Optional] : T 30 | 31 | export type Reverse = Arr extends [infer First, ...infer Rest] ? Reverse : Result 32 | export type At< 33 | Arr extends readonly unknown[], 34 | Index extends number 35 | > = 36 | GreaterThanOrEqual<0, Index> extends true 37 | ? LessThan> extends true 38 | ? Arr[Index] 39 | : `${Index}` extends `-${infer Positive extends number}` 40 | ? Reverse[Positive] 41 | : never 42 | : Arr[Index] 43 | export type Concat = [...Arr1, ...Arr2] 44 | 45 | export type Push = [...Arr, T] 46 | -------------------------------------------------------------------------------- /src/types/boolean.d.ts: -------------------------------------------------------------------------------- 1 | export type Equals = (() => V extends T ? 1 : 2) extends < 2 | V 3 | >() => V extends U ? 1 : 2 4 | ? true 5 | : false; 6 | -------------------------------------------------------------------------------- /src/types/errors.d.ts: -------------------------------------------------------------------------------- 1 | import { IsNever } from './misc' 2 | type TGenericError = [ 3 | never, 4 | Message, 5 | ...Rest, 6 | ] 7 | 8 | export type TError = TGenericError<`Error: ${Message}`> 9 | 10 | export type TTypeGenericError = TGenericError<`TypeError: ${Message}`, Rest> 11 | export type TTypeError = TGenericError<`TypeError: expected and actual types are not the same.`, ['Expected:', Expected, 'Actual:', Actual]> 12 | export type TRangeGenericError = TGenericError<`RangeError: ${Message}`, Rest> 13 | export type TRangeError = TGenericError<`RangeError: ${Num1} are out of range of ${Num2}`> 14 | 15 | export type IsTGenericError = T extends [infer First, ...infer Rest] ? IsNever : false 16 | 17 | export type InferMessage = T extends TGenericError ? M : never 18 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * as Array from "./array"; 2 | export * as Boolean from "./boolean"; 3 | export * from "./misc"; 4 | export * as Number from "./number"; 5 | export * as Object from "./object"; 6 | export * as String from "./string"; 7 | -------------------------------------------------------------------------------- /src/types/misc.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UnionToIntersection<{ foo: string } | { bar: string }> = 3 | * { foo: string } & { bar: string }. 4 | */ 5 | export type UnionToIntersection = ( 6 | U extends unknown ? (arg: U) => 0 : never 7 | ) extends (arg: infer I) => 0 8 | ? I 9 | : never; 10 | 11 | /** 12 | * LastInUnion<1 | 2> = 2. 13 | */ 14 | type LastInUnion = UnionToIntersection< 15 | U extends unknown ? (x: U) => 0 : never 16 | > extends (x: infer L) => 0 17 | ? L 18 | : never; 19 | 20 | /** 21 | * UnionToTuple<1 | 2> = [1, 2]. 22 | */ 23 | export type UnionToTuple> = [U] extends [never] 24 | ? [] 25 | : [...UnionToTuple>, Last]; 26 | 27 | export type IsUnknown = 28 | T extends number ? false 29 | : T extends string ? false 30 | : T extends string ? false 31 | : T extends boolean ? false 32 | : T extends symbol ? false 33 | : T extends undefined ? false 34 | : T extends null ? false 35 | : T extends object ? false 36 | : T extends unknown ? true 37 | : false 38 | 39 | export type ToPrimal = 40 | T extends number ? number 41 | : T extends string ? string 42 | : T extends boolean ? boolean 43 | : T extends bigint ? bigint 44 | : T extends symbol ? symbol 45 | : T extends readonly unknown[] ? unknown[] 46 | : T extends object ? object 47 | : never 48 | export type IsNever = [T] extends [never] ? true : false 49 | 50 | export type Fn< 51 | R = unknown, 52 | Args extends readonly unknown[] = [], 53 | This = void 54 | > = (this: This, ...args: Args) => R 55 | 56 | export type Return> = F extends Fn ? Res : never 57 | export type Param = F extends Fn ? Args : never 58 | 59 | export type Debug = [Name, T] 60 | -------------------------------------------------------------------------------- /src/types/number.d.ts: -------------------------------------------------------------------------------- 1 | import { NumberSchema } from '../schema/types'; 2 | import { Create } from './array' 3 | import { Reverse } from './string'; 4 | import { IsInteger, IsNegative } from 'type-fest' 5 | 6 | /** `T > U` */ 7 | export type GreaterThan = Create extends [...Create, ...infer _] ? false : true; 8 | /** `T >= U` */ 9 | export type GreaterThanOrEqual = Equal extends true ? true : GreaterThan 10 | 11 | /** `T < U` */ 12 | export type LessThan = GreaterThanOrEqual extends true ? false : true 13 | 14 | /** `T === U` */ 15 | export type Equal = Create['length'] extends Create['length'] ? true : false; 16 | 17 | /** `T !== U` */ 18 | export type NotEqual = Equal extends true ? false : true 19 | 20 | /** `T <= U` */ 21 | export type LessThanOrEqual = Equal extends true ? true : LessThan 22 | 23 | export type IsFloat = N extends number 24 | ? IsFloat<`${N}`> 25 | : N extends `${number}.${number extends 0 ? '' : number}` 26 | ? true 27 | : false 28 | export type IsPositiveInteger = IsInteger extends true ? IsNegative extends false ? true : false : false 29 | export type Negative = `${N}` extends `-${infer V extends number}` ? N : V 30 | 31 | export type IsNumberSubset = 32 | GreaterThanOrEqual extends false ? 33 | LessThanOrEqual extends false ? 34 | true : [false, 'less than '] : [false, 'greater than'] 35 | 36 | export type NumericStringifyType = IsFloat extends true ? "Float" : IsInteger extends true ? "Int" : "Unknown" -------------------------------------------------------------------------------- /src/types/object.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A TypeScript type alias called `Prettify`. 3 | * It takes a type as its argument and returns a new type that has the same properties as the original type, 4 | * but the properties are not intersected. This means that the new type is easier to read and understand. 5 | */ 6 | export type Prettify = { 7 | [K in keyof T]: T[K] 8 | } & {} 9 | /** 10 | * @example 11 | * interface User { 12 | * name?: string 13 | * age?: number 14 | * address?: string 15 | * } 16 | * 17 | * type UserRequiredName = RequiredByKeys 18 | * // { name: string; age?: number; address?: string } 19 | * @see https://github.com/type-challenges/type-challenges/issues/3180 20 | */ 21 | export type RequiredByKeys = { 22 | [P in keyof T as P extends K ? never : P]: T[P] 23 | } & { 24 | [P in keyof T as P extends K ? P : never]-?: T[P] 25 | } extends infer I 26 | ? { [P in keyof I]: I[P] } 27 | : never 28 | 29 | export type Merge = { 30 | [K in keyof T]: T[K] 31 | } 32 | 33 | export type OptionalByKey = Omit & { [Key in K]?: T[Key] } 34 | 35 | export type InferKeys = T extends Record ? K : never; 36 | 37 | export type OptionalUndefined< 38 | T, 39 | Props extends keyof T = keyof T, 40 | OptionsProps extends keyof T = 41 | Props extends keyof T ? 42 | undefined extends T[Props] ? 43 | Props : never 44 | : never 45 | > = 46 | Prettify]: T[K] 50 | }>> 51 | 52 | export type IndexType = { 53 | [K in keyof T]: T[K] 54 | } & { 55 | [K in string]: Index 56 | } 57 | 58 | export type OmitMany = Omit 59 | 60 | export type OmitByValue = { 61 | [K in keyof T as T[K] extends V ? never: K]: T[K] 62 | } 63 | 64 | export type PickMany = Pick 65 | -------------------------------------------------------------------------------- /src/types/string.d.ts: -------------------------------------------------------------------------------- 1 | export type Length = S extends `${infer F0}${infer F1}${infer F2}${infer F3}${infer F4}${infer F5}${infer F6}${infer F7}${infer F8}${infer F9}${infer R}` ? Length 2 | : S extends `${infer F}${infer R}` ? 3 | Length 4 | : C['length'] 5 | 6 | export type Reverse = S extends `${infer First}${infer Rest}` ? `${Reverse}${First}` : '' 7 | 8 | export type UUID = `${string}-${string}-${string}-${string}-${string}` 9 | 10 | export type Email = `${string}@${string}.${string}` 11 | 12 | export type JoinArray< 13 | Arr extends readonly string[], 14 | Result extends string = '' 15 | > = Arr extends [infer First, ...infer Rest] ? JoinArray : Result 16 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Boolean } from './types/index'; 2 | 3 | export const assertEqualType = (val: Boolean.Equals) => val; 4 | -------------------------------------------------------------------------------- /tests/array.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, assertType } from 'vitest' 2 | 3 | import { array, infer, number, object, s, SchemaBuilder, string } from '../src' 4 | 5 | const empty = s.array() 6 | const minTwo = s.array(s.string()).minLength(2); 7 | const maxTwo = s.array(s.string()).maxLength(2); 8 | const justTwo = s.array(s.string()).length(2); 9 | const intNum = s.array(s.string()).nonEmpty(); 10 | const nonEmptyMax = s.array(s.string()).nonEmpty().maxLength(2); 11 | const nonEmpty = s.array(s.string()).nonEmpty(); 12 | 13 | test('types', () => { 14 | type t0 = s.infer 15 | assertType([]) 16 | 17 | type t1 = s.infer; 18 | type A = typeof nonEmptyMax 19 | 20 | assertType(['string', 'sd']); 21 | 22 | // @ts-expect-error numbers are not acepted 23 | assertType(['string', 'sd', 123]); 24 | 25 | type t2 = s.infer; 26 | 27 | 28 | // @ts-expect-error only 2 or more items 29 | assertType(['qwe']); 30 | 31 | assertType(['qwe', 'zxc']); 32 | 33 | type t3 = s.infer 34 | assertType(['qwe', 'asd', 'zxcs']); 35 | 36 | type t4 = s.infer; 37 | // less items allowed 38 | assertType(['1']) 39 | assertType(['1', '2']) 40 | // @ts-expect-error only 2 items allowed 41 | assertType(['1', '2', '3']) 42 | 43 | // @ts-expect-error minLength !== length 44 | s.array().minLength(2).length(3) 45 | 46 | type t5 = s.infer 47 | assertType(['asd', 'zxc']) 48 | // @ts-expect-error expect exact 2 length 49 | assertType(['only 1']) 50 | // @ts-expect-error expect exact 2 length 51 | assertType(['only 1', 'asd', 'zxc']) 52 | }) 53 | 54 | test('create invalid schema', () => { 55 | // @ts-expect-error error here 56 | s.array().minLength(2).maxLength(1) 57 | // @ts-expect-error negative 58 | expect(() => s.array().length(-2)).toThrow(TypeError) 59 | }) 60 | 61 | test("passing validations", () => { 62 | expect(minTwo.schema).toMatchObject({ 63 | type: "array", 64 | items: { 65 | type: "string" 66 | }, 67 | minItems: 2 68 | }) 69 | minTwo.parse(["a", "a"]); 70 | minTwo.parse(["a", "a", "a"]); 71 | maxTwo.parse(["a", "a"]); 72 | maxTwo.parse(["a"]); 73 | justTwo.parse(["a", "a"]); 74 | intNum.parse(["a"]); 75 | nonEmptyMax.parse(["a"]); 76 | }); 77 | 78 | test("parse should fail given sparse array", () => { 79 | const schema = s.array(s.string()).nonEmpty().minLength(1).maxLength(3); 80 | 81 | expect(() => schema.parse(new Array(3))).toThrow(); 82 | }); 83 | 84 | 85 | test('invariant for array schema', () => { 86 | const str = s.string().array() 87 | const obj = s.object({ 88 | qwe: s.string().optional(), 89 | num: s.number(), 90 | }).array() 91 | 92 | type Obj = s.infer 93 | 94 | type Str = s.infer 95 | 96 | assertType(['']) 97 | assertType([{ qwe: 'qwe', num: 123 }, { num: 456 }]) 98 | }) 99 | 100 | test('addItems should append the schema for array', () => { 101 | const str = empty.addItems(s.string()) 102 | type T = s.infer; 103 | assertType(['asd', 123, 'a']) 104 | expect(str.schema).toMatchObject({ 105 | type: 'array', 106 | minItems: 0, 107 | items: [{ 108 | type: 'string' 109 | }] 110 | }) 111 | 112 | }) 113 | 114 | test('addItems should append array of elements for empty definition', () => { 115 | const empty = s.array() 116 | expect(empty.schema).toMatchObject({ 117 | type: 'array', 118 | minItems: 0, 119 | items: {} 120 | }) 121 | const nonEmpty = empty.addItems(s.string()) 122 | expect(nonEmpty.schema).toMatchObject({ 123 | type: 'array', 124 | minItems: 0, 125 | items: [{ type: 'string' }] 126 | }) 127 | }) 128 | 129 | test('addItems should replace items=false value', () => { 130 | const arr = s.array() 131 | arr.schema.items = false 132 | arr.addItems(s.string()) 133 | expect(arr.schema).toMatchObject({ 134 | type: 'array', 135 | minItems: 0, 136 | items: [{ 137 | type: 'string' 138 | }] 139 | }) 140 | 141 | }) 142 | 143 | test('element should returns SchemaBuilder instance', () => { 144 | const elemSchema = nonEmpty.element 145 | assertType>('asd') 146 | expect(elemSchema).toBeInstanceOf(s.SchemaBuilder) 147 | expect(elemSchema.schema).toMatchObject({ 148 | type: 'string' 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /tests/base.test.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv'; 2 | import { test, expect, assertType } from 'vitest' 3 | 4 | import s from '../src' 5 | 6 | test("type guard", () => { 7 | const str = s.string(); 8 | 9 | const s1 = s.object({ 10 | stringToNumber: str, 11 | }); 12 | type t1 = s.input; 13 | 14 | const data = { stringToNumber: "asdf" }; 15 | assertType(true) 16 | }); 17 | 18 | test('should support AJV custom ajv instance', () => { 19 | const newAjv = new Ajv() 20 | 21 | const newS = s.create(newAjv) 22 | 23 | const a = newS.number() 24 | 25 | expect(a.ajv).toBe(newAjv) 26 | expect(a.ajv).toBeInstanceOf(Ajv) 27 | }) 28 | 29 | test('postProcess should should transform output result', () => { 30 | const myNum = s.number().postprocess(v => String(v) as '1', s.string()) 31 | 32 | const res = myNum.parse(1) 33 | 34 | expect(res).toBe('1') 35 | }) 36 | 37 | test('preprocess should transform input result', () => { 38 | const envParsingSchema = s.boolean().preprocess(x => (String(x) === 'true' || String(x) === '1')) 39 | 40 | expect(envParsingSchema.parse('true')).toBe(true) 41 | }) 42 | 43 | test('preprocess should throw for unconsistant schema', () => { 44 | const numberSchema = s.number().preprocess(x => String(x)) 45 | expect(() => numberSchema.parse('hello')).toThrow(Error) 46 | }) 47 | 48 | test("test this binding", () => { 49 | const callback = (predicate: (val: string) => boolean) => { 50 | return predicate("hello"); 51 | }; 52 | 53 | expect(callback((value) => s.string().safeParse(value).success)).toBe(true); // true 54 | expect(callback((value) => s.string().safeParse(value).success)).toBe(true); // true 55 | }); 56 | 57 | test('cusom error support', () => { 58 | const schema = s.number().error('this is not a number') 59 | 60 | const { error } = schema.safeParse("not a number") 61 | 62 | expect(error).toBeInstanceOf(Error) 63 | expect(error?.message).toBe("this is not a number") 64 | expect(error?.cause).toBeDefined() 65 | }) 66 | 67 | test('keyof support', () => { 68 | const keys = s.keyof(s.object({ 69 | a: s.string(), 70 | b: s.object({ 71 | c: s.number(), 72 | }) 73 | })) 74 | expect(keys.schema).toMatchObject({ 75 | anyOf: [{ 76 | const: 'a' 77 | }, { 78 | const: 'b' 79 | }] 80 | }) 81 | }) 82 | 83 | test('never support', () => { 84 | const never = s.never() 85 | expect(never.schema).toMatchObject({ 86 | not: {}, 87 | }) 88 | }) 89 | 90 | test('not function support', () => { 91 | const notString = s.not(s.string()) 92 | 93 | expect(notString.schema).toMatchObject({ 94 | not: { type: "string" } 95 | }) 96 | 97 | const okNumber = notString.validate(42) 98 | const okObject = notString.validate({ "key": "value" }) 99 | const failString = notString.validate("I am a string") 100 | expect(okNumber).toBe(true) 101 | expect(okObject).toBe(true) 102 | expect(failString).toBe(false) 103 | }) 104 | 105 | test('not builder support', () => { 106 | const notStringSchema = s.string().not() 107 | 108 | expect(notStringSchema.schema).toMatchObject({ 109 | not: { type: 'string' } 110 | }) 111 | expect(notStringSchema.validate(52)).toBe(true) 112 | expect(notStringSchema.validate('random string')).toBe(false) 113 | }) 114 | 115 | test('exclude builder support', () => { 116 | const res = s.string().exclude(s.const('Jerry')) 117 | expect(res.schema).toMatchObject({ 118 | type: 'string', 119 | not: { const: 'Jerry' } 120 | }) 121 | 122 | expect(res.validate('random string')).toBe(true) 123 | 124 | expect(res.validate(123)).toBe(false) 125 | expect(res.validate('Jerry')).toBe(false) 126 | 127 | s.object({ 128 | a: s.number() 129 | }).and(s.object({ 130 | b: s.string() 131 | }))['_output'] 132 | }) 133 | 134 | test('should throws for "undefined" value for nullable schema', () => { 135 | const str = s.string().nullable() 136 | 137 | expect(() => str.parse(undefined)).toThrow(Error) 138 | }) 139 | 140 | test('async schema', () => { 141 | const Schema = s.object({ 142 | name: s.string() 143 | }).async() 144 | 145 | const a = Schema.parse({ name: 'hello' }) 146 | expect(Schema.schema).toMatchObject({ 147 | type: 'object', 148 | $async: true, 149 | properties: { 150 | name: { type: 'string' } 151 | } 152 | }) 153 | 154 | expect(a.name).toBe('hello') 155 | }) 156 | 157 | test('make sync schema', () => { 158 | const async = s.object({}).async() 159 | 160 | expect(async.schema).toMatchObject({ 161 | $async: true, 162 | type: 'object', 163 | properties: {}, 164 | }) 165 | const sync = async.sync() 166 | 167 | expect(sync.schema).toMatchObject({ 168 | type: 'object', 169 | $async: false, 170 | properties: {}, 171 | }) 172 | 173 | 174 | const syncRemoved = sync.sync(true) 175 | expect(syncRemoved.schema).toMatchObject({ 176 | type: 'object', 177 | properties: {}, 178 | }) 179 | }) 180 | 181 | test('refine should throws custom error', () => { 182 | const Schema = s.object({ 183 | active: s.boolean(), 184 | name: s.string() 185 | }).array().refine((arr) => { 186 | const subArr = arr.filter(el => el.active === true) 187 | if (subArr.length > 1) throw new Error('Array should contains only 1 "active" element') 188 | }) 189 | 190 | const result = Schema.safeParse([{ active: true, name: 'some 1' }, { active: true, name: 'some 2' }]) 191 | 192 | expect(result.success).toBe(false) 193 | expect(result.error).toBeInstanceOf(Error) 194 | expect(result.error?.message).toBe('Array should contains only 1 "active" element') 195 | }) 196 | 197 | test('refine should throws default error', () => { 198 | const Schema = s.object({ 199 | active: s.boolean(), 200 | name: s.string() 201 | }).array().refine((arr) => { 202 | const subArr = arr.filter(el => el.active === true) 203 | if (subArr.length > 1) return new Error('Array should contains only 1 "active" element') 204 | }) 205 | 206 | const result = Schema.safeParse([{ active: true, name: 'some 1' }, { active: true, name: 'some 2' }]) 207 | 208 | expect(result.success).toBe(false) 209 | expect(result.error).toBeInstanceOf(Error) 210 | expect(result.error?.message).toBe('refine error') 211 | }) 212 | 213 | test('refine should throws TypeError for not a function', () => { 214 | // @ts-expect-error 215 | expect(() => s.number().refine(false)).toThrow(TypeError) 216 | }) 217 | 218 | test('default should not allow to use in root', () => { 219 | const num = s.number().default(60000) 220 | expect(() => num.parse()).toThrow(Error) 221 | }) 222 | 223 | test('default should support property in object', () => { 224 | const ObjSchema = s.object({ 225 | age: s.int().default(18) 226 | }) 227 | const parsed = ObjSchema.parse({}) 228 | expect(parsed).toMatchObject({ 229 | age: 18 230 | }) 231 | }) 232 | 233 | test('default should support object', () => { 234 | const ObjSchema = s.object({ 235 | age: s.int().default(18) 236 | }) 237 | 238 | const parsed1 = ObjSchema.parse({}) 239 | expect(parsed1).toMatchObject({ 240 | age: 18 241 | }) 242 | 243 | expect(() => ObjSchema.parse(null)).toThrow(Error) 244 | }) 245 | 246 | test('should support schema overriding', () => { 247 | 248 | const MyJsonSchema = { 249 | "title": "Example Schema", 250 | "type": "object", 251 | "properties": { 252 | "name": { 253 | "type": "string" 254 | }, 255 | "age": { 256 | "description": "Age in years", 257 | "type": "integer", 258 | "minimum": 0 259 | }, 260 | }, 261 | "required": ["name", "age"] 262 | } as const 263 | 264 | type CustomObject = { 265 | name: string; 266 | age: number 267 | } 268 | const AnySchema = s.any() 269 | AnySchema.schema = MyJsonSchema 270 | 271 | const parsed = AnySchema.parse({ name: 'hello', age: 18 }) 272 | expect(parsed).toMatchObject({ 273 | name: 'hello', 274 | age: 18 275 | }) 276 | 277 | const Obj = s.object() 278 | Obj.schema = MyJsonSchema 279 | assertType extends CustomObject ? true : false>(true) 280 | }) 281 | 282 | test('should shape update schema property too', () => { 283 | const NumberSchema = { type: 'number', const: 123 } as const 284 | 285 | const AnySchema = s.any() 286 | AnySchema.shape = NumberSchema 287 | expect(AnySchema.schema).toMatchObject(NumberSchema) 288 | }) 289 | 290 | test('should schema update shape property too', () => { 291 | const NumberSchema = { type: 'number', const: 123 } as const 292 | 293 | const AnySchema = s.any() 294 | AnySchema.schema = NumberSchema 295 | expect(AnySchema.shape).toMatchObject(NumberSchema) 296 | }) 297 | 298 | test('preprocess should work', () => { 299 | const ss = 300 | s.string() 301 | const ssPre = ss.preprocess(() => '1' as const) 302 | expect(ssPre.parse()).toBe('1') 303 | }) 304 | 305 | test('should postprocess work', () => { 306 | const ss = 307 | s.string() 308 | const ssPre = ss.preprocess(() => '1' as const) 309 | expect(ssPre.parse('qwe')).toBe('1') 310 | }) 311 | 312 | test('fromJSON should work', () => { 313 | const qwe = s.fromJSON({ someProp: 'qwe' }, s.number()) 314 | expect(qwe.schema.someProp).toBe('qwe') 315 | expect(qwe.schema.type).toBe('number') 316 | }) 317 | 318 | test('example should work for array of examples', () => { 319 | const stringSchema = s.string().examples(['asd', 'zxc']) 320 | expect(stringSchema.schema).toMatchObject({ 321 | type: 'string', 322 | examples: ['asd', 'zxc'], 323 | }) 324 | }) 325 | 326 | test('example should work for spread array of examples', () => { 327 | const stringSchema1 = s.string().examples('asd', 'zxc') 328 | const stringSchema2 = s.string().examples('asd') 329 | expect(stringSchema1.schema).toMatchObject({ 330 | type: 'string', 331 | examples: ['asd', 'zxc'], 332 | }) 333 | expect(stringSchema2.schema).toMatchObject({ 334 | type: 'string', 335 | examples: ['asd'], 336 | }) 337 | }) 338 | 339 | test('example should throw type error for not output type', () => { 340 | // @ts-expect-error will throws 341 | const s1 = s.number().examples('asd', 'zxc') 342 | // NOTE: only typescript checking. Any schema is valid 343 | expect(s1.schema).toMatchObject({ 344 | type: 'number', 345 | examples: ['asd', 'zxc'] 346 | }) 347 | // @ts-expect-error will throws 348 | const s2 = s.number().examples(['asd', 'zxc']) 349 | // NOTE: only typescript checking. Any schema is valid 350 | expect(s2.schema).toMatchObject({ 351 | type: 'number', 352 | examples: ['asd', 'zxc'] 353 | }) 354 | }) 355 | 356 | test('examples in meta should not throw for not an array', () => { 357 | expect(() => s.string().meta({ 358 | examples: 'asd' 359 | })).not.toThrowError(TypeError) 360 | expect(s.string().meta({ 361 | examples: 'asd' 362 | }).schema).toMatchObject({ 363 | type: 'string', 364 | examples: ['asd'] 365 | }) 366 | }) 367 | 368 | test('examples in meta should use in output schema', () => { 369 | expect(s.string().meta({ 370 | examples: ['foo'] 371 | }).schema).toMatchObject({ 372 | type: 'string', examples: ['foo'] 373 | }) 374 | }) 375 | -------------------------------------------------------------------------------- /tests/const.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import * as s from "../src"; 4 | 5 | const constTuna = s.const("tuna"); 6 | const constFortyTwo = s.const(42); 7 | const constTrue = s.const(true); 8 | 9 | const terrificSymbol = Symbol("terrific"); 10 | const literalTerrificSymbol = s.literal(terrificSymbol as never); 11 | const date = new Date() 12 | const constDate = s.literal(date) 13 | 14 | test("passing validations", () => { 15 | constTuna.parse("tuna"); 16 | constFortyTwo.parse(42); 17 | constTrue.parse(true); 18 | constDate.parse(date) 19 | }); 20 | 21 | test("failing validations", () => { 22 | expect(() => constTuna.parse("shark")).toThrow(); 23 | expect(() => constFortyTwo.parse(43)).toThrow(); 24 | expect(() => constTrue.parse(false)).toThrow(); 25 | // symbol is not supported in JSON-schema 26 | expect(() => literalTerrificSymbol.parse(terrificSymbol)).toThrow() 27 | }); 28 | 29 | test("invalid_const should have `received` field with data", () => { 30 | const data = "shark"; 31 | const result = constTuna.safeParse(data); 32 | if (!result.success) { 33 | expect(result.error).toBeInstanceOf(Error) 34 | } 35 | }); -------------------------------------------------------------------------------- /tests/enum.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, assertType } from 'vitest' 2 | 3 | import s from '../src' 4 | 5 | test("create enum", () => { 6 | const MyEnum = s.enum(["Red", "Green", "Blue"]); 7 | expect(MyEnum.enum.Red).toEqual("Red"); 8 | expect(MyEnum.enum.Blue).toEqual("Blue"); 9 | expect(MyEnum.enum.Green).toEqual("Green"); 10 | }); 11 | 12 | test("infer enum", () => { 13 | const MyEnum = s.enum(["Red", "Green", "Blue"]); 14 | type MyEnum = s.infer; 15 | assertType('Red') 16 | assertType('Green') 17 | assertType('Blue') 18 | }); 19 | 20 | test("get options", () => { 21 | expect(s.enum(["tuna", "trout"]).options).toEqual(["tuna", "trout"]); 22 | }); 23 | 24 | test("readonly enum", () => { 25 | const HTTP_SUCCESS = ["200", "201"] as const; 26 | const arg = s.enum(HTTP_SUCCESS); 27 | type arg = s.infer; 28 | assertType('200') 29 | assertType('201') 30 | 31 | arg.parse("201"); 32 | expect(() => arg.parse("202")).toThrow(); 33 | }); 34 | 35 | 36 | test("nativeEnum test with consts", () => { 37 | const Fruits: { Apple: "apple"; Banana: "banana" } = { 38 | Apple: "apple", 39 | Banana: "banana", 40 | }; 41 | const fruitEnum = s.enum(Fruits); 42 | type fruitEnum = s.infer; 43 | fruitEnum.parse("apple"); 44 | fruitEnum.parse("banana"); 45 | fruitEnum.parse(Fruits.Apple); 46 | fruitEnum.parse(Fruits.Banana); 47 | assertType>("apple") 48 | assertType>("banana") 49 | }); 50 | 51 | test("nativeEnum test with real enum", () => { 52 | enum Fruits { 53 | Apple = "apple", 54 | Banana = "banana", 55 | } 56 | const fruitEnum = s.enum(Fruits); 57 | type fruitEnum = s.infer; 58 | fruitEnum.parse("apple"); 59 | fruitEnum.parse("banana"); 60 | fruitEnum.parse(Fruits.Apple); 61 | fruitEnum.parse(Fruits.Banana); 62 | assertType(true) 63 | }); 64 | 65 | test("nativeEnum test with const with numeric keys", () => { 66 | const FruitValues = { 67 | Apple: 10, 68 | Banana: 20, 69 | } as const; 70 | const fruitEnum = s.enum(FruitValues); 71 | type fruitEnum = s.infer; 72 | fruitEnum.parse(10); 73 | fruitEnum.parse(20); 74 | fruitEnum.parse(FruitValues.Apple); 75 | fruitEnum.parse(FruitValues.Banana); 76 | assertType(10) 77 | assertType(20) 78 | }); 79 | 80 | test("from enum", () => { 81 | enum Fruits { 82 | Cantaloupe, 83 | Apple = "apple", 84 | Banana = "banana", 85 | } 86 | 87 | const FruitEnum = s.enum(Fruits); 88 | type FruitEnum = s.infer; 89 | FruitEnum.parse(Fruits.Cantaloupe); 90 | FruitEnum.parse(Fruits.Apple); 91 | FruitEnum.parse("apple"); 92 | FruitEnum.parse(0); 93 | expect(() => FruitEnum.parse(1)).toThrow(); 94 | expect(() => FruitEnum.parse("Apple")).toThrow(); 95 | expect(() => FruitEnum.parse("Cantaloupe")).not.toThrow(); 96 | }); 97 | 98 | test("from const", () => { 99 | const Greek = { 100 | Alpha: "a", 101 | Beta: "b", 102 | Gamma: 3, 103 | } as const; 104 | 105 | const GreekEnum = s.enum(Greek); 106 | type GreekEnum = s.infer; 107 | GreekEnum.parse("a"); 108 | GreekEnum.parse("b"); 109 | GreekEnum.parse(3); 110 | expect(() => GreekEnum.parse("v")).toThrow(); 111 | expect(() => GreekEnum.parse("Alpha")).toThrow(); 112 | expect(() => GreekEnum.parse(2)).toThrow(); 113 | 114 | expect(GreekEnum.enum.Alpha).toEqual("a"); 115 | }); 116 | 117 | test('#61 nullable enum', () => { 118 | const sex = s.enum(['male', 'female']); 119 | const nullableSex = s.enum(['male', 'female']).nullable(); 120 | 121 | const optionalNullableObj = s.object({ 122 | sex: s.enum(['male', 'female']).nullable().optional(), 123 | }); 124 | 125 | expect(sex.parse('male')).toBe('male'); 126 | expect(sex.parse('female')).toBe('female'); 127 | expect(sex.validate(null)).toBe(false); 128 | expect(sex.validate(undefined)).toBe(false); 129 | 130 | expect(nullableSex.parse('male'),).toBe('male'); 131 | expect(nullableSex.parse('female'),).toBe('female'); 132 | expect(nullableSex.validate(null)).toBe(true); 133 | expect(nullableSex.validate(undefined)).toBe(false); 134 | }) 135 | -------------------------------------------------------------------------------- /tests/error.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | 3 | import { s } from "../src"; 4 | 5 | test("Default error should work", () => { 6 | const StringSchema = s.string().error("Default ERROR here"); 7 | 8 | const result = StringSchema.safeParse(123); 9 | expect(result.error).toBeInstanceOf(Error); 10 | expect(result.error?.message).toBe("Default ERROR here"); 11 | }); 12 | 13 | test("Default error as placeholder in object should work", () => { 14 | const StringSchema = s.string().error({ _: "any error here" }); 15 | 16 | const result = StringSchema.safeParse(123); 17 | expect(result.error).toBeInstanceOf(Error); 18 | expect(result.error?.message).toBe("any error here"); 19 | }); 20 | 21 | test("Type error should work", () => { 22 | const StringSchema = s 23 | .string() 24 | .error({ _: "any error here", type: "not a string. Custom" }); 25 | 26 | const result = StringSchema.safeParse(123); 27 | expect(result.error).toBeInstanceOf(Error); 28 | expect(result.error?.message).toBe("not a string. Custom"); 29 | }); 30 | 31 | test("properties error should work", () => { 32 | const Schema = s 33 | .object({ 34 | foo: s.string(), 35 | }) 36 | .strict() 37 | .error({ additionalProperties: "Not expected to pass additional props" }); 38 | 39 | const result = Schema.safeParse({ foo: "qwe", abc: 123 }); 40 | expect(result.error).toBeInstanceOf(Error); 41 | expect(result.error?.message).toBe("Not expected to pass additional props"); 42 | }); 43 | 44 | test("properties error should work", () => { 45 | const Schema = s 46 | .object({ 47 | foo: s.integer().minimum(2), 48 | bar: s.string().minLength(2), 49 | }) 50 | .strict() 51 | .error({ 52 | properties: { 53 | foo: "data.foo should be integer >= 2", 54 | bar: "data.bar should be string with length >= 2", 55 | }, 56 | }); 57 | 58 | const result = Schema.safeParse({ foo: 1, bar: "a" }); 59 | expect(result.error).toBeInstanceOf(Error); 60 | expect(result.error?.message).toBe("data.foo should be integer >= 2"); 61 | expect(result.error?.cause).toBeDefined(); 62 | expect(result.error?.cause).toBeInstanceOf(Array); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/nullable.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'vitest' 2 | 3 | import { s } from '../src' 4 | 5 | test("Should have error messages appropriate for the underlying type", () => { 6 | s.string().minLength(2).nullable().parse(null); 7 | s.number().gte(2).nullable().parse(null); 8 | s.boolean().nullable().parse(null); 9 | s.null().nullable().parse(null); 10 | s.null().nullable().parse(null); 11 | s.object({}).nullable().parse(null); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/number.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest' 2 | 3 | import s from '../src' 4 | 5 | const gtFive = s.number().gt(5); 6 | const gteFive = s.number().gte(5); 7 | const minFive = s.number().minimum(5); 8 | const ltFive = s.number().lt(5); 9 | const lteFive = s.number().lte(5); 10 | const maxFive = s.number().max(5); 11 | const intNum = s.number().format('int32'); 12 | const positive = s.number().positive(); 13 | const negative = s.number().negative(); 14 | const nonpositive = s.number().nonpositive(); 15 | const nonnegative = s.number().nonnegative(); 16 | const multipleOfFive = s.number().multipleOf(5); 17 | const multipleOfNegativeFive = s.number().multipleOf(-5); 18 | const safe = s.number().safe(); 19 | const stepPointOne = s.number().multipleOf(0.1); 20 | const stepPointseroseroseroOne = s.number().step(0.0001); 21 | const stepSixPointFour = s.number().step(6.4); 22 | 23 | test("passing validations", () => { 24 | s.number().parse(1); 25 | s.number().parse(1.5); 26 | s.number().parse(0); 27 | s.number().parse(-1.5); 28 | s.number().parse(-1); 29 | gtFive.parse(6); 30 | gteFive.parse(5); 31 | minFive.parse(5); 32 | ltFive.parse(4); 33 | lteFive.parse(5); 34 | maxFive.parse(5); 35 | intNum.parse(4); 36 | positive.parse(1); 37 | negative.parse(-1); 38 | nonpositive.parse(0); 39 | nonpositive.parse(-1); 40 | nonnegative.parse(0); 41 | nonnegative.parse(1); 42 | multipleOfFive.parse(15); 43 | multipleOfFive.parse(-15); 44 | multipleOfNegativeFive.parse(-15); 45 | multipleOfNegativeFive.parse(15); 46 | safe.parse(Number.MIN_SAFE_INTEGER); 47 | safe.parse(Number.MAX_SAFE_INTEGER); 48 | stepSixPointFour.parse(12.8); 49 | }); 50 | 51 | test("failing validations", () => { 52 | expect(() => ltFive.parse(5)).toThrow(); 53 | expect(() => lteFive.parse(6)).toThrow(); 54 | expect(() => maxFive.parse(6)).toThrow(); 55 | expect(() => gtFive.parse(5)).toThrow(); 56 | expect(() => gteFive.parse(4)).toThrow(); 57 | expect(() => minFive.parse(4)).toThrow(); 58 | expect(() => intNum.parse(3.14)).toThrow(); 59 | expect(() => positive.parse(0)).toThrow(); 60 | expect(() => positive.parse(-1)).toThrow(); 61 | expect(() => negative.parse(0)).toThrow(); 62 | expect(() => negative.parse(1)).toThrow(); 63 | expect(() => nonpositive.parse(1)).toThrow(); 64 | expect(() => nonnegative.parse(-1)).toThrow(); 65 | expect(() => multipleOfFive.parse(7.5)).toThrow(); 66 | expect(() => multipleOfFive.parse(-7.5)).toThrow(); 67 | expect(() => multipleOfNegativeFive.parse(-7.5)).toThrow(); 68 | expect(() => multipleOfNegativeFive.parse(7.5)).toThrow(); 69 | expect(() => safe.parse(Number.MIN_SAFE_INTEGER - 1)).toThrow(); 70 | expect(() => safe.parse(Number.MAX_SAFE_INTEGER + 1)).toThrow(); 71 | 72 | expect(() => stepPointOne.parse(6.11)).toThrow(); 73 | expect(() => stepPointOne.parse(6.1000000001)).toThrow(); 74 | expect(() => stepSixPointFour.parse(6.41)).toThrow(); 75 | expect(() => stepPointseroseroseroOne.parse(3.01)).toThrow() 76 | }); 77 | 78 | test("parse NaN", () => { 79 | expect(() => s.number().parse(NaN)).toThrow(); 80 | }); 81 | 82 | test('number builder should pass only numbers', () => { 83 | const schema = s.number() 84 | 85 | expect(schema.schema).toMatchObject({ 86 | type: 'number' 87 | }) 88 | expect(schema.validate("qwe")).toBe(false) 89 | expect(schema.validate({})).toBe(false) 90 | expect(schema.validate(null)).toBe(false) 91 | expect(schema.validate(() => { })).toBe(false) 92 | expect(schema.validate(123)).toBe(true) 93 | expect(schema.validate(12.4)).toBe(true) 94 | }) 95 | test('number builder "int32" format should supports only integers', () => { 96 | const schema = s.number().format('int32').maximum(300) 97 | 98 | expect(schema.schema).toMatchObject({ 99 | type: 'number', 100 | format: 'int32' 101 | }) 102 | expect(schema.validate("qwe")).toBe(false) 103 | expect(schema.validate({})).toBe(false) 104 | expect(schema.validate(null)).toBe(false) 105 | expect(schema.validate(() => { })).toBe(false) 106 | expect(schema.validate(123)).toBe(true) 107 | expect(schema.validate(400)).toBe(false) 108 | expect(schema.validate(12.4)).toBe(false) 109 | }) 110 | 111 | test('integer should supports only integers', () => { 112 | const schema = s.integer().maximum(300) 113 | 114 | expect(schema.schema).toMatchObject({ 115 | type: 'integer', 116 | }) 117 | expect(schema.validate("qwe")).toBe(false) 118 | expect(schema.validate({})).toBe(false) 119 | expect(schema.validate(null)).toBe(false) 120 | expect(schema.validate(() => { })).toBe(false) 121 | expect(schema.validate(123)).toBe(true) 122 | expect(schema.validate(400)).toBe(false) 123 | expect(schema.validate(12.4)).toBe(false) 124 | }) 125 | 126 | test('incompatible format should fail type', () => { 127 | // @ts-expect-error should fails 128 | const schema1 = s.int().format('double') 129 | // @ts-expect-error should fails 130 | const schema2 = s.int().format('float') 131 | // @ts-expect-error should fails 132 | const schema3 = s.int().const(3.4) 133 | 134 | // @ts-expect-error should fails 135 | const schema4 = s.int().max(3.4) 136 | // @ts-expect-error should fails 137 | const schema5 = s.int().min(3.4) 138 | // @ts-expect-error should fails 139 | const schema6 = s.int().const(3.4) 140 | }) 141 | 142 | test('ranges should fails for out of range', () => { 143 | // @ts-expect-error should fails 144 | s.int().min(1).max(3).const(-1) 145 | 146 | // @ts-expect-error should fails 147 | s.int().min(5).max(3) 148 | // @ts-expect-error should fails 149 | s.int().max(2).min(3) 150 | }) 151 | -------------------------------------------------------------------------------- /tests/object.test.ts: -------------------------------------------------------------------------------- 1 | import { assertType, expect, expectTypeOf, test } from "vitest"; 2 | 3 | import { assertEqualType } from "../src/utils"; 4 | import s from "../src"; 5 | 6 | const Test = s.object({ 7 | f1: s.number(), 8 | f2: s.string().optional(), 9 | f3: s.string().nullable(), 10 | f4: s 11 | .object({ 12 | t: s.union(s.string(), s.boolean()), 13 | }) 14 | .array(), 15 | }); 16 | 17 | type Test = s.infer; 18 | 19 | test("object type inference", () => { 20 | type TestType = { 21 | f1: number; 22 | f2?: string | undefined; 23 | f3: string | null; 24 | f4: { t: string | boolean }[]; 25 | }; 26 | 27 | assertEqualType(true); 28 | }); 29 | 30 | test("unknown throw", () => { 31 | const asdf: unknown = 35; 32 | expect(() => Test.parse(asdf)).toThrow(); 33 | }); 34 | 35 | test("correct parsing", () => { 36 | Test.parse({ 37 | f1: 12, 38 | f2: "string", 39 | f3: "string", 40 | f4: [ 41 | { 42 | t: "string", 43 | }, 44 | ], 45 | }); 46 | 47 | Test.parse({ 48 | f1: 12, 49 | f3: null, 50 | f4: [ 51 | { 52 | t: false, 53 | }, 54 | ], 55 | }); 56 | }); 57 | 58 | test("nonstrict by default", () => { 59 | s.object({ points: s.number() }).parse({ 60 | points: 2314, 61 | unknown: "asdf", 62 | }); 63 | }); 64 | 65 | const data = { 66 | points: 2314, 67 | unknown: "asdf", 68 | }; 69 | 70 | test("unknownkeys override", () => { 71 | const val = s 72 | .object({ points: s.number() }) 73 | .strict() 74 | .passthrough() 75 | .parse(data); 76 | 77 | expect(val).toEqual(data); 78 | }); 79 | 80 | test("passthrough unknown", () => { 81 | const val = s.object({ points: s.number() }).passthrough().parse(data); 82 | 83 | expect(val).toEqual(data); 84 | }); 85 | 86 | test("strict", () => { 87 | const val = s.object({ points: s.number() }).strict().safeParse(data); 88 | 89 | expect(val.success).toEqual(false); 90 | }); 91 | 92 | test("catchall inference", () => { 93 | const o1 = s.object({ 94 | first: s.string(), 95 | }); 96 | 97 | const d1 = o1.parse({ first: "asdf", num: 1243 }); 98 | assertEqualType(true); 99 | }); 100 | 101 | test("test that optional keys are unset", async () => { 102 | const SNamedEntity = s.object({ 103 | id: s.string(), 104 | set: s.string().optional(), 105 | unset: s.string().optional(), 106 | }); 107 | const result = await SNamedEntity.parse({ 108 | id: "asdf", 109 | set: undefined, 110 | }); 111 | // eslint-disable-next-line ban/ban 112 | expect(Object.keys(result)).toEqual(["id", "set"]); 113 | }); 114 | 115 | test("test nonexistent keys", async () => { 116 | const Schema = s.union( 117 | s.object({ a: s.string() }), 118 | s.object({ b: s.number() }), 119 | ); 120 | const obj = { a: "A" }; 121 | const result = Schema.safeParse(obj); // Works with 1.11.10, breaks with 2.0.0-beta.21 122 | expect(result.success).toBe(true); 123 | }); 124 | 125 | test("test async union", async () => { 126 | const Schema2 = s.union( 127 | s.object({ 128 | ty: s.string(), 129 | }), 130 | s.object({ 131 | ty: s.number(), 132 | }), 133 | ); 134 | 135 | const obj = { ty: "A" }; 136 | const result = Schema2.safeParse(obj); // Works with 1.11.10, breaks with 2.0.0-beta.21 137 | expect(result.success).toEqual(true); 138 | }); 139 | 140 | test("test inferred merged type", async () => { 141 | const a = s.object({ a: s.number() }); 142 | const c = a.extend({ a: s.string() }); 143 | type asdf = s.infer; 144 | assertEqualType(true); 145 | }); 146 | 147 | test("inferred merged object type with optional properties", async () => { 148 | const Merged = s 149 | .object({ a: s.string(), b: s.string().optional() }) 150 | .extend({ a: s.string().optional(), b: s.string() }); 151 | type Merged = s.infer; 152 | assertEqualType(true); 153 | // todo 154 | assertEqualType(true); 155 | }); 156 | 157 | test("inferred unioned object type with optional properties", async () => { 158 | const Unioned = s.union( 159 | s.object({ a: s.string(), b: s.string().optional() }), 160 | s.object({ a: s.string().optional(), b: s.string() }), 161 | ); 162 | type Unioned = s.infer; 163 | assertEqualType< 164 | Unioned, 165 | { a: string; b?: string } | { a?: string; b: string } 166 | >(true); 167 | }); 168 | 169 | test("inferred enum type", async () => { 170 | const Enum = s.object({ a: s.string(), b: s.string().optional() }).keyof(); 171 | 172 | expect(Enum.enum).toMatchObject({ 173 | a: "a", 174 | b: "b", 175 | }); 176 | expect(Enum.options).toEqual(["a", "b"]); 177 | type Enum = s.infer; 178 | assertEqualType(true); 179 | }); 180 | 181 | test("inferred partial object type with optional properties", async () => { 182 | const Partial = s 183 | .object({ a: s.string(), b: s.string().optional() }) 184 | .partial(); 185 | type Partial = s.infer; 186 | assertEqualType(true); 187 | }); 188 | 189 | test("inferred picked object type with optional properties", async () => { 190 | const Picked = s 191 | .object({ a: s.string(), b: s.string().optional() }) 192 | .pick("b"); 193 | type Picked = s.infer; 194 | assertEqualType(true); 195 | }); 196 | 197 | test("inferred type for unknown/any keys", () => { 198 | const myType = s.object({ 199 | anyRequired: s.any(), 200 | anyOptional: s.any().optional(), 201 | unknownOptional: s.unknown().optional(), 202 | unknownRequired: s.unknown(), 203 | }); 204 | type myType = s.infer; 205 | assertEqualType< 206 | myType, 207 | { 208 | anyOptional?: any; 209 | anyRequired?: any; 210 | unknownOptional?: unknown; 211 | unknownRequired?: unknown; 212 | } 213 | >(true); 214 | }); 215 | 216 | test("setKey", () => { 217 | const base = s.object({ name: s.string() }); 218 | const withNewKey = base.extend({ age: s.number() }); 219 | 220 | type withNewKey = s.infer; 221 | assertEqualType(true); 222 | withNewKey.parse({ name: "asdf", age: 1234 }); 223 | }); 224 | 225 | test("strictcreate", async () => { 226 | const strictObj = s 227 | .object({ 228 | name: s.string(), 229 | }) 230 | .strict(); 231 | 232 | const syncResult = strictObj.safeParse({ name: "asdf", unexpected: 13 }); 233 | expect(syncResult.success).toEqual(false); 234 | 235 | const asyncResult = strictObj.safeParse({ name: "asdf", unexpected: 13 }); 236 | expect(asyncResult.success).toEqual(false); 237 | }); 238 | 239 | test("constructor key", () => { 240 | const person = s 241 | .object({ 242 | name: s.string(), 243 | }) 244 | .strict(); 245 | 246 | expect(() => 247 | person.parse({ 248 | name: "bob dylan", 249 | constructor: 61, 250 | }), 251 | ).toThrow(); 252 | }); 253 | 254 | test("constructor key", () => { 255 | const Example = s.object({ 256 | prop: s.string(), 257 | opt: s.number().optional(), 258 | arr: s.array(s.string()), 259 | }); 260 | 261 | type Example = s.infer; 262 | assertEqualType(true); 263 | }); 264 | 265 | const personToExtend = s.object({ 266 | firstName: s.string(), 267 | lastName: s.string(), 268 | }); 269 | 270 | test("extend() should return schema with new key", () => { 271 | const PersonWithNickname = personToExtend.extend({ nickName: s.string() }); 272 | type PersonWithNickname = s.infer; 273 | 274 | const expected = { firstName: "f", nickName: "n", lastName: "l" }; 275 | const actual = PersonWithNickname.parse(expected); 276 | 277 | expect(actual).toEqual(expected); 278 | assertEqualType< 279 | keyof PersonWithNickname, 280 | "firstName" | "lastName" | "nickName" 281 | >(true); 282 | assertEqualType< 283 | PersonWithNickname, 284 | { firstName: string; lastName: string; nickName: string } 285 | >(true); 286 | }); 287 | 288 | test("extend() should have power to override existing key", () => { 289 | const PersonWithNumberAsLastName = personToExtend.extend({ 290 | lastName: s.number(), 291 | }); 292 | type PersonWithNumberAsLastName = s.infer; 293 | 294 | const expected = { firstName: "f", lastName: 42, nickName: "asd" }; 295 | const parseResult = PersonWithNumberAsLastName.safeParse(expected); 296 | 297 | expect(parseResult.success).toBe(true); 298 | expect(parseResult.data).toEqual(expected); 299 | assertEqualType< 300 | PersonWithNumberAsLastName, 301 | { firstName: string; lastName: number } 302 | >(true); 303 | }); 304 | 305 | test("passthrough index signature", () => { 306 | const a = s.object({ a: s.string() }); 307 | type a = s.infer; 308 | assertEqualType<{ a: string }, a>(true); 309 | const b = a.passthrough(); 310 | type b = s.infer; 311 | assertEqualType<{ a: string } & { [k: string]: unknown }, b>(true); 312 | }); 313 | 314 | test("[json schema] dependant requirements", () => { 315 | const Test1 = s 316 | .object({ 317 | name: s.string(), 318 | credit_card: s.number(), 319 | billing_address: s.string(), 320 | }) 321 | .requiredFor("name") 322 | .dependentRequired({ 323 | credit_card: ["billing_address"], 324 | }); 325 | 326 | expect(Test1.schema).toMatchObject({ 327 | type: "object", 328 | properties: { 329 | name: { type: "string" }, 330 | credit_card: { type: "number" }, 331 | billing_address: { type: "string" }, 332 | }, 333 | required: ["name"], 334 | dependentRequired: { 335 | credit_card: ["billing_address"], 336 | }, 337 | }); 338 | 339 | type Result = s.infer; 340 | assertEqualType< 341 | Result, 342 | { 343 | credit_card?: number | undefined; 344 | name: string; 345 | billing_address: string; 346 | } 347 | >(true); 348 | 349 | const Test2 = s 350 | .object({ 351 | name: s.string(), 352 | credit_card: s.number(), 353 | billing_address: s.string(), 354 | }) 355 | .requiredFor("name") 356 | .dependentRequired({ 357 | credit_card: ["billing_address"], 358 | billing_address: ["credit_card"], 359 | }); 360 | 361 | expect(Test2.schema).toMatchObject({ 362 | type: "object", 363 | properties: { 364 | name: { type: "string" }, 365 | credit_card: { type: "number" }, 366 | billing_address: { type: "string" }, 367 | }, 368 | required: ["name"], 369 | dependentRequired: { 370 | credit_card: ["billing_address"], 371 | billing_address: ["credit_card"], 372 | }, 373 | }); 374 | }); 375 | 376 | test("optional properties", () => { 377 | const Test = s 378 | .object({ 379 | qwe: s.string(), 380 | }) 381 | .rest(s.number()); 382 | type T = s.infer; 383 | 384 | assertType({ 385 | qwe: 'qwe', 386 | zxc: 1, 387 | } as never); 388 | }); 389 | 390 | test("object accepts type as generic", () => { 391 | type MyObj = { 392 | age: number; 393 | name: string; 394 | }; 395 | const Schema = s.object(); 396 | 397 | assertEqualType>(true); 398 | }); 399 | 400 | test('#57 merge() should not contains undefined after merge', () => { 401 | 402 | const AjvVehicleSchema = s.object({ 403 | make: s.string(), 404 | model: s.string(), 405 | year: s.number(), 406 | }); 407 | 408 | const AjvTruckSchema = s 409 | .object({ 410 | commercialCapacity: s.number(), 411 | forwardCabin: s.boolean(), 412 | wheels: s.number(), 413 | }) 414 | .merge(AjvVehicleSchema); 415 | 416 | type AjvTruck = s.infer; 417 | 418 | const resp = AjvTruckSchema.safeParse({ 419 | make: 'Bugatti', 420 | model: 'Model T', 421 | year: 2020, 422 | commercialCapacity: 1000, 423 | forwardCabin: true, 424 | wheels: 4 425 | }) 426 | expect(resp.success).toBe(true) 427 | 428 | expect(AjvTruckSchema.schema).toMatchObject( 429 | { 430 | type: 'object', 431 | properties: { 432 | commercialCapacity: { type: 'number' }, 433 | forwardCabin: { type: 'boolean' }, 434 | wheels: { type: 'number' }, 435 | make: { type: 'string' }, 436 | model: { type: 'string' }, 437 | year: { type: 'number' }, 438 | } 439 | }) 440 | assertType({ 441 | make: 'Bugatti', 442 | model: 'Model T', 443 | year: 2020, 444 | commercialCapacity: 1000, 445 | forwardCabin: true, 446 | wheels: 4 447 | }) 448 | }) 449 | 450 | test('#61 optional nullable object schema should parsed successfully', () => { 451 | const optionalNullableObj = s.object({ 452 | sex: s.enum(['male', 'female']).nullable().optional(), 453 | }); 454 | 455 | expect(optionalNullableObj.parse({})).toStrictEqual({}); 456 | expect(optionalNullableObj.parse({ sex: 'male' })).toStrictEqual({ sex: 'male' }); 457 | expect(optionalNullableObj.parse({ sex: null })).toStrictEqual({ sex: null }); 458 | }) 459 | 460 | test('#61 should parse big schema successfully', () => { 461 | const schema2 = s.object({ 462 | name: s.string().minLength(2).maxLength(20), 463 | age: s.number().integer().min(0).max(100), 464 | 465 | // default optional - string | null | undefined 466 | email: s.string().format('email').nullable().optional().default(null), 467 | 468 | //optional - string | null | undefined 469 | phone: s.string().nullable().optional(), 470 | 471 | // required - string | null 472 | surname: s.string().nullable(), 473 | }).strict(); 474 | 475 | expect(schema2.parse({ name: 'Alex', age: 10, })).toStrictEqual({ name: 'Alex', age: 10, email: null }); 476 | }) 477 | -------------------------------------------------------------------------------- /tests/parse.bench.test.ts: -------------------------------------------------------------------------------- 1 | /// NOTE: experimental module 2 | -------------------------------------------------------------------------------- /tests/string.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, assertType } from 'vitest' 2 | 3 | import s from '../src' 4 | 5 | const simpleStr = s.string() 6 | const optionalStr = s.string().optional() 7 | 8 | test('types', () => { 9 | // @ts-expect-error 10 | s.string().length(4).max(2) 11 | // @ts-expect-error invalid range 12 | s.string().minLength(3).maxLength(1) 13 | assertType>('qwe') 14 | assertType>(undefined) 15 | // @ts-expect-error only string available 16 | assertType>(123) 17 | }) 18 | 19 | test('should pass validation', () => { 20 | simpleStr.parse('') 21 | optionalStr.parse('') 22 | optionalStr.parse(null) 23 | expect(() => optionalStr.parse(undefined)).toThrow() 24 | }) 25 | 26 | test.todo('pattern should be applied for RegExp instance', () => { 27 | const etalon = s.string().pattern('^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$') 28 | const withRegExp = s.string().pattern(new RegExp('^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$')) 29 | 30 | etalon.parse() 31 | }) 32 | -------------------------------------------------------------------------------- /tests/union.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest' 2 | 3 | import s from '../src' 4 | 5 | test('#61 enum with constant values should work', () => { 6 | 7 | const externalJSONSchema = { 8 | anyOf: [{ const: 'lt' }, { const: 'gt' }], 9 | } 10 | const schema1 = s.fromJSON(externalJSONSchema); 11 | 12 | const schema2 = s.union(s.literal('lt'), s.literal('gt')); 13 | 14 | expect(schema2.schema).toStrictEqual(externalJSONSchema); 15 | 16 | expect(schema1.parse('gt')).toBe('gt'); // gt 17 | expect(schema1.parse('lt')).toBe('lt'); // lt 18 | 19 | expect(schema2.parse('gt')).toBe('gt'); 20 | }) 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "ESNext" 5 | ], 6 | "module": "esnext", 7 | "target": "esnext", 8 | "moduleResolution": "bundler", 9 | "moduleDetection": "force", 10 | "allowImportingTsExtensions": true, 11 | "noEmit": true, 12 | "strict": true, 13 | "downlevelIteration": true, 14 | "skipLibCheck": true, 15 | "jsx": "preserve", 16 | "allowSyntheticDefaultImports": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "allowJs": true, 19 | }, 20 | "include": [ 21 | "src", 22 | "tests" 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.mts: -------------------------------------------------------------------------------- 1 | import { build, Options } from 'tsup' 2 | 3 | const common: Options = { 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], 6 | external: [], 7 | splitting: true, 8 | cjsInterop: true, 9 | dts: true, 10 | target: ['node18'], 11 | shims: true, 12 | tsconfig: './tsconfig.json', 13 | 14 | } 15 | // minify 16 | await build({ 17 | ...common, 18 | clean: true, 19 | minify: true, 20 | minifySyntax: true, 21 | minifyWhitespace: true, 22 | minifyIdentifiers: true, 23 | outExtension({ format }) { 24 | return { 25 | js: format === 'cjs' ? '.min.cjs' : format === 'esm' ? `.min.mjs` : '.min.js', 26 | } 27 | }, 28 | }) 29 | 30 | await build({ 31 | ...common, 32 | outExtension({ format }) { 33 | return { 34 | js: format === 'cjs' ? '.cjs' : format === 'esm' ? `.mjs` : '.js', 35 | } 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig, defaultExclude } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | test: { 6 | reporters: process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : [], 7 | exclude: [ 8 | ...defaultExclude, 9 | '**/*.bench.test.*' 10 | ], 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------