├── .changeset ├── README.md └── config.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── version-or-publish.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── jsr.json ├── mod.ts ├── package-lock.json ├── package.json ├── src ├── index.esm.mts └── index.ts ├── tests ├── Type.test.ts ├── ValitaError.test.ts ├── ValitaResult.test.ts ├── array.test.ts ├── bigint.test.ts ├── boolean.test.ts ├── lazy.test.ts ├── literal.test.ts ├── never.test.ts ├── null.test.ts ├── number.test.ts ├── object.test.ts ├── ok.test.ts ├── record.test.ts ├── string.test.ts ├── tuple.test.ts ├── undefined.test.ts ├── union.test.ts └── unknown.test.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json └── tsconfig.json /.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@3.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "badrap/valita" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | RUN apk add --no-cache \ 3 | git \ 4 | openssh \ 5 | ripgrep 6 | RUN mkdir -p /workspace && chown node:node /workspace 7 | USER node 8 | RUN mkdir -p /workspace/node_modules /workspace/dist 9 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@badrap/valita", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "postCreateCommand": "npm ci", 7 | "remoteUser": "node", 8 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", 9 | "workspaceFolder": "/workspace", 10 | "mounts": [ 11 | "type=volume,target=/workspace/node_modules", 12 | "type=volume,target=/workspace/dist" 13 | ], 14 | "customizations": { 15 | "vscode": { 16 | "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: "chore" 9 | include: "scope" 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: ["push", "pull_request"] 4 | 5 | # Disable all permissions by default, requiring explicit permission definitions for all jobs. 6 | permissions: {} 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | with: 16 | persist-credentials: false 17 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 18 | with: 19 | node-version: 22 20 | cache: npm 21 | - run: npm ci 22 | - run: npm run lint 23 | - run: npm run typecheck 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /.github/workflows/version-or-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | # Disable all permissions by default, requiring explicit permission definitions for all jobs. 11 | permissions: {} 12 | 13 | jobs: 14 | check: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | persist-credentials: false 22 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 23 | with: 24 | node-version: 22 25 | cache: npm 26 | - run: npm ci 27 | - run: npm run lint 28 | - run: npm run typecheck 29 | - run: npm test 30 | 31 | publish: 32 | needs: check 33 | runs-on: ubuntu-latest 34 | permissions: 35 | contents: write 36 | id-token: write 37 | pull-requests: write 38 | issues: read 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | with: 42 | persist-credentials: false 43 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 44 | with: 45 | node-version: 22 46 | cache: npm 47 | - name: Install dependencies 48 | run: npm ci 49 | - name: Create Release Pull Request or Publish to npm 50 | id: changesets 51 | uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3 52 | with: 53 | version: npm run bump 54 | publish: npm run release 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript", "typescript"], 3 | "files.exclude": { 4 | "node_modules": true, 5 | "dist": true 6 | }, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | "eslint.format.enable": true, 9 | "editor.formatOnSave": true, 10 | "editor.tabSize": 2, 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "[javascript]": { 13 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 14 | }, 15 | "[typescript]": { 16 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @badrap/valita 2 | 3 | ## 0.4.5 4 | 5 | ### Patch Changes 6 | 7 | - [`d312e6d`](https://github.com/badrap/valita/commit/d312e6da5813887d2baa8e7d6806280b9324b4b9) Thanks [@jviide](https://github.com/jviide)! - feat: always normalize custom errors 8 | 9 | Custom errors listed in issue lists (`ValitaError.issue` and `ValitaResult.issue`) are now always normalized to match the type `{ code: "custom_error", path: (string | number)[], message?: string | undefined }`. 10 | 11 | - [`e66a42e`](https://github.com/badrap/valita/commit/e66a42e994254429583febbe58f0f8df915ccc5c) Thanks [@jviide](https://github.com/jviide)! - Allow passing a type to .chain() 12 | 13 | The `.chain()` method of types now accepts other types as-is: 14 | 15 | ```ts 16 | v.string() // Accept strings as input, 17 | .map((s) => Number(s)) // then parse the strings to numbers, 18 | .chain(v.literal(1)); // and ensure that the parsed number is 1. 19 | ``` 20 | 21 | The parsing mode is propagated to the chained type: 22 | 23 | ```ts 24 | const example = v.unknown().parse(v.object({ a: v.number() })); 25 | 26 | example.parse({ a: 1, b: 2 }, { mode: "strip" }); 27 | // { a: 1 } 28 | ``` 29 | 30 | ## 0.4.4 31 | 32 | ### Patch Changes 33 | 34 | - [`b9d9c30`](https://github.com/badrap/valita/commit/b9d9c30dd365dedf33f80540b71b08c8f4dd3825) Thanks [@jviide](https://github.com/jviide)! - Add support for `.nullable(() => x)` 35 | 36 | The `.nullable()` method now supports default value functions_similarly to `.optional()`. 37 | 38 | ## 0.4.3 39 | 40 | ### Patch Changes 41 | 42 | - [#76](https://github.com/badrap/valita/pull/76) [`4acc481`](https://github.com/badrap/valita/commit/4acc481afe701fcdfe8be35b6d5b2ce13138c715) Thanks [@jozan](https://github.com/jozan)! - export `ParseOptions` type 43 | 44 | ## 0.4.2 45 | 46 | ### Patch Changes 47 | 48 | - [`c648586`](https://github.com/badrap/valita/commit/c6485866829a9f235d7bb6e790cb721a0a321c1a) Thanks [@jviide](https://github.com/jviide)! - Include an array of sub-issues in "invalid_union" issues 49 | 50 | ## 0.4.1 51 | 52 | ### Patch Changes 53 | 54 | - [`4b0a837`](https://github.com/badrap/valita/commit/4b0a837f2db81439f0cd9f6e570c10558895dec8) Thanks [@jviide](https://github.com/jviide)! - Make Optional#type public 55 | 56 | ## 0.4.0 57 | 58 | ### Minor Changes 59 | 60 | - [`c4f7eaf`](https://github.com/badrap/valita/commit/c4f7eaf5303672abd0e7eae78fe161f89c5233d1) Thanks [@jviide](https://github.com/jviide)! - Require Node.js v18 61 | 62 | - [`01ff112`](https://github.com/badrap/valita/commit/01ff112217b249eed30218fe936a989428dccaca) Thanks [@jviide](https://github.com/jviide)! - Mark multiple internal methods and properties as internal 63 | 64 | ## 0.3.16 65 | 66 | ### Patch Changes 67 | 68 | - [`59b89be`](https://github.com/badrap/valita/commit/59b89bef1e0a8371571e66a05cdedf915d07c23f) Thanks [@arv](https://github.com/arv) for reporting this! - Revert changes since v0.3.12 as they were backwards incompatible 69 | 70 | ## 0.3.15 71 | 72 | ### Patch Changes 73 | 74 | - [`4a1e635`](https://github.com/badrap/valita/commit/4a1e63595ddaa40afdff60daaf5a1e904ab61dbc) Thanks [@jviide](https://github.com/jviide)! - Fix more slow types pointed out by JSR 75 | 76 | ## 0.3.14 77 | 78 | ### Patch Changes 79 | 80 | - [`5be204e`](https://github.com/badrap/valita/commit/5be204e6e29af285b32bc560913c7686ad96b027) Thanks [@jviide](https://github.com/jviide)! - Fix slow types pointed out by JSR 81 | 82 | ## 0.3.12 83 | 84 | ### Patch Changes 85 | 86 | - [`8aaad50`](https://github.com/badrap/valita/commit/8aaad504c693047b62a1ae5f57d406f4f2f4cad4) Thanks [@jviide](https://github.com/jviide)! - Mark `.optional(() => ...)` as non-experimental and recommend it over the now-deprecated `.default(x)` 87 | 88 | ## 0.3.11 89 | 90 | ### Patch Changes 91 | 92 | - [`f78c082`](https://github.com/badrap/valita/commit/f78c0825b1a59d6f6cd7e73354526ee517a2bd0b) Thanks [@jviide](https://github.com/jviide)! - Add **experimental** support for `.optional(() => x)` 93 | 94 | The `.optional()` method now supports _default value functions_ for replacing `undefined` and missing values from the input and wrapped validator. The functionality is similar to `.default(x)`, except that `defaultFn` has to be a function and is executed for each validation run. This allows patterns like the following: 95 | 96 | ```ts 97 | const Item = v.object({ id: v.string() }); 98 | 99 | const Items = v.array(Item).optional(() => []); 100 | ``` 101 | 102 | This avoids a common pitfall with using `.default([])` for the same pattern. As the newly created empty arrays are not shared, mutating them is safe(r) as it doesn't affect other validation outputs. 103 | 104 | This feature is marked **experimental** for the time being. 105 | 106 | ## 0.3.10 107 | 108 | ### Patch Changes 109 | 110 | - [`43513b6`](https://github.com/badrap/valita/commit/43513b60087a17e15378fcac1bfce3275d7a6bd4) Thanks [@jviide](https://github.com/jviide)! - Add support for variadic tuple types 111 | 112 | Tuple and array types now have a new method, `.concat()` that can be used to create [variadic tuple types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types). 113 | 114 | - [`43513b6`](https://github.com/badrap/valita/commit/43513b60087a17e15378fcac1bfce3275d7a6bd4) Thanks [@jviide](https://github.com/jviide)! - Make `v.array()` a shorthand for `v.array(v.unknown())` 115 | 116 | ## 0.3.9 117 | 118 | ### Patch Changes 119 | 120 | - [`e452c08`](https://github.com/badrap/valita/commit/e452c088855277740404cdf019790141e55938e3) Thanks [@jviide](https://github.com/jviide)! - Avoid dual package hazard 121 | 122 | ## 0.3.8 123 | 124 | ### Patch Changes 125 | 126 | - [`d2f85db`](https://github.com/badrap/valita/commit/d2f85dbd08da70f572b67c63cbe754a265d3b49f) Thanks [@jviide](https://github.com/jviide)! - Fix release automation, name scripts bump/release instead of version/publish 127 | 128 | ## 0.3.7 129 | 130 | ### Patch Changes 131 | 132 | - [#57](https://github.com/badrap/valita/pull/57) [`d162bb9`](https://github.com/badrap/valita/commit/d162bb9367bea6131943d36cb9848947d80ff4e3) Thanks [@jviide](https://github.com/jviide)! - Add changesets-based releases 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Badrap Oy 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 | # @badrap/valita [![CI](https://github.com/badrap/valita/actions/workflows/ci.yml/badge.svg)](https://github.com/badrap/valita/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/@badrap/valita.svg)](https://www.npmjs.com/package/@badrap/valita) [![JSR](https://jsr.io/badges/@badrap/valita)](https://jsr.io/@badrap/valita) 2 | 3 | A TypeScript library for validating & parsing structured objects. The API is _heavily_ influenced by [Zod's](https://zod.dev/) excellent API, while the implementation side aims for the impressive performance of [simple-runtypes](https://github.com/hoeck/simple-runtypes). 4 | 5 | ```ts 6 | const vehicle = v.union( 7 | v.object({ type: v.literal("plane"), airline: v.string() }), 8 | v.object({ type: v.literal("train") }), 9 | v.object({ type: v.literal("automobile"), make: v.string() }), 10 | ); 11 | vehicle.parse({ type: "bike" }); 12 | // ValitaError: invalid_literal at .type (expected "plane", "train" or "automobile") 13 | ``` 14 | 15 | > [!NOTE] 16 | > While this package is still evolving, we're currently not accepting any new feature requests or suggestions. Please use the issue tracker for bug reports and security concerns, which we highly value and welcome. Thank you for your understanding ❤️ 17 | 18 | ## Goals and Non-Goals 19 | 20 | ### Goals 21 | 22 | 1. **Input Validation & Parsing**: The fundamental goal of the library is to ensure that incoming data, which might not be from a trusted source, aligns with the predetermined format. 23 | 2. **Minimalism**: Deliver a streamlined and concentrated library that offers just the essentials. 24 | 3. **Extensibility**: Allow users to create their own validators and parsers that cater to specific validation scenarios. 25 | 26 | ### Non-Goals 27 | 28 | 1. **Data Definition**: The library is designed to validate and parse input data as it enters the program, rather than serving as an exhaustive tool for defining all types within the program after obtaining input. 29 | 2. **Extensive Built-In Formats**: The library does not prioritize having a large array of built-in validation formats out of the box. 30 | 3. **Asynchronous Parsing**: Asynchronous operations are outside the scope for this library. 31 | 32 | ## Installation 33 | 34 | ### For Node.js 35 | 36 | ```sh 37 | npm i @badrap/valita 38 | ``` 39 | 40 | ### For Deno 41 | 42 | ```sh 43 | deno add @badrap/valita 44 | ``` 45 | 46 | ## API Reference 47 | 48 | This section contains an overview of all validation methods. 49 | 50 | ### Primitive Types 51 | 52 | Let's start with the basics! Like every validation library we support all primitive types like strings, numbers, booleans and more. For example the `v.string()` primitive can be used like this to check whether some input value is a string: 53 | 54 | ```ts 55 | import * as v from "@badrap/valita"; 56 | 57 | const t = v.string(); 58 | t.parse("Hello, World!"); 59 | // "Hello, World!" 60 | ``` 61 | 62 | Try to parse anything that's not a string and you get an error: 63 | 64 | ```ts 65 | t.parse(1); 66 | // ValitaError: invalid_type at . (expected string) 67 | ``` 68 | 69 | `.parse(...)` is typed in a way that it accepts any type of input value, but returns a properly typed value on success: 70 | 71 | ```ts 72 | const u: unknown = "Hello, World!"; 73 | 74 | // TypeScript type checking is happy with this! 75 | const s: string = t.parse(u); 76 | ``` 77 | 78 | The primitive types are: 79 | 80 | - `v.string()`: Check that the value is a string. 81 | - `v.number()`: Check that the value is a number (i.e. `typeof value === "number"`, which includes NaN and ±Infinity). 82 | - `v.bigint()`: Check that the value is a [bigint](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt) (i.e. `typeof value === "bigint"`) 83 | - `v.boolean()`: Check that the value is a boolean. 84 | - `v.null()`: Check that the value is `null`. 85 | - `v.undefined()`: Check that the value is `undefined`. 86 | 87 | ### Literal Types 88 | 89 | Sometimes knowing if a value is of a certain type is not enough. We can use the `.literal()` method to check for actual values, like checking if a string is either `"red"`, `"green"` or `"blue"` and not just any string. 90 | 91 | ```ts 92 | const rgb = v.union(v.literal("red"), v.literal("green"), v.literal("blue")); 93 | 94 | rgb.parse("green"); 95 | // "green" 96 | 97 | rgb.parse("magenta"); 98 | // ValitaError: invalid_literal at . (expected "red", "green" or "blue") 99 | ``` 100 | 101 | We can also use this to check for concrete numbers, bigint literals or boolean values: 102 | 103 | ```ts 104 | v.literal(1); // must be number 1 105 | v.literal(1n); // must be bigint 1n 106 | v.literal(true); // must be true 107 | ``` 108 | 109 | For more complex values you can use the `.assert()`-method. Check out [Custom Validators](#custom-validators) to learn more about it. 110 | 111 | ### Allow Any / Forbid All 112 | 113 | Valita doesn't contain a built-in equivalent to TypeScript's `any` type. However, `v.unknown()` is analogous to TypeScript's `unknown` type and can be used to accept any input value: 114 | 115 | ```ts 116 | const u = v.unknown(); 117 | 118 | u.parse(1); 119 | // 1 120 | ``` 121 | 122 | The inverse of `v.unknown()` is `v.never()` that fails for every value. This is analogous to TypeScript's `never` type. 123 | 124 | ```ts 125 | const n = v.never(); 126 | 127 | n.parse(1); 128 | // ValitaError: invalid_type at . (expected nothing) 129 | ``` 130 | 131 | By themselves `v.unknown()` and `v.never()` are not terribly useful, but they become more relevant with composite types such as [Object Types](#object-types). 132 | 133 | ### Object Types 134 | 135 | Validators can be further combined to larger, arbitrarily complex validators. One such combinator is `v.object(...)`, used to check that the input value is an object that has some named properties, and that those properties have a specific type. 136 | 137 | ```ts 138 | const o = v.object({ 139 | company: v.string(), 140 | 141 | // Nested objects work fine too! 142 | address: v.object({ 143 | city: v.string(), 144 | country: v.string(), 145 | }), 146 | }); 147 | 148 | o.parse({ 149 | name: "Acme Inc.", 150 | address: { city: "Springfield", country: "Freedomland" }, 151 | }); 152 | // { 153 | // name: "Acme Inc.", 154 | // address: { city: "Springfield", country: "Freedomland" }, 155 | // } 156 | 157 | o.parse({ name: "Acme Inc." }); 158 | // ValitaError: missing_value at .address (missing value) 159 | o.parse({ 160 | name: "Acme Inc.", 161 | ceo: "Wiley E. Coyote", 162 | address: { city: "Springfield", country: "Freedomland" }, 163 | }); 164 | // ValitaError: unrecognized_keys at . (unrecognized key "ceo") 165 | ``` 166 | 167 | As seen above, unexpected keys like `"ceo"` are prohibited by default. That default can be changed with [Parsing Modes](#parsing-modes). 168 | 169 | #### Parsing Modes 170 | 171 | By default `v.object(...)` throws an error when it encounters an object with unexpected keys. That behavior can be changed by explicitly passing a _parsing mode_ to `.parse(...)`: 172 | 173 | ```ts 174 | const o = v.object({ 175 | name: v.string(), 176 | }); 177 | 178 | // Strip away the extra keys 179 | o.parse({ name: "Acme Inc.", ceo: "Wiley E. Coyote" }, { mode: "strip" }); 180 | // { name: "Acme Inc." } 181 | 182 | // Pass the extra keys through as-is 183 | o.parse({ name: "Acme Inc.", ceo: "Wiley E. Coyote" }, { mode: "passthrough" }); 184 | // { name: "Acme Inc.", ceo: "Wiley E. Coyote" } 185 | 186 | // Forbid extra keys. This is the default. 187 | o.parse({ name: "Acme Inc.", ceo: "Wiley E. Coyote" }, { mode: "strict" }); 188 | // ValitaError: unrecognized_keys at . (unrecognized key "ceo") 189 | ``` 190 | 191 | The possible values are: 192 | 193 | - `{ mode: "strict" }`: Forbid extra keys. This is the default. 194 | - `{ mode: "strip" }`: Don't fail on extra keys - instead strip them away from the output object. 195 | - `{ mode: "passthrough" }`: Just ignore the extra keys and pretend you didn't see them. 196 | 197 | The parsing mode applies to all levels of your validation hierarcy, even to nested objects. 198 | 199 | ```ts 200 | const o = v.object({ 201 | company: v.object({ 202 | name: v.string(), 203 | }), 204 | }); 205 | 206 | o.parse( 207 | { 208 | company: { name: "Acme Inc.", ceo: "Wiley E. Coyote" }, 209 | greeting: "Hello!", 210 | }, 211 | { mode: "strip" }, 212 | ); 213 | // { company: { name: "Acme Inc." } } 214 | ``` 215 | 216 | #### Rest Properties & Records 217 | 218 | Sometimes you may want to allow extra keys in addition to the defined keys. For that you can use `.rest(...)`, and additionally require the extra keys to have a specific type of value: 219 | 220 | ```ts 221 | const o = v 222 | .object({ 223 | name: v.string(), 224 | age: v.number(), 225 | }) 226 | .rest(v.string()); 227 | 228 | o.parse({ name: "Example McExampleface", age: 42, socks: "yellow" }); 229 | // { name: "Example McExampleface", age: 42, socks: "yellow" } 230 | 231 | o.parse({ name: "Example McExampleface", age: 42, numberOfDogs: 2 }); 232 | // ValitaError: invalid_type at .numberOfDogs (expected string) 233 | ``` 234 | 235 | The `.rest(...)` method is also handy for allowing or forbidding extra keys for a specific parts of your object hierarchy, regardless of the parsing mode. 236 | 237 | ```ts 238 | const lenient = v.object({}).rest(v.unknown()); // *Always* allow extra keys 239 | lenient.parse({ socks: "yellow" }, { mode: "strict" }); 240 | // { socks: "yellow" } 241 | 242 | const strict = v.object({}).rest(v.never()); // *Never* allow extra keys 243 | strict.parse({ socks: "yellow" }, { mode: "strip" }); 244 | // ValitaError: invalid_type at .socks (expected nothing) 245 | ``` 246 | 247 | For always allowing a completely arbitrary number of properties, `v.record(...)` is shorthand for `v.object({}).rest(...)`. This is analogous to the `Record` type in TypeScript. 248 | 249 | ```ts 250 | const r = v.record(v.number()); 251 | 252 | r.parse({ a: 1, b: 2 }); 253 | // { a: 1, b: 2 } 254 | 255 | r.parse({ a: 1, b: "hello" }); 256 | // ValitaError: invalid_type at .b (expected number) 257 | ``` 258 | 259 | For allowing any collection of properties of any type, `v.record()` is a shorthand for `v.record(v.unknown())`. 260 | 261 | ```ts 262 | const r = v.record(); 263 | 264 | r.parse({ a: 1, b: 2 }); 265 | // { a: 1, b: 2 } 266 | r.parse({ a: 1, b: "hello" }); 267 | // { a: 1, b: "hello" } 268 | 269 | // The input still has to be an object. 270 | r.parse([]); 271 | // ValitaError: invalid_type at . (expected object) 272 | ``` 273 | 274 | #### Optional Properties 275 | 276 | One common API pattern is that some object fields are _optional_, i.e. they can be missing completely or be set to `undefined`. You can allow some keys to be missing by annotating them with `.optional()`. 277 | 278 | ```ts 279 | const person = v.object({ 280 | name: v.string(), 281 | // Not everyone filled in their theme song 282 | themeSong: v.string().optional(), 283 | }); 284 | 285 | person.parse({ name: "Jane Doe", themeSong: "Never gonna give you up" }); 286 | // { name: "Jane Doe", themeSong: "Never gonna give you up" } 287 | person.parse({ name: "Jane Doe" }); 288 | // { name: "Jane Doe" } 289 | person.parse({ name: "Jane Doe", themeSong: undefined }); 290 | // { name: "Jane Doe", themeSong: undefined } 291 | ``` 292 | 293 | Optionals are only used with `v.object(...)` and don't work as standalone parsers. 294 | 295 | ```ts 296 | const t = v.string().optional(); 297 | 298 | // TypeScript error: Property 'parse' does not exist on type 'Optional' 299 | t.parse("Hello, World!"); 300 | ``` 301 | 302 | An optional function can be used to replace a missing or undefined values with some other default value: 303 | 304 | ```ts 305 | const person = v.object({ 306 | name: v.string(), 307 | // Set a sensible default for those unwilling to fill in their theme song 308 | themeSong: v.string().optional(() => "Tribute"), 309 | }); 310 | 311 | person.parse({ name: "Jane Doe", themeSong: "Never gonna give you up" }); 312 | // { name: "Jane Doe", themeSong: "Never gonna give you up" } 313 | person.parse({ name: "Jane Doe" }); 314 | // { name: "Jane Doe", themeSong: "Tribute" } 315 | person.parse({ name: "Jane Doe", themeSong: undefined }); 316 | // { name: "Jane Doe", themeSong: "Tribute" } 317 | ``` 318 | 319 | The default function is re-evaluated every for every missing or undefined value to avoid accidentally sharing mutable default values like objects or arrays between different parsed values. 320 | 321 | ### Array Types 322 | 323 | The `v.array(...)` combinator can be used to check that the value is an array, and that its items have a specific type. The validated arrays may be of arbitrary length, including empty arrays. 324 | 325 | ```ts 326 | const a = v.array(v.object({ name: v.string() })); 327 | 328 | a.parse([{ name: "Acme Inc." }, { name: "Evil Corporation" }]); 329 | // [{ name: "Acme Inc." }, { name: "Evil Corporation" }] 330 | a.parse([]); 331 | // [] 332 | 333 | a.parse({ 0: { name: "Acme Inc." } }); 334 | // ValitaError: invalid_type at . (expected array) 335 | ``` 336 | 337 | For allowing any array, `v.array()` is a shorthand for `v.array(v.unknown())`. 338 | 339 | ```ts 340 | const a = v.array(); 341 | 342 | a.parse(["foo", 1]); 343 | // ["foo", 1] 344 | a.parse([]); 345 | // [] 346 | 347 | // Only arrays are allowed, though. 348 | a.parse({ 0: { name: "Acme Inc." } }); 349 | // ValitaError: invalid_type at . (expected array) 350 | ``` 351 | 352 | ### Tuple Types 353 | 354 | Despite JavaScript not having tuple values ([...yet?](https://github.com/tc39/proposal-record-tuple)), many APIs emulate them with arrays. For example, if we needed to encode a range between two numbers we might choose `type Range = [number, number]` as the data type. From JavaScript's point of view it's just an array but TypeScript knows about the value of each position and that the array **must** have two entries. 355 | 356 | We can express this kind of type with `v.tuple(...)`: 357 | 358 | ```ts 359 | const range = v.tuple([v.number(), v.number()]); 360 | 361 | range.parse([1, 2]); 362 | // [1, 2] 363 | range.parse([200, 2]); 364 | // [200, 2] 365 | 366 | range.parse([1]); 367 | // ValitaError: invalid_length at . (expected an array with 2 item(s)) 368 | range.parse([1, 2, 3]); 369 | // ValitaError: invalid_length at . (expected an array with 2 item(s)) 370 | range.parse([1, "2"]); 371 | // ValitaError: invalid_type at .1 (expected number) 372 | ``` 373 | 374 | Tuples can be concatenated with other tuples to create new tuple types, and with even arrays to create [variadic tuple types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#variadic-tuple-types). 375 | 376 | ```ts 377 | const twoNumbers = v.tuple([v.number(), v.number()]); // Type<[number number]> 378 | const twoStrings = v.tuple([v.string(), v.string()]); // Type<[string, string]> 379 | const booleans = v.array(v.boolean()); // Type 380 | 381 | twoNumbers.concat(twoStrings); 382 | // Type<[number, number, string, string]> 383 | 384 | twoNumbers.concat(booleans); 385 | // Type<[number, number, ...boolean[]]> 386 | 387 | booleans.concat(twoStrings); 388 | // Type<[...boolean[], string, string]> 389 | 390 | twoNumbers.concat(booleans).concat(twoStrings); 391 | // Type<[number, number, ...boolean[], string, string]> 392 | ``` 393 | 394 | Two variable-length tuples or arrays can not be concatenated, though, so this is a type-level error and also throws an error at runtime: 395 | 396 | ```ts 397 | v.tuple([]).concat(v.array()).concat(v.array()); 398 | // TypeError: can not concatenate two variadic types 399 | ``` 400 | 401 | ### Union Types 402 | 403 | A union type is a value which can have several different representations. Let's imagine we have a value of type `Shape` that can be either a triangle, a circle or a square: 404 | 405 | ```ts 406 | const triangle = v.object({ type: v.literal("triangle") }); 407 | const square = v.object({ type: v.literal("square") }); 408 | const circle = v.object({ type: v.literal("circle") }); 409 | 410 | const shape = v.union(triangle, square, circle); 411 | 412 | shape.parse({ type: "triangle" }); 413 | // { type: "triangle" } 414 | 415 | shape.parse({ type: "heptagon" }); 416 | // ValitaError: invalid_literal at .type (expected "triangle", "square" or "circle") 417 | ``` 418 | 419 | Note that although in this example all representations are objects and have the shared property `type`, it's not necessary at all. Each representation can have completely different base type. 420 | 421 | ```ts 422 | const primitive = v.union(v.number(), v.string(), v.boolean()); 423 | 424 | primitive.parse("Hello, World!"); 425 | // "Hello, World!" 426 | 427 | primitive.parse({}); 428 | // ValitaError: invalid_type at . (expected number, string or boolean) 429 | ``` 430 | 431 | #### Nullable Type 432 | 433 | When working with APIs or databases some types may be nullable. The `t.nullable()` shorthand returns a validator equivalent to `v.union(v.null(), t)`. 434 | 435 | ```ts 436 | // type name = null | string 437 | const name = v.string().nullable(); 438 | 439 | // Passes 440 | name.parse("Acme Inc."); 441 | // Passes 442 | name.parse(null); 443 | ``` 444 | 445 | Similarly to `.optional()`, a default value function can be used to replace a `null` values with some other default value: 446 | 447 | ```ts 448 | const name = v.string().nullable(() => "Unknown"); 449 | 450 | name.parse("Jane Doe"); 451 | // "Jane Doe" 452 | name.parse(null); 453 | // "Unknown" 454 | ``` 455 | 456 | The default function is re-evaluated every for every `null` value to avoid accidentally sharing mutable default values like objects or arrays between different parsed values. 457 | 458 | ### Recursive Types 459 | 460 | Some types can contain arbitrary nesting, like `type T = string | T[]`. We can express such types with `.lazy(...)`. 461 | 462 | Note that TypeScript can not infer return types of recursive functions. That's why `v.lazy(...)` validators need to be explicitly typed with `v.Type`. 463 | 464 | ```ts 465 | type T = string | T[]; 466 | const myType: v.Type = v.lazy(() => v.union(v.string(), v.array(myType))); 467 | ``` 468 | 469 | ### Custom Validators 470 | 471 | The `.assert()` method can be used for custom validation logic, like checking that object properties are internally consistent. 472 | 473 | ```ts 474 | const Span = v 475 | .object({ start: v.number(), end: v.number() }) 476 | .assert((obj) => obj.start <= obj.end); 477 | 478 | Span.parse({ start: 1, end: 2 }); 479 | // { start: 1, end: 2 } 480 | 481 | Span.parse({ start: 2, end: 1 }); 482 | // ValitaError: custom_error at . (validation failed) 483 | ``` 484 | 485 | You can also _refine_ the input type by passing in a [type predicate](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates). Note that the type predicate must have a compatible input type. 486 | 487 | ```ts 488 | function isEventHandlerName(s: string): s is `on${string}` { 489 | return s.startsWith("on"); 490 | } 491 | 492 | const e = v.string().assert(isEventHandlerName); 493 | 494 | const name: `on${string}` = e.parse("onscroll"); 495 | // "onscroll" 496 | 497 | e.parse("Steven"); 498 | // ValitaError: custom_error at . (validation failed) 499 | ``` 500 | 501 | Each `.assert(...)` returns a new validator, so you can further refine already refined types. You can also pass in a custom failure messages. 502 | 503 | ```js 504 | const Integer = v.number().assert((n) => Number.isInteger(n), "not an integer"); 505 | 506 | const Byte = Integer.assert((i) => i >= 0 && i <= 255, "not between 0 and 255"); 507 | 508 | Byte.parse(1); 509 | // 1 510 | 511 | Byte.parse(1.5); 512 | // ValitaError: custom_error at . (not an integer) 513 | Byte.parse(300); 514 | // ValitaError: custom_error at . (not between 0 and 255) 515 | ``` 516 | 517 | Custom validators can be used like any other built-in validator. This means that you can define helpers tailored to your specific use cases and reuse them over and over. 518 | 519 | ```js 520 | // Reusable custom validator 521 | const Organization = v 522 | .object({ 523 | name: v.string(), 524 | active: v.boolean(), 525 | }) 526 | .assert((org) => org.active); 527 | 528 | // Reuse the custom validator 529 | const Transaction = v.object({ 530 | buyer: Organization, 531 | seller: Organization, 532 | amount: v.number(), 533 | }); 534 | ``` 535 | 536 | ### Custom Parsers 537 | 538 | While `.assert(...)` can ensure that a value is valid and even refine the value's type, it can't alter the value itself. Yet sometimes we may want to validate and transform the value in one go. 539 | 540 | The `.map(...)` method is great for cases when you know that the transformation can't fail. The output type doesn't have to stay same: 541 | 542 | ```ts 543 | const l = v.string().map((s) => s.length); 544 | 545 | l.parse("Hello, World!"); 546 | // 13 547 | 548 | l.parse(1); 549 | // ValitaError: invalid_type at . (expected string) 550 | ``` 551 | 552 | The `.chain(...)` method is more powerful: it can also be used for cases where the parsing might fail. Imagine a JSON API which outputs dates in the YYYY-MM-DD format and we want to return a valid `Date` from our validation phase: 553 | 554 | ```json 555 | { 556 | "created_at": "2022-01-01" 557 | } 558 | ``` 559 | 560 | `.chain(...)`, much like map, receives a function to which it will pass the raw value as the first argument. If the transformation fails, we return an error (with an optional message) with `v.err(...)`. If not, then we return the transformed value with `v.ok(...)`. 561 | 562 | ```js 563 | const DateType = v.string().chain((s) => { 564 | const date = new Date(s); 565 | 566 | if (isNaN(+date)) { 567 | return v.err("invalid date"); 568 | } 569 | 570 | return v.ok(date); 571 | }); 572 | 573 | const APIResponse = v.object({ 574 | created_at: DateType, 575 | }); 576 | 577 | APIResponse.parse({ created_at: "2022-01-01" }); 578 | // { created_at: 2022-01-01T00:00:00.000Z } 579 | 580 | APIResponse.parse({ created_at: "YOLO" }); 581 | // ValitaError: custom_error at .created_at (invalid date) 582 | ``` 583 | 584 | For both `.map(...)` and `.chain(...)` we highly recommend that you avoid mutating the input value. Prefer returning a new value instead. 585 | 586 | ```ts 587 | v.object({ name: v.string() }).map((obj) => { 588 | // Mutating the input value like below is highly discouraged: 589 | // obj.id = randomUUID(); 590 | // Return a new value instead: 591 | return { ...obj, id: randomUUID() }; 592 | }); 593 | ``` 594 | 595 | ### Parsing Without Throwing 596 | 597 | The `.parse(...)` method used thus far throws a ValitaError when validation or parsing fails. The `.try(...)` method can be used when you'd rather throw only actually exceptional cases such as coding errors. Parsing modes are also supported. 598 | 599 | ```ts 600 | const o = v.object({ name: v.string() }); 601 | 602 | o.try({ name: "Acme Inc." }); 603 | // { ok: true, value: { name: "Acme Inc." } } 604 | o.try({ name: "Acme Inc.", country: "Freedomland" }, { mode: "strip" }); 605 | // { ok: true, value: { name: "Acme Inc." } } 606 | 607 | o.try({}); 608 | // { ok: false, message: "missing_value at .name (missing value)" } 609 | ``` 610 | 611 | The `.ok` property can be used to inspect the outcome in a typesafe way. 612 | 613 | ```ts 614 | // Fail about 50% of the time 615 | const r = o.try(Math.random() < 0.5 ? { name: "Acme Inc." } : {}); 616 | 617 | if (r.ok) { 618 | // r.value is defined within this block 619 | console.log(`Success: ${r.value}`); 620 | } else { 621 | // r.message is defined within this block 622 | console.log(`Failure: ${r.message}`); 623 | } 624 | ``` 625 | 626 | For allow further composition, `.try(...)`'s return values are compatible with `.chain(...)`. The chained function also receives a second parameter that contains the parsing mode, and can be passed forward to `.try(...)`. 627 | 628 | ```ts 629 | const Company = v.object({ name: v.string() }); 630 | 631 | const CompanyString = v.string().chain((json, options) => { 632 | let value: unknown; 633 | try { 634 | value = JSON.parse(json); 635 | } catch { 636 | return v.err("not valid JSON"); 637 | } 638 | return Company.try(value, options); 639 | }); 640 | 641 | CompanyString.parse('{ "name": "Acme Inc." }'); 642 | // { name: "Acme Inc." } 643 | 644 | CompanyString.parse('{ "name": "Acme Inc.", "ceo": "Wiley E. Coyote" }'); 645 | // ValitaError: unrecognized_keys at . (unrecognized key "ceo") 646 | 647 | // The parsing mode is forwarded to .try(...) 648 | CompanyString.parse('{ "name": "Acme Inc.", "ceo": "Wiley E. Coyote" }', { 649 | mode: "strip", 650 | }); 651 | // { name: 'Acme Inc.' } 652 | ``` 653 | 654 | Turns out that composing parsers like this is relatively common. Therefore you can pass types to `.chain(...)`. The following is equal to the above definition of `CompanyString`: 655 | 656 | ```ts 657 | const Company = v.object({ name: v.string() }); 658 | 659 | // We now have a handy common helper for parsing JSON strings! 660 | const JsonString = v.string().chain((json) => { 661 | try { 662 | return v.ok(JSON.parse(json)); 663 | } catch { 664 | return v.err("not valid JSON"); 665 | } 666 | }); 667 | 668 | const CompanyString = JsonString.chain(Company); 669 | ``` 670 | 671 | ### Inferring Output Types 672 | 673 | The exact output type of a validator can be _inferred_ from a type validator's using with `v.Infer`: 674 | 675 | ```ts 676 | const Person = v.object({ 677 | name: v.string(), 678 | age: v.number().optional(), 679 | }); 680 | 681 | type Person = v.Infer; 682 | // type Person = { name: string, age?: number }; 683 | ``` 684 | 685 | ### Type Composition Tips & Tricks 686 | 687 | #### Reduce, Reuse, Recycle 688 | 689 | The API interface of this library is intentionally kept small - for some definition of small. As such we encourage curating a library of helpers tailored for your specific needs. For example a reusable helper for ensuring that a number falls between a specific range could be defined and used like this: 690 | 691 | ```ts 692 | function between(min: number, max: number) { 693 | return (n: number) => { 694 | if (n < min || n > max) { 695 | return v.err("outside range"); 696 | } 697 | return v.ok(n); 698 | }; 699 | } 700 | 701 | const num = v.number().chain(between(0, 255)); 702 | ``` 703 | 704 | #### Type Inference & Generics 705 | 706 | Every standalone validator fits the type `v.Type`, `Output` being the validator's output type. TypeScript's generics and type inference can be used to define helpers that take in validators and do something with them. For example a `readonly(...)` helper that casts the output type to a readonly (non-recursively) could be defined and used as follows: 707 | 708 | ```ts 709 | function readonly(t: v.Type): v.Type> { 710 | return t as v.Type>; 711 | } 712 | 713 | const User = readonly(v.object({ id: v.string() })); 714 | type User = v.Infer; 715 | // type User = { readonly id: string; } 716 | ``` 717 | 718 | #### Deconstructed Helpers 719 | 720 | Some validator types offer additional properties and methods for introspecting and transforming them further. One such case is `v.object(...)`'s `.shape` property that contains the validators for each property. 721 | 722 | ```ts 723 | const Company = v.object({ 724 | name: v.string().assert((s) => s.length > 0, "empty name"), 725 | }); 726 | Company.shape.name.parse("Acme Inc."); 727 | // "Acme Inc." 728 | ``` 729 | 730 | However, because `.assert(...)`, `.map(...)` and `.chain(...)` may all restrict and transform the output type almost arbitrarily, their returned validators may not have the properties or methods specific to the original ones. For example a refined `v.object(...)` validator will not have the `.shape` property. Therefore the following will not work: 731 | 732 | ```ts 733 | const Company = v 734 | .object({ 735 | name: v.string().assert((s) => s.length > 0, "empty name"), 736 | employees: v.number(), 737 | }) 738 | .assert((c) => c.employees >= 0); 739 | 740 | const Organization = v.object({ 741 | // Try to reuse Company's handy name validator 742 | name: Company.shape.name, 743 | }); 744 | // TypeScript error: Property 'shape' does not exist on type 'Type<{ name: string; }>' 745 | ``` 746 | 747 | The recommended solution is to deconstruct the original validators enough so that the common pieces can be directly reused: 748 | 749 | ```ts 750 | const NonEmptyString = v.string().assert((s) => s.length > 0, "empty"); 751 | 752 | const Company = v 753 | .object({ 754 | name: NonEmptyString, 755 | employees: v.number(), 756 | }) 757 | .assert((c) => c.employees >= 0); 758 | 759 | const Organization = v.object({ 760 | name: NonEmptyString, 761 | }); 762 | ``` 763 | 764 | ## License 765 | 766 | This library is licensed under the MIT license. See [LICENSE](./LICENSE). 767 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import prettierRecommended from "eslint-plugin-prettier/recommended"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | export default [ 6 | eslint.configs.recommended, 7 | ...tseslint.configs.strictTypeChecked, 8 | ...tseslint.configs.stylisticTypeChecked, 9 | prettierRecommended, 10 | { 11 | linterOptions: { 12 | reportUnusedDisableDirectives: true, 13 | }, 14 | languageOptions: { 15 | parserOptions: { 16 | projectService: { 17 | allowDefaultProject: ["eslint.config.mjs"], 18 | }, 19 | tsconfigRootDir: import.meta.dirname, 20 | }, 21 | }, 22 | rules: { 23 | "prettier/prettier": "warn", 24 | eqeqeq: ["error", "smart"], 25 | "linebreak-style": ["error", "unix"], 26 | "no-console": "error", 27 | "no-multi-assign": "error", 28 | "no-return-assign": "error", 29 | "no-unused-expressions": "error", 30 | "@typescript-eslint/prefer-for-of": "off", 31 | "@typescript-eslint/no-dynamic-delete": "off", 32 | "@typescript-eslint/no-non-null-assertion": "off", 33 | "@typescript-eslint/no-confusing-void-expression": "off", 34 | "@typescript-eslint/consistent-type-definitions": "off", 35 | "@typescript-eslint/consistent-indexed-object-style": "off", 36 | "@typescript-eslint/prefer-return-this-type": "off", 37 | "@typescript-eslint/unbound-method": "off", 38 | "@typescript-eslint/unified-signatures": "off", 39 | "@typescript-eslint/restrict-template-expressions": [ 40 | "error", 41 | { 42 | allowAny: false, 43 | allowBoolean: false, 44 | allowNullish: false, 45 | allowNumber: true, 46 | allowRegExp: false, 47 | }, 48 | ], 49 | "@typescript-eslint/explicit-member-accessibility": [ 50 | "error", 51 | { accessibility: "no-public" }, 52 | ], 53 | "@typescript-eslint/no-unused-vars": [ 54 | "error", 55 | { 56 | varsIgnorePattern: "^_", 57 | argsIgnorePattern: "^_", 58 | caughtErrorsIgnorePattern: "^_", 59 | }, 60 | ], 61 | "@typescript-eslint/switch-exhaustiveness-check": "error", 62 | }, 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@badrap/valita", 3 | "version": "0.4.5", 4 | "exports": "./src/index.ts" 5 | } 6 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/index.ts"; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@badrap/valita", 3 | "version": "0.4.5", 4 | "type": "commonjs", 5 | "description": "A validation & parsing library for TypeScript", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/mjs/index.mjs", 8 | "exports": { 9 | "bun": "./src/index.ts", 10 | "node": { 11 | "module": "./dist/node-mjs/index.mjs", 12 | "import": "./dist/node-cjs/index.esm.mjs", 13 | "require": "./dist/node-cjs/index.js" 14 | }, 15 | "default": "./dist/mjs/index.mjs" 16 | }, 17 | "sideEffects": false, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/badrap/valita.git" 21 | }, 22 | "author": "Joachim Viide ", 23 | "license": "MIT", 24 | "publishConfig": { 25 | "provenance": true 26 | }, 27 | "engines": { 28 | "node": ">= 18" 29 | }, 30 | "scripts": { 31 | "lint": "eslint --max-warnings 0 .", 32 | "typecheck": "tsc --skipLibCheck --noEmit", 33 | "test": "vitest run", 34 | "build": "rm -rf dist/* && npm run build:cjs && npm run build:mjs && npm run build:node-mjs && npm run build:node-cjs", 35 | "build:cjs": "tsc -p ./tsconfig.cjs.json --outDir ./dist/cjs", 36 | "build:node-cjs": "tsc -p ./tsconfig.cjs.json --target es2021 --outDir ./dist/node-cjs", 37 | "build:mjs": "tsc -p ./tsconfig.esm.json --outDir ./dist/mjs && mv ./dist/mjs/index.js ./dist/mjs/index.mjs && mv ./dist/mjs/index.d.ts ./dist/mjs/index.d.mts", 38 | "build:node-mjs": "tsc -p ./tsconfig.esm.json --target es2021 --outDir ./dist/node-mjs && mv ./dist/node-mjs/index.js ./dist/node-mjs/index.mjs && mv ./dist/node-mjs/index.d.ts ./dist/node-mjs/index.d.mts", 39 | "changeset": "changeset", 40 | "bump": "changeset version && sed --in-place \"s/\\\"version\\\": \\\".*\\\"/\\\"version\\\": \\\"$(sed -n 's/^\\s*\\\"version\\\": \\\"\\([^\\\"/]*\\)\\\".*/\\1/p' package.json)\\\"/\" jsr.json", 41 | "release": "npm run build && changeset publish && jsr publish" 42 | }, 43 | "devDependencies": { 44 | "@changesets/changelog-github": "^0.5.1", 45 | "@changesets/cli": "^2.29.4", 46 | "@eslint/js": "^9.27.0", 47 | "eslint": "^9.27.0", 48 | "eslint-config-prettier": "^10.1.5", 49 | "eslint-plugin-prettier": "^5.4.0", 50 | "jsr": "^0.13.4", 51 | "prettier": "^3.5.3", 52 | "typescript": "^5.8.3", 53 | "typescript-eslint": "^8.32.1", 54 | "vitest": "^3.1.4" 55 | }, 56 | "files": [ 57 | "src", 58 | "dist" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /src/index.esm.mts: -------------------------------------------------------------------------------- 1 | export * from "./index.js"; 2 | -------------------------------------------------------------------------------- /tests/Type.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("Type", () => { 5 | it("is not assignable from Optional", () => { 6 | expectTypeOf(v.unknown().optional()).not.toExtend(); 7 | }); 8 | it("is not assignable to Optional", () => { 9 | expectTypeOf(v.unknown()).not.toExtend(); 10 | }); 11 | 12 | describe("try", () => { 13 | it("returns ValitaResult when called for v.Type", () => { 14 | function _(type: v.Type, value: unknown): v.ValitaResult { 15 | return type.try(value); 16 | } 17 | }); 18 | it("returns type v.ValitaResult>", () => { 19 | function _( 20 | type: T, 21 | value: unknown, 22 | ): v.ValitaResult> { 23 | return type.try(value); 24 | } 25 | }); 26 | it("returns type discriminated by .ok", () => { 27 | const result = v.number().try(1); 28 | if (result.ok) { 29 | expectTypeOf(result.value).toEqualTypeOf(); 30 | expectTypeOf(result).not.toMatchObjectType<{ message: string }>(); 31 | } else { 32 | expectTypeOf(result).not.toMatchObjectType<{ value: number }>(); 33 | expectTypeOf(result.message).toEqualTypeOf(); 34 | } 35 | }); 36 | it("returns { ok: true, value: ... } on success", () => { 37 | const result = v.number().try(1); 38 | expect(result.ok).to.equal(true); 39 | expect(result.ok && result.value).to.equal(1); 40 | }); 41 | it("keeps the original instance for .value when possible", () => { 42 | const o = {}; 43 | const t = v.object({}); 44 | const result = t.try(o); 45 | expect(result.ok && result.value).to.equal(o); 46 | }); 47 | it("creates a new instance for .value when necessary", () => { 48 | const o = { a: 1 }; 49 | const t = v.object({}); 50 | const result = t.try(o, { mode: "strip" }); 51 | expect(result.ok && result.value).to.not.equal(o); 52 | }); 53 | it("returns { ok: false, ... } on failure", () => { 54 | const t = v.number(); 55 | const result = t.try("test"); 56 | expect(result.ok).to.equal(false); 57 | }); 58 | }); 59 | describe("parse", () => { 60 | it("returns T when called for v.Type", () => { 61 | function _(type: v.Type, value: unknown): T { 62 | return type.parse(value); 63 | } 64 | }); 65 | it("returns type v.Infer<...>", () => { 66 | function _(type: T, value: unknown): v.Infer { 67 | return type.parse(value); 68 | } 69 | }); 70 | }); 71 | describe("assert", () => { 72 | it("passes the type through by default", () => { 73 | const _t = v.number().assert(() => true); 74 | expectTypeOf>().toEqualTypeOf(); 75 | }); 76 | it("turns optional input into non-optional output", () => { 77 | const t = v.object({ 78 | a: v 79 | .number() 80 | .optional() 81 | .assert(() => true), 82 | }); 83 | expect(t.parse({})).to.deep.equal({ a: undefined }); 84 | expectTypeOf>().toEqualTypeOf<{ 85 | a: number | undefined; 86 | }>(); 87 | }); 88 | it("accepts type predicates", () => { 89 | type Branded = number & { readonly brand: unique symbol }; 90 | const _t = v.number().assert((n): n is Branded => true); 91 | expectTypeOf>().toEqualTypeOf(); 92 | expectTypeOf>().not.toEqualTypeOf(); 93 | }); 94 | it("accepts type parameters", () => { 95 | const _t = v.number().assert<1>((n) => n === 1); 96 | expectTypeOf>().toEqualTypeOf<1>(); 97 | }); 98 | it("passes in the parsed value", () => { 99 | let value: unknown; 100 | const t = v.number().assert((v) => { 101 | value = v; 102 | return true; 103 | }); 104 | t.parse(1000); 105 | expect(value).to.equal(1000); 106 | }); 107 | it("passes in normalized parse options", () => { 108 | let options: unknown; 109 | const t = v.number().assert((n, opts) => { 110 | options = opts; 111 | return true; 112 | }); 113 | t.parse(1, { mode: "strict" }); 114 | expect(options).to.deep.equal({ mode: "strict" }); 115 | t.parse(1, { mode: "strip" }); 116 | expect(options).to.deep.equal({ mode: "strip" }); 117 | t.parse(1, { mode: "passthrough" }); 118 | expect(options).to.deep.equal({ mode: "passthrough" }); 119 | t.parse(1, { mode: "strict" }); 120 | expect(options).to.deep.equal({ mode: "strict" }); 121 | }); 122 | it("passes the value through on success", () => { 123 | const t = v.number().assert(() => true); 124 | expect(t.parse(1000)).to.equal(1000); 125 | }); 126 | it("creates a custom error on failure", () => { 127 | const t = v.number().assert(() => false); 128 | expect(() => t.parse(1)) 129 | .to.throw(v.ValitaError) 130 | .with.nested.property("issues[0]") 131 | .that.includes({ code: "custom_error" }); 132 | }); 133 | it("allows passing in a custom error message", () => { 134 | const t = v.number().assert(() => false, "test"); 135 | expect(() => t.parse(1)) 136 | .to.throw(v.ValitaError) 137 | .with.nested.property("issues[0]") 138 | .that.deep.includes({ 139 | code: "custom_error", 140 | error: "test", 141 | }); 142 | }); 143 | it("allows passing in a custom error message in an object", () => { 144 | const t = v.number().assert(() => false, { message: "test" }); 145 | expect(() => t.parse(1)) 146 | .to.throw(v.ValitaError) 147 | .with.nested.property("issues[0]") 148 | .that.deep.includes({ 149 | code: "custom_error", 150 | error: { message: "test" }, 151 | }); 152 | }); 153 | it("allows passing in a error path", () => { 154 | const t = v.number().assert(() => false, { path: ["test"] }); 155 | expect(() => t.parse(1)) 156 | .to.throw(v.ValitaError) 157 | .with.nested.property("issues[0]") 158 | .that.deep.includes({ 159 | code: "custom_error", 160 | path: ["test"], 161 | }); 162 | }); 163 | it("runs multiple asserts in order", () => { 164 | const t = v 165 | .string() 166 | .assert((s) => s !== "a", "a") 167 | .assert(() => false, "b"); 168 | expect(() => t.parse("a")) 169 | .to.throw(v.ValitaError) 170 | .with.nested.property("issues[0]") 171 | .that.deep.includes({ 172 | code: "custom_error", 173 | error: "a", 174 | }); 175 | expect(() => t.parse("b")) 176 | .to.throw(v.ValitaError) 177 | .with.nested.property("issues[0]") 178 | .that.deep.includes({ 179 | code: "custom_error", 180 | error: "b", 181 | }); 182 | }); 183 | it("always gets the value transformed by previous maps and chains", () => { 184 | const x = {}; 185 | const t = v 186 | .string() 187 | .assert((s) => s === "a") 188 | .map(() => x) 189 | .assert((s) => s === x); 190 | expect(t.parse("a")).to.equal(x); 191 | }); 192 | }); 193 | describe("map", () => { 194 | it("changes the output type to the function's return type", () => { 195 | const _t = v.number().map(String); 196 | expectTypeOf>().toEqualTypeOf(); 197 | }); 198 | it("infers literals when possible", () => { 199 | const _t = v.number().map(() => "test"); 200 | expectTypeOf>().toEqualTypeOf<"test">(); 201 | }); 202 | it("passes in the parsed value", () => { 203 | let value: unknown; 204 | const t = v.number().map((v) => (value = v)); 205 | t.parse(1000); 206 | expect(value).to.equal(1000); 207 | }); 208 | it("passes in normalized parse options", () => { 209 | let options: unknown; 210 | const t = v.number().map((n, opts) => { 211 | options = opts; 212 | }); 213 | t.parse(1, { mode: "strict" }); 214 | expect(options).to.deep.equal({ mode: "strict" }); 215 | t.parse(1, { mode: "strip" }); 216 | expect(options).to.deep.equal({ mode: "strip" }); 217 | t.parse(1, { mode: "passthrough" }); 218 | expect(options).to.deep.equal({ mode: "passthrough" }); 219 | t.parse(1, { mode: "strict" }); 220 | expect(options).to.deep.equal({ mode: "strict" }); 221 | }); 222 | it("passes on the return value", () => { 223 | const t = v.number().map(() => "test"); 224 | expect(t.parse(1000)).to.equal("test"); 225 | }); 226 | it("runs multiple maps in order", () => { 227 | const t = v 228 | .string() 229 | .map((s) => s + "b") 230 | .map((s) => s + "c"); 231 | expect(t.parse("a")).to.equal("abc"); 232 | }); 233 | }); 234 | describe("chain", () => { 235 | it("changes the output type to the given function's return type", () => { 236 | const _t = v.number().chain((n) => v.ok(String(n))); 237 | expectTypeOf>().toEqualTypeOf(); 238 | }); 239 | 240 | it("changes the output type to given type's output type", () => { 241 | const _t = v 242 | .number() 243 | .map((n) => String(n)) 244 | .chain(v.literal("1")); 245 | expectTypeOf>().toEqualTypeOf<"1">(); 246 | }); 247 | 248 | it("infers literals as the given function's output type when possible", () => { 249 | const _t = v.number().chain(() => ({ ok: true, value: "test" })); 250 | expectTypeOf>().toEqualTypeOf<"test">(); 251 | }); 252 | 253 | it("passes in the parsed value to the given function", () => { 254 | let value: unknown; 255 | const t = v.number().chain((n) => { 256 | value = n; 257 | return v.ok("test"); 258 | }); 259 | t.parse(1000); 260 | expect(value).to.equal(1000); 261 | }); 262 | 263 | it("passes in the parsed value to the given type", () => { 264 | let value: unknown; 265 | const t = v.number().chain( 266 | v.unknown().chain((n) => { 267 | value = n; 268 | return v.ok("test"); 269 | }), 270 | ); 271 | t.parse(1000); 272 | expect(value).to.equal(1000); 273 | }); 274 | 275 | it("passes in normalized parse options to the given function", () => { 276 | let options: unknown; 277 | const t = v.number().chain((n, opts) => { 278 | options = opts; 279 | return v.ok("test"); 280 | }); 281 | t.parse(1, { mode: "strict" }); 282 | expect(options).to.deep.equal({ mode: "strict" }); 283 | t.parse(1, { mode: "strip" }); 284 | expect(options).to.deep.equal({ mode: "strip" }); 285 | t.parse(1, { mode: "passthrough" }); 286 | expect(options).to.deep.equal({ mode: "passthrough" }); 287 | t.parse(1, { mode: "strict" }); 288 | expect(options).to.deep.equal({ mode: "strict" }); 289 | }); 290 | 291 | it("propagates parse options to the given type", () => { 292 | let options: unknown; 293 | const t = v.number().chain( 294 | v.unknown().chain((n, opts) => { 295 | options = opts; 296 | return v.ok("test"); 297 | }), 298 | ); 299 | t.parse(1, { mode: "strict" }); 300 | expect(options).to.deep.equal({ mode: "strict" }); 301 | t.parse(1, { mode: "strip" }); 302 | expect(options).to.deep.equal({ mode: "strip" }); 303 | t.parse(1, { mode: "passthrough" }); 304 | expect(options).to.deep.equal({ mode: "passthrough" }); 305 | t.parse(1, { mode: "strict" }); 306 | expect(options).to.deep.equal({ mode: "strict" }); 307 | }); 308 | 309 | it("passes on the success value from the given function", () => { 310 | const t = v.number().chain(() => v.ok("test")); 311 | expect(t.parse(1)).to.equal("test"); 312 | }); 313 | 314 | it("passes on the success value from the given type", () => { 315 | const t = v.number().chain(v.unknown().map(() => "test")); 316 | expect(t.parse(1)).to.equal("test"); 317 | }); 318 | 319 | it("fails on error result from the given function", () => { 320 | const t = v.number().chain(() => v.err()); 321 | expect(() => t.parse(1)) 322 | .to.throw(v.ValitaError) 323 | .with.nested.property("issues[0]") 324 | .that.deep.includes({ 325 | code: "custom_error", 326 | }); 327 | }); 328 | 329 | it("fails on error result from the given type", () => { 330 | const t = v.number().chain(v.string()); 331 | expect(() => t.parse(1)) 332 | .to.throw(v.ValitaError) 333 | .with.nested.property("issues[0]") 334 | .that.deep.includes({ 335 | code: "invalid_type", 336 | expected: ["string"], 337 | }); 338 | }); 339 | 340 | it("allows passing in a custom error message from the given function", () => { 341 | const t = v.number().chain(() => v.err("test")); 342 | expect(() => t.parse(1)) 343 | .to.throw(v.ValitaError) 344 | .with.nested.property("issues[0]") 345 | .that.deep.includes({ 346 | code: "custom_error", 347 | error: "test", 348 | }); 349 | }); 350 | 351 | it("allows passing in a custom error message in an object from the given function", () => { 352 | const t = v.number().chain(() => v.err({ message: "test" })); 353 | expect(() => t.parse(1)) 354 | .to.throw(v.ValitaError) 355 | .with.nested.property("issues[0]") 356 | .that.deep.includes({ 357 | code: "custom_error", 358 | error: { message: "test" }, 359 | }); 360 | }); 361 | 362 | it("allows passing in an error path from the given function", () => { 363 | const t = v.number().chain(() => v.err({ path: ["test"] })); 364 | expect(() => t.parse(1)) 365 | .to.throw(v.ValitaError) 366 | .with.nested.property("issues[0]") 367 | .that.deep.includes({ 368 | code: "custom_error", 369 | path: ["test"], 370 | }); 371 | }); 372 | 373 | it("runs multiple chains in order", () => { 374 | const t = v 375 | .string() 376 | .chain((s) => v.ok(s + "b")) 377 | .chain((s) => v.ok(s + "c")) 378 | .chain(v.string().map((s) => s + "d")); 379 | expect(t.parse("a")).to.equal("abcd"); 380 | }); 381 | 382 | it("works together with .try()", () => { 383 | const s = v.string(); 384 | const t = v.unknown().chain((x) => s.try(x)); 385 | expectTypeOf>().toEqualTypeOf(); 386 | expect(t.parse("a")).to.equal("a"); 387 | expect(() => t.parse(1)).to.throw(v.ValitaError); 388 | }); 389 | }); 390 | 391 | describe("optional", () => { 392 | it("returns an Optional", () => { 393 | expectTypeOf(v.unknown().optional()).toExtend(); 394 | expectTypeOf(v.unknown().optional()).not.toExtend(); 395 | }); 396 | 397 | it("accepts missing values", () => { 398 | const t = v.object({ 399 | a: v.string().optional(), 400 | }); 401 | expect(t.parse({})).to.deep.equal({}); 402 | }); 403 | 404 | it("accepts undefined", () => { 405 | const t = v.object({ 406 | a: v.string().optional(), 407 | }); 408 | expect(t.parse({ a: undefined })).to.deep.equal({ a: undefined }); 409 | }); 410 | 411 | it("accepts the original type", () => { 412 | const t = v.object({ 413 | a: v.string().optional(), 414 | }); 415 | expect(t.parse({ a: "test" })).to.deep.equal({ a: "test" }); 416 | }); 417 | 418 | it("adds undefined to output", () => { 419 | const _t = v.string().optional(); 420 | expectTypeOf>().toEqualTypeOf(); 421 | }); 422 | 423 | it("makes the output type optional", () => { 424 | const _t = v.object({ a: v.number().optional() }); 425 | expectTypeOf>().toEqualTypeOf<{ 426 | a?: number | undefined; 427 | }>(); 428 | }); 429 | 430 | it("short-circuits previous optionals", () => { 431 | const t = v.object({ 432 | a: v 433 | .string() 434 | .optional() 435 | .map(() => 1) 436 | .optional(), 437 | }); 438 | expect(t.parse({ a: undefined })).to.deep.equal({ a: undefined }); 439 | expectTypeOf>().toEqualTypeOf<{ a?: 1 | undefined }>(); 440 | }); 441 | 442 | it("short-circuits undefined()", () => { 443 | const t = v.object({ 444 | a: v 445 | .undefined() 446 | .map(() => 1) 447 | .optional(), 448 | }); 449 | expect(t.parse({ a: undefined })).to.deep.equal({ a: undefined }); 450 | expectTypeOf>().toEqualTypeOf<{ a?: 1 | undefined }>(); 451 | }); 452 | 453 | it("passes undefined to assert() for missing values", () => { 454 | let value: unknown = null; 455 | const t = v.object({ 456 | missing: v 457 | .string() 458 | .optional() 459 | .assert((input) => { 460 | value = input; 461 | return true; 462 | }), 463 | }); 464 | t.parse({}); 465 | expect(value).toBe(undefined); 466 | }); 467 | 468 | it("passes undefined to map() for missing values", () => { 469 | let value: unknown = null; 470 | const t = v.object({ 471 | missing: v 472 | .string() 473 | .optional() 474 | .map((input) => { 475 | value = input; 476 | }), 477 | }); 478 | t.parse({}); 479 | expect(value).toBe(undefined); 480 | }); 481 | 482 | it("passes undefined to chain() for missing values", () => { 483 | let value: unknown = null; 484 | const t = v.object({ 485 | missing: v 486 | .string() 487 | .optional() 488 | .chain((input) => { 489 | value = input; 490 | return v.ok(true); 491 | }), 492 | }); 493 | t.parse({}); 494 | expect(value).toBe(undefined); 495 | }); 496 | 497 | it("accepts a default value function that maps undefined input to a value", () => { 498 | const t = v.string().optional(() => 1); 499 | expect(t.parse(undefined)).toEqual(1); 500 | }); 501 | 502 | it("applies the default value function when the wrapped parser maps to `undefined`", () => { 503 | const t = v 504 | .string() 505 | .map(() => undefined) 506 | .optional(() => 1); 507 | expect(t.parse("foo")).toEqual(1); 508 | }); 509 | 510 | it("applies the default value function when the input value is missing", () => { 511 | const t = v.object({ 512 | a: v.string().optional(() => 1), 513 | }); 514 | expect(t.parse({})).toEqual({ a: 1 }); 515 | }); 516 | 517 | it("marks output properties as non-optional when given a default value functions", () => { 518 | const _t = v.object({ 519 | a: v.string().optional(() => Math.random()), 520 | }); 521 | expectTypeOf>().toEqualTypeOf<{ 522 | a: string | number; 523 | }>(); 524 | }); 525 | 526 | it("includes the default value function output type to the inferred output type", () => { 527 | const _t = v.string().optional(() => Math.random()); 528 | expectTypeOf>().toEqualTypeOf(); 529 | }); 530 | 531 | it("replaces `undefined` with the default value function's output type", () => { 532 | const _t = v.undefined().optional(() => Math.random()); 533 | expectTypeOf>().toEqualTypeOf(); 534 | }); 535 | 536 | it("infers literal outputs from default value functions when possible when used standalone", () => { 537 | const _t1 = v.string().optional(() => 1); 538 | expectTypeOf>().toEqualTypeOf(); 539 | 540 | const _t2 = v.number().optional(() => "foo"); 541 | expectTypeOf>().toEqualTypeOf(); 542 | 543 | const _t3 = v.number().optional(() => true); 544 | expectTypeOf>().toEqualTypeOf(); 545 | 546 | const _t4 = v.string().optional(() => 1n); 547 | expectTypeOf>().toEqualTypeOf(); 548 | 549 | const _t5 = v.string().optional(() => null); 550 | expectTypeOf>().toEqualTypeOf(); 551 | 552 | const _t6 = v.string().optional(() => undefined); 553 | expectTypeOf>().toEqualTypeOf(); 554 | }); 555 | 556 | it("infers literal outputs from default value functions when possible when used as a property", () => { 557 | const _t1 = v.object({ a: v.string().optional(() => 1) }); 558 | expectTypeOf>().toEqualTypeOf<{ a: string | 1 }>(); 559 | 560 | const _t2 = v.object({ a: v.number().optional(() => "foo") }); 561 | expectTypeOf>().toEqualTypeOf<{ 562 | a: number | "foo"; 563 | }>(); 564 | 565 | const _t3 = v.object({ a: v.number().optional(() => true) }); 566 | expectTypeOf>().toEqualTypeOf<{ a: number | true }>(); 567 | 568 | const _t4 = v.object({ a: v.string().optional(() => 1n) }); 569 | expectTypeOf>().toEqualTypeOf<{ a: string | 1n }>(); 570 | 571 | const _t5 = v.object({ a: v.string().optional(() => null) }); 572 | expectTypeOf>().toEqualTypeOf<{ a: string | null }>(); 573 | 574 | const _t6 = v.object({ a: v.string().optional(() => undefined) }); 575 | expectTypeOf>().toEqualTypeOf<{ 576 | a: string | undefined; 577 | }>(); 578 | }); 579 | 580 | it("infers original non-literal output type from the default value function when possible", () => { 581 | const _t = v.array(v.object({ a: v.string() })).optional(() => []); 582 | expectTypeOf>().toEqualTypeOf<{ a: string }[]>(); 583 | }); 584 | 585 | it("creates a new default value for each validation call", () => { 586 | const t = v.string().optional(() => []); 587 | expect(t.parse(undefined)).not.toBe(t.parse(undefined)); 588 | }); 589 | 590 | it("allows widening the default value function's output type with an explicit annotation", () => { 591 | const _t = v.undefined().optional(() => 1); 592 | expectTypeOf>().toEqualTypeOf(); 593 | }); 594 | }); 595 | 596 | describe("nullable()", () => { 597 | it("accepts null", () => { 598 | const t = v.object({ 599 | a: v.string().nullable(), 600 | }); 601 | expect(t.parse({ a: null })).to.deep.equal({ a: null }); 602 | }); 603 | 604 | it("accepts the original type", () => { 605 | const t = v.object({ 606 | a: v.string().nullable(), 607 | }); 608 | expect(t.parse({ a: "test" })).to.deep.equal({ a: "test" }); 609 | }); 610 | 611 | it("adds null to output", () => { 612 | const _t = v.string().nullable(); 613 | expectTypeOf>().toEqualTypeOf(); 614 | }); 615 | 616 | it("makes the output type nullable", () => { 617 | const _t = v.object({ a: v.number().nullable() }); 618 | expectTypeOf>().toEqualTypeOf<{ a: number | null }>(); 619 | }); 620 | 621 | it("short-circuits previous nulls", () => { 622 | const t = v.object({ 623 | a: v 624 | .string() 625 | .nullable() 626 | .map(() => 1) 627 | .nullable(), 628 | }); 629 | expect(t.parse({ a: null })).to.deep.equal({ a: null }); 630 | expectTypeOf>().toEqualTypeOf<{ a: 1 | null }>(); 631 | }); 632 | 633 | it("short-circuits null()", () => { 634 | const t = v.object({ 635 | a: v 636 | .null() 637 | .map(() => 1) 638 | .nullable(), 639 | }); 640 | expect(t.parse({ a: null })).to.deep.equal({ a: null }); 641 | expectTypeOf>().toEqualTypeOf<{ a: 1 | null }>(); 642 | }); 643 | 644 | it("accepts a default value function that maps null input to a value", () => { 645 | const t = v.string().nullable(() => 1); 646 | expect(t.parse(null)).toEqual(1); 647 | }); 648 | 649 | it("applies the default value function when the wrapped parser maps to `null`", () => { 650 | const t = v 651 | .string() 652 | .map(() => null) 653 | .nullable(() => 1); 654 | expect(t.parse("foo")).toEqual(1); 655 | }); 656 | 657 | it("includes the default value function output type to the inferred output type", () => { 658 | const _t = v.string().nullable(() => Math.random()); 659 | expectTypeOf>().toEqualTypeOf(); 660 | }); 661 | 662 | it("replaces `null` with the default value function's output type", () => { 663 | const _t = v.null().nullable(() => Math.random()); 664 | expectTypeOf>().toEqualTypeOf(); 665 | }); 666 | 667 | it("infers literal outputs from default value functions when possible when used standalone", () => { 668 | const _t1 = v.string().nullable(() => 1); 669 | expectTypeOf>().toEqualTypeOf(); 670 | 671 | const _t2 = v.number().nullable(() => "foo"); 672 | expectTypeOf>().toEqualTypeOf(); 673 | 674 | const _t3 = v.number().nullable(() => true); 675 | expectTypeOf>().toEqualTypeOf(); 676 | 677 | const _t4 = v.string().nullable(() => 1n); 678 | expectTypeOf>().toEqualTypeOf(); 679 | 680 | const _t5 = v.string().nullable(() => null); 681 | expectTypeOf>().toEqualTypeOf(); 682 | 683 | const _t6 = v.string().nullable(() => undefined); 684 | expectTypeOf>().toEqualTypeOf(); 685 | }); 686 | 687 | it("infers literal outputs from default value functions when possible when used as a property", () => { 688 | const _t1 = v.object({ a: v.string().nullable(() => 1) }); 689 | expectTypeOf>().toEqualTypeOf<{ a: string | 1 }>(); 690 | 691 | const _t2 = v.object({ a: v.number().nullable(() => "foo") }); 692 | expectTypeOf>().toEqualTypeOf<{ 693 | a: number | "foo"; 694 | }>(); 695 | 696 | const _t3 = v.object({ a: v.number().nullable(() => true) }); 697 | expectTypeOf>().toEqualTypeOf<{ a: number | true }>(); 698 | 699 | const _t4 = v.object({ a: v.string().nullable(() => 1n) }); 700 | expectTypeOf>().toEqualTypeOf<{ a: string | 1n }>(); 701 | 702 | const _t5 = v.object({ a: v.string().nullable(() => null) }); 703 | expectTypeOf>().toEqualTypeOf<{ a: string | null }>(); 704 | 705 | const _t6 = v.object({ a: v.string().nullable(() => undefined) }); 706 | expectTypeOf>().toEqualTypeOf<{ 707 | a: string | undefined; 708 | }>(); 709 | }); 710 | 711 | it("infers original non-literal output type from the default value function when possible", () => { 712 | const _t = v.array(v.object({ a: v.string() })).nullable(() => []); 713 | expectTypeOf>().toEqualTypeOf<{ a: string }[]>(); 714 | }); 715 | 716 | it("creates a new default value for each validation call", () => { 717 | const t = v.string().nullable(() => []); 718 | expect(t.parse(null)).not.toBe(t.parse(null)); 719 | }); 720 | 721 | it("allows widening the default value function's output type with an explicit annotation", () => { 722 | const _t = v.null().nullable(() => 1); 723 | expectTypeOf>().toEqualTypeOf(); 724 | }); 725 | }); 726 | 727 | describe("default", () => { 728 | it("accepts undefined", () => { 729 | // eslint-disable-next-line @typescript-eslint/no-deprecated 730 | const t = v.number().default(2); 731 | expect(t.parse(undefined)).to.deep.equal(2); 732 | }); 733 | 734 | it("maps undefined output from any parser", () => { 735 | const t = v 736 | .string() 737 | .map(() => undefined) 738 | .default(2); // eslint-disable-line @typescript-eslint/no-deprecated 739 | expect(t.parse("test")).to.deep.equal(2); 740 | }); 741 | 742 | it("makes input optional", () => { 743 | const t = v.object({ 744 | // eslint-disable-next-line @typescript-eslint/no-deprecated 745 | a: v.number().default(2), 746 | }); 747 | expect(t.parse({})).to.deep.equal({ a: 2 }); 748 | }); 749 | 750 | it("infers literals when possible", () => { 751 | // eslint-disable-next-line @typescript-eslint/no-deprecated 752 | const _t = v.undefined().default(2); 753 | expectTypeOf>().toEqualTypeOf<2>(); 754 | }); 755 | 756 | it("removes undefined from the return type", () => { 757 | // eslint-disable-next-line @typescript-eslint/no-deprecated 758 | const _t = v.union(v.string(), v.undefined()).default(2); 759 | expectTypeOf>().toEqualTypeOf(); 760 | }); 761 | }); 762 | }); 763 | -------------------------------------------------------------------------------- /tests/ValitaError.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("ValitaError", () => { 5 | const error = new v.ValitaError({ 6 | ok: false, 7 | code: "invalid_type", 8 | expected: ["bigint"], 9 | }); 10 | 11 | it("is derived from Error", () => { 12 | expect(error).to.be.instanceof(Error); 13 | }); 14 | 15 | it("has a name", () => { 16 | expect(error.name).to.equal("ValitaError"); 17 | }); 18 | 19 | describe("issues", () => { 20 | it("lists issues", () => { 21 | expect(error.issues).to.deep.equal([ 22 | { 23 | path: [], 24 | code: "invalid_type", 25 | expected: ["bigint"], 26 | }, 27 | ]); 28 | }); 29 | 30 | it("supports multiple issues", () => { 31 | const error = new v.ValitaError({ 32 | ok: false, 33 | code: "join", 34 | left: { 35 | ok: false, 36 | code: "invalid_type", 37 | expected: ["bigint"], 38 | }, 39 | right: { 40 | ok: false, 41 | code: "prepend", 42 | key: "first", 43 | tree: { 44 | ok: false, 45 | code: "invalid_type", 46 | expected: ["string"], 47 | }, 48 | }, 49 | }); 50 | expect(error.issues).to.deep.equal([ 51 | { 52 | path: [], 53 | code: "invalid_type", 54 | expected: ["bigint"], 55 | }, 56 | { 57 | path: ["first"], 58 | code: "invalid_type", 59 | expected: ["string"], 60 | }, 61 | ]); 62 | }); 63 | 64 | it("caches the issues list", () => { 65 | expect(error.issues).to.equal(error.issues); 66 | }); 67 | 68 | it("appends custom error paths to the issue", () => { 69 | expect(() => 70 | v 71 | .object({ 72 | foo: v.unknown().chain(() => v.err({ path: [0, "bar"] })), 73 | }) 74 | .chain(() => v.err()) 75 | .parse({ foo: 1 }), 76 | ).toThrowError( 77 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 78 | expect.objectContaining({ 79 | issues: [ 80 | expect.objectContaining({ 81 | code: "custom_error", 82 | path: ["foo", 0, "bar"], 83 | message: undefined, 84 | }), 85 | ], 86 | }), 87 | ); 88 | }); 89 | 90 | it("normalizes custom errors", () => { 91 | expect(() => 92 | v 93 | .unknown() 94 | .chain(() => v.err()) 95 | .parse(1), 96 | ).toThrowError( 97 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 98 | expect.objectContaining({ 99 | issues: [ 100 | expect.objectContaining({ 101 | code: "custom_error", 102 | path: [], 103 | message: undefined, 104 | }), 105 | ], 106 | }), 107 | ); 108 | 109 | expect(() => 110 | v 111 | .unknown() 112 | .chain(() => v.err({ path: ["foo"] })) 113 | .parse(1), 114 | ).toThrowError( 115 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 116 | expect.objectContaining({ 117 | issues: [ 118 | expect.objectContaining({ 119 | code: "custom_error", 120 | path: ["foo"], 121 | message: undefined, 122 | }), 123 | ], 124 | }), 125 | ); 126 | 127 | expect(() => 128 | v 129 | .unknown() 130 | .chain(() => v.err({ message: "test", path: ["bar"] })) 131 | .parse(1), 132 | ).toThrowError( 133 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 134 | expect.objectContaining({ 135 | issues: [ 136 | expect.objectContaining({ 137 | code: "custom_error", 138 | path: ["bar"], 139 | message: "test", 140 | }), 141 | ], 142 | }), 143 | ); 144 | 145 | expect(() => 146 | v 147 | .unknown() 148 | .chain(() => v.err({ message: "test" })) 149 | .parse(1), 150 | ).toThrowError( 151 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 152 | expect.objectContaining({ 153 | issues: [ 154 | expect.objectContaining({ 155 | code: "custom_error", 156 | path: [], 157 | message: "test", 158 | }), 159 | ], 160 | }), 161 | ); 162 | 163 | expect(() => 164 | v 165 | .unknown() 166 | .chain(() => v.err("test")) 167 | .parse(1), 168 | ).toThrowError( 169 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 170 | expect.objectContaining({ 171 | issues: [ 172 | expect.objectContaining({ 173 | code: "custom_error", 174 | path: [], 175 | message: "test", 176 | }), 177 | ], 178 | }), 179 | ); 180 | }); 181 | }); 182 | 183 | describe("message", () => { 184 | it("describes the issue when there's only one issue", () => { 185 | const t = v.bigint(); 186 | expect(() => t.parse("test")).throws( 187 | v.ValitaError, 188 | "invalid_type at . (expected bigint)", 189 | ); 190 | }); 191 | 192 | it("describes the leftmost issue when there are two issues", () => { 193 | const t = v.tuple([v.bigint(), v.string()]); 194 | expect(() => t.parse(["test", 1])).throws( 195 | v.ValitaError, 196 | "invalid_type at .0 (expected bigint) (+ 1 other issue)", 197 | ); 198 | }); 199 | 200 | it("describes the leftmost issue when there are more than two issues", () => { 201 | const t = v.tuple([v.bigint(), v.string(), v.number()]); 202 | expect(() => t.parse(["test", 1, "other"])).throws( 203 | v.ValitaError, 204 | "invalid_type at .0 (expected bigint) (+ 2 other issues)", 205 | ); 206 | }); 207 | 208 | it("uses description 'validation failed' by default for custom_error", () => { 209 | const t = v.unknown().chain(() => v.err()); 210 | expect(() => t.parse(1)).throws( 211 | v.ValitaError, 212 | "custom_error at . (validation failed)", 213 | ); 214 | }); 215 | 216 | it("takes the custom_error description from the given value when given as string", () => { 217 | const t = v.unknown().chain(() => v.err("test")); 218 | expect(() => t.parse(1)).throws( 219 | v.ValitaError, 220 | "custom_error at . (test)", 221 | ); 222 | }); 223 | 224 | it("takes the custom_error description from the .message property when given in an object", () => { 225 | const t = v.unknown().chain(() => v.err({ message: "test" })); 226 | expect(() => t.parse(1)).throws( 227 | v.ValitaError, 228 | "custom_error at . (test)", 229 | ); 230 | }); 231 | 232 | it("includes to custom_error path the .path property when given in an object", () => { 233 | const t = v.object({ 234 | a: v.unknown().chain(() => v.err({ message: "test", path: [1, "b"] })), 235 | }); 236 | expect(() => t.parse({ a: 1 })).throws( 237 | v.ValitaError, 238 | "custom_error at .a.1.b (test)", 239 | ); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /tests/ValitaResult.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("ValitaResult", () => { 5 | describe("issues", () => { 6 | it("lists issues", () => { 7 | const result = v.bigint().try("test"); 8 | expect(!result.ok && result.issues).to.deep.equal([ 9 | { 10 | path: [], 11 | code: "invalid_type", 12 | expected: ["bigint"], 13 | }, 14 | ]); 15 | }); 16 | 17 | it("supports multiple issues", () => { 18 | const result = v 19 | .object({ a: v.bigint(), b: v.string() }) 20 | .try({ a: "test", b: 1 }); 21 | expect(!result.ok && result.issues).to.have.deep.members([ 22 | { 23 | path: ["a"], 24 | code: "invalid_type", 25 | expected: ["bigint"], 26 | }, 27 | { 28 | path: ["b"], 29 | code: "invalid_type", 30 | expected: ["string"], 31 | }, 32 | ]); 33 | }); 34 | 35 | it("caches the issues list", () => { 36 | const result = v.bigint().try("test"); 37 | expect(!result.ok && result.issues).to.equal(!result.ok && result.issues); 38 | }); 39 | 40 | it("appends custom error paths to the issue", () => { 41 | expect( 42 | v 43 | .object({ 44 | foo: v.unknown().chain(() => v.err({ path: [0, "bar"] })), 45 | }) 46 | .chain(() => v.err()) 47 | .try({ foo: 1 }), 48 | ).toMatchObject({ 49 | issues: [ 50 | { 51 | code: "custom_error", 52 | path: ["foo", 0, "bar"], 53 | message: undefined, 54 | }, 55 | ], 56 | }); 57 | }); 58 | 59 | it("normalizes custom errors", () => { 60 | expect( 61 | v 62 | .unknown() 63 | .chain(() => v.err()) 64 | .try(1), 65 | ).toMatchObject({ 66 | issues: [ 67 | { 68 | code: "custom_error", 69 | path: [], 70 | message: undefined, 71 | }, 72 | ], 73 | }); 74 | 75 | expect( 76 | v 77 | .unknown() 78 | .chain(() => v.err({ path: ["foo"] })) 79 | .try(1), 80 | ).toMatchObject({ 81 | issues: [ 82 | { 83 | code: "custom_error", 84 | path: ["foo"], 85 | message: undefined, 86 | }, 87 | ], 88 | }); 89 | 90 | expect( 91 | v 92 | .unknown() 93 | .chain(() => v.err({ message: "test", path: ["bar"] })) 94 | .try(1), 95 | ).toMatchObject({ 96 | issues: [ 97 | { 98 | code: "custom_error", 99 | path: ["bar"], 100 | message: "test", 101 | }, 102 | ], 103 | }); 104 | 105 | expect( 106 | v 107 | .unknown() 108 | .chain(() => v.err({ message: "test" })) 109 | .try(1), 110 | ).toMatchObject({ 111 | issues: [ 112 | { 113 | code: "custom_error", 114 | path: [], 115 | message: "test", 116 | }, 117 | ], 118 | }); 119 | 120 | expect( 121 | v 122 | .unknown() 123 | .chain(() => v.err("test")) 124 | .try(1), 125 | ).toMatchObject({ 126 | issues: [ 127 | { 128 | code: "custom_error", 129 | path: [], 130 | message: "test", 131 | }, 132 | ], 133 | }); 134 | }); 135 | }); 136 | 137 | describe("message", () => { 138 | it("describes the issue when there's only one issue", () => { 139 | const result = v.bigint().try("test"); 140 | expect(!result.ok && result.message).to.equal( 141 | "invalid_type at . (expected bigint)", 142 | ); 143 | }); 144 | 145 | it("describes the leftmost issue when there are two issues", () => { 146 | const result = v.tuple([v.bigint(), v.string()]).try(["test", 1]); 147 | expect(!result.ok && result.message).to.equal( 148 | "invalid_type at .0 (expected bigint) (+ 1 other issue)", 149 | ); 150 | }); 151 | 152 | it("describes the leftmost issue when there are more than two issues", () => { 153 | const result = v 154 | .tuple([v.bigint(), v.string(), v.number()]) 155 | .try(["test", 1, "other"]); 156 | expect(!result.ok && result.message).to.equal( 157 | "invalid_type at .0 (expected bigint) (+ 2 other issues)", 158 | ); 159 | }); 160 | 161 | it("uses description 'validation failed' by default for custom_error", () => { 162 | const result = v 163 | .unknown() 164 | .chain(() => v.err()) 165 | .try(1); 166 | expect(!result.ok && result.message).to.equal( 167 | "custom_error at . (validation failed)", 168 | ); 169 | }); 170 | 171 | it("takes the custom_error description from the given value when given as string", () => { 172 | const result = v 173 | .unknown() 174 | .chain(() => v.err("test")) 175 | .try(1); 176 | expect(!result.ok && result.message).to.equal("custom_error at . (test)"); 177 | }); 178 | 179 | it("takes the custom_error description from the .message property when given in an object", () => { 180 | const result = v 181 | .unknown() 182 | .chain(() => v.err({ message: "test" })) 183 | .try(1); 184 | expect(!result.ok && result.message).to.equal("custom_error at . (test)"); 185 | }); 186 | 187 | it("includes to custom_error path the .path property when given in an object", () => { 188 | const result = v 189 | .object({ 190 | a: v 191 | .unknown() 192 | .chain(() => v.err({ message: "test", path: [1, "b"] })), 193 | }) 194 | .try({ a: 1 }); 195 | expect(!result.ok && result.message).to.equal( 196 | "custom_error at .a.1.b (test)", 197 | ); 198 | }); 199 | }); 200 | 201 | describe("throw", () => { 202 | it("throws a corresponding ValitaError", () => { 203 | const result = v.bigint().try("test"); 204 | expect(() => !result.ok && result.throw()) 205 | .to.throw(v.ValitaError) 206 | .with.deep.property("issues", !result.ok && result.issues); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /tests/array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("array()", () => { 5 | it("accepts arrays", () => { 6 | const t = v.array(v.number()); 7 | expect(t.parse([1])).to.deep.equal([1]); 8 | }); 9 | 10 | it("rejects other types", () => { 11 | const t = v.array(v.number()); 12 | for (const val of ["1", 1n, true, null, undefined, { 0: 1 }]) { 13 | expect(() => t.parse(val)).to.throw(v.ValitaError); 14 | } 15 | }); 16 | 17 | it("defaults to unknown[]", () => { 18 | const t = v.array(); 19 | expect(t.parse(["foo", 1])).to.deep.equal(["foo", 1]); 20 | expectTypeOf>().toEqualTypeOf(); 21 | }); 22 | 23 | it("throws on item mismatch", () => { 24 | const t = v.array(v.string()); 25 | expect(() => t.parse([1])).to.throw(v.ValitaError); 26 | }); 27 | 28 | it("returns the original array instance if possible", () => { 29 | const t = v.array(v.number()); 30 | const a = [1]; 31 | expect(t.parse(a)).to.equal(a); 32 | }); 33 | 34 | it("returns a new array instance if the items change", () => { 35 | const t = v.array(v.number().map(() => "test")); 36 | const a = [1]; 37 | expect(t.parse(a)).to.not.equal(a); 38 | }); 39 | 40 | it("infers array", () => { 41 | const _t = v.array(v.number()); 42 | expectTypeOf>().toEqualTypeOf(); 43 | }); 44 | 45 | describe("concat()", () => { 46 | it("creates a variadic tuple from an array and a fixed-length tuple", () => { 47 | const s = v.string(); 48 | const t = v.tuple([v.number(), v.number()]).concat(v.array(v.string())); 49 | expect(t._prefix).toHaveLength(2); 50 | expect(t._rest).toStrictEqual(s); 51 | expect(t._suffix).to.toHaveLength(0); 52 | }); 53 | 54 | it("prohibits concatenating variadic types at type level", () => { 55 | const t = v.array(v.string()); 56 | 57 | try { 58 | // @ts-expect-error two variadic tuples can't be concatenated 59 | t.concat(v.tuple([]).concat(v.array(v.number()))); 60 | } catch { 61 | // pass 62 | } 63 | 64 | try { 65 | // @ts-expect-error an array can't be concatenated to a variadic tuple 66 | t.concat(v.array(v.number())); 67 | } catch { 68 | // pass 69 | } 70 | }); 71 | 72 | it("prohibits concatenating variadic types at runtime", () => { 73 | const t: { concat(v: unknown): unknown } = v.array(v.string()); 74 | 75 | expect(() => t.concat(v.tuple([]).concat(v.array(v.number())))).to.throw( 76 | TypeError, 77 | "can not concatenate two variadic types", 78 | ); 79 | expect(() => t.concat(v.array(v.number()))).to.throw( 80 | TypeError, 81 | "can not concatenate two variadic types", 82 | ); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/bigint.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("bigint()", () => { 5 | it("accepts bigints", () => { 6 | const t = v.bigint(); 7 | expect(t.parse(1n)).to.equal(1n); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.bigint(); 11 | for (const val of ["1", 1, true, null, undefined, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'bigint'", () => { 16 | const _t = v.bigint(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/boolean.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("boolean()", () => { 5 | it("accepts booleans", () => { 6 | const t = v.boolean(); 7 | expect(t.parse(true)).to.equal(true); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.boolean(); 11 | for (const val of ["1", 1, 1n, null, undefined, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'boolean'", () => { 16 | const _t = v.boolean(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/lazy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("lazy()", () => { 5 | it("allows recursive type definitions", () => { 6 | type T = 7 | | undefined 8 | | { 9 | t: T; 10 | }; 11 | const t: v.Type = v.lazy(() => v.union(v.undefined(), v.object({ t }))); 12 | expectTypeOf>().toEqualTypeOf(); 13 | }); 14 | it("allows mutually recursive type definitions", () => { 15 | type A = 16 | | undefined 17 | | { 18 | b: B; 19 | }; 20 | type B = undefined | A[]; 21 | const a: v.Type = v.lazy(() => v.union(v.undefined(), v.object({ b }))); 22 | const b: v.Type = v.lazy(() => v.union(v.undefined(), v.array(a))); 23 | expectTypeOf>().toEqualTypeOf(); 24 | expectTypeOf>().toEqualTypeOf(); 25 | }); 26 | it("allows referencing object matchers that are defined later", () => { 27 | const a: v.Type = v.lazy(() => v.union(v.undefined(), b)); 28 | const b = v.object({ a }); 29 | expect(a.parse(undefined)).to.equal(undefined); 30 | }); 31 | it("allows referencing union matchers that are defined later", () => { 32 | const a: v.Type = v.lazy(() => v.union(v.undefined(), b)); 33 | const b = v.union(a); 34 | expect(a.parse(undefined)).to.equal(undefined); 35 | }); 36 | it("fail typecheck on conflicting return type", () => { 37 | type T = 38 | | undefined 39 | | { 40 | t: T; 41 | }; 42 | expectTypeOf( 43 | v.lazy(() => v.union(v.undefined(), v.object({ t: v.number() }))), 44 | ).not.toExtend>(); 45 | }); 46 | it("parses recursively", () => { 47 | type T = 48 | | undefined 49 | | { 50 | t: T; 51 | }; 52 | const t: v.Type = v.lazy(() => v.union(v.undefined(), v.object({ t }))); 53 | expect(t.parse({ t: { t: { t: undefined } } })).to.deep.equal({ 54 | t: { t: { t: undefined } }, 55 | }); 56 | expect(() => t.parse({ t: { t: { t: 1 } } })).to.throw( 57 | v.ValitaError, 58 | "invalid_type at .t.t.t (expected undefined or object)", 59 | ); 60 | }); 61 | it("parses recursively", () => { 62 | type T = { 63 | t?: T; 64 | }; 65 | const t: v.Type = v.lazy(() => v.object({ t: t.optional() })); 66 | expect(t.parse({ t: { t: { t: undefined } } })).to.deep.equal({ 67 | t: { t: { t: undefined } }, 68 | }); 69 | expect(() => t.parse({ t: { t: { t: 1 } } })).to.throw( 70 | v.ValitaError, 71 | "invalid_type at .t.t.t (expected object)", 72 | ); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/literal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("literal()", () => { 5 | it("accepts string literals", () => { 6 | const t = v.literal("test"); 7 | expect(t.parse("test")).to.equal("test"); 8 | expectTypeOf>().toEqualTypeOf<"test">(); 9 | }); 10 | it("accepts number literals", () => { 11 | const t = v.literal(1); 12 | expect(t.parse(1)).to.equal(1); 13 | expectTypeOf>().toEqualTypeOf<1>(); 14 | }); 15 | it("accepts bigint literals", () => { 16 | const t = v.literal(1n); 17 | expect(t.parse(1n)).to.equal(1n); 18 | expectTypeOf>().toEqualTypeOf<1n>(); 19 | }); 20 | it("accepts boolean literals", () => { 21 | const t = v.literal(true); 22 | expect(t.parse(true)).to.equal(true); 23 | expectTypeOf>().toEqualTypeOf(); 24 | }); 25 | it("rejects other literals when expecting a string literal", () => { 26 | const t = v.literal("test"); 27 | expect(() => t.parse("other")).to.throw(v.ValitaError); 28 | expect(() => t.parse(1)).to.throw(v.ValitaError); 29 | expect(() => t.parse(1n)).to.throw(v.ValitaError); 30 | expect(() => t.parse(true)).to.throw(v.ValitaError); 31 | }); 32 | it("rejects other literals when expecting a numeric literal", () => { 33 | const t = v.literal(1); 34 | expect(() => t.parse("test")).to.throw(v.ValitaError); 35 | expect(() => t.parse(2)).to.throw(v.ValitaError); 36 | expect(() => t.parse(1n)).to.throw(v.ValitaError); 37 | expect(() => t.parse(true)).to.throw(v.ValitaError); 38 | }); 39 | it("rejects other literals when expecting a bigint literal", () => { 40 | const t = v.literal(1n); 41 | expect(() => t.parse("test")).to.throw(v.ValitaError); 42 | expect(() => t.parse(1)).to.throw(v.ValitaError); 43 | expect(() => t.parse(2n)).to.throw(v.ValitaError); 44 | expect(() => t.parse(true)).to.throw(v.ValitaError); 45 | }); 46 | it("rejects other literals when expecting a boolean literal", () => { 47 | const t = v.literal(true); 48 | expect(() => t.parse("test")).to.throw(v.ValitaError); 49 | expect(() => t.parse(1)).to.throw(v.ValitaError); 50 | expect(() => t.parse(1n)).to.throw(v.ValitaError); 51 | expect(() => t.parse(false)).to.throw(v.ValitaError); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/never.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("never()", () => { 5 | it("rejects everything", () => { 6 | const t = v.never(); 7 | for (const val of ["1", 1, 1n, true, null, undefined, [], {}]) { 8 | expect(() => t.parse(val)).to.throw(v.ValitaError); 9 | } 10 | }); 11 | it("has output type 'never'", () => { 12 | const _t = v.never(); 13 | expectTypeOf>().toEqualTypeOf(); 14 | }); 15 | it("never propagates to assert()", () => { 16 | let called = false; 17 | const t = v.never().assert(() => { 18 | called = true; 19 | return true; 20 | }); 21 | expect(() => t.parse(null)).to.throw(v.ValitaError); 22 | expect(called).toBe(false); 23 | }); 24 | it("never propagates to map()", () => { 25 | let called = false; 26 | const t = v.never().map(() => { 27 | called = true; 28 | return undefined; 29 | }); 30 | expect(() => t.parse(null)).to.throw(v.ValitaError); 31 | expect(called).toBe(false); 32 | }); 33 | it("never propagates to chain()", () => { 34 | let called = false; 35 | const t = v.never().chain(() => { 36 | called = true; 37 | return v.ok(true); 38 | }); 39 | expect(() => t.parse(null)).to.throw(v.ValitaError); 40 | expect(called).toBe(false); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/null.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("null()", () => { 5 | it("accepts null", () => { 6 | const t = v.null(); 7 | expect(t.parse(null)).to.equal(null); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.null(); 11 | for (const val of ["1", 1, 1n, true, undefined, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'null'", () => { 16 | const _t = v.null(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/number.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("number()", () => { 5 | it("accepts numbers", () => { 6 | const t = v.number(); 7 | expect(t.parse(1)).to.equal(1); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.number(); 11 | for (const val of ["1", 1n, true, null, undefined, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'number'", () => { 16 | const _t = v.number(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/object.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("object()", () => { 5 | it("acceps empty objects", () => { 6 | const t = v.object({}); 7 | expect(t.parse({})).to.deep.equal({}); 8 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 9 | expectTypeOf>().toEqualTypeOf<{}>(); 10 | }); 11 | 12 | it("infers required keys object({})", () => { 13 | const _t = v.object({ 14 | a: v.object({}), 15 | }); 16 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 17 | expectTypeOf>().toEqualTypeOf<{ a: {} }>(); 18 | }); 19 | 20 | it("infers optional keys for optional()", () => { 21 | const _t = v.object({ 22 | a: v.undefined().optional(), 23 | }); 24 | expectTypeOf>().toEqualTypeOf<{ a?: undefined }>(); 25 | }); 26 | 27 | it("infers required keys for never()", () => { 28 | const _t = v.object({ 29 | a: v.never(), 30 | }); 31 | expectTypeOf>().toEqualTypeOf<{ a: never }>(); 32 | }); 33 | 34 | it("infers required keys for undefined()", () => { 35 | const _t = v.object({ 36 | a: v.undefined(), 37 | }); 38 | expectTypeOf>().toEqualTypeOf<{ a: undefined }>(); 39 | }); 40 | 41 | it("infers required keys for unknown()", () => { 42 | const _t = v.object({ 43 | a: v.unknown(), 44 | }); 45 | expectTypeOf>().toEqualTypeOf<{ a: unknown }>(); 46 | }); 47 | 48 | it("throws on missing required keys", () => { 49 | const t = v.object({ a: v.string() }); 50 | expect(() => t.parse({})) 51 | .to.throw(v.ValitaError) 52 | .with.nested.property("issues[0].code", "missing_value"); 53 | }); 54 | 55 | it("reports multiple missing required keys", () => { 56 | const result = v.object({ a: v.string(), b: v.number() }).try({}); 57 | expect(!result.ok && result.issues).to.have.deep.members([ 58 | { 59 | path: ["a"], 60 | code: "missing_value", 61 | }, 62 | { 63 | path: ["b"], 64 | code: "missing_value", 65 | }, 66 | ]); 67 | }); 68 | 69 | it("does not throw on missing optional keys", () => { 70 | const t = v.object({ a: v.string().optional() }); 71 | expect(t.parse({})).to.deep.equal({}); 72 | }); 73 | 74 | it("returns the original object instance if possible", () => { 75 | const t = v.object({ a: v.number() }); 76 | const o = { a: 1 }; 77 | expect(t.parse(o)).to.equal(o); 78 | }); 79 | 80 | it("returns a new object instance if the fields change", () => { 81 | const t = v.object({ 82 | a: v.number().map(() => "test"), 83 | }); 84 | const o = { a: 1 }; 85 | expect(t.parse(o)).to.not.equal(o); 86 | }); 87 | 88 | it("supports more than 32 keys (33rd key required)", () => { 89 | const shape: Record = {}; 90 | for (let i = 0; i < 32; i++) { 91 | shape[`key-${i}`] = v.unknown().optional(); 92 | } 93 | shape["key-32"] = v.unknown(); 94 | expect(() => v.object(shape).parse({})).to.throw( 95 | v.ValitaError, 96 | "missing_value at .key-32 (missing value)", 97 | ); 98 | }); 99 | 100 | it("supports more than 32 keys (33rd key optional)", () => { 101 | const shape: Record = {}; 102 | shape["key-0"] = v.unknown(); 103 | for (let i = 1; i <= 32; i++) { 104 | shape[`key-${i}`] = v.unknown().optional(); 105 | } 106 | expect(() => v.object(shape).parse({ "key-32": 1 })).to.throw( 107 | v.ValitaError, 108 | "missing_value at .key-0 (missing value)", 109 | ); 110 | }); 111 | 112 | it("doesn't lose enumerable optional keys when there are transformed non-enumerable optional keys", () => { 113 | const o = { a: 1 }; 114 | Object.defineProperty(o, "b", { 115 | value: 2, 116 | enumerable: false, 117 | }); 118 | const t = v.object({ 119 | a: v.number().optional(), 120 | b: v 121 | .number() 122 | .map((n) => n + 1) 123 | .optional(), 124 | }); 125 | expect(t.parse(o)).to.deep.equal({ a: 1, b: 3 }); 126 | }); 127 | 128 | it("sets cloned output's prototype to Object.prototype when data doesn't contain __proto__", () => { 129 | const t = v.object({ a: v.unknown().map(() => 1) }); 130 | const r = t.parse({ a: "test" }); 131 | expect(Object.getPrototypeOf(r)).toBe(Object.prototype); 132 | }); 133 | 134 | it("sets cloned output's prototype to Object.prototype when the data contains __proto__", () => { 135 | const o = Object.create(null) as Record; 136 | o.__proto__ = v.unknown().map(() => ({ a: 1 })); 137 | const t = v.object(o); 138 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 139 | expect(Object.getPrototypeOf(r)).toBe(Object.prototype); 140 | }); 141 | 142 | it("safely sets __proto__ in a cloned output when __proto__ is transformed", () => { 143 | const o = Object.create(null) as Record; 144 | o.__proto__ = v.unknown().map(() => ({ a: 1 })); 145 | const t = v.object(o); 146 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 147 | expect(r).to.not.have.property("a"); 148 | expect(r).to.not.have.property("b"); 149 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 150 | }); 151 | 152 | it("safely sets __proto__ in cloned output when __proto__ is transformed as a part of rest()", () => { 153 | const t = v.object({}).rest(v.unknown().map(() => ({ a: 1 }))); 154 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 155 | expect(r).to.not.have.property("a"); 156 | expect(r).to.not.have.property("b"); 157 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 158 | }); 159 | 160 | it("safely sets __proto__ in a cloned output another key is transformed", () => { 161 | const t = v.object({ x: v.unknown().map((x) => !x) }).rest(v.unknown()); 162 | const r = t.parse(JSON.parse('{ "x": 1, "__proto__": { "b": 2 } }')); 163 | expect(r).to.not.have.property("b"); 164 | expect(r).to.have.deep.own.property("__proto__", { b: 2 }); 165 | }); 166 | 167 | it("safely sets __proto__ when it's added to output (causing cloning)", () => { 168 | const o = Object.create(null) as Record; 169 | o.__proto__ = v.unknown().default({ a: 1 }); 170 | const t = v.object(o); 171 | 172 | // Parse a __proto__-less object 173 | const r = t.parse(Object.create(null)); 174 | expect(r).to.not.have.property("a"); 175 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 176 | }); 177 | 178 | it("safely sets __proto__ when it's added to already cloned output", () => { 179 | const o = Object.create(null) as Record; 180 | // eslint-disable-next-line @typescript-eslint/no-deprecated 181 | o.x = v.unknown().default(true); 182 | o.__proto__ = v.unknown().default({ a: 1 }); 183 | const t = v.object(o); 184 | 185 | // Parse a __proto__-less object 186 | const r = t.parse(Object.create(null)); 187 | expect(r).to.not.have.property("a"); 188 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 189 | }); 190 | 191 | it("sets __proto__ property as own-writable-enumerable-configurable in cloned output", () => { 192 | const o = Object.create(null) as Record; 193 | o.__proto__ = v.unknown().map(() => ({ a: 1 })); 194 | const t = v.object(o); 195 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 196 | expect(Object.getOwnPropertyDescriptor(r, "__proto__")).to.deep.equal({ 197 | value: { a: 1 }, 198 | writable: true, 199 | enumerable: true, 200 | configurable: true, 201 | }); 202 | }); 203 | 204 | it("safely sets __proto__ in a cloned output when the input is cloned in the 'strip' mode", () => { 205 | const o = Object.create(null) as Record; 206 | o.__proto__ = v.unknown().map(() => ({ a: 1 })); 207 | const t = v.object(o); 208 | const r = t.parse(JSON.parse('{ "x": 1, "__proto__": { "b": 2 } }'), { 209 | mode: "strip", 210 | }); 211 | expect(r).to.not.have.property("x"); 212 | expect(r).to.not.have.property("a"); 213 | expect(r).to.not.have.property("b"); 214 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 215 | }); 216 | 217 | it("rejects other types", () => { 218 | const t = v.object({}); 219 | for (const val of ["1", 1n, true, null, undefined, []]) { 220 | expect(() => t.parse(val)).to.throw(v.ValitaError); 221 | } 222 | }); 223 | 224 | it("checks non-enumerable required keys", () => { 225 | const t = v.object({ a: v.string() }); 226 | const o = {}; 227 | Object.defineProperty(o, "a", { 228 | value: 1, 229 | enumerable: false, 230 | }); 231 | expect(() => t.parse(o)) 232 | .to.throw(v.ValitaError) 233 | .with.nested.property("issues[0]") 234 | .that.deep.includes({ 235 | code: "invalid_type", 236 | path: ["a"], 237 | expected: ["string"], 238 | }); 239 | }); 240 | 241 | it("checks non-enumerable optional keys", () => { 242 | const t = v.object({ a: v.string().optional() }); 243 | const o = {}; 244 | Object.defineProperty(o, "a", { 245 | value: 1, 246 | enumerable: false, 247 | }); 248 | expect(() => t.parse(o)) 249 | .to.throw(v.ValitaError) 250 | .with.nested.property("issues[0]") 251 | .that.deep.includes({ 252 | code: "invalid_type", 253 | path: ["a"], 254 | expected: ["string"], 255 | }); 256 | }); 257 | 258 | it("fails on unrecognized keys by default", () => { 259 | const t = v.object({ a: v.number() }); 260 | expect(() => t.parse({ a: 1, b: 2 })) 261 | .to.throw(v.ValitaError) 262 | .with.deep.nested.include({ 263 | "issues[0].code": "unrecognized_keys", 264 | "issues[0].keys": ["b"], 265 | }); 266 | }); 267 | 268 | it("fails on unrecognized keys when mode=strict", () => { 269 | const t = v.object({ a: v.number() }); 270 | expect(() => t.parse({ a: 1, b: 2 }, { mode: "strict" })) 271 | .to.throw(v.ValitaError) 272 | .with.deep.nested.include({ 273 | "issues[0].code": "unrecognized_keys", 274 | "issues[0].keys": ["b"], 275 | }); 276 | }); 277 | 278 | it("reports multiple unrecognized keys when mode=strict", () => { 279 | const t = v.object({}); 280 | expect(() => t.parse({ a: 1, b: 2 }, { mode: "strict" })) 281 | .to.throw(v.ValitaError) 282 | .with.deep.nested.include({ 283 | "issues[0].code": "unrecognized_keys", 284 | "issues[0].keys": ["a", "b"], 285 | }); 286 | }); 287 | 288 | it("passes through unrecognized keys when mode=passthrough", () => { 289 | const t = v.object({ a: v.number() }); 290 | const o = t.parse({ a: 1, b: 2 }, { mode: "passthrough" }); 291 | expect(o).to.deep.equal({ a: 1, b: 2 }); 292 | }); 293 | 294 | it("strips unrecognized keys when mode=strip", () => { 295 | const t = v.object({ a: v.number() }); 296 | const o = t.parse({ a: 1, b: 2 }, { mode: "strip" }); 297 | expect(o).to.deep.equal({ a: 1 }); 298 | }); 299 | 300 | it("strips unrecognized keys when mode=strip and there are transformed values", () => { 301 | const t = v.object({ a: v.number().map((x) => x + 1) }); 302 | const o = t.parse({ a: 1, b: 2 }, { mode: "strip" }); 303 | expect(o).to.deep.equal({ a: 2 }); 304 | }); 305 | 306 | it("doesn't lose optional keys when mode=strip and there unrecognized non-enumerable keys", () => { 307 | const o = { a: 1 } as Record; 308 | o.b = 2; 309 | o.c = 3; 310 | const t = v.object({ 311 | a: v.number().optional(), 312 | c: v.number().optional(), 313 | }); 314 | expect(t.parse(o, { mode: "strip" })).to.deep.equal({ a: 1, c: 3 }); 315 | }); 316 | 317 | it("doesn't fail on unrecognized non-enumerable keys when mode=strict", () => { 318 | const o = { a: 1 }; 319 | Object.defineProperty(o, "b", { 320 | value: 2, 321 | enumerable: false, 322 | }); 323 | const t = v.object({ a: v.number(), b: v.number() }); 324 | expect(t.parse(o, { mode: "strict" })).to.equal(o); 325 | }); 326 | 327 | it("doesn't get confused by recognized non-enumerable keys when mode=strict", () => { 328 | const o = { x: 1 }; 329 | Object.defineProperties(o, { 330 | a: { 331 | value: 1, 332 | enumerable: false, 333 | }, 334 | b: { 335 | value: 2, 336 | enumerable: false, 337 | }, 338 | }); 339 | const t = v.object({ a: v.number(), b: v.number() }); 340 | expect(() => t.parse(o, { mode: "strict" })) 341 | .to.throw(v.ValitaError) 342 | .with.deep.nested.include({ 343 | "issues[0].code": "unrecognized_keys", 344 | "issues[0].keys": ["x"], 345 | }); 346 | }); 347 | 348 | it("keeps missing optionals missing when mode=strip", () => { 349 | const t = v.object({ a: v.number().optional() }); 350 | const o = t.parse({ b: 2 }, { mode: "strip" }); 351 | expect(o).to.deep.equal({}); 352 | }); 353 | 354 | it("doesn't consider undefined() optional when mode=strict", () => { 355 | const t = v.object({ a: v.undefined() }); 356 | expect(() => t.parse({}, { mode: "strict" })) 357 | .to.throw(v.ValitaError) 358 | .with.deep.nested.include({ 359 | "issues[0].code": "missing_value", 360 | "issues[0].path": ["a"], 361 | }); 362 | }); 363 | 364 | it("doesn't consider undefined() optional when mode=passthrough", () => { 365 | const t = v.object({ a: v.undefined() }); 366 | expect(() => t.parse({}, { mode: "passthrough" })) 367 | .to.throw(v.ValitaError) 368 | .with.deep.nested.include({ 369 | "issues[0].code": "missing_value", 370 | "issues[0].path": ["a"], 371 | }); 372 | }); 373 | 374 | it("doesn't consider undefined() optional when mode=strip", () => { 375 | const t = v.object({ a: v.undefined() }); 376 | expect(() => t.parse({}, { mode: "strip" })) 377 | .to.throw(v.ValitaError) 378 | .with.deep.nested.include({ 379 | "issues[0].code": "missing_value", 380 | "issues[0].path": ["a"], 381 | }); 382 | }); 383 | 384 | it("forwards parsing mode to nested types", () => { 385 | const t = v.object({ nested: v.object({ a: v.number() }) }); 386 | const i = { nested: { a: 1, b: 2 } }; 387 | expect(() => t.parse(i)).to.throw(v.ValitaError); 388 | expect(() => t.parse(i, { mode: "strict" })).to.throw(v.ValitaError); 389 | expect(t.parse(i, { mode: "passthrough" })).to.equal(i); 390 | expect(t.parse(i, { mode: "strip" })).to.deep.equal({ nested: { a: 1 } }); 391 | }); 392 | 393 | describe("omit", () => { 394 | it("omits given keys", () => { 395 | const t = v.object({ a: v.literal(1), b: v.literal(2) }).omit("b"); 396 | expectTypeOf>().toEqualTypeOf<{ a: 1 }>(); 397 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 398 | }); 399 | 400 | it("allows zero arguments", () => { 401 | const t = v.object({ a: v.literal(1), b: v.literal(2) }).omit(); 402 | expectTypeOf>().toEqualTypeOf<{ a: 1; b: 2 }>(); 403 | expect(t.parse({ a: 1, b: 2 })).to.deep.equal({ a: 1, b: 2 }); 404 | }); 405 | 406 | it("allows multiple", () => { 407 | const t = v 408 | .object({ a: v.literal(1), b: v.literal(2), c: v.literal(3) }) 409 | .omit("a", "b"); 410 | expectTypeOf>().toEqualTypeOf<{ c: 3 }>(); 411 | expect(t.parse({ c: 3 })).to.deep.equal({ c: 3 }); 412 | }); 413 | 414 | it("keeps rest", () => { 415 | const t = v 416 | .object({ a: v.literal(1), b: v.literal(2) }) 417 | .rest(v.number()) 418 | .omit("b"); 419 | expectTypeOf>().toEqualTypeOf<{ 420 | a: 1; 421 | [K: string]: number; 422 | }>(); 423 | expect(t.parse({ a: 1, b: 1000 })).to.deep.equal({ a: 1, b: 1000 }); 424 | }); 425 | 426 | it("removes checks", () => { 427 | const t = v 428 | .object({ a: v.literal(1), b: v.literal(2) }) 429 | .check(() => false) 430 | .omit("b"); 431 | expectTypeOf>().toEqualTypeOf<{ a: 1 }>(); 432 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 433 | }); 434 | }); 435 | 436 | describe("pick", () => { 437 | it("omits given keys", () => { 438 | const t = v.object({ a: v.literal(1), b: v.literal(2) }).pick("a"); 439 | expectTypeOf>().toEqualTypeOf<{ a: 1 }>(); 440 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 441 | }); 442 | 443 | it("allows zero arguments", () => { 444 | const t = v.object({ a: v.literal(1), b: v.literal(2) }).pick(); 445 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 446 | expectTypeOf>().toEqualTypeOf<{}>(); 447 | expect(t.parse({})).to.deep.equal({}); 448 | }); 449 | 450 | it("allows multiple", () => { 451 | const t = v 452 | .object({ a: v.literal(1), b: v.literal(2), c: v.literal(3) }) 453 | .pick("a", "b"); 454 | expectTypeOf>().toEqualTypeOf<{ a: 1; b: 2 }>(); 455 | expect(t.parse({ a: 1, b: 2 })).to.deep.equal({ a: 1, b: 2 }); 456 | }); 457 | 458 | it("removes rest", () => { 459 | const t = v 460 | .object({ a: v.literal(1), b: v.literal(2) }) 461 | .rest(v.string()) 462 | .pick("a"); 463 | expectTypeOf>().toEqualTypeOf<{ a: 1 }>(); 464 | expect(() => t.parse({ a: 1, b: "test" }, { mode: "strict" })).to.throw( 465 | v.ValitaError, 466 | ); 467 | }); 468 | 469 | it("removes checks", () => { 470 | const t = v 471 | .object({ a: v.literal(1), b: v.literal(2) }) 472 | .check(() => false) 473 | .pick("a"); 474 | expectTypeOf>().toEqualTypeOf<{ a: 1 }>(); 475 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 476 | }); 477 | }); 478 | 479 | describe("partial", () => { 480 | it("makes all keys optional", () => { 481 | const t = v.object({ a: v.literal(1), b: v.literal(2) }).partial(); 482 | expectTypeOf>().toEqualTypeOf< 483 | Partial<{ a: 1; b: 2 }> 484 | >(); 485 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 486 | }); 487 | 488 | it("makes rest accept undefined as well as the original type", () => { 489 | const t = v 490 | .object({ a: v.literal(1) }) 491 | .rest(v.number()) 492 | .partial(); 493 | expectTypeOf>().toEqualTypeOf< 494 | Partial<{ a: 1; [K: string]: number }> 495 | >(); 496 | expect(t.parse({ a: 1, x: undefined, y: 1000 })).to.deep.equal({ 497 | a: 1, 498 | x: undefined, 499 | y: 1000, 500 | }); 501 | }); 502 | 503 | it("removes checks", () => { 504 | const t = v 505 | .object({ a: v.literal(1), b: v.literal(2) }) 506 | .check(() => false) 507 | .partial(); 508 | expectTypeOf>().toEqualTypeOf< 509 | Partial<{ a: 1; b: 2 }> 510 | >(); 511 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 512 | }); 513 | }); 514 | 515 | describe("rest", () => { 516 | it("accepts extra keys", () => { 517 | const t = v.object({}).rest(v.unknown()); 518 | expect(t.parse({ a: "test", b: 1 })).to.deep.equal({ a: "test", b: 1 }); 519 | }); 520 | 521 | it("requires the given type from defined keys", () => { 522 | const t = v.object({ a: v.number() }).rest(v.unknown()); 523 | expect(() => t.parse({ a: "test", b: 1 })) 524 | .to.throw(v.ValitaError) 525 | .with.nested.property("issues[0]") 526 | .that.deep.includes({ 527 | code: "invalid_type", 528 | path: ["a"], 529 | expected: ["number"], 530 | }); 531 | }); 532 | 533 | it("adds an index signature to the inferred type", () => { 534 | const _t = v.object({ a: v.literal(1) }).rest(v.number()); 535 | expectTypeOf>().toEqualTypeOf<{ 536 | a: 1; 537 | [K: string]: number; 538 | }>(); 539 | expectTypeOf>().not.toEqualTypeOf<{ a: string }>(); 540 | }); 541 | 542 | it("accepts matching unexpected key values", () => { 543 | const t = v.object({ a: v.literal("test") }).rest(v.literal(1)); 544 | expect(t.parse({ a: "test", b: 1 })).to.deep.equal({ a: "test", b: 1 }); 545 | }); 546 | 547 | it("returns the original object instance if possible", () => { 548 | const t = v.object({ a: v.number() }).rest(v.number()); 549 | const o = { a: 1, b: 2 }; 550 | expect(t.parse(o)).to.equal(o); 551 | }); 552 | 553 | it("returns a new object instance if the fields change", () => { 554 | const t = v 555 | .object({ 556 | a: v.number(), 557 | }) 558 | .rest(v.number().map((x) => x)); 559 | const o = { a: 1, b: 2 }; 560 | expect(t.parse(o)).to.not.equal(o); 561 | }); 562 | 563 | it("doesn't lose the extra fields if the object has to be copied", () => { 564 | const t = v 565 | .object({ 566 | a: v.number(), 567 | c: v.number().map((n) => -n), 568 | }) 569 | .rest(v.number()); 570 | const r = { a: 1, b: 2, c: 3 } as Record; 571 | const o = Object.create(r) as Record; 572 | o.d = 4; 573 | expect(t.parse(o)).to.deep.equal({ a: 1, b: 2, c: -3, d: 4 }); 574 | }); 575 | 576 | it("ignores non-enumerable keys", () => { 577 | const t = v.object({ a: v.literal("test") }).rest(v.literal(1)); 578 | const o = { a: "test" }; 579 | Object.defineProperty(o, "b", { 580 | value: "string", 581 | enumerable: false, 582 | }); 583 | expect(t.parse(o)).to.deep.equal({ a: "test" }); 584 | }); 585 | 586 | it("rejects non-matching unexpected key values", () => { 587 | const t = v.object({ a: v.literal("test") }).rest(v.literal(1)); 588 | expect(() => t.parse({ a: "test", b: 2 })) 589 | .to.throw(v.ValitaError) 590 | .with.nested.property("issues") 591 | .with.lengthOf(1) 592 | .that.deep.includes({ 593 | code: "invalid_literal", 594 | path: ["b"], 595 | expected: [1], 596 | }); 597 | }); 598 | 599 | it("applies only to unexpected keys", () => { 600 | const t = v.object({ a: v.literal("test") }).rest(v.literal(1)); 601 | expect(() => t.parse({ a: 1 })) 602 | .to.throw(v.ValitaError) 603 | .with.nested.property("issues") 604 | .with.lengthOf(1) 605 | .that.deep.includes({ 606 | code: "invalid_literal", 607 | path: ["a"], 608 | expected: ["test"], 609 | }); 610 | }); 611 | 612 | it("takes precedence over mode=strict", () => { 613 | const t = v.object({}).rest(v.literal(1)); 614 | expect(t.parse({ a: 1 }, { mode: "strict" })).to.deep.equal({ a: 1 }); 615 | expect(() => t.parse({ a: 2 }, { mode: "strict" })) 616 | .to.throw(v.ValitaError) 617 | .with.nested.property("issues") 618 | .with.lengthOf(1) 619 | .that.deep.includes({ 620 | code: "invalid_literal", 621 | path: ["a"], 622 | expected: [1], 623 | }); 624 | }); 625 | 626 | it("takes precedence over mode=strip", () => { 627 | const t = v.object({}).rest(v.literal(1)); 628 | expect(t.parse({ a: 1 }, { mode: "strip" })).to.deep.equal({ a: 1 }); 629 | expect(() => t.parse({ a: 2 }, { mode: "strip" })) 630 | .to.throw(v.ValitaError) 631 | .with.nested.property("issues") 632 | .with.lengthOf(1) 633 | .that.deep.includes({ 634 | code: "invalid_literal", 635 | path: ["a"], 636 | expected: [1], 637 | }); 638 | }); 639 | 640 | it("takes precedence over mode=passthrough", () => { 641 | const t = v.object({}).rest(v.literal(1)); 642 | expect(t.parse({ a: 1 }, { mode: "passthrough" })).to.deep.equal({ 643 | a: 1, 644 | }); 645 | expect(() => t.parse({ a: 2 }, { mode: "passthrough" })) 646 | .to.throw(v.ValitaError) 647 | .with.nested.property("issues") 648 | .with.lengthOf(1) 649 | .that.deep.includes({ 650 | code: "invalid_literal", 651 | path: ["a"], 652 | expected: [1], 653 | }); 654 | }); 655 | }); 656 | 657 | it("attaches paths to issues", () => { 658 | const t = v.object({ 659 | type: v.literal(2), 660 | other: v.literal("test"), 661 | }); 662 | expect(() => t.parse({ type: 2, other: "not_test" })) 663 | .to.throw(v.ValitaError) 664 | .with.nested.property("issues[0]") 665 | .that.deep.includes({ 666 | code: "invalid_literal", 667 | path: ["other"], 668 | expected: ["test"], 669 | }); 670 | }); 671 | 672 | it("attaches nested paths to issues", () => { 673 | const t = v.object({ 674 | type: v.literal(2), 675 | other: v.object({ 676 | key: v.literal("test"), 677 | }), 678 | }); 679 | expect(() => t.parse({ type: 2, other: { key: "not_test" } })) 680 | .to.throw(v.ValitaError) 681 | .with.nested.property("issues[0]") 682 | .that.deep.includes({ 683 | code: "invalid_literal", 684 | path: ["other", "key"], 685 | expected: ["test"], 686 | }); 687 | }); 688 | 689 | describe("extend()", () => { 690 | it("extends the base shape", () => { 691 | const t = v.object({ a: v.string() }).extend({ b: v.number() }); 692 | expect(t.parse({ a: "test", b: 1 })).to.deep.equal({ a: "test", b: 1 }); 693 | expectTypeOf>().toEqualTypeOf<{ 694 | a: string; 695 | b: number; 696 | }>(); 697 | }); 698 | 699 | it("overwrites already existing keys", () => { 700 | const t = v.object({ a: v.string() }).extend({ a: v.number() }); 701 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 702 | expect(() => t.parse({ a: "test" })).to.throw(v.ValitaError); 703 | expectTypeOf>().toEqualTypeOf<{ a: number }>(); 704 | }); 705 | }); 706 | 707 | describe("shape", () => { 708 | it("contains the object property validators", () => { 709 | const s = v.string(); 710 | const n = v.number(); 711 | const t = v.object({ s, n }); 712 | expect(t.shape).toEqual({ s, n }); 713 | }); 714 | }); 715 | 716 | describe("check()", () => { 717 | it("accepts a function returning boolean", () => { 718 | const t = v.object({ a: v.string() }).check((_v) => true); 719 | expect(t.parse({ a: "test" })).to.deep.equal({ a: "test" }); 720 | }); 721 | 722 | it("doesn't affect the base shape", () => { 723 | const _t = v.object({ a: v.string() }).check((v): boolean => Boolean(v)); 724 | expectTypeOf>().toEqualTypeOf<{ a: string }>(); 725 | }); 726 | 727 | it("skips all checks if any property fails to parse", () => { 728 | let didRun = false; 729 | const t = v.object({ a: v.string(), b: v.number() }).check(() => { 730 | didRun = true; 731 | return true; 732 | }); 733 | expect(() => t.parse({ a: "test" })).to.throw(v.ValitaError); 734 | expect(didRun).toBe(false); 735 | }); 736 | 737 | it("runs multiple checks in order", () => { 738 | const t = v 739 | .object({ a: v.string() }) 740 | .check((v) => v.a === "test", "first") 741 | .check(() => false, "second"); 742 | expect(() => t.parse({ a: "test" })) 743 | .to.throw(v.ValitaError) 744 | .with.nested.property("issues[0]") 745 | .that.deep.includes({ 746 | code: "custom_error", 747 | error: "second", 748 | }); 749 | expect(() => t.parse({ a: "other" })) 750 | .to.throw(v.ValitaError) 751 | .with.nested.property("issues[0]") 752 | .that.deep.includes({ 753 | code: "custom_error", 754 | error: "first", 755 | }); 756 | }); 757 | 758 | it("runs checks after the object has otherwise been parsed", () => { 759 | const t = v 760 | .object({ a: v.string() }) 761 | .check((v) => (v as Record).b === 2) 762 | .extend({ b: v.undefined().map(() => 2) }) 763 | .check((v) => v.b === 2); 764 | expect(() => t.parse({ a: "test", b: null })) 765 | .to.throw(v.ValitaError) 766 | .with.nested.property("issues[0]") 767 | .that.deep.includes({ 768 | code: "invalid_type", 769 | path: ["b"], 770 | }); 771 | expect(t.parse({ a: "test", b: undefined })).to.deep.equal({ 772 | a: "test", 773 | b: 2, 774 | }); 775 | }); 776 | 777 | it("allows extending the base type after adding checks", () => { 778 | const t = v 779 | .object({ a: v.string() }) 780 | .check((v): boolean => Boolean(v)) 781 | .extend({ b: v.number() }); 782 | expect(t.parse({ a: "test", b: 1 })).to.deep.equal({ a: "test", b: 1 }); 783 | expectTypeOf>().toEqualTypeOf<{ 784 | a: string; 785 | b: number; 786 | }>(); 787 | }); 788 | 789 | it("creates a custom error on failure", () => { 790 | const t = v.object({ a: v.string() }).check(() => false); 791 | expect(() => t.parse({ a: "test" })) 792 | .to.throw(v.ValitaError) 793 | .with.nested.property("issues[0]") 794 | .that.includes({ code: "custom_error" }); 795 | }); 796 | 797 | it("allows passing in a custom error message", () => { 798 | const t = v.object({ a: v.string() }).check(() => false, "test"); 799 | expect(() => t.parse({ a: "test" })) 800 | .to.throw(v.ValitaError) 801 | .with.nested.property("issues[0]") 802 | .that.deep.includes({ 803 | code: "custom_error", 804 | error: "test", 805 | }); 806 | }); 807 | 808 | it("allows passing in a custom error message in an object", () => { 809 | const t = v 810 | .object({ a: v.string() }) 811 | .check(() => false, { message: "test" }); 812 | expect(() => t.parse({ a: "test" })) 813 | .to.throw(v.ValitaError) 814 | .with.nested.property("issues[0]") 815 | .that.deep.includes({ 816 | code: "custom_error", 817 | error: { message: "test" }, 818 | }); 819 | }); 820 | 821 | it("allows passing in a error path", () => { 822 | const t = v 823 | .object({ a: v.string() }) 824 | .check(() => false, { path: ["test"] }); 825 | expect(() => t.parse({ a: "test" })) 826 | .to.throw(v.ValitaError) 827 | .with.nested.property("issues[0]") 828 | .that.deep.includes({ 829 | code: "custom_error", 830 | path: ["test"], 831 | }); 832 | }); 833 | }); 834 | }); 835 | -------------------------------------------------------------------------------- /tests/ok.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("ok()", () => { 5 | it("infers literals when possible", () => { 6 | const _t = v.number().chain(() => v.ok("test")); 7 | expectTypeOf>().toEqualTypeOf<"test">(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/record.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("record()", () => { 5 | it("acceps empty objects", () => { 6 | const t = v.record(v.unknown()); 7 | expect(t.parse({})).to.deep.equal({}); 8 | expectTypeOf>().toEqualTypeOf>(); 9 | }); 10 | it("does not accept arrays", () => { 11 | const t = v.record(v.unknown()); 12 | expect(() => t.parse([])).to.throw(v.ValitaError); 13 | }); 14 | it("acceps the defined types of values", () => { 15 | const t = v.record(v.number()); 16 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 17 | expectTypeOf>().toEqualTypeOf>(); 18 | }); 19 | it("defaults to Record", () => { 20 | const t = v.record(); 21 | expect(t.parse({ a: 1 })).to.deep.equal({ a: 1 }); 22 | expectTypeOf>().toEqualTypeOf>(); 23 | }); 24 | it("rejects values other than the defined type", () => { 25 | const t = v.record(v.number()); 26 | expect(() => t.parse({ a: "test" })).to.throw(v.ValitaError); 27 | }); 28 | it("does not react to parsing modes", () => { 29 | const t = v.record(v.number()); 30 | expect(t.parse({ a: 1 }, { mode: "strict" })).to.deep.equal({ a: 1 }); 31 | expect(() => t.parse({ a: 1, b: "test" }, { mode: "strict" })).to.throw( 32 | v.ValitaError, 33 | ); 34 | expect(t.parse({ a: 1 }, { mode: "strip" })).to.deep.equal({ a: 1 }); 35 | expect(() => t.parse({ a: 1, b: "test" }, { mode: "strip" })).to.throw( 36 | v.ValitaError, 37 | ); 38 | expect(() => 39 | t.parse({ a: 1, b: "test" }, { mode: "passthrough" }), 40 | ).to.throw(v.ValitaError); 41 | }); 42 | it("safely sets __proto__ in cloned output when values are transformed", () => { 43 | const t = v.record(v.unknown().map(() => ({ a: 1 }))); 44 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 45 | expect(r).to.not.have.property("a"); 46 | expect(r).to.not.have.property("b"); 47 | expect(r).to.have.deep.own.property("__proto__", { a: 1 }); 48 | }); 49 | it("sets __proto__ property as own-writable-enumerable-configurable in cloned output", () => { 50 | const t = v.record(v.unknown().map(() => ({ a: 1 }))); 51 | const r = t.parse(JSON.parse('{ "__proto__": { "b": 2 } }')); 52 | expect(Object.getOwnPropertyDescriptor(r, "__proto__")).to.deep.equal({ 53 | value: { a: 1 }, 54 | writable: true, 55 | enumerable: true, 56 | configurable: true, 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/string.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("string()", () => { 5 | it("accepts strings", () => { 6 | const t = v.string(); 7 | expect(t.parse("test")).to.equal("test"); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.string(); 11 | for (const val of [1, 1n, true, null, undefined, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'string'", () => { 16 | const _t = v.string(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/tuple.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("tuple()", () => { 5 | it("returns a fixed-length tuple", () => { 6 | const t = v.tuple([v.number(), v.number()]); 7 | expect(t._prefix).toHaveLength(2); 8 | expect(t._rest).toBe(undefined); 9 | expect(t._suffix).toHaveLength(0); 10 | }); 11 | 12 | describe("concat()", () => { 13 | it("creates a fixed-length tuple from two fixed-length tuples", () => { 14 | const t = v.tuple([v.number()]).concat(v.tuple([v.string()])); 15 | expect(t._prefix).toHaveLength(2); 16 | expect(t._rest).toBe(undefined); 17 | expect(t._suffix).toHaveLength(0); 18 | }); 19 | 20 | it("creates a variadic tuple from a fixed-length tuple and an array", () => { 21 | const s = v.string(); 22 | const t = v.tuple([v.number(), v.number()]).concat(v.array(v.string())); 23 | expect(t._prefix).toHaveLength(2); 24 | expect(t._rest).toStrictEqual(s); 25 | expect(t._suffix).toHaveLength(0); 26 | }); 27 | 28 | it("creates a variadic tuple from a variadic and a fixed-length tuple", () => { 29 | const n = v.number(); 30 | const b = v.boolean(); 31 | const s = v.string(); 32 | const u = v.undefined(); 33 | 34 | const t1 = v.tuple([n, b]).concat(v.array(s)); 35 | const t = t1.concat(v.tuple([u])); 36 | expect(t._prefix).toEqual([n, b]); 37 | expect(t._rest).toStrictEqual(s); 38 | expect(t._suffix).toEqual([u]); 39 | }); 40 | 41 | it("creates a variadic tuple from a fixed-length and a variadic tuple", () => { 42 | const n = v.number(); 43 | const b = v.boolean(); 44 | const s = v.string(); 45 | const i = v.bigint(); 46 | const u = v.undefined(); 47 | 48 | const t1 = v.tuple([n, b]); 49 | const t2 = v 50 | .tuple([i]) 51 | .concat(v.array(s)) 52 | .concat(v.tuple([u])); 53 | const t = t1.concat(t2); 54 | expect(t._prefix).toEqual([n, b, i]); 55 | expect(t._rest).toStrictEqual(s); 56 | expect(t._suffix).toEqual([u]); 57 | }); 58 | 59 | it("prohibits concatenating variadic types at type level", () => { 60 | const t = v.tuple([]).concat(v.array(v.number())); 61 | 62 | try { 63 | // @ts-expect-error two variadic tuples can't be concatenated 64 | t.concat(v.tuple([]).concat(v.array(v.number()))); 65 | } catch { 66 | // pass 67 | } 68 | 69 | try { 70 | // @ts-expect-error an array can't be concatenated to a variadic tuple 71 | t.concat(v.array(v.number())); 72 | } catch { 73 | // pass 74 | } 75 | }); 76 | 77 | it("prohibits concatenating variadic types at runtime", () => { 78 | const t: { concat(v: unknown): unknown } = v 79 | .tuple([]) 80 | .concat(v.array(v.number())); 81 | 82 | expect(() => t.concat(v.tuple([]).concat(v.array(v.number())))).to.throw( 83 | TypeError, 84 | "can not concatenate two variadic types", 85 | ); 86 | expect(() => t.concat(v.array(v.number()))).to.throw( 87 | TypeError, 88 | "can not concatenate two variadic types", 89 | ); 90 | }); 91 | }); 92 | 93 | describe("fixed-length tuple", () => { 94 | it("accepts arrays", () => { 95 | const t = v.tuple([v.number(), v.number()]); 96 | expect(t.parse([1, 1])).to.deep.equal([1, 1]); 97 | }); 98 | 99 | it("rejects non-arrays", () => { 100 | const t = v.tuple([v.number(), v.number()]); 101 | expect(() => t.parse(1)) 102 | .to.throw(v.ValitaError) 103 | .with.nested.property("issues[0]") 104 | .that.deep.includes({ 105 | code: "invalid_type", 106 | path: [], 107 | expected: ["array"], 108 | }); 109 | }); 110 | 111 | it("accepts tuples of different types", () => { 112 | const t = v.tuple([v.number(), v.string()]); 113 | expect(t.parse([1, "string"])).to.deep.equal([1, "string"]); 114 | }); 115 | 116 | it("throws on item mismatch", () => { 117 | const t = v.tuple([v.number(), v.string()]); 118 | expect(() => t.parse([1, 1])) 119 | .to.throw(v.ValitaError) 120 | .with.nested.property("issues[0]") 121 | .that.deep.includes({ 122 | code: "invalid_type", 123 | path: [1], 124 | expected: ["string"], 125 | }); 126 | }); 127 | 128 | it("throws on minimum length mismatch", () => { 129 | const t = v.tuple([v.number(), v.number()]); 130 | expect(() => t.parse([1])) 131 | .to.throw(v.ValitaError) 132 | .with.nested.property("issues[0]") 133 | .that.deep.includes({ 134 | code: "invalid_length", 135 | path: [], 136 | minLength: 2, 137 | maxLength: 2, 138 | }); 139 | }); 140 | 141 | it("throws on maximum length mismatch", () => { 142 | const t = v.tuple([v.number(), v.number()]); 143 | expect(() => t.parse([1, 1, 1])) 144 | .to.throw(v.ValitaError) 145 | .with.nested.property("issues[0]") 146 | .that.deep.includes({ 147 | code: "invalid_length", 148 | path: [], 149 | minLength: 2, 150 | maxLength: 2, 151 | }); 152 | }); 153 | 154 | it("infers tuple", () => { 155 | const _t = v.tuple([v.number(), v.string()]); 156 | expectTypeOf>().toEqualTypeOf<[number, string]>(); 157 | }); 158 | 159 | it("returns the original array instance if possible", () => { 160 | const t = v.tuple([v.number(), v.number()]); 161 | const a = [1, 2]; 162 | expect(t.parse(a)).to.equal(a); 163 | }); 164 | 165 | it("returns a new array instance if the items change", () => { 166 | const t = v.tuple([v.number().map(() => "test"), v.number()]); 167 | const a = [1, 2]; 168 | expect(t.parse(a)).to.not.equal(a); 169 | }); 170 | }); 171 | 172 | describe("variadic tuple with an empty suffix", () => { 173 | it("accepts arrays", () => { 174 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 175 | expect(t.parse([1, "foo", true])).to.deep.equal([1, "foo", true]); 176 | }); 177 | 178 | it("rejects non-arrays", () => { 179 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 180 | expect(() => t.parse(1)) 181 | .to.throw(v.ValitaError) 182 | .with.nested.property("issues[0]") 183 | .that.deep.includes({ 184 | code: "invalid_type", 185 | path: [], 186 | expected: ["array"], 187 | }); 188 | }); 189 | 190 | it("accepts variable length arrays", () => { 191 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 192 | expect(t.parse([1, "foo"])).to.deep.equal([1, "foo"]); 193 | expect(t.parse([1, "foo", true])).to.deep.equal([1, "foo", true]); 194 | expect(t.parse([1, "foo", true, false])).to.deep.equal([ 195 | 1, 196 | "foo", 197 | true, 198 | false, 199 | ]); 200 | expect(t.parse([1, "foo", false, true, false])).to.deep.equal([ 201 | 1, 202 | "foo", 203 | false, 204 | true, 205 | false, 206 | ]); 207 | }); 208 | 209 | it("throws on item mismatch", () => { 210 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 211 | expect(() => t.parse([1, "foo", 1])) 212 | .to.throw(v.ValitaError) 213 | .with.nested.property("issues[0]") 214 | .that.deep.includes({ 215 | code: "invalid_type", 216 | path: [2], 217 | expected: ["boolean"], 218 | }); 219 | }); 220 | 221 | it("throws on minimum length mismatch", () => { 222 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 223 | expect(() => t.parse([1])) 224 | .to.throw(v.ValitaError) 225 | .with.nested.property("issues[0]") 226 | .that.deep.includes({ 227 | code: "invalid_length", 228 | path: [], 229 | minLength: 2, 230 | maxLength: undefined, 231 | }); 232 | }); 233 | 234 | it("infers a variadic tuple", () => { 235 | const _t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 236 | expectTypeOf>().toEqualTypeOf< 237 | [number, string, ...boolean[]] 238 | >(); 239 | }); 240 | 241 | it("returns the original array instance if possible", () => { 242 | const t = v.tuple([v.number(), v.string()]).concat(v.array(v.boolean())); 243 | const a = [1, "foo", true]; 244 | expect(t.parse(a)).to.equal(a); 245 | }); 246 | 247 | it("returns a new array instance if the items change", () => { 248 | const t = v 249 | .tuple([v.number(), v.string()]) 250 | .concat(v.array(v.boolean().map(() => "test"))); 251 | const a = [1, "foo", true]; 252 | expect(t.parse(a)).to.not.equal(a); 253 | }); 254 | }); 255 | 256 | describe("variadic tuple with a non-empty suffix", () => { 257 | it("accepts arrays", () => { 258 | const t = v 259 | .tuple([v.number()]) 260 | .concat(v.array(v.boolean())) 261 | .concat(v.tuple([v.string(), v.null()])); 262 | expect(t.parse([1, true, "foo", null])).to.deep.equal([ 263 | 1, 264 | true, 265 | "foo", 266 | null, 267 | ]); 268 | }); 269 | 270 | it("rejects non-arrays", () => { 271 | const t = v 272 | .tuple([v.number()]) 273 | .concat(v.array(v.boolean())) 274 | .concat(v.tuple([v.string(), v.null()])); 275 | expect(() => t.parse(1)) 276 | .to.throw(v.ValitaError) 277 | .with.nested.property("issues[0]") 278 | .that.deep.includes({ 279 | code: "invalid_type", 280 | path: [], 281 | expected: ["array"], 282 | }); 283 | }); 284 | 285 | it("accepts variable length arrays", () => { 286 | const t = v 287 | .tuple([v.number()]) 288 | .concat(v.array(v.boolean())) 289 | .concat(v.tuple([v.string(), v.null()])); 290 | expect(t.parse([1, "foo", null])).to.deep.equal([1, "foo", null]); 291 | expect(t.parse([1, true, "foo", null])).to.deep.equal([ 292 | 1, 293 | true, 294 | "foo", 295 | null, 296 | ]); 297 | expect(t.parse([1, false, true, "foo", null])).to.deep.equal([ 298 | 1, 299 | false, 300 | true, 301 | "foo", 302 | null, 303 | ]); 304 | expect(t.parse([1, true, false, true, "foo", null])).to.deep.equal([ 305 | 1, 306 | true, 307 | false, 308 | true, 309 | "foo", 310 | null, 311 | ]); 312 | }); 313 | 314 | it("throws on item mismatch", () => { 315 | const t = v 316 | .tuple([v.number()]) 317 | .concat(v.array(v.boolean())) 318 | .concat(v.tuple([v.string(), v.null()])); 319 | expect(() => t.parse([1, true, "foo", 1])) 320 | .to.throw(v.ValitaError) 321 | .with.nested.property("issues[0]") 322 | .that.deep.includes({ 323 | code: "invalid_type", 324 | path: [3], 325 | expected: ["null"], 326 | }); 327 | }); 328 | 329 | it("throws on minimum length mismatch", () => { 330 | const t = v 331 | .tuple([v.number()]) 332 | .concat(v.array(v.boolean())) 333 | .concat(v.tuple([v.string(), v.null()])); 334 | expect(() => t.parse([1, true])) 335 | .to.throw(v.ValitaError) 336 | .with.nested.property("issues[0]") 337 | .that.deep.includes({ 338 | code: "invalid_length", 339 | path: [], 340 | minLength: 3, 341 | maxLength: undefined, 342 | }); 343 | }); 344 | 345 | it("infers a variadic tuple with a suffix", () => { 346 | const _t = v 347 | .tuple([v.number()]) 348 | .concat(v.array(v.boolean())) 349 | .concat(v.tuple([v.string(), v.null()])); 350 | expectTypeOf>().toEqualTypeOf< 351 | [number, ...boolean[], string, null] 352 | >(); 353 | }); 354 | 355 | it("returns the original array instance if possible", () => { 356 | const t = v 357 | .tuple([v.number()]) 358 | .concat(v.array(v.boolean())) 359 | .concat(v.tuple([v.string(), v.null()])); 360 | const a = [1, true, "foo", null]; 361 | expect(t.parse(a)).to.equal(a); 362 | }); 363 | 364 | it("returns a new array instance if the items change", () => { 365 | const t = v 366 | .tuple([v.number()]) 367 | .concat(v.array(v.boolean())) 368 | .concat(v.tuple([v.string(), v.null().map(() => "test")])); 369 | const a = [1, true, "foo", null]; 370 | expect(t.parse(a)).to.not.equal(a); 371 | }); 372 | }); 373 | }); 374 | -------------------------------------------------------------------------------- /tests/undefined.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("undefined()", () => { 5 | it("accepts undefined", () => { 6 | const t = v.undefined(); 7 | expect(t.parse(undefined)).to.equal(undefined); 8 | }); 9 | it("rejects other types", () => { 10 | const t = v.undefined(); 11 | for (const val of ["1", 1, 1n, true, null, [], {}]) { 12 | expect(() => t.parse(val)).to.throw(v.ValitaError); 13 | } 14 | }); 15 | it("has output type 'undefined'", () => { 16 | const _t = v.undefined(); 17 | expectTypeOf>().toEqualTypeOf(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/union.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf, assert } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("union()", () => { 5 | it("accepts two subvalidators", () => { 6 | const t = v.union(v.string(), v.number()); 7 | expect(t.parse("test")).to.equal("test"); 8 | expect(t.parse(1)).to.equal(1); 9 | expect(() => t.parse({})).to.throw(v.ValitaError); 10 | }); 11 | 12 | it("ignores never()", () => { 13 | const t = v.union(v.string(), v.never()); 14 | expect(t.parse("test")).to.equal("test"); 15 | expect(() => t.parse(1)).to.throw(v.ValitaError); 16 | expectTypeOf>().toEqualTypeOf(); 17 | }); 18 | 19 | it("picks the first successful parse", () => { 20 | const t = v.union( 21 | v 22 | .string() 23 | .map(() => 1) 24 | .assert(() => false), 25 | v.string().map(() => 2), 26 | ); 27 | expect(t.parse("test")).to.equal(2); 28 | }); 29 | 30 | it("respects the order of overlapping parsers", () => { 31 | const a = v.literal(1).map(() => "literal"); 32 | const b = v.number().map(() => "number"); 33 | const c = v.unknown().map(() => "unknown"); 34 | expect(v.union(a, b, c).parse(1)).to.equal("literal"); 35 | expect(v.union(a, c, b).parse(1)).to.equal("literal"); 36 | expect(v.union(b, a, c).parse(1)).to.equal("number"); 37 | expect(v.union(b, c, a).parse(1)).to.equal("number"); 38 | expect(v.union(c, b, a).parse(1)).to.equal("unknown"); 39 | expect(v.union(c, a, b).parse(1)).to.equal("unknown"); 40 | }); 41 | 42 | it("deduplicates strictly equal parsers", () => { 43 | const a = v.unknown().assert(() => false, "test"); 44 | expect(() => v.union(a, a).parse(1)) 45 | .to.throw(v.ValitaError) 46 | .with.property("issues") 47 | .with.lengthOf(1); 48 | }); 49 | 50 | it("keeps the matching order when deduplicating", () => { 51 | const a = v.unknown().map(() => "a"); 52 | const b = v.unknown().map(() => "b"); 53 | expect(v.union(a, b, a).parse(1)).to.equal("a"); 54 | }); 55 | 56 | it("accepts more than two subvalidators", () => { 57 | const t = v.union( 58 | v.string(), 59 | v.number(), 60 | v.null(), 61 | v.undefined(), 62 | v.boolean(), 63 | ); 64 | expect(t.parse("test")).to.equal("test"); 65 | expect(t.parse(1)).to.equal(1); 66 | expect(t.parse(null)).to.equal(null); 67 | expect(t.parse(undefined)).to.equal(undefined); 68 | expect(t.parse(true)).to.equal(true); 69 | expect(() => t.parse({})).to.throw(v.ValitaError); 70 | }); 71 | 72 | it("accepts optional input if it maps to non-optional output", () => { 73 | const t = v.object({ 74 | a: v.union( 75 | v.undefined(), 76 | v 77 | .unknown() 78 | .optional() 79 | .map(() => 1), 80 | ), 81 | }); 82 | expect(t.parse({})).toEqual({ a: 1 }); 83 | }); 84 | 85 | it("reports the expected type even for literals when the base type doesn't match", () => { 86 | const t = v.union(v.literal(1), v.literal("test")); 87 | expect(() => t.parse(true)) 88 | .to.throw(v.ValitaError) 89 | .with.nested.property("issues[0]") 90 | .that.deep.includes({ 91 | code: "invalid_literal", 92 | expected: [1, "test"], 93 | }); 94 | }); 95 | 96 | it("reports the expected literals when the base type matches", () => { 97 | const t = v.union(v.literal(1), v.literal("test")); 98 | expect(() => t.parse(2)) 99 | .to.throw(v.ValitaError) 100 | .with.nested.property("issues[0]") 101 | .that.deep.includes({ 102 | code: "invalid_literal", 103 | expected: [1, "test"], 104 | }); 105 | }); 106 | 107 | it("reports the errors from a branch that doesn't overlap with any other branch", () => { 108 | const t = v.union(v.literal(1), v.number(), v.object({ a: v.number() })); 109 | expect(() => t.parse({ a: "test" })) 110 | .to.throw(v.ValitaError) 111 | .with.nested.property("issues[0]") 112 | .that.deep.includes({ 113 | code: "invalid_type", 114 | path: ["a"], 115 | expected: ["number"], 116 | }); 117 | }); 118 | 119 | it("reports expected types in the order they were first listed", () => { 120 | const t1 = v.union(v.literal(2), v.string(), v.literal(2)); 121 | expect(() => t1.parse(true)) 122 | .to.throw(v.ValitaError) 123 | .with.nested.property("issues[0]") 124 | .that.deep.includes({ 125 | code: "invalid_type", 126 | path: [], 127 | expected: ["number", "string"], 128 | }); 129 | 130 | const t2 = v.union(v.string(), v.literal(2), v.string()); 131 | expect(() => t2.parse(true)) 132 | .to.throw(v.ValitaError) 133 | .with.nested.property("issues[0]") 134 | .that.deep.includes({ 135 | code: "invalid_type", 136 | path: [], 137 | expected: ["string", "number"], 138 | }); 139 | }); 140 | 141 | it("reports expected literals in the order they were first listed", () => { 142 | const t1 = v.union(v.literal(2), v.literal(1), v.literal(2)); 143 | expect(() => t1.parse(3)) 144 | .to.throw(v.ValitaError) 145 | .with.nested.property("issues[0]") 146 | .that.deep.includes({ 147 | code: "invalid_literal", 148 | path: [], 149 | expected: [2, 1], 150 | }); 151 | 152 | const t2 = v.union(v.literal(1), v.literal(2), v.literal(1)); 153 | expect(() => t2.parse(3)) 154 | .to.throw(v.ValitaError) 155 | .with.nested.property("issues[0]") 156 | .that.deep.includes({ 157 | code: "invalid_literal", 158 | path: [], 159 | expected: [1, 2], 160 | }); 161 | }); 162 | 163 | it("matches unknowns if nothing else matches", () => { 164 | const t = v.union( 165 | v.literal(1), 166 | v.literal(2), 167 | v.unknown().assert(() => false, "test"), 168 | ); 169 | expect(() => t.parse({ a: 1 })) 170 | .to.throw(v.ValitaError) 171 | .with.nested.property("issues[0]") 172 | .that.deep.includes({ 173 | code: "custom_error", 174 | error: "test", 175 | }); 176 | }); 177 | 178 | it("considers never() to not overlap with anything", () => { 179 | const t = v.union( 180 | v.never(), 181 | v.unknown().assert(() => false, "unknown"), 182 | ); 183 | expect(() => t.parse(2)) 184 | .to.throw(v.ValitaError) 185 | .with.nested.property("issues[0]") 186 | .that.deep.includes({ 187 | code: "custom_error", 188 | error: "unknown", 189 | }); 190 | }); 191 | 192 | it("considers unknown() to overlap with everything except never()", () => { 193 | const t = v.union( 194 | v.literal(1), 195 | v.literal(2).assert(() => false), 196 | v.unknown().assert(() => false), 197 | ); 198 | expect(() => t.parse(2)) 199 | .to.throw(v.ValitaError) 200 | .with.nested.property("issues[0]") 201 | .that.deep.includes({ 202 | code: "invalid_union", 203 | }); 204 | }); 205 | 206 | it("considers unknown() to overlap with objects", () => { 207 | const t = v.union( 208 | v.unknown(), 209 | v.object({ type: v.literal("a") }), 210 | v.object({ type: v.literal("b") }), 211 | ); 212 | expect(t.parse({ type: "c" })).to.deep.equal({ type: "c" }); 213 | }); 214 | 215 | it("considers array() and tuple() to overlap", () => { 216 | const t = v.union(v.array(v.number()), v.tuple([v.string()])); 217 | expect(() => t.parse(2)) 218 | .to.throw(v.ValitaError) 219 | .with.nested.property("issues[0]") 220 | .that.deep.includes({ 221 | code: "invalid_type", 222 | expected: ["array"], 223 | }); 224 | }); 225 | 226 | it("keeps transformed values", () => { 227 | const t = v.union( 228 | v.literal("test1").map(() => 1), 229 | v.literal("test2").map(() => 2), 230 | ); 231 | expect(t.parse("test1")).to.deep.equal(1); 232 | }); 233 | 234 | describe("of objects", () => { 235 | it("discriminates based on base types", () => { 236 | const t = v.union( 237 | v.object({ type: v.number() }), 238 | v.object({ type: v.string() }), 239 | ); 240 | expect(() => t.parse({ type: true })) 241 | .to.throw(v.ValitaError) 242 | .with.nested.property("issues[0]") 243 | .that.deep.includes({ 244 | code: "invalid_type", 245 | path: ["type"], 246 | expected: ["number", "string"], 247 | }); 248 | }); 249 | 250 | it("discriminates based on literal values", () => { 251 | const t = v.union( 252 | v.object({ type: v.literal(1) }), 253 | v.object({ type: v.literal(2) }), 254 | ); 255 | expect(() => t.parse({ type: 3 })) 256 | .to.throw(v.ValitaError) 257 | .with.nested.property("issues[0]") 258 | .that.deep.includes({ 259 | code: "invalid_literal", 260 | path: ["type"], 261 | expected: [1, 2], 262 | }); 263 | }); 264 | 265 | it("discriminates based on mixture of base types and literal values", () => { 266 | const t = v.union( 267 | v.object({ type: v.literal(1) }), 268 | v.object({ type: v.string() }), 269 | ); 270 | expect(() => t.parse({ type: true })) 271 | .to.throw(v.ValitaError) 272 | .with.nested.property("issues[0]") 273 | .that.deep.includes({ 274 | code: "invalid_type", 275 | path: ["type"], 276 | expected: ["number", "string"], 277 | }); 278 | }); 279 | 280 | it("considers unknown() to overlap with everything except never()", () => { 281 | const t = v.union( 282 | v.object({ type: v.literal(1) }), 283 | v.object({ type: v.unknown().assert(() => false) }), 284 | ); 285 | expect(() => t.parse({ type: "test" })) 286 | .to.throw(v.ValitaError) 287 | .with.nested.property("issues[0]") 288 | .that.deep.includes({ code: "invalid_union" }); 289 | }); 290 | 291 | it("considers literals to overlap with their base types", () => { 292 | const t = v.union( 293 | v.object({ type: v.literal(1) }), 294 | v.object({ type: v.number() }), 295 | ); 296 | expect(() => t.parse({ type: "test" })) 297 | .to.throw(v.ValitaError) 298 | .with.nested.property("issues[0]") 299 | .that.deep.includes({ code: "invalid_union" }); 300 | }); 301 | 302 | it("considers optional() its own type", () => { 303 | const t = v.union( 304 | v.object({ type: v.literal(1) }), 305 | v.object({ type: v.literal(2).optional() }), 306 | ); 307 | expect(() => t.parse({ type: "test" })) 308 | .to.throw(v.ValitaError) 309 | .with.nested.property("issues[0]") 310 | .that.deep.includes({ 311 | code: "invalid_type", 312 | expected: ["number", "undefined"], 313 | }); 314 | }); 315 | 316 | it("matches missing values to optional()", () => { 317 | const t = v.union(v.object({ a: v.unknown().optional() })); 318 | expect(t.parse({})).to.deep.equal({}); 319 | }); 320 | 321 | it("considers equal literals to overlap", () => { 322 | const t = v.union( 323 | v.object({ type: v.literal(1) }), 324 | v.object({ type: v.literal(1) }), 325 | ); 326 | expect(() => t.parse({ type: "test" })) 327 | .to.throw(v.ValitaError) 328 | .with.nested.property("issues[0]") 329 | .that.deep.includes({ code: "invalid_union" }); 330 | }); 331 | 332 | it("allows mixing literals and non-literals as long as they don't overlap", () => { 333 | const t = v.union( 334 | v.object({ type: v.literal(1) }), 335 | v.object({ type: v.literal(2) }), 336 | v.object({ type: v.string() }), 337 | ); 338 | expect(t.parse({ type: 1 })).toEqual({ type: 1 }); 339 | expect(t.parse({ type: 2 })).toEqual({ type: 2 }); 340 | expect(t.parse({ type: "test" })).toEqual({ type: "test" }); 341 | }); 342 | 343 | it("folds multiple overlapping types together in same branch", () => { 344 | const t = v.union( 345 | v.object({ 346 | type: v.union(v.string(), v.union(v.string(), v.literal("test"))), 347 | }), 348 | v.object({ 349 | type: v.union(v.literal(2), v.undefined()), 350 | other: v.literal("test"), 351 | }), 352 | ); 353 | expect(() => t.parse({ type: 2, other: "not_test" })) 354 | .to.throw(v.ValitaError) 355 | .with.nested.property("issues[0]") 356 | .that.deep.includes({ 357 | code: "invalid_literal", 358 | path: ["other"], 359 | expected: ["test"], 360 | }); 361 | }); 362 | 363 | it("considers two optionals to overlap", () => { 364 | const t = v.union( 365 | v.object({ type: v.literal(1).optional() }), 366 | v.object({ type: v.literal(2).optional() }), 367 | ); 368 | expect(() => t.parse({ type: 3 })) 369 | .to.throw(v.ValitaError) 370 | .with.nested.property("issues[0].code", "invalid_union"); 371 | }); 372 | 373 | it("considers two optionals and undefineds to overlap", () => { 374 | const t = v.union( 375 | v.object({ type: v.undefined() }), 376 | v.object({ type: v.literal(2).optional() }), 377 | ); 378 | expect(() => t.parse({ type: 3 })) 379 | .to.throw(v.ValitaError) 380 | .with.nested.property("issues[0].code", "invalid_union"); 381 | }); 382 | 383 | it("considers two unions with partially same types to overlap", () => { 384 | const t = v.union( 385 | v.object({ type: v.union(v.literal(1), v.literal(2)) }), 386 | v.object({ type: v.union(v.literal(2), v.literal(3)) }), 387 | ); 388 | expect(() => t.parse({ type: 4 })) 389 | .to.throw(v.ValitaError) 390 | .with.nested.property("issues[0].code", "invalid_union"); 391 | }); 392 | 393 | it("keeps transformed values", () => { 394 | const t = v.union( 395 | v.object({ type: v.literal("test1").map(() => 1) }), 396 | v.object({ type: v.literal("test2").map(() => 2) }), 397 | ); 398 | expect(t.parse({ type: "test1" })).to.deep.equal({ type: 1 }); 399 | }); 400 | 401 | it("includes an array of sub-issues in 'invalid_union' issues", () => { 402 | const t = v.union( 403 | v.object({ type: v.literal(1).optional() }), 404 | v.object({ type: v.literal(2).optional() }), 405 | ); 406 | const result = t.try({ type: 3 }); 407 | assert(!result.ok); 408 | assert(result.issues[0].code === "invalid_union"); 409 | expect(result.issues[0].issues).toEqual([ 410 | { 411 | code: "invalid_literal", 412 | expected: [1], 413 | path: ["type"], 414 | }, 415 | { 416 | code: "invalid_literal", 417 | expected: [2], 418 | path: ["type"], 419 | }, 420 | ]); 421 | }); 422 | }); 423 | }); 424 | -------------------------------------------------------------------------------- /tests/unknown.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, expectTypeOf } from "vitest"; 2 | import * as v from "../src"; 3 | 4 | describe("unknown()", () => { 5 | it("accepts anything", () => { 6 | const t = v.unknown(); 7 | for (const val of ["test", 1, 1n, true, null, undefined, [], {}]) { 8 | expect(t.parse(val)).to.equal(val); 9 | } 10 | }); 11 | it("has output type 'unknown'", () => { 12 | const _t = v.unknown(); 13 | expectTypeOf>().toEqualTypeOf(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "noEmit": false, 6 | "allowImportingTsExtensions": false 7 | }, 8 | "include": ["src/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "module": "ES2015", 6 | "moduleResolution": "Node", 7 | "noEmit": false, 8 | "allowImportingTsExtensions": false 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["src/index.esm.mts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "declaration": true, 9 | "isolatedModules": true, 10 | "erasableSyntaxOnly": true, 11 | "outDir": "./dist/main", 12 | "sourceMap": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "allowImportingTsExtensions": true 16 | }, 17 | "include": ["mod.ts", "src/**/*", "tests/**/*"] 18 | } 19 | --------------------------------------------------------------------------------