├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ ├── rollingversions-canary.yml │ ├── rollingversions.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── LICENSE ├── README.md ├── SECURITY.md ├── api-extractor.json ├── package.json ├── readonly └── package.json ├── rollup.config.js ├── scripts ├── finalize-build.js ├── format.js └── test-import.js ├── src ├── assertType.spec.ts ├── assertType.ts ├── asynccontract.spec.ts ├── asynccontract.ts ├── contract.ts ├── errors.ts ├── index.spec.ts ├── index.ts ├── result.ts ├── runtype.spec.ts ├── runtype.ts ├── show.spec.ts ├── show.ts ├── showValue.ts ├── types │ ├── Enum.spec.ts │ ├── Enum.ts │ ├── KeyOf.spec.ts │ ├── KeyOf.ts │ ├── Mutable.spec.ts │ ├── Mutable.ts │ ├── Named.spec.ts │ ├── Named.ts │ ├── Object.spec.ts │ ├── Object.ts │ ├── ParsedValue.spec.ts │ ├── ParsedValue.ts │ ├── Readonly.spec.ts │ ├── Readonly.ts │ ├── Record.spec.ts │ ├── Record.ts │ ├── Sealed.spec.ts │ ├── Sealed.ts │ ├── array.spec.ts │ ├── array.ts │ ├── brand.ts │ ├── constraint.spec.ts │ ├── constraint.ts │ ├── instanceof.ts │ ├── intersect.spec.ts │ ├── intersect.ts │ ├── lazy.ts │ ├── literal.ts │ ├── never.ts │ ├── primative.spec.ts │ ├── primative.ts │ ├── tuple.ts │ ├── union.spec.ts │ ├── union.ts │ └── unknown.ts └── util.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.ts] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:import/errors", 6 | "plugin:import/warnings" 7 | ], 8 | "env": { 9 | "es6": true, 10 | "mocha": true 11 | }, 12 | "ecmaFeatures": { 13 | "modules": true 14 | }, 15 | "rules": { 16 | "indent": ["warn", 2, { "SwitchCase": 1 }], 17 | "quotes": [1, "single", {"avoidEscape": true, "allowTemplateLiterals": true}], 18 | "no-console": 1, 19 | "no-debugger": 1, 20 | "no-var": 1, 21 | "prefer-const": 1, 22 | "semi": [1, "never"], 23 | "no-trailing-spaces": 0, 24 | "eol-last": 0, 25 | "no-unused-vars": 1, 26 | "no-underscore-dangle": 0, 27 | "no-alert": 1, 28 | "no-lone-blocks": 0, 29 | "no-empty": 1, 30 | "no-empty-pattern": 1, 31 | "no-unreachable": 1, 32 | "no-constant-condition": 1, 33 | "jsx-quotes": 1, 34 | "comma-dangle": 1, 35 | "import/no-named-as-default": 0 36 | }, 37 | "globals": { 38 | "console": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # TypeScript files will always have LF line endings on checkout, 5 | # so that running 'yarn format' on Windows will not need to change 6 | # line endings from CRLF to LF causing git to report modified files 7 | # with no code changes. 8 | *.ts text eol=lf 9 | -------------------------------------------------------------------------------- /.github/workflows/rollingversions-canary.yml: -------------------------------------------------------------------------------- 1 | name: Release Canary 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 20.x 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: yarn install --frozen-lockfile 18 | - run: yarn build 19 | - run: npx rollingversions publish --canary $GITHUB_RUN_NUMBER 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/rollingversions.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | repository_dispatch: 5 | types: [rollingversions_publish_approved] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [18.x, 20.x, 22.x] 14 | # typescript-version: ['4.0.2'] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: yarn install --frozen-lockfile 22 | # - run: yarn add -D typescript@${{ matrix.typescript-version }} 23 | - run: yarn build 24 | - run: yarn test 25 | 26 | publish: 27 | runs-on: ubuntu-latest 28 | needs: test 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-node@v1 32 | with: 33 | node-version: 20.x 34 | registry-url: 'https://registry.npmjs.org' 35 | - run: yarn install --frozen-lockfile 36 | - run: yarn build 37 | - run: npx rollingversions publish 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | format: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v1 18 | with: 19 | node-version: 20.x 20 | - run: yarn install --frozen-lockfile 21 | - run: yarn format 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | 26 | strategy: 27 | matrix: 28 | node-version: [18.x, 20.x, 22.x] 29 | # typescript-version: ['4.0.2'] 30 | 31 | steps: 32 | - uses: actions/checkout@v2 33 | - uses: actions/setup-node@v1 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | - run: yarn install --frozen-lockfile 37 | # - run: yarn add -D typescript@${{ matrix.typescript-version }} 38 | - run: yarn build 39 | - run: yarn test 40 | - name: Coveralls Parallel 41 | uses: coverallsapp/github-action@master 42 | with: 43 | github-token: ${{ secrets.github_token }} 44 | flag-name: node-${{ matrix.node-version }} 45 | # flag-name: node-${{ matrix.node-version }}-ts-${{ matrix.typescript-version }} 46 | parallel: true 47 | 48 | finish: 49 | runs-on: ubuntu-latest 50 | 51 | needs: test 52 | 53 | steps: 54 | - name: Coveralls Finished 55 | uses: coverallsapp/github-action@master 56 | with: 57 | github-token: ${{ secrets.github_token }} 58 | parallel-finished: true 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled output 2 | lib/ 3 | 4 | # Code coverage output 5 | coverage/ 6 | 7 | # NPM stuff 8 | node_modules/ 9 | npm-debug.log* 10 | 11 | # Mac stuff 12 | .DS_Store 13 | 14 | .size-snapshot.json 15 | funtypes-0.0.0.tgz 16 | test-output/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | coverage 3 | README.md 4 | package.json 5 | examples/src/**/* -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 100 4 | semi: true 5 | tabWidth: 2 6 | trailingComma: all 7 | singleQuote: true 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Tom Crockett 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Funtypes 2 | 3 | ### Safely bring untyped data into the fold 4 | 5 | Funtypes allow you to take values about which you have no assurances and check that they conform to some type `A`. 6 | This is done by means of composable type validators of primitives, literals, arrays, tuples, records, unions, 7 | intersections and more. 8 | 9 | [![Build Status](https://img.shields.io/github/actions/workflow/status/ForbesLindesay/funtypes/test.yml?event=push&style=for-the-badge)](https://github.com/ForbesLindesay/funtypes/actions?query=workflow%3ATest+branch%3Amaster) 10 | [![Coveralls github branch](https://img.shields.io/coveralls/github/ForbesLindesay/funtypes/master?color=brightgreen&style=for-the-badge)](https://coveralls.io/github/ForbesLindesay/funtypes) 11 | [![Rolling Versions](https://img.shields.io/badge/Rolling%20Versions-Enabled-brightgreen?style=for-the-badge)](https://rollingversions.com/ForbesLindesay/funtypes) 12 | [![NPM version](https://img.shields.io/npm/v/funtypes?style=for-the-badge)](https://www.npmjs.com/package/funtypes) 13 | 14 | > This library is a fork of the excellent [runtypes](https://github.com/pelotom/runtypes) by Tom Crockett 15 | 16 | ## Installation 17 | 18 | ``` 19 | npm install --save funtypes 20 | ``` 21 | 22 | ## Example 23 | 24 | Suppose you have objects which represent asteroids, planets, ships and crew members. In TypeScript, you might write their types like so: 25 | 26 | ```ts 27 | type Vector = [number, number, number]; 28 | 29 | type Asteroid = { 30 | type: 'asteroid'; 31 | location: Vector; 32 | mass: number; 33 | }; 34 | 35 | type Planet = { 36 | type: 'planet'; 37 | location: Vector; 38 | mass: number; 39 | population: number; 40 | habitable: boolean; 41 | }; 42 | 43 | type Rank = 'captain' | 'first mate' | 'officer' | 'ensign'; 44 | 45 | type CrewMember = { 46 | name: string; 47 | age: number; 48 | rank: Rank; 49 | home: Planet; 50 | }; 51 | 52 | type Ship = { 53 | type: 'ship'; 54 | location: Vector; 55 | mass: number; 56 | name: string; 57 | crew: CrewMember[]; 58 | }; 59 | 60 | type SpaceObject = Asteroid | Planet | Ship; 61 | ``` 62 | 63 | If the objects which are supposed to have these shapes are loaded from some external source, perhaps a JSON file, we need to 64 | validate that the objects conform to their specifications. We do so by building corresponding `Runtype`s in a very straightforward 65 | manner: 66 | 67 | ```ts 68 | import { Boolean, Number, String, Literal, Array, Tuple, Object, Union } from 'funtypes'; 69 | 70 | const Vector = Tuple(Number, Number, Number); 71 | 72 | const Asteroid = Object({ 73 | type: Literal('asteroid'), 74 | location: Vector, 75 | mass: Number, 76 | }); 77 | 78 | const Planet = Object({ 79 | type: Literal('planet'), 80 | location: Vector, 81 | mass: Number, 82 | population: Number, 83 | habitable: Boolean, 84 | }); 85 | 86 | const Rank = Union( 87 | Literal('captain'), 88 | Literal('first mate'), 89 | Literal('officer'), 90 | Literal('ensign'), 91 | ); 92 | 93 | const CrewMember = Object({ 94 | name: String, 95 | age: Number, 96 | rank: Rank, 97 | home: Planet, 98 | }); 99 | 100 | const Ship = Object({ 101 | type: Literal('ship'), 102 | location: Vector, 103 | mass: Number, 104 | name: String, 105 | crew: Array(CrewMember), 106 | }); 107 | 108 | const SpaceObject = Union(Asteroid, Planet, Ship); 109 | ``` 110 | 111 | (See the [examples](examples) directory for an expanded version of this.) 112 | 113 | Now if we are given a putative `SpaceObject` we can validate it like so: 114 | 115 | ```ts 116 | // spaceObject: SpaceObject 117 | const spaceObject = SpaceObject.check(obj); 118 | ``` 119 | 120 | If the object doesn't conform to the type specification, `check` will throw an exception. 121 | 122 | ## Static type inference 123 | 124 | In TypeScript, the inferred type of `Asteroid` in the above example is 125 | 126 | ```ts 127 | Runtype<{ 128 | type: 'asteroid' 129 | location: [number, number, number] 130 | mass: number 131 | }> 132 | ``` 133 | 134 | That is, it's a `Runtype`, and you could annotate it as such. But we don't really have to define the 135 | `Asteroid` type in TypeScript at all now, because the inferred type is correct. Defining each of your types 136 | twice, once at the type level and then again at the value level, is a pain and not very [DRY](https://en.wikipedia.org/wiki/Don't_repeat_yourself). 137 | Fortunately you can define a static `Asteroid` type which is an alias to the `Runtype`-derived type like so: 138 | 139 | ```ts 140 | import { Static } from 'funtypes'; 141 | 142 | type Asteroid = Static; 143 | ``` 144 | 145 | which achieves the same result as 146 | 147 | ```ts 148 | type Asteroid = { 149 | type: 'asteroid'; 150 | location: [number, number, number]; 151 | mass: number; 152 | }; 153 | ``` 154 | 155 | ## Type guards 156 | 157 | In addition to providing a `check` method, funtypes can be used as [type guards](https://basarat.gitbook.io/typescript/type-system/typeguard): 158 | 159 | ```ts 160 | function disembark(obj: {}) { 161 | if (SpaceObject.test(obj)) { 162 | // obj: SpaceObject 163 | if (obj.type === 'ship') { 164 | // obj: Ship 165 | obj.crew = []; 166 | } 167 | } 168 | } 169 | ``` 170 | 171 | ## Pattern matching 172 | 173 | The `Union` runtype offers the ability to do type-safe, exhaustive case analysis across its variants using the `match` method: 174 | 175 | ```ts 176 | const isHabitable = SpaceObject.match( 177 | asteroid => false, 178 | planet => planet.habitable, 179 | ship => true, 180 | ); 181 | 182 | if (isHabitable(spaceObject)) { 183 | // ... 184 | } 185 | ``` 186 | 187 | There's also a top-level `match` function which allows testing an ad-hoc sequence of funtypes: 188 | 189 | ```ts 190 | const makeANumber = match( 191 | [Number, n => n * 3], 192 | [Boolean, b => b ? 1 : 0], 193 | [String, s => s.length], 194 | ); 195 | 196 | makeANumber(9); // = 27 197 | ``` 198 | 199 | To allow the function to be applied to anything and then handle match failures, simply use an `Unknown` case at the end: 200 | 201 | ```ts 202 | const makeANumber = match( 203 | [Number, n => n * 3], 204 | [Boolean, b => b ? 1 : 0], 205 | [String, s => s.length], 206 | [Unknown, () => 42] 207 | ); 208 | ``` 209 | 210 | ## Constraint checking 211 | 212 | Beyond mere type checking, we can add arbitrary runtime constraints to a `Runtype`: 213 | 214 | ```ts 215 | const Positive = Number.withConstraint(n => n > 0); 216 | 217 | Positive.check(-3); // Throws error: Failed constraint check 218 | ``` 219 | 220 | You can provide more descriptive error messages for failed constraints by returning 221 | a string instead of `false`: 222 | 223 | ```ts 224 | const Positive = Number.withConstraint(n => n > 0 || `${n} is not positive`); 225 | 226 | Positive.check(-3); // Throws error: -3 is not positive 227 | ``` 228 | 229 | You can set a custom name for your runtype, which will be used in default error 230 | messages and reflection, by using the `name` prop on the optional `options` 231 | parameter: 232 | 233 | ```typescript 234 | const C = Number.withConstraint(n => n > 0, {name: 'PositiveNumber'}); 235 | ``` 236 | 237 | To change the type, there are two ways to do it: passing a type guard function 238 | to a new `Runtype.withGuard()` method, or using the familiar 239 | `Runtype.withConstraint()` method. (Both methods also accept an `options` 240 | parameter to optionally set the name.) 241 | 242 | Using a type guard function is the easiest option to change the static type, 243 | because TS will infer the desired type from the return type of the guard 244 | function. 245 | 246 | ```typescript 247 | // use Buffer.isBuffer, which is typed as: isBuffer(obj: any): obj is Buffer; 248 | const B = Unknown.withGuard(Buffer.isBuffer); 249 | type T = Static; // T is Buffer 250 | ``` 251 | 252 | However, if you want to return a custom error message from your constraint 253 | function, you can't do this with a type guard because these functions can only 254 | return boolean values. Instead, you can roll your own constraint function and 255 | use the `withConstraint()` method. Remember to specify the type parameter for 256 | the `Constraint` because it can't be inferred from your check function! 257 | 258 | ```typescript 259 | const check = (o: any) => Buffer.isBuffer(o) || 'Dude, not a Buffer!'; 260 | const B = Unknown.withConstraint(check); 261 | type T = Static; // T will have type of `Buffer` 262 | ``` 263 | 264 | One important choice when changing `Constraint` static types is choosing the 265 | correct underlying type. The implementation of `Constraint` will validate the 266 | underlying type *before* running your constraint function. So it's important to 267 | use a lowest-common-denominator type that will pass validation for all expected 268 | inputs of your constraint function or type test. If there's no obvious 269 | lowest-common-denominator type, you can always use `Unknown` as the underlying 270 | type, as shown in the `Buffer` examples above. 271 | 272 | Speaking of base types, if you're using a type guard function and your base type 273 | is `Unknown`, then there's a convenience runtype `Guard` available, which is a 274 | shorthand for `Unknown.withGuard`. 275 | 276 | ```typescript 277 | // use Buffer.isBuffer, which is typed as: isBuffer(obj: any): obj is Buffer; 278 | const B = Guard(Buffer.isBuffer); 279 | type T = Static; // T will have type of `Buffer` 280 | ``` 281 | 282 | ## Function contracts 283 | 284 | Funtypes along with constraint checking are a natural fit for enforcing function 285 | contracts. You can construct a contract from `Runtype`s for the parameters and 286 | return type of the function: 287 | 288 | ```ts 289 | const divide = Contract( 290 | // Parameters: 291 | Number, 292 | Number.withConstraint(n => n !== 0 || 'division by zero'), 293 | // Return type: 294 | Number, 295 | ).enforce((n, m) => n / m); 296 | 297 | divide(10, 2); // 5 298 | 299 | divide(10, 0); // Throws error: division by zero 300 | ``` 301 | 302 | ## Optional values 303 | 304 | Funtypes can be used to represent a variable that may be null or undefined 305 | as well as representing keys within records that may or may not be present. 306 | 307 | 308 | ```ts 309 | // For variables that might be undefined or null 310 | const MyString = String; // string (e.g. 'text') 311 | const MyStringMaybe = String.Or(Undefined); // string | undefined (e.g. 'text', undefined) 312 | const MyStringNullable = String.Or(Null); // string | null (e.g. 'text', null) 313 | ``` 314 | 315 | If a `Object` may or may not have some keys, we can declare the optional 316 | keys using `myRecord.And(Partial({ ... }))`. Partial keys validate successfully if 317 | they are absent or undefined (but not null) or the type specified 318 | (which can be null). 319 | 320 | ```ts 321 | // Using `Ship` from above 322 | const RegisteredShip = Ship.And(Object({ 323 | // All registered ships must have this flag 324 | isRegistered: Literal(true), 325 | })).And(Partial({ 326 | // We may or may not know the ship's classification 327 | shipClass: Union(Literal('military'), Literal('civilian')), 328 | 329 | // We may not know the ship's rank (so we allow it to be undefined via `Partial`), 330 | // we may also know that a civilian ship doesn't have a rank (e.g. null) 331 | rank: Rank.Or(Null), 332 | })); 333 | ``` 334 | 335 | If a record has keys which _must be present_ but can be null, then use 336 | the `Object` runtype normally instead. 337 | 338 | ```ts 339 | const MilitaryShip = Ship.And(Object({ 340 | shipClass: Literal('military'), 341 | 342 | // Must NOT be undefined, but can be null 343 | lastDeployedTimestamp: Number.Or(Null), 344 | })); 345 | ``` 346 | 347 | ## Readonly records and arrays 348 | 349 | Array and Object funtypes have a special function `.asReadonly()`, that creates a new runtype where the values are readonly. 350 | 351 | For example: 352 | 353 | ```typescript 354 | const Asteroid = Object({ 355 | type: Literal('asteroid'), 356 | location: Vector, 357 | mass: Number, 358 | }).asReadonly() 359 | 360 | Static // { readonly type: 'asteroid', readonly location: Vector, readonly mass: number } 361 | 362 | const AsteroidArray = Array(Asteroid).asReadonly() 363 | 364 | Static // ReadonlyArray 365 | ``` 366 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "mainEntryPointFilePath": "/lib/index.d.ts", 3 | "apiReport": { 4 | // turn off 5 | "enabled": false 6 | }, 7 | "docModel": { 8 | // turn off 9 | "enabled": false 10 | }, 11 | "dtsRollup": { 12 | // Whether to generate the .d.ts rollup file. 13 | "enabled": true, 14 | // Where we wanna create our .d.ts rollup 15 | "untrimmedFilePath": "/dist/index.d.ts" 16 | }, 17 | "messages": { 18 | // turn off various warnings, that might not be useful right now 19 | // check the official docs for more! 20 | "extractorMessageReporting": { 21 | "default": { 22 | "logLevel": "none" 23 | }, 24 | "ae-forgotten-export": { 25 | "logLevel": "none" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "funtypes", 3 | "version": "0.0.0", 4 | "description": "Runtime validation for static types", 5 | "main": "./lib/index.js", 6 | "module": "./lib/index.mjs", 7 | "types": "./lib/index.d.ts", 8 | "files": [ 9 | "lib/index.js", 10 | "lib/index.d.ts", 11 | "lib/index.mjs", 12 | "lib/readonly.js", 13 | "lib/readonly.d.ts", 14 | "lib/readonly.mjs", 15 | "readonly/package.json" 16 | ], 17 | "sideEffects": false, 18 | "exports": { 19 | "./readonly": { 20 | "types": "./lib/readonly.d.ts", 21 | "import": "./lib/readonly.mjs", 22 | "default": "./lib/readonly.js" 23 | }, 24 | ".": { 25 | "types": "./lib/index.d.ts", 26 | "import": "./lib/index.mjs", 27 | "default": "./lib/index.js" 28 | } 29 | }, 30 | "typesVersions": { 31 | "*": { 32 | "readonly": [ 33 | "lib/readonly.d.ts" 34 | ] 35 | } 36 | }, 37 | "scripts": { 38 | "build": "tsc --noEmit && rollup -c && yarn api-extractor run --local && node scripts/finalize-build.js", 39 | "format": "node scripts/format.js", 40 | "test": "jest $([ \"$CI\" = true ] && echo --coverage || echo --watch)", 41 | "typecheck": "tsc --noEmit --watch" 42 | }, 43 | "author": "Thomas Crockett", 44 | "license": "MIT", 45 | "devDependencies": { 46 | "@babel/parser": "^7.17.8", 47 | "@microsoft/api-extractor": "^7.47.9", 48 | "@types/jest": "^27.4.1", 49 | "jest": "^27.5.1", 50 | "prettier": "^2.6.0", 51 | "rollup": "^2.26.11", 52 | "rollup-plugin-prettier": "^2.1.0", 53 | "rollup-plugin-terser": "^7.0.2", 54 | "rollup-plugin-typescript2": "^0.27.2", 55 | "ts-jest": "^27.1.3", 56 | "type-assertions": "^1.1.0", 57 | "typescript": "^4.6.2" 58 | }, 59 | "keywords:": [ 60 | "runtime", 61 | "type", 62 | "validation", 63 | "typescript" 64 | ], 65 | "repository": { 66 | "type": "git", 67 | "url": "https://github.com/ForbesLindesay/funtypes" 68 | }, 69 | "jest": { 70 | "verbose": false, 71 | "testRegex": ".*/*.spec.ts$", 72 | "moduleFileExtensions": [ 73 | "js", 74 | "ts" 75 | ], 76 | "transform": { 77 | "\\.ts$": "ts-jest" 78 | }, 79 | "testEnvironment": "node" 80 | }, 81 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 82 | } 83 | -------------------------------------------------------------------------------- /readonly/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../lib/readonly.js", 3 | "module": "../lib/readonly.mjs", 4 | "types": "../lib/readonly.d.ts", 5 | "exports": { 6 | ".": { 7 | "types": "./../lib/readonly.d.ts", 8 | "import": "./../lib/readonly.mjs", 9 | "default": "./../lib/readonly.js" 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | import prettier from 'rollup-plugin-prettier'; 4 | import pkg from './package.json'; 5 | 6 | export default { 7 | input: { 8 | index: 'src/index.ts', 9 | }, 10 | output: [ 11 | { 12 | dir: 'lib/', 13 | entryFileNames: '[name].js', 14 | chunkFileNames: 'chunk-[hash].js', 15 | format: 'cjs', 16 | }, 17 | { 18 | dir: 'lib/', 19 | entryFileNames: '[name].mjs', 20 | chunkFileNames: 'chunk-[hash].mjs', 21 | format: 'es', 22 | }, 23 | ], 24 | external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], 25 | plugins: [ 26 | typescript({ 27 | typescript: require('typescript'), 28 | }), 29 | terser({ 30 | format: { 31 | ecma: '2020', 32 | }, 33 | compress: { 34 | ecma: '2020', 35 | hoist_funs: true, 36 | passes: 10, 37 | pure_getters: true, 38 | unsafe_arrows: true, 39 | unsafe_methods: true, 40 | }, 41 | mangle: { 42 | keep_classnames: true, 43 | keep_fnames: true, 44 | }, 45 | }), 46 | prettier({ 47 | tabWidth: 2, 48 | singleQuote: false, 49 | parser: 'babel', 50 | }), 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /scripts/finalize-build.js: -------------------------------------------------------------------------------- 1 | const { writeFileSync, readFileSync, rmSync, readdirSync } = require('fs'); 2 | const { parse } = require('@babel/parser'); 3 | 4 | function getExports(filename) { 5 | const ast = parse(readFileSync(`${__dirname}/../${filename}`, `utf8`), { 6 | plugins: [`typescript`], 7 | sourceType: 'module', 8 | sourceFilename: filename, 9 | }); 10 | const exports = { value: [], type: [] }; 11 | for (const statement of ast.program.body) { 12 | switch (statement.type) { 13 | case 'ExpressionStatement': 14 | case 'ImportDeclaration': 15 | break; 16 | case 'ExportNamedDeclaration': 17 | for (const specifier of statement.specifiers) { 18 | const exportKind = 19 | specifier.exportKind === 'type' ? 'type' : statement.exportKind ?? 'value'; 20 | exports[exportKind].push(specifier.exported.name); 21 | } 22 | break; 23 | default: 24 | console.log(statement); 25 | process.exit(1); 26 | } 27 | } 28 | return { 29 | value: [...new Set(exports.value)].sort(), 30 | all: [...new Set([...exports.value, ...exports.type])].sort(), 31 | }; 32 | } 33 | 34 | const EXPORTS_TO_RENAME = new Set([`Array`, `Object`, `Partial`, `Record`, `Tuple`]); 35 | 36 | const baseModule = getExports(`src/index.ts`); 37 | 38 | const commonJs = [`"use strict";`, `const m = require("./index.js")`, ``]; 39 | const esModule = [ 40 | `import {`, 41 | ...baseModule.value.filter(v => !EXPORTS_TO_RENAME.has(v)).map(v => ` ${v},`), 42 | `} from "./index.mjs";`, 43 | `export {`, 44 | ]; 45 | for (const exportName of baseModule.value) { 46 | if (EXPORTS_TO_RENAME.has(exportName)) { 47 | commonJs.push(`exports.${exportName} = m.Readonly${exportName};`); 48 | esModule.push(` Readonly${exportName} as ${exportName},`); 49 | } else { 50 | commonJs.push(`exports.${exportName} = m.${exportName};`); 51 | esModule.push(` ${exportName},`); 52 | } 53 | } 54 | commonJs.push(``); 55 | esModule.push(`};`, ``); 56 | 57 | const types = [`export {`]; 58 | for (const exportName of baseModule.all) { 59 | if (EXPORTS_TO_RENAME.has(exportName)) { 60 | types.push(` Readonly${exportName} as ${exportName},`); 61 | } else { 62 | types.push(` ${exportName},`); 63 | } 64 | } 65 | types.push(`} from "./index";`, ``); 66 | 67 | writeFileSync(`lib/readonly.js`, commonJs.join(`\n`)); 68 | writeFileSync(`lib/readonly.mjs`, esModule.join(`\n`)); 69 | writeFileSync(`lib/readonly.d.ts`, types.join(`\n`)); 70 | 71 | const typesSrc = readFileSync(`dist/index.d.ts`, `utf8`).replace(/\r\n/g, '\n').split('\n'); 72 | const aliasedTypes = new Set(); 73 | for (const line of typesSrc) { 74 | const match = /^export *\{ *([A-Za-z0-9_]+)(?: +as +[A-Za-z0-9_]+)? *\}$/.exec(line); 75 | if (match) { 76 | aliasedTypes.add(match[1]); 77 | } 78 | } 79 | const internalTypePrefix = [ 80 | `/**`, 81 | ` * This is an internal type that should not be referenced directly from outside of the funtypes package.`, 82 | ` * @internal`, 83 | ` */`, 84 | ]; 85 | writeFileSync( 86 | `lib/index.d.ts`, 87 | typesSrc 88 | .map(line => { 89 | const match = /^declare (?:(?:type)|(?:interface)) ([A-Za-z0-9_]+)[ <]/.exec(line); 90 | if (match && !aliasedTypes.has(match[1])) { 91 | return `${internalTypePrefix.join(`\n`)}\nexport ${line}`; 92 | } 93 | return line; 94 | }) 95 | .join('\n'), 96 | ); 97 | 98 | rmSync(`dist`, { recursive: true, force: true }); 99 | 100 | const ALLOWED_FILES = new Set( 101 | JSON.parse(readFileSync(`package.json`, `utf8`)) 102 | .files.filter(f => f.startsWith('lib/')) 103 | .map(f => f.substring('lib/'.length)), 104 | ); 105 | 106 | readdirSync(`lib`).forEach(f => { 107 | if (!ALLOWED_FILES.has(f)) { 108 | rmSync(`lib/${f}`, { recursive: true, force: true }); 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /scripts/format.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Makes the script crash on unhandled rejections instead of silently 4 | // ignoring them. In the future, promise rejections that are not handled will 5 | // terminate the Node.js process with a non-zero exit code. 6 | process.on('unhandledRejection', err => { 7 | throw err; 8 | }); 9 | 10 | const { spawn } = require('child_process'); 11 | 12 | const command = [ 13 | 'prettier', 14 | process.env.CI ? '--list-different' : '--write', 15 | './**/*.{ts,tsx,js,json,css}', 16 | ]; 17 | 18 | spawn(`yarn`, command, { stdio: 'inherit' }).on('exit', exitCode => { 19 | if (exitCode) { 20 | console.error('Found formatting issues'); 21 | console.error('Looks like someone forgot to run `yarn format` before pushing 😱'); 22 | process.exit(1); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /scripts/test-import.js: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require('child_process'); 2 | const { mkdtempSync, writeFileSync, mkdirSync, readFileSync } = require('fs'); 3 | const { tmpdir } = require('os'); 4 | const { join, resolve, relative } = require('path'); 5 | 6 | const { parse } = require('@babel/parser'); 7 | 8 | function getExports(filename) { 9 | const ast = parse(readFileSync(`${__dirname}/../${filename}`, `utf8`), { 10 | plugins: [`typescript`], 11 | sourceType: 'module', 12 | sourceFilename: filename, 13 | }); 14 | const exports = { value: [], type: [] }; 15 | for (const statement of ast.program.body) { 16 | switch (statement.type) { 17 | case 'ExpressionStatement': 18 | case 'ImportDeclaration': 19 | break; 20 | case 'ExportNamedDeclaration': 21 | for (const specifier of statement.specifiers) { 22 | const exportKind = 23 | specifier.exportKind === 'type' ? 'type' : statement.exportKind ?? 'value'; 24 | exports[exportKind].push(specifier.exported.name); 25 | } 26 | break; 27 | default: 28 | console.log(statement); 29 | process.exit(1); 30 | } 31 | } 32 | return { filename, value: new Set(exports.value), type: new Set(exports.type) }; 33 | } 34 | 35 | const mutableExports = getExports(`src/index.ts`); 36 | 37 | console.info(`$ npm pack`); 38 | inheritExit(spawnSync(`npm`, [`pack`], { cwd: join(__dirname, `..`), stdio: `inherit` })); 39 | 40 | const OUTPUTS = [ 41 | { 42 | name: `test.cjs`, 43 | header: [ 44 | `const { strictEqual } = require('assert');`, 45 | `const t = require('funtypes');`, 46 | `const r = require('funtypes/readonly');`, 47 | ], 48 | }, 49 | { 50 | name: `test.mjs`, 51 | header: [ 52 | `import { strictEqual } from 'assert';`, 53 | `import * as t from 'funtypes';`, 54 | `import * as r from 'funtypes/readonly';`, 55 | ], 56 | }, 57 | ]; 58 | 59 | const assertions = [ 60 | ...[`Readonly`, `Object`, `Record`].flatMap(n => [ 61 | `strictEqual(typeof t.${n}, 'function', "${n} should be a function in the default 'funtypes' entrypoint");`, 62 | `strictEqual(typeof r.${n}, 'function', "${n} should be a function in the 'funtypes/readonly' entrypoint");`, 63 | ]), 64 | ...[...mutableExports.value] 65 | .sort() 66 | .flatMap(n => [ 67 | `strictEqual(t.${n} === undefined, false, "${n} should be exported in the default 'funtypes' entrypoint");`, 68 | `strictEqual(typeof t.${n}, typeof r.${n}, "${n} should have the same type in both entrypoints");`, 69 | ]), 70 | `strictEqual(t.Object({}).isReadonly, false, "Object should not be readonly in the default 'funtypes' entrypoint");`, 71 | `strictEqual(r.Object({}).isReadonly, true, "Object should be readonly in the 'funtypes/readonly' entrypoint");`, 72 | ]; 73 | 74 | const dir = mkdtempSync(join(tmpdir(), `funtypes`)); 75 | for (const { name, header } of OUTPUTS) { 76 | writeFileSync( 77 | join(dir, name), 78 | [...header, ``, ...assertions, ``, `console.log("✅ ${name} Import Tests Passed")`, ``].join( 79 | `\n`, 80 | ), 81 | ); 82 | } 83 | 84 | mkdirSync(join(dir, `src`)); 85 | writeFileSync( 86 | join(dir, `src`, `test.ts`), 87 | [ 88 | `import { strictEqual } from 'assert';`, 89 | `import * as t from 'funtypes';`, 90 | `import * as r from 'funtypes/readonly';`, 91 | ``, 92 | `export const schemaA = t.Object({value: t.String});`, 93 | `export const schemaB = r.Object({value: t.String});`, 94 | `export const schemaC = t.Named("MySchema",`, 95 | ` t.Union(`, 96 | ` t.Object({ kind: t.Literal("string"), value: t.String }),`, 97 | ` t.Object({ kind: t.Literal("number"), value: t.Number }),`, 98 | ` ),`, 99 | `);`, 100 | `export type schemaCType = t.Static;`, 101 | `export type schemaCTypeNotInferred = { kind: "string", value: string } | { kind: "number", value: number }`, 102 | `export function doubleNumbers(value: schemaCTypeNotInferred): schemaCTypeNotInferred {`, 103 | ` if (value.kind === "number") return { kind: "number", value: value.value * 2 };`, 104 | ` return value;`, 105 | `}`, 106 | `export function doubleNumbersX(value: unknown): schemaCTypeNotInferred {`, 107 | ` return doubleNumbers(schemaC.parse(value));`, 108 | `}`, 109 | ``, 110 | `const valueA = schemaA.parse({value: 'hello world'});`, 111 | `valueA.value = 'updated value';`, 112 | ``, 113 | `const valueB = schemaB.parse({value: 'hello world'});`, 114 | `// @ts-expect-error - valueB.value is readonly`, 115 | `valueB.value = 'updated value';`, 116 | ``, 117 | `valueA.value = valueB.value`, 118 | ``, 119 | ...assertions, 120 | ``, 121 | 'console.log(`✅ TypeScript Import Tests Passed ${process.argv[2]}`)', 122 | ``, 123 | ].join(`\n`), 124 | ); 125 | 126 | writeFileSync( 127 | join(dir, `package.json`), 128 | JSON.stringify({ 129 | name: 'funtypes-test-import', 130 | private: true, 131 | dependencies: { 132 | '@types/node': '^22.7.4', 133 | typescript: '5.6.2', 134 | }, 135 | scripts: { 136 | typecheck: 'tsc --build', 137 | }, 138 | }) + `\n`, 139 | ); 140 | 141 | console.info(`$ npm install`); 142 | inheritExit(spawnSync(`npm`, [`install`], { cwd: dir, stdio: `inherit` })); 143 | 144 | const packPath = relative( 145 | join(dir, `package.json`), 146 | resolve(join(__dirname, `..`, `funtypes-0.0.0.tgz`)), 147 | ); 148 | console.info(`$ npm install ${packPath}`); 149 | inheritExit(spawnSync(`npm`, [`install`, packPath], { cwd: dir, stdio: `inherit` })); 150 | 151 | for (const { name } of OUTPUTS) { 152 | console.info(`$ node ${join(dir, name)}`); 153 | inheritExit(spawnSync(`node`, [join(dir, name)], { cwd: dir, stdio: `inherit` })); 154 | } 155 | 156 | const modes = [ 157 | { module: 'commonjs', type: 'commonjs' }, 158 | { module: 'nodenext', type: 'module' }, 159 | { module: 'preserve', type: 'module' }, 160 | ]; 161 | for (const mode of modes) { 162 | writeFileSync( 163 | join(dir, `tsconfig.json`), 164 | JSON.stringify({ 165 | compilerOptions: { 166 | module: mode.module, 167 | outDir: 'lib', 168 | noImplicitAny: true, 169 | skipLibCheck: false, 170 | strict: true, 171 | isolatedModules: true, 172 | declaration: true, 173 | }, 174 | include: ['src'], 175 | }) + `\n`, 176 | ); 177 | 178 | writeFileSync( 179 | join(dir, `package.json`), 180 | JSON.stringify({ 181 | name: 'funtypes-test-import', 182 | private: true, 183 | type: mode.type, 184 | dependencies: { 185 | '@types/node': '^17.0.21', 186 | typescript: '4.0.2', 187 | }, 188 | scripts: { 189 | typecheck: 'tsc --build', 190 | }, 191 | }) + `\n`, 192 | ); 193 | 194 | console.info(`$ npm run typecheck`); 195 | inheritExit(spawnSync(`npm`, [`run`, `typecheck`], { cwd: dir, stdio: `inherit` })); 196 | console.info(`$ node lib/test.js`); 197 | inheritExit( 198 | spawnSync(`node`, [`lib/test.js`, `${mode.module}/${mode.type}`], { 199 | cwd: dir, 200 | stdio: `inherit`, 201 | }), 202 | ); 203 | mkdirSync(`test-output/${mode.module}-${mode.type}`, { recursive: true }); 204 | for (const file of [`test.js`, `test.d.ts`]) { 205 | writeFileSync( 206 | `test-output/${mode.module}-${mode.type}/${file}`, 207 | readFileSync(join(dir, `lib`, file), `utf8`), 208 | ); 209 | } 210 | } 211 | 212 | function inheritExit(proc) { 213 | if (proc.status !== 0) process.exit(proc.status); 214 | } 215 | -------------------------------------------------------------------------------- /src/assertType.spec.ts: -------------------------------------------------------------------------------- 1 | import { String, assertType } from './'; 2 | 3 | test('assertType', async () => { 4 | const x = 'hello' as unknown; 5 | // @ts-expect-error 6 | expectString(x); 7 | assertType(String, x); 8 | expectString(x); 9 | 10 | expect(() => assertType(String, 42)).toThrowErrorMatchingInlineSnapshot( 11 | `"Expected string, but was 42"`, 12 | ); 13 | }); 14 | 15 | // this helper is just to check that the type inference works 16 | function expectString(value: string) { 17 | return value; 18 | } 19 | -------------------------------------------------------------------------------- /src/assertType.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase, Static } from './runtype'; 2 | 3 | export function assertType( 4 | rt: TRuntypeBase, 5 | v: unknown, 6 | ): asserts v is Static { 7 | rt.assert(v); 8 | } 9 | -------------------------------------------------------------------------------- /src/asynccontract.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncContract, Number } from '.'; 2 | 3 | describe('AsyncContract', () => { 4 | describe('when function does not return a promise', () => { 5 | it('throws a validation error', async () => { 6 | const contractedFunction = AsyncContract([], Number).enforce(() => 7 as any); 7 | await expect(contractedFunction()).rejects.toMatchInlineSnapshot( 8 | `[ValidationError: Expected function to return a promise, but instead got 7]`, 9 | ); 10 | }); 11 | }); 12 | describe('when a function does return a promise, but for the wrong type', () => { 13 | it('throws a validation error asynchronously', async () => { 14 | const contractedFunction = AsyncContract([], Number).enforce(() => 15 | Promise.resolve('hi' as any), 16 | ); 17 | await expect(contractedFunction()).rejects.toMatchInlineSnapshot( 18 | `[ValidationError: Expected number, but was "hi" (i.e. a string literal)]`, 19 | ); 20 | }); 21 | }); 22 | describe('when a function does return a promise, for the correct type', () => { 23 | it('should validate successfully', async () => { 24 | const contractedFunction = AsyncContract([], Number).enforce(() => Promise.resolve(7)); 25 | await expect(contractedFunction()).resolves.toBe(7); 26 | }); 27 | }); 28 | describe('when not enough arguments are provided', () => { 29 | it('throws a validation error', async () => { 30 | const contractedFunction = AsyncContract([Number], Number).enforce(n => 31 | Promise.resolve(n + 1), 32 | ); 33 | await expect((contractedFunction as any)()).rejects.toMatchInlineSnapshot( 34 | `[ValidationError: Expected 1 arguments but only received 0]`, 35 | ); 36 | }); 37 | }); 38 | describe('when arguments are of the wrong type', () => { 39 | it('throws a validation error', async () => { 40 | const contractedFunction = AsyncContract([Number], Number).enforce(n => 41 | Promise.resolve(n + 1), 42 | ); 43 | await expect(contractedFunction('whatever' as any)).rejects.toMatchInlineSnapshot( 44 | `[ValidationError: Expected number, but was "whatever" (i.e. a string literal)]`, 45 | ); 46 | }); 47 | }); 48 | describe('when arguments are valid', () => { 49 | it('throws a validation error', async () => { 50 | const contractedFunction = AsyncContract([Number], Number).enforce(n => 51 | Promise.resolve(n + 1), 52 | ); 53 | await expect(contractedFunction(41)).resolves.toEqual(42); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/asynccontract.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from './errors'; 2 | import { 3 | createGuardVisitedState, 4 | createVisitedState, 5 | innerGuard, 6 | innerValidate, 7 | OpaqueVisitedState, 8 | RuntypeBase, 9 | } from './runtype'; 10 | 11 | export interface AsyncContract { 12 | enforce(f: (...a: A) => Promise): (...a: A) => Promise; 13 | } 14 | 15 | /** 16 | * Create a function contract. 17 | */ 18 | export function AsyncContract( 19 | argTypes: { [key in keyof A]: key extends 'length' ? A['length'] : RuntypeBase }, 20 | returnType: RuntypeBase, 21 | ): AsyncContract { 22 | return { 23 | enforce: 24 | (f: (...args: any[]) => any) => 25 | (...args: any[]) => { 26 | if (args.length < argTypes.length) { 27 | return Promise.reject( 28 | new ValidationError({ 29 | message: `Expected ${argTypes.length} arguments but only received ${args.length}`, 30 | }), 31 | ); 32 | } 33 | const visited: OpaqueVisitedState = createVisitedState(); 34 | for (let i = 0; i < argTypes.length; i++) { 35 | const result = innerValidate(argTypes[i], args[i], visited, false); 36 | if (result.success) { 37 | args[i] = result.value; 38 | } else { 39 | return Promise.reject(new ValidationError(result)); 40 | } 41 | } 42 | const returnedPromise = f(...args); 43 | if (!(returnedPromise instanceof Promise)) { 44 | return Promise.reject( 45 | new ValidationError({ 46 | message: `Expected function to return a promise, but instead got ${returnedPromise}`, 47 | }), 48 | ); 49 | } 50 | return returnedPromise.then(value => { 51 | const result = innerGuard(returnType, value, createGuardVisitedState(), false, false); 52 | if (result) { 53 | throw new ValidationError(result); 54 | } 55 | return value; 56 | }); 57 | }, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/contract.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGuardVisitedState, 3 | createVisitedState, 4 | innerGuard, 5 | innerValidate, 6 | OpaqueVisitedState, 7 | RuntypeBase, 8 | } from './runtype'; 9 | import { ValidationError } from './errors'; 10 | 11 | export interface Contract { 12 | enforce(f: (...a: A) => Z): (...a: A) => Z; 13 | } 14 | 15 | /** 16 | * Create a function contract. 17 | */ 18 | export function Contract( 19 | argTypes: { [key in keyof A]: key extends 'length' ? A['length'] : RuntypeBase }, 20 | returnType: RuntypeBase, 21 | ): Contract { 22 | return { 23 | enforce: 24 | (f: (...args: A) => Z) => 25 | (...args: A): Z => { 26 | if (args.length < argTypes.length) 27 | throw new ValidationError({ 28 | message: `Expected ${argTypes.length} arguments but only received ${args.length}`, 29 | }); 30 | const visited: OpaqueVisitedState = createVisitedState(); 31 | for (let i = 0; i < argTypes.length; i++) { 32 | const result = innerValidate(argTypes[i], args[i], visited, false); 33 | if (result.success) { 34 | args[i] = result.value; 35 | } else { 36 | throw new ValidationError(result); 37 | } 38 | } 39 | const rawResult = f(...args); 40 | const result = innerGuard(returnType, rawResult, createGuardVisitedState(), false, false); 41 | if (result) { 42 | throw new ValidationError(result); 43 | } 44 | return rawResult; 45 | }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { Failure, FullError, showError } from './result'; 2 | 3 | export class ValidationError extends Error { 4 | public name: string = 'ValidationError'; 5 | public readonly shortMessage: string; 6 | public readonly key: string | undefined; 7 | public readonly fullError: FullError | undefined; 8 | 9 | constructor(failure: Omit) { 10 | super(showError(failure)); 11 | this.shortMessage = failure.message; 12 | this.key = failure.key; 13 | this.fullError = failure.fullError; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { provideHelpers } from './runtype'; 2 | import { Brand } from './types/brand'; 3 | import { Constraint } from './types/constraint'; 4 | import { Intersect } from './types/intersect'; 5 | import { ParsedValue } from './types/ParsedValue'; 6 | import { Union } from './types/union'; 7 | 8 | export { AsyncContract } from './asynccontract'; 9 | export { Contract } from './contract'; 10 | export { assertType } from './assertType'; 11 | export type { Runtype, RuntypeBase, Codec, Static } from './runtype'; 12 | export type { Success, Failure, Result } from './result'; 13 | export { showError } from './result'; 14 | export { ValidationError } from './errors'; 15 | export { default as showType } from './show'; 16 | export { default as showValue } from './showValue'; 17 | 18 | export { Readonly } from './types/Readonly'; 19 | export { Mutable } from './types/Mutable'; 20 | 21 | export { Array, Array as MutableArray, ReadonlyArray } from './types/array'; 22 | export { 23 | Object, 24 | Object as MutableObject, 25 | ReadonlyObject, 26 | Partial, 27 | Partial as MutablePartial, 28 | ReadonlyPartial, 29 | } from './types/Object'; 30 | export { Record, Record as MutableRecord, ReadonlyRecord } from './types/Record'; 31 | export { Tuple, Tuple as MutableTuple, ReadonlyTuple } from './types/tuple'; 32 | 33 | export type { ConstraintCheck } from './types/constraint'; 34 | export { Constraint, Guard } from './types/constraint'; 35 | export { Enum } from './types/Enum'; 36 | export { InstanceOf } from './types/instanceof'; 37 | export { Intersect } from './types/intersect'; 38 | export { KeyOf } from './types/KeyOf'; 39 | export { Lazy } from './types/lazy'; 40 | export type { LiteralValue } from './types/literal'; 41 | export { Literal, Null, Undefined } from './types/literal'; 42 | export { Named } from './types/Named'; 43 | export { Never } from './types/never'; 44 | export { Boolean, Function, Number, String, Symbol, BigInt } from './types/primative'; 45 | export { Sealed } from './types/Sealed'; 46 | export { Union } from './types/union'; 47 | export { Unknown } from './types/unknown'; 48 | export { Brand } from './types/brand'; 49 | export { ParsedValue } from './types/ParsedValue'; 50 | 51 | provideHelpers({ 52 | Union, 53 | Intersect, 54 | Constraint, 55 | Brand, 56 | ParsedValue, 57 | }); 58 | -------------------------------------------------------------------------------- /src/result.ts: -------------------------------------------------------------------------------- 1 | import type { RuntypeBase } from './runtype'; 2 | import show from './show'; 3 | import showValue from './showValue'; 4 | 5 | export function success(value: T): Success { 6 | return { success: true, value }; 7 | } 8 | 9 | export function failure( 10 | message: string, 11 | options: Omit = {}, 12 | ): Failure { 13 | return { success: false, message, ...options }; 14 | } 15 | 16 | export function expected( 17 | expected: RuntypeBase | string, 18 | value: unknown, 19 | options: Omit = {}, 20 | ): Failure { 21 | return failure( 22 | `Expected ${typeof expected === 'string' ? expected : show(expected)}, but was ${showValue( 23 | value, 24 | )}`, 25 | options, 26 | ); 27 | } 28 | 29 | type FullErrorInput = FullError | Failure | string; 30 | 31 | export function unableToAssign( 32 | value: unknown, 33 | expected: RuntypeBase | string, 34 | ...children: FullErrorInput[] 35 | ): FullError { 36 | return [ 37 | `Unable to assign ${showValue(value)} to ${ 38 | typeof expected === 'string' ? expected : show(expected) 39 | }`, 40 | ...children.map(toFullError), 41 | ]; 42 | } 43 | export function andError([msg, ...children]: FullError): FullError { 44 | return [`And ${msg[0].toLocaleLowerCase()}${msg.substr(1)}`, ...children]; 45 | } 46 | 47 | export function typesAreNotCompatible(property: string, ...children: FullErrorInput[]): FullError { 48 | return [`The types of ${property} are not compatible`, ...children.map(toFullError)]; 49 | } 50 | function toFullError(v: FullErrorInput): FullError { 51 | return typeof v === 'string' ? [v] : Array.isArray(v) ? v : toFullError(v.fullError || v.message); 52 | } 53 | 54 | /** 55 | * A successful validation result. 56 | */ 57 | export type Success = { 58 | /** 59 | * A tag indicating success. 60 | */ 61 | success: true; 62 | 63 | /** 64 | * The original value, cast to its validated type. 65 | */ 66 | value: T; 67 | }; 68 | 69 | /** 70 | * A failed validation result. 71 | */ 72 | export type Failure = { 73 | /** 74 | * A tag indicating failure. 75 | */ 76 | success: false; 77 | 78 | /** 79 | * A message indicating the reason validation failed. 80 | */ 81 | message: string; 82 | 83 | fullError?: FullError; 84 | 85 | /** 86 | * A key indicating the location at which validation failed. 87 | */ 88 | key?: string; 89 | }; 90 | 91 | export type FullError = [string, ...FullError[]]; 92 | 93 | /** 94 | * The result of a type validation. 95 | */ 96 | export type Result = Success | Failure; 97 | 98 | export function showError(failure: Omit): string { 99 | return failure.fullError 100 | ? showFullError(failure.fullError) 101 | : failure.key 102 | ? `${failure.message} in ${failure.key}` 103 | : failure.message; 104 | } 105 | export function showFullError([title, ...children]: FullError, indent: string = ''): string { 106 | return [`${indent}${title}`, ...children.map(e => showFullError(e, `${indent} `))].join('\n'); 107 | } 108 | -------------------------------------------------------------------------------- /src/runtype.spec.ts: -------------------------------------------------------------------------------- 1 | import { String, Number, Object } from './'; 2 | 3 | test('Runtype.safeParse', () => { 4 | expect(String.safeParse('hello')).toMatchInlineSnapshot(` 5 | Object { 6 | "success": true, 7 | "value": "hello", 8 | } 9 | `); 10 | expect(String.safeParse(42)).toMatchInlineSnapshot(` 11 | Object { 12 | "message": "Expected string, but was 42", 13 | "success": false, 14 | } 15 | `); 16 | }); 17 | 18 | test('Runtype.assert', () => { 19 | expect(() => String.assert('hello')).not.toThrow(); 20 | expect(() => String.assert(42)).toThrowErrorMatchingInlineSnapshot( 21 | `"Expected string, but was 42"`, 22 | ); 23 | expect(() => Object({ value: String }).assert({ value: 42 })).toThrowErrorMatchingInlineSnapshot(` 24 | "Unable to assign {value: 42} to { value: string; } 25 | The types of \\"value\\" are not compatible 26 | Expected string, but was 42" 27 | `); 28 | }); 29 | 30 | test('Runtype.assert', () => { 31 | expect(String.assert('hello')).toBe(undefined); 32 | expect(() => String.assert(42)).toThrowErrorMatchingInlineSnapshot( 33 | `"Expected string, but was 42"`, 34 | ); 35 | }); 36 | 37 | test('Runtype.check', () => { 38 | expect(String.parse('hello')).toBe('hello'); 39 | expect(() => String.parse(42)).toThrowErrorMatchingInlineSnapshot( 40 | `"Expected string, but was 42"`, 41 | ); 42 | }); 43 | 44 | test('Runtype.test', () => { 45 | expect(String.test('hello')).toBe(true); 46 | expect(String.test(42)).toBe(false); 47 | }); 48 | 49 | test('Runtype.Or', () => { 50 | expect(String.Or(Number).test('hello')).toBe(true); 51 | expect(String.Or(Number).test(42)).toBe(true); 52 | expect(String.Or(Number).test(true)).toBe(false); 53 | }); 54 | 55 | test('Runtype.And', () => { 56 | expect( 57 | Object({ a: String }) 58 | .And(Object({ b: Number })) 59 | .test({ a: 'hello', b: 42 }), 60 | ).toBe(true); 61 | expect( 62 | Object({ a: String }) 63 | .And(Object({ b: Number })) 64 | .test({ a: 42, b: 42 }), 65 | ).toBe(false); 66 | expect( 67 | Object({ a: String }) 68 | .And(Object({ b: Number })) 69 | .test({ a: 'hello', b: 'hello' }), 70 | ).toBe(false); 71 | }); 72 | -------------------------------------------------------------------------------- /src/runtype.ts: -------------------------------------------------------------------------------- 1 | import type * as t from '.'; 2 | // import { Union, Intersect, Constraint, ConstraintCheck, Brand, ParsedValue, ParsedValueConfig } from './index'; 3 | import type { Result, Failure } from './result'; 4 | import show from './show'; 5 | import { ValidationError } from './errors'; 6 | import { ParsedValueConfig } from './types/ParsedValue'; 7 | import showValue from './showValue'; 8 | import { failure, success } from './result'; 9 | 10 | // let Union: typeof t.Union; 11 | // let Intersect: typeof t.Intersect; 12 | // let Constraint: typeof t.Constraint; 13 | // let Brand: typeof t.Brand; 14 | // let ParsedValue: typeof t.ParsedValue; 15 | // Importing these directly creates a cycle 16 | interface Helpers { 17 | readonly Union: typeof t.Union; 18 | readonly Intersect: typeof t.Intersect; 19 | readonly Constraint: typeof t.Constraint; 20 | readonly Brand: typeof t.Brand; 21 | readonly ParsedValue: typeof t.ParsedValue; 22 | } 23 | let helpers: Helpers; 24 | export function provideHelpers(h: Helpers) { 25 | helpers = h; 26 | } 27 | 28 | export type InnerValidateHelper = (runtype: RuntypeBase, value: unknown) => Result; 29 | declare const internalSymbol: unique symbol; 30 | const internal: typeof internalSymbol = 31 | '__internal_runtype_methods__' as unknown as typeof internalSymbol; 32 | 33 | export function assertRuntype(...values: RuntypeBase[]) { 34 | for (const value of values) { 35 | if (!value || !value[internal]) { 36 | throw new Error(`Expected Runtype but got ${showValue(value)}`); 37 | } 38 | } 39 | } 40 | export function isRuntype(value: unknown): value is RuntypeBase { 41 | return typeof value === 'object' && value != null && internal in value; 42 | } 43 | 44 | export type ResultWithCycle = (Result & { cycle?: false }) | Cycle; 45 | 46 | export type SealedState = 47 | | { readonly keysFromIntersect?: ReadonlySet; readonly deep: boolean } 48 | | false; 49 | export interface InternalValidation { 50 | /** 51 | * parse 52 | */ 53 | p( 54 | x: any, 55 | innerValidate: (runtype: RuntypeBase, value: unknown, sealed?: SealedState) => Result, 56 | innerValidateToPlaceholder: ( 57 | runtype: RuntypeBase, 58 | value: unknown, 59 | sealed?: SealedState, 60 | ) => ResultWithCycle, 61 | mode: 'p' | 's' | 't', 62 | sealed: SealedState, 63 | ): ResultWithCycle; 64 | /** 65 | * test 66 | */ 67 | t?: ( 68 | x: any, 69 | innerValidate: ( 70 | runtype: RuntypeBase, 71 | value: unknown, 72 | sealed?: SealedState, 73 | ) => Failure | undefined, 74 | sealed: SealedState, 75 | isOptionalTest: boolean, 76 | ) => Failure | undefined; 77 | /** 78 | * serialize 79 | */ 80 | s?: ( 81 | // any is used here to ensure TypeScript still treats RuntypeBase as 82 | // covariant. 83 | x: any, 84 | innerSerialize: (runtype: RuntypeBase, value: unknown, sealed?: SealedState) => Result, 85 | innerSerializeToPlaceholder: ( 86 | runtype: RuntypeBase, 87 | value: unknown, 88 | sealed?: SealedState, 89 | ) => ResultWithCycle, 90 | mode: 's', 91 | sealed: SealedState, 92 | ) => ResultWithCycle; 93 | /** 94 | * get underlying type 95 | */ 96 | u?: (mode: 'p' | 's' | 't') => RuntypeBase | undefined; 97 | 98 | /** 99 | * get fields, not called if "u" is implemented, can return 100 | * undefined to indicate that arbitrarily many fields are 101 | * possible. 102 | */ 103 | f?: (mode: 'p' | 't' | 's') => ReadonlySet | undefined; 104 | } 105 | 106 | /** 107 | * A runtype determines at runtime whether a value conforms to a type specification. 108 | */ 109 | export interface RuntypeBase { 110 | readonly tag: string; 111 | 112 | /** 113 | * Verifies that a value conforms to this runtype. When given a value that does 114 | * not conform to the runtype, throws an exception. 115 | * 116 | * @throws ValidationError 117 | */ 118 | assert(x: any): asserts x is TParsed; 119 | 120 | /** 121 | * A type guard for this runtype. 122 | */ 123 | test(x: any): x is TParsed; 124 | 125 | /** 126 | * Validates the value conforms to this type, and performs 127 | * the `parse` action for any `ParsedValue` types. 128 | * 129 | * If the value is valid, it returns the parsed value, 130 | * otherwise it throws a ValidationError. 131 | * 132 | * @throws ValidationError 133 | */ 134 | parse(x: any): TParsed; 135 | 136 | /** 137 | * Validates the value conforms to this type, and performs 138 | * the `parse` action for any `ParsedValue` types. 139 | * 140 | * Returns a `Result`, constaining the parsed value or 141 | * error message. Does not throw! 142 | */ 143 | safeParse(x: any): Result; 144 | 145 | show?: (needsParens: boolean) => string; 146 | 147 | [internal]: InternalValidation; 148 | } 149 | 150 | /** 151 | * A runtype determines at runtime whether a value conforms to a type specification. 152 | */ 153 | export interface Runtype extends RuntypeBase { 154 | /** 155 | * Union this Runtype with another. 156 | */ 157 | Or(B: B): t.Union<[this, B]>; 158 | 159 | /** 160 | * Intersect this Runtype with another. 161 | */ 162 | And(B: B): t.Intersect<[this, B]>; 163 | 164 | /** 165 | * Use an arbitrary constraint function to validate a runtype, and optionally 166 | * to change its name and/or its static type. 167 | * 168 | * @template T - Optionally override the static type of the resulting runtype 169 | * @param {(x: Static) => boolean | string} constraint - Custom function 170 | * that returns `true` if the constraint is satisfied, `false` or a custom 171 | * error message if not. 172 | * @param [options] 173 | * @param {string} [options.name] - allows setting the name of this 174 | * constrained runtype, which is helpful in reflection or diagnostic 175 | * use-cases. 176 | */ 177 | withConstraint, K = unknown>( 178 | constraint: t.ConstraintCheck, 179 | options?: { name?: string; args?: K }, 180 | ): t.Constraint; 181 | 182 | /** 183 | * Helper function to convert an underlying Runtype into another static type 184 | * via a type guard function. The static type of the runtype is inferred from 185 | * the type of the test function. 186 | * 187 | * @template T - Typically inferred from the return type of the type guard 188 | * function, so usually not needed to specify manually. 189 | * @param {(x: Static) => x is T} test - Type test function (see 190 | * https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards) 191 | * 192 | * @param [options] 193 | * @param {string} [options.name] - allows setting the name of this 194 | * constrained runtype, which is helpful in reflection or diagnostic 195 | * use-cases. 196 | */ 197 | withGuard, K = unknown>( 198 | test: (x: Static) => x is T, 199 | options?: { name?: string; args?: K }, 200 | ): t.Constraint; 201 | 202 | /** 203 | * Adds a brand to the type. 204 | */ 205 | withBrand(brand: B): t.Brand; 206 | 207 | /** 208 | * Apply conversion functions when parsing/serializing this value 209 | */ 210 | withParser(value: ParsedValueConfig): t.ParsedValue; 211 | } 212 | 213 | export interface Codec extends Runtype { 214 | /** 215 | * Validates the value conforms to this type, and performs 216 | * the `serialize` action for any `ParsedValue` types. 217 | * 218 | * If the value is valid, and the type supports serialize, 219 | * it returns the serialized value, otherwise it throws a 220 | * ValidationError. 221 | * 222 | * @throws ValidationError 223 | */ 224 | serialize: (x: TParsed) => unknown; 225 | /** 226 | * Validates the value conforms to this type, and performs 227 | * the `serialize` action for any `ParsedValue` types. 228 | * 229 | * Returns a `Result`, constaining the serialized value or 230 | * error message. Does not throw! 231 | */ 232 | safeSerialize: (x: TParsed) => Result; 233 | } 234 | /** 235 | * Obtains the static type associated with a Runtype. 236 | */ 237 | export type Static> = A extends RuntypeBase ? T : unknown; 238 | 239 | export function create>( 240 | tag: TConfig['tag'], 241 | internalImplementation: 242 | | InternalValidation> 243 | | InternalValidation>['p'], 244 | config: Omit< 245 | TConfig, 246 | | typeof internal 247 | | 'tag' 248 | | 'assert' 249 | | 'test' 250 | | 'parse' 251 | | 'safeParse' 252 | | 'serialize' 253 | | 'safeSerialize' 254 | | 'Or' 255 | | 'And' 256 | | 'withConstraint' 257 | | 'withGuard' 258 | | 'withBrand' 259 | | 'withParser' 260 | >, 261 | ): TConfig { 262 | const A: Codec> = { 263 | ...config, 264 | tag, 265 | assert(x: any): asserts x is Static { 266 | const validated = innerGuard(A, x, createGuardVisitedState(), false, false); 267 | if (validated) { 268 | throw new ValidationError(validated); 269 | } 270 | }, 271 | parse, 272 | safeParse, 273 | test, 274 | serialize(x: any) { 275 | const validated = safeSerialize(x); 276 | if (!validated.success) { 277 | throw new ValidationError(validated); 278 | } 279 | return validated.value; 280 | }, 281 | safeSerialize, 282 | Or: (B: B): t.Union<[Codec>, B]> => helpers.Union(A, B), 283 | And: (B: B): t.Intersect<[Codec>, B]> => 284 | helpers.Intersect(A, B), 285 | withConstraint: , K = unknown>( 286 | constraint: t.ConstraintCheck>>, 287 | options?: { name?: string; args?: K }, 288 | ): t.Constraint>, T, K> => 289 | helpers.Constraint>, T, K>(A, constraint, options), 290 | withGuard: , K = unknown>( 291 | test: (x: Static) => x is T, 292 | options?: { name?: string; args?: K }, 293 | ): t.Constraint>, T, K> => 294 | helpers.Constraint>, T, K>(A, test, options), 295 | withBrand: (B: B): t.Brand>> => 296 | helpers.Brand>>(B, A), 297 | withParser: ( 298 | config: ParsedValueConfig>, TParsed>, 299 | ): t.ParsedValue>, TParsed> => helpers.ParsedValue(A as any, config), 300 | toString: () => `Runtype<${show(A)}>`, 301 | [internal]: 302 | typeof internalImplementation === 'function' 303 | ? { p: internalImplementation } 304 | : internalImplementation, 305 | }; 306 | 307 | return A as unknown as TConfig; 308 | 309 | function safeParse(x: any) { 310 | return innerValidate(A, x, createVisitedState(), false); 311 | } 312 | function safeSerialize(x: any) { 313 | return innerSerialize(A, x, createVisitedState(), false); 314 | } 315 | function parse(x: any) { 316 | const validated = safeParse(x); 317 | if (!validated.success) { 318 | throw new ValidationError(validated); 319 | } 320 | return validated.value; 321 | } 322 | 323 | function test(x: any): x is Static { 324 | const validated = innerGuard(A, x, createGuardVisitedState(), false, false); 325 | return validated === undefined; 326 | } 327 | } 328 | 329 | export interface Cycle { 330 | success: true; 331 | cycle: true; 332 | placeholder: Partial; 333 | unwrap: () => Result; 334 | } 335 | 336 | function attemptMixin(placeholder: any, value: T): Result { 337 | if (placeholder === value) { 338 | return success(value); 339 | } 340 | if (Array.isArray(placeholder) && Array.isArray(value)) { 341 | placeholder.splice(0, placeholder.length, ...value); 342 | return success(placeholder as any); 343 | } 344 | if ( 345 | placeholder && 346 | typeof placeholder === 'object' && 347 | !Array.isArray(placeholder) && 348 | value && 349 | typeof value === 'object' && 350 | !Array.isArray(value) 351 | ) { 352 | Object.assign(placeholder, value); 353 | return success(placeholder); 354 | } 355 | return failure( 356 | `Cannot convert a value of type "${ 357 | Array.isArray(placeholder) ? 'Array' : typeof placeholder 358 | }" into a value of type "${ 359 | value === null ? 'null' : Array.isArray(value) ? 'Array' : typeof value 360 | }" when it contains cycles.`, 361 | ); 362 | } 363 | 364 | /** 365 | * Get the underlying type of a runtype, if it is a wrapper around another type 366 | */ 367 | export function unwrapRuntype(t: RuntypeBase, mode: 'p' | 's' | 't'): RuntypeBase { 368 | const i = t[internal]; 369 | const unwrapped = i.u ? i.u(mode) : undefined; 370 | if (unwrapped && unwrapped !== t) { 371 | return unwrapRuntype(unwrapped, mode); 372 | } 373 | return t; 374 | } 375 | 376 | export function createValidationPlaceholder( 377 | placeholder: T, 378 | fn: (placeholder: T) => Failure | undefined, 379 | ): Cycle { 380 | return innerMapValidationPlaceholder(placeholder, () => fn(placeholder) || success(placeholder)); 381 | } 382 | 383 | export function mapValidationPlaceholder( 384 | source: ResultWithCycle, 385 | fn: (placeholder: T) => Result, 386 | extraGuard?: RuntypeBase, 387 | ): ResultWithCycle { 388 | if (!source.success) return source; 389 | if (!source.cycle) { 390 | const result = fn(source.value); 391 | return ( 392 | (result.success && 393 | extraGuard && 394 | innerGuard(extraGuard, result.value, createGuardVisitedState(), false, true)) || 395 | result 396 | ); 397 | } 398 | 399 | return innerMapValidationPlaceholder( 400 | Array.isArray(source.placeholder) ? [...source.placeholder] : { ...source.placeholder }, 401 | () => source.unwrap(), 402 | fn, 403 | extraGuard, 404 | ); 405 | } 406 | 407 | function innerMapValidationPlaceholder( 408 | placeholder: any, 409 | populate: () => Result, 410 | fn?: (placeholder: any) => Result, 411 | extraGuard?: RuntypeBase, 412 | ): Cycle { 413 | let hasCycle = false; 414 | let cache: Result | undefined; 415 | const cycle: Cycle = { 416 | success: true, 417 | cycle: true, 418 | placeholder, 419 | unwrap: () => { 420 | if (cache) { 421 | hasCycle = true; 422 | return cache; 423 | } 424 | cache = success(placeholder); 425 | 426 | const sourceResult = populate(); 427 | const result = sourceResult.success && fn ? fn(sourceResult.value) : sourceResult; 428 | if (!result.success) return (cache = result); 429 | if (hasCycle) { 430 | const unwrapResult = attemptMixin(cache.value, result.value); 431 | const guardFailure = 432 | unwrapResult.success && 433 | extraGuard && 434 | innerGuard(extraGuard, unwrapResult.value, createGuardVisitedState(), false, true); 435 | cache = guardFailure || unwrapResult; 436 | } else { 437 | const guardFailure = 438 | extraGuard && 439 | innerGuard(extraGuard, result.value, createGuardVisitedState(), false, true); 440 | cache = guardFailure || result; 441 | } 442 | 443 | if (cache.success) { 444 | cycle.placeholder = cache.value; 445 | } 446 | 447 | return cache; 448 | }, 449 | }; 450 | return cycle; 451 | } 452 | 453 | declare const OpaqueVisitedState: unique symbol; 454 | export type OpaqueVisitedState = typeof OpaqueVisitedState; 455 | type VisitedState = Map, Map>>; 456 | 457 | function unwrapVisitedState(o: OpaqueVisitedState): VisitedState { 458 | return o as any; 459 | } 460 | function wrapVisitedState(o: VisitedState): OpaqueVisitedState { 461 | return o as any; 462 | } 463 | 464 | export function createVisitedState(): OpaqueVisitedState { 465 | return wrapVisitedState(new Map()); 466 | } 467 | 468 | declare const OpaqueGuardVisitedState: unique symbol; 469 | export type OpaqueGuardVisitedState = typeof OpaqueGuardVisitedState; 470 | type GuardVisitedState = Map, Set>; 471 | 472 | function unwrapGuardVisitedState(o: OpaqueGuardVisitedState): GuardVisitedState { 473 | return o as any; 474 | } 475 | function wrapGuardVisitedState(o: GuardVisitedState): OpaqueGuardVisitedState { 476 | return o as any; 477 | } 478 | 479 | export function createGuardVisitedState(): OpaqueGuardVisitedState { 480 | return wrapGuardVisitedState(new Map()); 481 | } 482 | 483 | export function innerValidate( 484 | targetType: RuntypeBase, 485 | value: any, 486 | $visited: OpaqueVisitedState, 487 | sealed: SealedState, 488 | ): Result { 489 | const result = innerValidateToPlaceholder(targetType, value, $visited, sealed); 490 | if (result.cycle) { 491 | return result.unwrap(); 492 | } 493 | return result; 494 | } 495 | 496 | function innerValidateToPlaceholder( 497 | targetType: RuntypeBase, 498 | value: any, 499 | $visited: OpaqueVisitedState, 500 | sealed: SealedState, 501 | ): ResultWithCycle { 502 | const visited = unwrapVisitedState($visited); 503 | const validator = targetType[internal]; 504 | const cached = visited.get(targetType)?.get(value); 505 | if (cached !== undefined) { 506 | return cached; 507 | } 508 | const result = validator.p( 509 | value, 510 | (t, v, s) => innerValidate(t, v, $visited, s ?? sealed), 511 | (t, v, s) => innerValidateToPlaceholder(t, v, $visited, s ?? sealed), 512 | 'p', 513 | sealed, 514 | ); 515 | if (result.cycle) { 516 | visited.set(targetType, (visited.get(targetType) || new Map()).set(value, result)); 517 | return result; 518 | } 519 | return result; 520 | } 521 | 522 | export function innerSerialize( 523 | targetType: RuntypeBase, 524 | value: any, 525 | $visited: OpaqueVisitedState, 526 | sealed: SealedState, 527 | ): Result { 528 | const result = innerSerializeToPlaceholder(targetType, value, $visited, sealed); 529 | if (result.cycle) { 530 | return result.unwrap(); 531 | } 532 | return result; 533 | } 534 | function innerSerializeToPlaceholder( 535 | targetType: RuntypeBase, 536 | value: any, 537 | $visited: OpaqueVisitedState, 538 | sealed: SealedState, 539 | ): ResultWithCycle { 540 | const visited = unwrapVisitedState($visited); 541 | const validator = targetType[internal]; 542 | const cached = visited.get(targetType)?.get(value); 543 | if (cached !== undefined) { 544 | return cached; 545 | } 546 | let result = (validator.s || validator.p)( 547 | value, 548 | (t, v, s) => innerSerialize(t, v, $visited, s ?? sealed), 549 | (t, v, s) => innerSerializeToPlaceholder(t, v, $visited, s ?? sealed), 550 | 's', 551 | sealed, 552 | ); 553 | if (result.cycle) { 554 | visited.set(targetType, (visited.get(targetType) || new Map()).set(value, result)); 555 | return result; 556 | } 557 | return result; 558 | } 559 | 560 | export function innerGuard( 561 | targetType: RuntypeBase, 562 | value: any, 563 | $visited: OpaqueGuardVisitedState, 564 | sealed: SealedState, 565 | isOptionalTest: boolean, 566 | ): Failure | undefined { 567 | const visited = unwrapGuardVisitedState($visited); 568 | const validator = targetType[internal]; 569 | if (value && (typeof value === 'object' || typeof value === 'function')) { 570 | const cached = visited.get(targetType)?.has(value); 571 | if (cached) return undefined; 572 | visited.set(targetType, (visited.get(targetType) || new Set()).add(value)); 573 | } 574 | if (validator.t) { 575 | return validator.t( 576 | value, 577 | (t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest), 578 | sealed, 579 | isOptionalTest, 580 | ); 581 | } 582 | let result = validator.p( 583 | value, 584 | (t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest) || success(v as any), 585 | (t, v, s) => innerGuard(t, v, $visited, s ?? sealed, isOptionalTest) || success(v as any), 586 | 't', 587 | sealed, 588 | ); 589 | if (result.cycle) result = result.unwrap(); 590 | if (result.success) return undefined; 591 | else return result; 592 | } 593 | 594 | /** 595 | * Get the possible fields for a runtype 596 | * Returns "undefined" if there can be arbitrary fields (e.g. Record) 597 | */ 598 | export function getFields(t: RuntypeBase, mode: 'p' | 's' | 't'): ReadonlySet | undefined { 599 | const b = unwrapRuntype(t, mode); 600 | const i = b[internal]; 601 | return i.f ? i.f(mode) : undefined; 602 | } 603 | -------------------------------------------------------------------------------- /src/show.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Unknown, 3 | Never, 4 | Undefined, 5 | Null, 6 | Boolean, 7 | Number, 8 | String, 9 | Symbol, 10 | Literal, 11 | Array, 12 | Record, 13 | Object, 14 | Partial, 15 | Tuple, 16 | Union, 17 | Intersect, 18 | Function, 19 | Lazy, 20 | InstanceOf, 21 | } from '.'; 22 | import show from './show'; 23 | import { RuntypeBase } from './runtype'; 24 | 25 | class TestClass {} 26 | 27 | const cases: [RuntypeBase, string][] = [ 28 | [Unknown, 'unknown'], 29 | [Never, 'never'], 30 | [Undefined, 'undefined'], 31 | [Null, 'null'], 32 | [Boolean, 'boolean'], 33 | [Number, 'number'], 34 | [String, 'string'], 35 | [Symbol, 'symbol'], 36 | [Literal(true), 'true'], 37 | [Literal(3), '3'], 38 | [Literal('foo'), '"foo"'], 39 | [Array(String), 'string[]'], 40 | [Array(String).asReadonly(), 'readonly string[]'], 41 | [Record(String, Array(Boolean)), '{ [_: string]: boolean[] }'], 42 | [Record(String, Array(Boolean)), '{ [_: string]: boolean[] }'], 43 | [Record(Number, Array(Boolean)), '{ [_: number]: boolean[] }'], 44 | [Object({}), '{}'], 45 | [Object({}).asReadonly(), '{}'], 46 | [Partial({}), '{}'], 47 | [InstanceOf(TestClass), 'InstanceOf'], 48 | [Array(InstanceOf(TestClass)), 'InstanceOf[]'], 49 | [Object({ x: String, y: Array(Boolean) }), '{ x: string; y: boolean[]; }'], 50 | [Object({ x: String, y: Array(Boolean) }), '{ x: string; y: boolean[]; }'], 51 | [Object({ x: Number }).And(Partial({ y: Number })), '{ x: number; } & { y?: number; }'], 52 | [ 53 | Object({ x: String, y: Array(Boolean) }).asReadonly(), 54 | '{ readonly x: string; readonly y: boolean[]; }', 55 | ], 56 | [Object({ x: String, y: Array(Boolean).asReadonly() }), '{ x: string; y: readonly boolean[]; }'], 57 | [ 58 | Object({ x: String, y: Array(Boolean).asReadonly() }).asReadonly(), 59 | '{ readonly x: string; readonly y: readonly boolean[]; }', 60 | ], 61 | [Partial({ x: String, y: Array(Boolean) }), '{ x?: string; y?: boolean[]; }'], 62 | [Object({ x: String, y: Array(Boolean) }).asPartial(), '{ x?: string; y?: boolean[]; }'], 63 | [Tuple(Boolean, Number), '[boolean, number]'], 64 | [Union(Boolean, Number), 'boolean | number'], 65 | [Intersect(Boolean, Number), 'boolean & number'], 66 | [Function, 'function'], 67 | [Lazy(() => Boolean), 'boolean'], 68 | [Number.withConstraint(x => x > 3), 'WithConstraint'], 69 | [Number.withBrand('someNumber'), 'number'], 70 | [Number.withBrand('someNumber').withConstraint(x => x > 3), 'WithConstraint'], 71 | 72 | // Parenthesization 73 | [Boolean.And(Number.Or(String)), 'boolean & (number | string)'], 74 | [Boolean.Or(Number.And(String)), 'boolean | (number & string)'], 75 | [Boolean.Or(Object({ x: String, y: Number })), 'boolean | { x: string; y: number; }'], 76 | ]; 77 | 78 | for (const [T, expected] of cases) { 79 | const s = show(T); 80 | it(s, () => { 81 | expect(s).toBe(expected); 82 | expect(T.toString()).toBe(`Runtype<${s}>`); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/show.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase } from './runtype'; 2 | 3 | export const parenthesize = (s: string, needsParens: boolean) => (needsParens ? `(${s})` : s); 4 | const circular = new Set>(); 5 | const show = (runtype: RuntypeBase, needsParens: boolean = false): string => { 6 | if (circular.has(runtype) && runtype.tag !== 'lazy') { 7 | return parenthesize(`CIRCULAR ${runtype.tag}`, needsParens); 8 | } 9 | 10 | if (runtype.show) { 11 | circular.add(runtype); 12 | 13 | try { 14 | return runtype.show(needsParens); 15 | } finally { 16 | circular.delete(runtype); 17 | } 18 | } 19 | 20 | return runtype.tag; 21 | }; 22 | 23 | export default show; 24 | -------------------------------------------------------------------------------- /src/showValue.ts: -------------------------------------------------------------------------------- 1 | import { isRuntype } from './runtype'; 2 | 3 | export default function showValue( 4 | value: unknown, 5 | remainingDepth: number = 3, 6 | remainingLength: number = 30, 7 | ): string { 8 | switch (typeof value) { 9 | case 'bigint': 10 | case 'boolean': 11 | case 'number': 12 | return `${value}`; 13 | case 'string': 14 | return JSON.stringify(value); 15 | case 'object': 16 | if (value === null) { 17 | return 'null'; 18 | } 19 | if (Array.isArray(value)) { 20 | if (remainingDepth === 0 || remainingLength === 0) { 21 | return '[Array]'; 22 | } else { 23 | let result = '['; 24 | let i = 0; 25 | for (i = 0; i < value.length && remainingLength > result.length; i++) { 26 | if (i !== 0) result += ', '; 27 | result += showValue(value[i], remainingDepth - 1, remainingLength - result.length); 28 | } 29 | if (i < value.length) { 30 | result += ' ... '; 31 | } 32 | result += ']'; 33 | return result; 34 | } 35 | } 36 | if (isRuntype(value)) { 37 | return value.toString(); 38 | } 39 | if (remainingDepth === 0) { 40 | return '{Object}'; 41 | } else { 42 | const props = Object.entries(value); 43 | let result = '{'; 44 | let i = 0; 45 | for (i = 0; i < props.length && remainingLength > result.length; i++) { 46 | if (i !== 0) result += ', '; 47 | const [key, v] = props[i]; 48 | result += `${/\s/.test(key) ? JSON.stringify(key) : key}: ${showValue( 49 | v, 50 | remainingDepth - 1, 51 | remainingLength - result.length, 52 | )}`; 53 | } 54 | if (i < props.length) { 55 | result += ' ... '; 56 | } 57 | result += '}'; 58 | return result; 59 | } 60 | case 'function': 61 | case 'symbol': 62 | case 'undefined': 63 | default: 64 | return typeof value; 65 | } 66 | } 67 | export function showValueNonString(value: unknown): string { 68 | return `${showValue(value)}${typeof value === 'string' ? ` (i.e. a string literal)` : ``}`; 69 | } 70 | -------------------------------------------------------------------------------- /src/types/Enum.spec.ts: -------------------------------------------------------------------------------- 1 | import { Enum } from '..'; 2 | import show from '../show'; 3 | 4 | enum NumericEnum { 5 | foo = 12, 6 | bar = 20, 7 | } 8 | enum StringEnum { 9 | Foo = 'Bar', 10 | Helo = 'World', 11 | } 12 | 13 | test('Numeric Enum', () => { 14 | expect(Enum('NumericEnum', NumericEnum).safeParse(12)).toMatchInlineSnapshot(` 15 | Object { 16 | "success": true, 17 | "value": 12, 18 | } 19 | `); 20 | expect(Enum('NumericEnum', NumericEnum).safeParse(16)).toMatchInlineSnapshot(` 21 | Object { 22 | "message": "Expected NumericEnum, but was 16", 23 | "success": false, 24 | } 25 | `); 26 | expect(Enum('NumericEnum', NumericEnum).safeParse('bar')).toMatchInlineSnapshot(` 27 | Object { 28 | "message": "Expected NumericEnum, but was \\"bar\\"", 29 | "success": false, 30 | } 31 | `); 32 | const typed: NumericEnum = Enum('NumericEnum', NumericEnum).parse(20); 33 | expect(typed).toBe(NumericEnum.bar); 34 | 35 | expect(show(Enum('NumericEnum', NumericEnum))).toMatchInlineSnapshot(`"NumericEnum"`); 36 | }); 37 | 38 | test('String Enum', () => { 39 | expect(Enum('StringEnum', StringEnum).safeParse('World')).toMatchInlineSnapshot(` 40 | Object { 41 | "success": true, 42 | "value": "World", 43 | } 44 | `); 45 | expect(Enum('StringEnum', StringEnum).safeParse('Hello')).toMatchInlineSnapshot(` 46 | Object { 47 | "message": "Expected StringEnum, but was \\"Hello\\"", 48 | "success": false, 49 | } 50 | `); 51 | const typed: StringEnum = Enum('StringEnum', StringEnum).parse('Bar'); 52 | expect(typed).toBe(StringEnum.Foo); 53 | }); 54 | -------------------------------------------------------------------------------- /src/types/Enum.ts: -------------------------------------------------------------------------------- 1 | import { expected, success } from '../result'; 2 | import { create, Codec } from '../runtype'; 3 | 4 | export interface Enum 5 | extends Codec { 6 | readonly tag: 'enum'; 7 | readonly enumObject: TEnum; 8 | } 9 | 10 | export function Enum( 11 | name: string, 12 | e: TEnum, 13 | ): Enum { 14 | const values = Object.values(e); 15 | const enumValues = new Set( 16 | values.some(v => typeof v === 'number') ? values.filter(v => typeof v === 'number') : values, 17 | ); 18 | return create>( 19 | 'enum', 20 | value => (enumValues.has(value as any) ? success(value as any) : expected(name, value)), 21 | { 22 | enumObject: e, 23 | show: () => name, 24 | }, 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/types/KeyOf.spec.ts: -------------------------------------------------------------------------------- 1 | import { KeyOf } from '..'; 2 | import show from '../show'; 3 | 4 | const StringObjectKeys = { 5 | foo: 1, 6 | bar: 2, 7 | }; 8 | 9 | const NumbericObjectKeys = { 10 | 2: 1, 11 | 4: '2', 12 | }; 13 | 14 | const MixedObjectKeys = { 15 | foo: 'bar', 16 | 5: 1, 17 | '4': 3, 18 | }; 19 | 20 | test('Numeric Object Keys', () => { 21 | expect(KeyOf(NumbericObjectKeys).safeParse(2)).toMatchInlineSnapshot(` 22 | Object { 23 | "success": true, 24 | "value": 2, 25 | } 26 | `); 27 | expect(KeyOf(NumbericObjectKeys).safeParse('2')).toMatchInlineSnapshot(` 28 | Object { 29 | "success": true, 30 | "value": "2", 31 | } 32 | `); 33 | expect(KeyOf(NumbericObjectKeys).safeParse('foobar')).toMatchInlineSnapshot(` 34 | Object { 35 | "message": "Expected \\"2\\" | \\"4\\", but was \\"foobar\\"", 36 | "success": false, 37 | } 38 | `); 39 | expect(KeyOf(NumbericObjectKeys).safeParse('5')).toMatchInlineSnapshot(` 40 | Object { 41 | "message": "Expected \\"2\\" | \\"4\\", but was \\"5\\"", 42 | "success": false, 43 | } 44 | `); 45 | const typed: keyof typeof NumbericObjectKeys = KeyOf(NumbericObjectKeys).parse(2); 46 | 47 | expect(typed).toBe(2); 48 | 49 | expect(show(KeyOf(NumbericObjectKeys))).toMatchInlineSnapshot(`"\\"2\\" | \\"4\\""`); 50 | }); 51 | 52 | test('String Object Keys', () => { 53 | expect(KeyOf(StringObjectKeys).safeParse('foo')).toMatchInlineSnapshot(` 54 | Object { 55 | "success": true, 56 | "value": "foo", 57 | } 58 | `); 59 | expect(KeyOf(StringObjectKeys).safeParse(55)).toMatchInlineSnapshot(` 60 | Object { 61 | "message": "Expected \\"bar\\" | \\"foo\\", but was 55", 62 | "success": false, 63 | } 64 | `); 65 | const typed: keyof typeof StringObjectKeys = KeyOf(StringObjectKeys).parse('bar'); 66 | 67 | expect(typed).toBe('bar'); 68 | 69 | expect(show(KeyOf(StringObjectKeys))).toMatchInlineSnapshot(`"\\"bar\\" | \\"foo\\""`); 70 | }); 71 | 72 | test('Mixed Object Keys', () => { 73 | expect(KeyOf(MixedObjectKeys).safeParse('foo')).toMatchInlineSnapshot(` 74 | Object { 75 | "success": true, 76 | "value": "foo", 77 | } 78 | `); 79 | expect(KeyOf(MixedObjectKeys).safeParse(5)).toMatchInlineSnapshot(` 80 | Object { 81 | "success": true, 82 | "value": 5, 83 | } 84 | `); 85 | expect(KeyOf(MixedObjectKeys).safeParse('foobar')).toMatchInlineSnapshot(` 86 | Object { 87 | "message": "Expected \\"4\\" | \\"5\\" | \\"foo\\", but was \\"foobar\\"", 88 | "success": false, 89 | } 90 | `); 91 | expect(KeyOf(MixedObjectKeys).safeParse(4)).toMatchInlineSnapshot(` 92 | Object { 93 | "success": true, 94 | "value": 4, 95 | } 96 | `); 97 | const stringNumber: keyof typeof MixedObjectKeys = KeyOf(MixedObjectKeys).parse('4'); 98 | expect(stringNumber).toBe('4'); 99 | 100 | const number: keyof typeof MixedObjectKeys = KeyOf(MixedObjectKeys).parse(5); 101 | expect(number).toBe(5); 102 | 103 | expect(show(KeyOf(MixedObjectKeys))).toMatchInlineSnapshot(`"\\"4\\" | \\"5\\" | \\"foo\\""`); 104 | }); 105 | -------------------------------------------------------------------------------- /src/types/KeyOf.ts: -------------------------------------------------------------------------------- 1 | import { showValue } from '..'; 2 | import { expected, success } from '../result'; 3 | import { create, Codec } from '../runtype'; 4 | import { parenthesize } from '../show'; 5 | 6 | export interface KeyOf extends Codec { 7 | readonly tag: 'keyOf'; 8 | readonly keys: Set; 9 | } 10 | 11 | export function KeyOf(object: TObject): KeyOf { 12 | const keys = new Set(Object.keys(object)); 13 | const name = [...keys] 14 | .sort() 15 | .map(k => showValue(k)) 16 | .join(` | `); 17 | return create>( 18 | 'keyOf', 19 | value => 20 | keys.has(typeof value === 'number' ? value.toString() : (value as any)) 21 | ? success(value as any) 22 | : expected(name, value), 23 | { 24 | keys, 25 | show: needsParens => parenthesize(name, needsParens), 26 | }, 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/types/Mutable.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ta from 'type-assertions'; 2 | import * as ft from '..'; 3 | 4 | test('Mutable(Record)', () => { 5 | const dictionary = ft.ReadonlyRecord(ft.String, ft.Number); 6 | ta.assert< 7 | ta.Equal, { readonly [key in string]?: number }> 8 | >(); 9 | const rDictionary = ft.Mutable(dictionary); 10 | ta.assert, { [key in string]?: number }>>(); 11 | expect(rDictionary.safeParse({ foo: 1, bar: 2 })).toMatchInlineSnapshot(` 12 | Object { 13 | "success": true, 14 | "value": Object { 15 | "bar": 2, 16 | "foo": 1, 17 | }, 18 | } 19 | `); 20 | }); 21 | 22 | test('Mutable(Object)', () => { 23 | const obj = ft.ReadonlyObject({ whatever: ft.Number }); 24 | expect(obj.isReadonly).toBe(true); 25 | ta.assert, { readonly whatever: number }>>(); 26 | const rObj = ft.Mutable(obj); 27 | expect(rObj.isReadonly).toBe(false); 28 | ta.assert, { whatever: number }>>(); 29 | expect(rObj.safeParse({ whatever: 2 })).toMatchInlineSnapshot(` 30 | Object { 31 | "success": true, 32 | "value": Object { 33 | "whatever": 2, 34 | }, 35 | } 36 | `); 37 | expect(obj.asPartial().isReadonly).toBe(true); 38 | expect(rObj.asPartial().isReadonly).toBe(false); 39 | }); 40 | 41 | test('Mutable(Tuple)', () => { 42 | const tuple = ft.ReadonlyTuple(ft.Number, ft.String); 43 | ta.assert, readonly [number, string]>>(); 44 | const rTuple = ft.Mutable(tuple); 45 | ta.assert, [number, string]>>(); 46 | expect(rTuple.safeParse([10, `world`])).toMatchInlineSnapshot(` 47 | Object { 48 | "success": true, 49 | "value": Array [ 50 | 10, 51 | "world", 52 | ], 53 | } 54 | `); 55 | }); 56 | 57 | test('Mutable(Array)', () => { 58 | const array = ft.ReadonlyArray(ft.Number); 59 | ta.assert, readonly number[]>>(); 60 | const rArray = ft.Mutable(array); 61 | ta.assert, number[]>>(); 62 | expect(rArray.safeParse([10, 3])).toMatchInlineSnapshot(` 63 | Object { 64 | "success": true, 65 | "value": Array [ 66 | 10, 67 | 3, 68 | ], 69 | } 70 | `); 71 | }); 72 | -------------------------------------------------------------------------------- /src/types/Mutable.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase } from '../runtype'; 2 | import { Array as Arr, ReadonlyArray } from './array'; 3 | import { InternalRecord, RecordFields } from './Object'; 4 | import { Record, KeyRuntypeBase, ReadonlyRecord } from './Record'; 5 | import { Tuple, ReadonlyTuple } from './tuple'; 6 | 7 | export type Mutable = T extends InternalRecord< 8 | infer TFields, 9 | infer TPartial, 10 | true 11 | > 12 | ? InternalRecord 13 | : T extends ReadonlyArray 14 | ? Arr 15 | : T extends ReadonlyTuple 16 | ? Tuple 17 | : T extends ReadonlyRecord 18 | ? Record 19 | : unknown; 20 | 21 | export function Mutable( 22 | input: InternalRecord, 23 | ): InternalRecord; 24 | export function Mutable( 25 | input: ReadonlyArray, 26 | ): Arr; 27 | export function Mutable[] = RuntypeBase[]>( 28 | input: ReadonlyTuple, 29 | ): Tuple; 30 | export function Mutable>( 31 | record: ReadonlyRecord, 32 | ): Record; 33 | export function Mutable(input: any): any { 34 | const result = { ...input }; 35 | result.isReadonly = false; 36 | for (const m of [`asPartial`, `pick`, `omit`]) { 37 | if (typeof input[m] === 'function') { 38 | result[m] = (...args: any[]) => Mutable(input[m](...args)); 39 | } 40 | } 41 | return result; 42 | } 43 | -------------------------------------------------------------------------------- /src/types/Named.spec.ts: -------------------------------------------------------------------------------- 1 | import { Literal, Null, Named, Union, Number, Object } from '..'; 2 | 3 | it('should make unions easier to understand', () => { 4 | const Square = Named(`Square`, Object({ shape: Literal(`Square`), size: Number })); 5 | const Rectangle = Named( 6 | `Rectangle`, 7 | Object({ shape: Literal(`Rectangle`), width: Number, height: Number }), 8 | ); 9 | const Circle = Named(`Circle`, Object({ shape: Literal(`Circle`), radius: Number })); 10 | 11 | const Shape = Union(Square, Rectangle, Circle, Null); 12 | 13 | expect(Shape.safeParse({ shape: `Rectangle`, size: new Date() })).toMatchInlineSnapshot(` 14 | Object { 15 | "fullError": Array [ 16 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to Square | Rectangle | Circle | null", 17 | Array [ 18 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to null", 19 | Array [ 20 | "Expected literal null, but was {shape: \\"Rectangle\\", size: {}}", 21 | ], 22 | ], 23 | Array [ 24 | "And unable to assign {shape: \\"Rectangle\\", size: {}} to Square | Rectangle | Circle", 25 | Array [ 26 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to { shape: \\"Rectangle\\"; width: number; height: number; }", 27 | Array [ 28 | "The types of \\"width\\" are not compatible", 29 | Array [ 30 | "Expected number, but was undefined", 31 | ], 32 | ], 33 | Array [ 34 | "The types of \\"height\\" are not compatible", 35 | Array [ 36 | "Expected number, but was undefined", 37 | ], 38 | ], 39 | ], 40 | ], 41 | ], 42 | "message": "Expected Square | Rectangle | Circle | null, but was {shape: \\"Rectangle\\", size: {}}", 43 | "success": false, 44 | } 45 | `); 46 | expect(Shape.parse(null)).toEqual(null); 47 | expect(Shape.parse({ shape: `Square`, size: 42 })).toEqual({ shape: `Square`, size: 42 }); 48 | 49 | const Shape2 = Union(Union(Square, Rectangle, Circle), Null); 50 | expect(Shape2.safeParse({ shape: `Rectangle`, size: new Date() })).toMatchInlineSnapshot(` 51 | Object { 52 | "fullError": Array [ 53 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to Square | Rectangle | Circle | null", 54 | Array [ 55 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to null", 56 | Array [ 57 | "Expected literal null, but was {shape: \\"Rectangle\\", size: {}}", 58 | ], 59 | ], 60 | Array [ 61 | "And unable to assign {shape: \\"Rectangle\\", size: {}} to Square | Rectangle | Circle", 62 | Array [ 63 | "Unable to assign {shape: \\"Rectangle\\", size: {}} to { shape: \\"Rectangle\\"; width: number; height: number; }", 64 | Array [ 65 | "The types of \\"width\\" are not compatible", 66 | Array [ 67 | "Expected number, but was undefined", 68 | ], 69 | ], 70 | Array [ 71 | "The types of \\"height\\" are not compatible", 72 | Array [ 73 | "Expected number, but was undefined", 74 | ], 75 | ], 76 | ], 77 | ], 78 | ], 79 | "message": "Expected Square | Rectangle | Circle | null, but was {shape: \\"Rectangle\\", size: {}}", 80 | "success": false, 81 | } 82 | `); 83 | expect(Shape2.parse(null)).toEqual(null); 84 | expect(Shape2.parse({ shape: `Square`, size: 42 })).toEqual({ shape: `Square`, size: 42 }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/types/Named.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase, Static, create, Codec, assertRuntype } from '../runtype'; 2 | 3 | export type ConstraintCheck> = (x: Static) => boolean | string; 4 | 5 | export interface Named> 6 | extends Codec> { 7 | readonly tag: 'named'; 8 | readonly underlying: TUnderlying; 9 | readonly name: string; 10 | } 11 | 12 | export function Named>( 13 | name: string, 14 | underlying: TUnderlying, 15 | ): Named { 16 | assertRuntype(underlying); 17 | const runtype: Named = create>( 18 | 'named', 19 | { 20 | p: (value, _innerValidate, innerValidateToPlaceholder) => { 21 | return innerValidateToPlaceholder(underlying as any, value); 22 | }, 23 | u: () => underlying, 24 | }, 25 | { 26 | underlying, 27 | name, 28 | show() { 29 | return name; 30 | }, 31 | }, 32 | ); 33 | return runtype; 34 | } 35 | -------------------------------------------------------------------------------- /src/types/Object.spec.ts: -------------------------------------------------------------------------------- 1 | import { Object as ObjectType, showType, String } from '..'; 2 | import { ReadonlyPartial } from './Object'; 3 | 4 | test('pick', () => { 5 | const CrewMember = ObjectType({ 6 | name: String, 7 | rank: String, 8 | home: String, 9 | }); 10 | const PetMember = CrewMember.pick('name', 'home'); 11 | 12 | expect(Object.keys(PetMember.fields)).toEqual(['name', 'home']); 13 | expect(PetMember.safeParse({ name: 'my name', home: 'my home' })).toMatchInlineSnapshot(` 14 | Object { 15 | "success": true, 16 | "value": Object { 17 | "home": "my home", 18 | "name": "my name", 19 | }, 20 | } 21 | `); 22 | }); 23 | 24 | test('omit', () => { 25 | const CrewMember = ObjectType({ 26 | name: String, 27 | rank: String, 28 | home: String, 29 | }); 30 | const PetMember = CrewMember.omit('rank'); 31 | 32 | expect(Object.keys(PetMember.fields)).toEqual(['name', 'home']); 33 | expect(PetMember.safeParse({ name: 'my name', home: 'my home' })).toMatchInlineSnapshot(` 34 | Object { 35 | "success": true, 36 | "value": Object { 37 | "home": "my home", 38 | "name": "my name", 39 | }, 40 | } 41 | `); 42 | }); 43 | 44 | test('omit arbitrary string does not actually break', () => { 45 | const CrewMember = ObjectType({ 46 | name: String, 47 | rank: String, 48 | home: String, 49 | }); 50 | 51 | // The type inference will fail here, but we shouldn't get a runtime error 52 | const PetMember = CrewMember.omit('rank', 'foobar' as any); 53 | 54 | expect(Object.keys(PetMember.fields)).toEqual(['name', 'home']); 55 | expect(PetMember.safeParse({ name: 'my name', home: 'my home' })).toMatchInlineSnapshot(` 56 | Object { 57 | "success": true, 58 | "value": Object { 59 | "home": "my home", 60 | "name": "my name", 61 | }, 62 | } 63 | `); 64 | }); 65 | 66 | test('ReadonlyPartial', () => { 67 | const CrewMember = ReadonlyPartial({ 68 | name: String, 69 | rank: String, 70 | home: String, 71 | }); 72 | 73 | expect(showType(CrewMember)).toMatchInlineSnapshot( 74 | `"{ readonly name?: string; readonly rank?: string; readonly home?: string; }"`, 75 | ); 76 | expect(CrewMember.safeParse({ name: 'my name', home: 'my home' })).toMatchInlineSnapshot(` 77 | Object { 78 | "success": true, 79 | "value": Object { 80 | "home": "my home", 81 | "name": "my name", 82 | }, 83 | } 84 | `); 85 | }); 86 | -------------------------------------------------------------------------------- /src/types/Object.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Static, 3 | create, 4 | RuntypeBase, 5 | Codec, 6 | createValidationPlaceholder, 7 | assertRuntype, 8 | } from '../runtype'; 9 | import { hasKey } from '../util'; 10 | import show from '../show'; 11 | import { Failure } from '..'; 12 | import { expected, failure, FullError, typesAreNotCompatible, unableToAssign } from '../result'; 13 | 14 | export type RecordFields = { readonly [_: string]: RuntypeBase }; 15 | type MutableRecordStaticType = { 16 | -readonly [K in keyof O]: Static; 17 | }; 18 | type ReadonlyRecordStaticType = { 19 | readonly [K in keyof O]: Static; 20 | }; 21 | type PartialMutableRecordStaticType = { 22 | -readonly [K in keyof O]?: Static; 23 | }; 24 | type PartialReadonlyRecordStaticType = { 25 | readonly [K in keyof O]?: Static; 26 | }; 27 | type RecordStaticType< 28 | O extends RecordFields, 29 | IsPartial extends boolean, 30 | IsReadonly extends boolean, 31 | > = IsPartial extends false 32 | ? IsReadonly extends false 33 | ? MutableRecordStaticType 34 | : ReadonlyRecordStaticType 35 | : IsReadonly extends false 36 | ? PartialMutableRecordStaticType 37 | : PartialReadonlyRecordStaticType; 38 | 39 | export interface InternalRecord< 40 | O extends RecordFields, 41 | IsPartial extends boolean, 42 | IsReadonly extends boolean, 43 | > extends Codec> { 44 | readonly tag: 'object'; 45 | readonly fields: O; 46 | readonly isPartial: IsPartial; 47 | readonly isReadonly: IsReadonly; 48 | asPartial(): Partial; 49 | asReadonly(): IsPartial extends false ? Obj : Partial; 50 | pick( 51 | ...keys: TKeys 52 | ): InternalRecord, IsPartial, IsReadonly>; 53 | omit( 54 | ...keys: TKeys 55 | ): InternalRecord, IsPartial, IsReadonly>; 56 | } 57 | 58 | export { Obj as Object }; 59 | type Obj = InternalRecord; 60 | 61 | export type Partial = InternalRecord< 62 | O, 63 | true, 64 | IsReadonly 65 | >; 66 | 67 | export function isObjectRuntype( 68 | runtype: RuntypeBase, 69 | ): runtype is InternalRecord { 70 | return ( 71 | 'tag' in runtype && (runtype as InternalRecord).tag === 'object' 72 | ); 73 | } 74 | 75 | /** 76 | * Construct an object runtype from runtypes for its values. 77 | */ 78 | export function InternalObject( 79 | fields: O, 80 | isPartial: Part, 81 | isReadonly: RO, 82 | ): InternalRecord { 83 | assertRuntype(...Object.values(fields)); 84 | const fieldNames: ReadonlySet = new Set(Object.keys(fields)); 85 | const runtype: InternalRecord = create>( 86 | 'object', 87 | { 88 | p: (x, innerValidate, _innerValidateToPlaceholder, _getFields, sealed) => { 89 | if (x === null || x === undefined || typeof x !== 'object') { 90 | return expected(runtype, x); 91 | } 92 | if (Array.isArray(x)) { 93 | return failure(`Expected ${show(runtype)}, but was an Array`); 94 | } 95 | 96 | return createValidationPlaceholder(Object.create(null), (placeholder: any) => { 97 | let fullError: FullError | undefined = undefined; 98 | let firstError: Failure | undefined; 99 | for (const key in fields) { 100 | if (!isPartial || (hasKey(key, x) && x[key] !== undefined)) { 101 | const value = isPartial || hasKey(key, x) ? x[key] : undefined; 102 | let validated = innerValidate( 103 | fields[key], 104 | value, 105 | sealed && sealed.deep ? { deep: true } : false, 106 | ); 107 | if (!validated.success) { 108 | if (!fullError) { 109 | fullError = unableToAssign(x, runtype); 110 | } 111 | fullError.push(typesAreNotCompatible(`"${key}"`, validated)); 112 | firstError = 113 | firstError || 114 | failure(validated.message, { 115 | key: validated.key ? `${key}.${validated.key}` : key, 116 | fullError: fullError, 117 | }); 118 | } else { 119 | placeholder[key] = validated.value; 120 | } 121 | } 122 | } 123 | if (!firstError && sealed) { 124 | for (const key of Object.keys(x)) { 125 | if (!fieldNames.has(key) && !sealed.keysFromIntersect?.has(key)) { 126 | const message = `Unexpected property: ${key}`; 127 | if (!fullError) { 128 | fullError = unableToAssign(x, runtype); 129 | } 130 | fullError.push([message]); 131 | firstError = 132 | firstError || 133 | failure(message, { 134 | key: key, 135 | fullError: fullError, 136 | }); 137 | } 138 | } 139 | } 140 | return firstError; 141 | }); 142 | }, 143 | f: () => fieldNames, 144 | }, 145 | { 146 | isPartial, 147 | isReadonly, 148 | fields, 149 | asPartial, 150 | asReadonly, 151 | pick, 152 | omit, 153 | show() { 154 | const keys = Object.keys(fields); 155 | return keys.length 156 | ? `{ ${keys 157 | .map( 158 | k => 159 | `${isReadonly ? 'readonly ' : ''}${k}${isPartial ? '?' : ''}: ${show( 160 | fields[k], 161 | false, 162 | )};`, 163 | ) 164 | .join(' ')} }` 165 | : '{}'; 166 | }, 167 | }, 168 | ); 169 | 170 | return runtype; 171 | 172 | function asPartial() { 173 | return InternalObject(runtype.fields, true, runtype.isReadonly); 174 | } 175 | 176 | function asReadonly(): any { 177 | return InternalObject(runtype.fields, runtype.isPartial, true); 178 | } 179 | 180 | function pick( 181 | ...keys: TKeys 182 | ): InternalRecord, Part, RO> { 183 | const newFields: Pick = {} as any; 184 | for (const key of keys) { 185 | newFields[key] = fields[key]; 186 | } 187 | return InternalObject(newFields, isPartial, isReadonly); 188 | } 189 | 190 | function omit( 191 | ...keys: TKeys 192 | ): InternalRecord, Part, RO> { 193 | const newFields: Omit = { ...fields } as any; 194 | for (const key of keys) { 195 | if (key in newFields) delete (newFields as any)[key]; 196 | } 197 | return InternalObject(newFields, isPartial, isReadonly); 198 | } 199 | } 200 | 201 | function Obj(fields: O): Obj { 202 | return InternalObject(fields, false, false); 203 | } 204 | export function ReadonlyObject(fields: O): Obj { 205 | return InternalObject(fields, false, true); 206 | } 207 | 208 | export function Partial(fields: O): Partial { 209 | return InternalObject(fields, true, false); 210 | } 211 | 212 | export function ReadonlyPartial(fields: O): Partial { 213 | return InternalObject(fields, true, true); 214 | } 215 | -------------------------------------------------------------------------------- /src/types/ParsedValue.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ta from 'type-assertions'; 2 | import { 3 | Array, 4 | String, 5 | Number, 6 | ParsedValue, 7 | Static, 8 | Literal, 9 | Object, 10 | Union, 11 | Tuple, 12 | Codec, 13 | Sealed, 14 | showError, 15 | } from '..'; 16 | import show from '../show'; 17 | import { InstanceOf } from './instanceof'; 18 | import { Lazy } from './lazy'; 19 | import { Null } from './literal'; 20 | 21 | test('TrimmedString', () => { 22 | const TrimmedString = ParsedValue(String, { 23 | name: 'TrimmedString', 24 | parse(value) { 25 | return { success: true, value: value.trim() }; 26 | }, 27 | test: String.withConstraint( 28 | value => 29 | value.trim() === value || `Expected the string to be trimmed, but this one has whitespace`, 30 | ), 31 | }); 32 | 33 | expect(TrimmedString.safeParse(' foo bar ')).toMatchInlineSnapshot(` 34 | Object { 35 | "success": true, 36 | "value": "foo bar", 37 | } 38 | `); 39 | expect(TrimmedString.safeParse(42)).toMatchInlineSnapshot(` 40 | Object { 41 | "message": "Expected string, but was 42", 42 | "success": false, 43 | } 44 | `); 45 | 46 | expect(() => TrimmedString.assert(' foo bar ')).toThrowErrorMatchingInlineSnapshot(` 47 | "Unable to assign \\" foo bar \\" to WithConstraint 48 | Expected the string to be trimmed, but this one has whitespace" 49 | `); 50 | expect(() => TrimmedString.assert('foo bar')).not.toThrow(); 51 | }); 52 | 53 | test('DoubledNumber', () => { 54 | const DoubledNumber = ParsedValue(Number, { 55 | name: 'DoubledNumber', 56 | parse(value) { 57 | return { success: true, value: value * 2 }; 58 | }, 59 | test: Number.withConstraint(value => value % 2 === 0 || `Expected an even number`), 60 | }); 61 | 62 | expect(DoubledNumber.safeParse(10)).toMatchInlineSnapshot(` 63 | Object { 64 | "success": true, 65 | "value": 20, 66 | } 67 | `); 68 | 69 | expect(() => DoubledNumber.assert(11)).toThrowErrorMatchingInlineSnapshot(` 70 | "Unable to assign 11 to WithConstraint 71 | Expected an even number" 72 | `); 73 | expect(() => DoubledNumber.assert(12)).not.toThrow(); 74 | 75 | expect(DoubledNumber.safeSerialize(10)).toMatchInlineSnapshot(` 76 | Object { 77 | "message": "DoubledNumber does not support Runtype.serialize", 78 | "success": false, 79 | } 80 | `); 81 | }); 82 | 83 | test('DoubledNumber - 2', () => { 84 | const DoubledNumber = Number.withParser({ 85 | name: 'DoubledNumber', 86 | parse(value) { 87 | return { success: true, value: value * 2 }; 88 | }, 89 | test: Number.withConstraint(value => value % 2 === 0 || `Expected an even number`), 90 | serialize(value) { 91 | return { success: true, value: value / 2 }; 92 | }, 93 | }); 94 | 95 | expect(DoubledNumber.safeParse(10)).toMatchInlineSnapshot(` 96 | Object { 97 | "success": true, 98 | "value": 20, 99 | } 100 | `); 101 | 102 | expect(() => DoubledNumber.assert(11)).toThrowErrorMatchingInlineSnapshot(` 103 | "Unable to assign 11 to WithConstraint 104 | Expected an even number" 105 | `); 106 | expect(() => DoubledNumber.assert(12)).not.toThrow(); 107 | 108 | expect(DoubledNumber.safeSerialize(10)).toMatchInlineSnapshot(` 109 | Object { 110 | "success": true, 111 | "value": 5, 112 | } 113 | `); 114 | 115 | expect(DoubledNumber.safeSerialize(11)).toMatchInlineSnapshot(` 116 | Object { 117 | "fullError": Array [ 118 | "Unable to assign 11 to WithConstraint", 119 | Array [ 120 | "Expected an even number", 121 | ], 122 | ], 123 | "message": "Expected an even number", 124 | "success": false, 125 | } 126 | `); 127 | }); 128 | 129 | test('Upgrade Example', () => { 130 | const ShapeV1 = Object({ version: Literal(1), size: Number }); 131 | const ShapeV2 = Object({ version: Literal(2), width: Number, height: Number }); 132 | const Shape = Union( 133 | ShapeV1.withParser({ 134 | parse: ({ size }) => ({ 135 | success: true, 136 | value: { version: 2 as const, width: size, height: size }, 137 | }), 138 | }), 139 | ShapeV2, 140 | ); 141 | type X = Static; 142 | ta.assert>(); 143 | expect(Shape.parse({ version: 1, size: 42 })).toEqual({ version: 2, width: 42, height: 42 }); 144 | expect(Shape.parse({ version: 2, width: 10, height: 20 })).toEqual({ 145 | version: 2, 146 | width: 10, 147 | height: 20, 148 | }); 149 | expect(Shape.safeSerialize({ version: 2, width: 10, height: 20 })).toMatchInlineSnapshot(` 150 | Object { 151 | "success": true, 152 | "value": Object { 153 | "height": 20, 154 | "version": 2, 155 | "width": 10, 156 | }, 157 | } 158 | `); 159 | expect(Shape.safeSerialize({ version: 1, size: 20 } as any)).toMatchInlineSnapshot(` 160 | Object { 161 | "fullError": Array [ 162 | "Unable to assign {version: 1, size: 20} to { version: 2; width: number; height: number; }", 163 | Array [ 164 | "The types of \\"version\\" are not compatible", 165 | Array [ 166 | "Expected literal 2, but was 1", 167 | ], 168 | ], 169 | Array [ 170 | "The types of \\"width\\" are not compatible", 171 | Array [ 172 | "Expected number, but was undefined", 173 | ], 174 | ], 175 | Array [ 176 | "The types of \\"height\\" are not compatible", 177 | Array [ 178 | "Expected number, but was undefined", 179 | ], 180 | ], 181 | ], 182 | "key": "version", 183 | "message": "Expected literal 2, but was 1", 184 | "success": false, 185 | } 186 | `); 187 | 188 | expect(Shape.serialize({ version: 2, width: 10, height: 20 })).toMatchInlineSnapshot(` 189 | Object { 190 | "height": 20, 191 | "version": 2, 192 | "width": 10, 193 | } 194 | `); 195 | expect(() => Shape.serialize({ version: 1, size: 20 } as any)) 196 | .toThrowErrorMatchingInlineSnapshot(` 197 | "Unable to assign {version: 1, size: 20} to { version: 2; width: number; height: number; } 198 | The types of \\"version\\" are not compatible 199 | Expected literal 2, but was 1 200 | The types of \\"width\\" are not compatible 201 | Expected number, but was undefined 202 | The types of \\"height\\" are not compatible 203 | Expected number, but was undefined" 204 | `); 205 | }); 206 | 207 | test('URL', () => { 208 | const URLString = ParsedValue(String, { 209 | name: 'URLString', 210 | parse(value) { 211 | try { 212 | return { success: true, value: new URL(value) }; 213 | } catch (ex) { 214 | return { success: false, message: `Expected a valid URL but got '${value}'` }; 215 | } 216 | }, 217 | test: InstanceOf(URL), 218 | }); 219 | 220 | const value: URL = URLString.parse('https://example.com'); 221 | expect(value).toBeInstanceOf(URL); 222 | expect(value).toMatchInlineSnapshot(`"https://example.com/"`); 223 | expect(URLString.safeParse('https://example.com')).toMatchInlineSnapshot(` 224 | Object { 225 | "success": true, 226 | "value": "https://example.com/", 227 | } 228 | `); 229 | expect(URLString.safeParse(42)).toMatchInlineSnapshot(` 230 | Object { 231 | "message": "Expected string, but was 42", 232 | "success": false, 233 | } 234 | `); 235 | expect(URLString.safeParse('not a url')).toMatchInlineSnapshot(` 236 | Object { 237 | "message": "Expected a valid URL but got 'not a url'", 238 | "success": false, 239 | } 240 | `); 241 | }); 242 | 243 | test('test is optional', () => { 244 | const TrimmedString = ParsedValue(String, { 245 | name: 'TrimmedString', 246 | parse(value) { 247 | return { success: true, value: value.trim() }; 248 | }, 249 | serialize(value) { 250 | // we're trusting the backend here, because there is no test! 251 | return { success: true, value }; 252 | }, 253 | }); 254 | expect(() => TrimmedString.assert('foo bar')).toThrowErrorMatchingInlineSnapshot( 255 | `"TrimmedString does not support Runtype.test"`, 256 | ); 257 | expect(TrimmedString.safeSerialize(' value ')).toMatchInlineSnapshot(` 258 | Object { 259 | "success": true, 260 | "value": " value ", 261 | } 262 | `); 263 | // even though we're not testing before serialize, the value is still 264 | // validated after it has been serialized 265 | expect(TrimmedString.safeSerialize(42 as any)).toMatchInlineSnapshot(` 266 | Object { 267 | "message": "Expected string, but was 42", 268 | "success": false, 269 | } 270 | `); 271 | expect(show(TrimmedString)).toMatchInlineSnapshot(`"TrimmedString"`); 272 | const AnonymousStringTrim = ParsedValue(String, { 273 | parse(value) { 274 | return { success: true, value: value.trim() }; 275 | }, 276 | }); 277 | expect(() => AnonymousStringTrim.assert('foo bar')).toThrowErrorMatchingInlineSnapshot( 278 | `"ParsedValue does not support Runtype.test"`, 279 | ); 280 | expect(show(AnonymousStringTrim)).toMatchInlineSnapshot(`"ParsedValue"`); 281 | }); 282 | 283 | test('serialize can return an error', () => { 284 | const URLString = ParsedValue(String, { 285 | name: 'URLString', 286 | parse(value) { 287 | try { 288 | return { success: true, value: new URL(value) }; 289 | } catch (ex) { 290 | return { success: false, message: `Expected a valid URL but got '${value}'` }; 291 | } 292 | }, 293 | test: InstanceOf(URL), 294 | serialize(value) { 295 | if (value.protocol === 'https:') return { success: true, value: value.href }; 296 | else return { success: false, message: `Refusing to serialize insecure URL: ${value.href}` }; 297 | }, 298 | }); 299 | 300 | expect(URLString.safeSerialize(new URL('https://example.com'))).toMatchInlineSnapshot(` 301 | Object { 302 | "success": true, 303 | "value": "https://example.com/", 304 | } 305 | `); 306 | expect(URLString.safeSerialize(new URL('http://example.com'))).toMatchInlineSnapshot(` 307 | Object { 308 | "message": "Refusing to serialize insecure URL: http://example.com/", 309 | "success": false, 310 | } 311 | `); 312 | }); 313 | 314 | test('serialize returns an error if not implemented', () => { 315 | const URLString = ParsedValue(String, { 316 | parse(value) { 317 | try { 318 | return { success: true, value: new URL(value) }; 319 | } catch (ex) { 320 | return { success: false, message: `Expected a valid URL but got '${value}'` }; 321 | } 322 | }, 323 | }); 324 | 325 | expect(URLString.safeSerialize(new URL('https://example.com'))).toMatchInlineSnapshot(` 326 | Object { 327 | "message": "ParsedValue does not support Runtype.serialize", 328 | "success": false, 329 | } 330 | `); 331 | }); 332 | 333 | test('Handle Being Within Cycles', () => { 334 | const TrimmedString = ParsedValue(String, { 335 | name: 'TrimmedString', 336 | parse(value) { 337 | return { success: true, value: value.trim() }; 338 | }, 339 | test: String.withConstraint( 340 | value => 341 | value.trim() === value || `Expected the string to be trimmed, but this one has whitespace`, 342 | ), 343 | serialize(value) { 344 | return { success: true, value: ` ${value} ` }; 345 | }, 346 | }); 347 | type RecursiveType = [string, RecursiveType]; 348 | const RecursiveType: Codec = Lazy(() => Tuple(TrimmedString, RecursiveType)); 349 | 350 | const example = [' hello world ', undefined as any] as RecursiveType; 351 | example[1] = example; 352 | 353 | const expected = ['hello world', undefined as any] as RecursiveType; 354 | expected[1] = expected; 355 | 356 | const parsed = RecursiveType.parse(example); 357 | expect(parsed).toEqual(expected); 358 | 359 | const serialized = RecursiveType.serialize(parsed); 360 | expect(serialized).toEqual(example); 361 | 362 | expect(() => RecursiveType.assert(parsed)).not.toThrow(); 363 | expect(() => RecursiveType.assert(serialized)).toThrowErrorMatchingInlineSnapshot(` 364 | "Unable to assign [\\" hello world \\", [\\" hello world \\" ... ]] to [TrimmedString, CIRCULAR tuple] 365 | The types of [0] are not compatible 366 | Unable to assign \\" hello world \\" to WithConstraint 367 | Expected the string to be trimmed, but this one has whitespace" 368 | `); 369 | }); 370 | 371 | test('Handle Being Outside Cycles', () => { 372 | type RecursiveTypePreParse = (string | RecursiveTypePreParse)[]; 373 | type RecursiveType = RecursiveType[]; 374 | const RecursiveTypeWithoutParse: Codec = Lazy(() => 375 | Array(RecursiveTypeWithoutParse), 376 | ); 377 | const RecursiveType: Codec = Lazy(() => 378 | Array(Union(String, RecursiveType)).withParser({ 379 | parse(arr) { 380 | return { 381 | success: true, 382 | value: arr.filter( 383 | (value: T): value is Exclude => typeof value !== 'string', 384 | ), 385 | }; 386 | }, 387 | serialize(arr: RecursiveType) { 388 | return { success: true, value: ['hello world', ...arr] }; 389 | }, 390 | test: RecursiveTypeWithoutParse, 391 | }), 392 | ); 393 | 394 | const example: RecursiveTypePreParse = ['hello world']; 395 | example.push(example); 396 | 397 | const expected: RecursiveType = []; 398 | expected.push(expected); 399 | 400 | const parsed = RecursiveType.parse(example); 401 | expect(parsed).toEqual(expected); 402 | 403 | const serialized = RecursiveType.serialize(parsed); 404 | expect(serialized).toEqual(example); 405 | 406 | expect(() => RecursiveType.assert(parsed)).not.toThrow(); 407 | expect(() => RecursiveType.assert(serialized)).toThrowErrorMatchingInlineSnapshot(` 408 | "Unable to assign [\\"hello world\\", [\\"hello world\\" ... ]] to (CIRCULAR array)[] 409 | The types of [0] are not compatible 410 | Expected an Array, but was \\"hello world\\"" 411 | `); 412 | }); 413 | 414 | test('Handle Being Outside Cycles - objects', () => { 415 | type RecursiveTypePreParse = { value: string | null; child: RecursiveTypePreParse }; 416 | type RecursiveType = { child: RecursiveType }; 417 | const RecursiveTypeWithoutParse: Codec = Lazy(() => 418 | Object({ child: RecursiveTypeWithoutParse }), 419 | ); 420 | const RecursiveType: Codec = Lazy(() => 421 | Object({ value: Union(String, Null), child: RecursiveType }).withParser({ 422 | parse({ value, ...rest }) { 423 | return { 424 | success: true, 425 | value: rest, 426 | }; 427 | }, 428 | serialize(obj: RecursiveType) { 429 | return { 430 | success: true, 431 | value: { 432 | value: 'hello world', 433 | child: obj.child, 434 | }, 435 | }; 436 | }, 437 | test: RecursiveTypeWithoutParse, 438 | }), 439 | ); 440 | 441 | const example: RecursiveTypePreParse = { value: 'hello world', child: null as any }; 442 | example.child = example; 443 | 444 | const expected: RecursiveType = { child: null as any }; 445 | expected.child = expected; 446 | 447 | const parsed = RecursiveType.parse(example); 448 | expect(parsed).toEqual(expected); 449 | 450 | const serialized = RecursiveType.serialize(parsed); 451 | expect(serialized).toEqual(example); 452 | 453 | expect(() => RecursiveType.assert(parsed)).not.toThrow(); 454 | }); 455 | 456 | test('Fails when cycles modify types', () => { 457 | type RecursiveTypePreParse = RecursiveTypePreParse[]; 458 | type RecursiveType = { values: RecursiveType[] }; 459 | const RecursiveTypeWithoutParse: Codec = Lazy(() => 460 | Object({ values: Array(RecursiveTypeWithoutParse) }), 461 | ); 462 | const RecursiveType: Codec = Lazy( 463 | () => 464 | Array(RecursiveType).withParser({ 465 | name: 'Parser', 466 | parse(arr) { 467 | return { 468 | success: true, 469 | value: { values: arr }, 470 | }; 471 | }, 472 | serialize(obj) { 473 | return { success: true, value: obj.values }; 474 | }, 475 | test: RecursiveTypeWithoutParse, 476 | }), 477 | // TODO: the type for serialize doesn't quite line up here 478 | ) as any; 479 | 480 | const example: RecursiveTypePreParse = []; 481 | example.push(example); 482 | 483 | const expected: RecursiveType = { values: [] }; 484 | expected.values.push(expected); 485 | 486 | // parse doesn't work because the recursive passing in `Array(RecursiveType)` "locks in" a type of "Array" 487 | // and we later change it to object 488 | expect(RecursiveType.safeParse(example)).toMatchInlineSnapshot(` 489 | Object { 490 | "message": "Cannot convert a value of type \\"Array\\" into a value of type \\"object\\" when it contains cycles.", 491 | "success": false, 492 | } 493 | `); 494 | 495 | // we can still use this recursive type to parse arbitrarily nested data 496 | expect(RecursiveType.safeParse([[], [[]]])).toMatchInlineSnapshot(` 497 | Object { 498 | "success": true, 499 | "value": Object { 500 | "values": Array [ 501 | Object { 502 | "values": Array [], 503 | }, 504 | Object { 505 | "values": Array [ 506 | Object { 507 | "values": Array [], 508 | }, 509 | ], 510 | }, 511 | ], 512 | }, 513 | } 514 | `); 515 | 516 | // the type conversion in serialize happens before the data type has locked in 517 | // so we can handle cyclic data structures, although it's probably not a great 518 | // idea 519 | expect(RecursiveType.safeSerialize(expected)).toMatchInlineSnapshot(` 520 | Object { 521 | "success": true, 522 | "value": Array [ 523 | [Circular], 524 | ], 525 | } 526 | `); 527 | }); 528 | 529 | test('Handles partial tests on parse', () => { 530 | // Result type doesn't support `undefined` when parsing 531 | // but only because we haven't implemented that test 532 | const ResultType = Sealed( 533 | Union( 534 | Object({ hello: Literal('world') }), 535 | ParsedValue(Object({}), { 536 | parse(_value) { 537 | return { success: true, value: undefined }; 538 | }, 539 | }), 540 | ), 541 | { deep: true }, 542 | ); 543 | const JsonType = ParsedValue(String, { 544 | test: ResultType, 545 | parse(value) { 546 | try { 547 | return ResultType.safeParse(JSON.parse(value)); 548 | } catch (ex) { 549 | return { 550 | success: false, 551 | message: `Expected a JSON string but got ${JSON.stringify(value)}`, 552 | }; 553 | } 554 | }, 555 | serialize(value) { 556 | const r = ResultType.safeSerialize(value); 557 | return r.success ? { success: true, value: JSON.stringify(r.value) } : r; 558 | }, 559 | }); 560 | // The test supports { hello 'world' } as one of the values in the union 561 | expect(() => ResultType.assert({ hello: 'world' })).not.toThrow(); 562 | expect(() => JsonType.assert({ hello: 'world' })).not.toThrow(); 563 | 564 | // although undefined can be returned from the "parse", it is not supported by the test, 565 | // but this is only because it is not implemented 566 | expect(() => ResultType.assert(undefined)).toThrowErrorMatchingInlineSnapshot(` 567 | "Unable to assign undefined to { hello: \\"world\\"; } | ParsedValue<{}> 568 | Unable to assign undefined to { hello: \\"world\\"; } 569 | Expected { hello: \\"world\\"; }, but was undefined 570 | And unable to assign undefined to ParsedValue<{}> 571 | ParsedValue<{}> does not support Runtype.test" 572 | `); 573 | expect(() => JsonType.assert(undefined)).toThrowErrorMatchingInlineSnapshot(` 574 | "Unable to assign undefined to { hello: \\"world\\"; } | ParsedValue<{}> 575 | Unable to assign undefined to { hello: \\"world\\"; } 576 | Expected { hello: \\"world\\"; }, but was undefined 577 | And unable to assign undefined to ParsedValue<{}> 578 | ParsedValue<{}> does not support Runtype.test" 579 | `); 580 | 581 | // We used Sealed, so extra properties are not allowed 582 | expect(() => JsonType.assert({ hello: 'world', whatever: true })) 583 | .toThrowErrorMatchingInlineSnapshot(` 584 | "Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } | ParsedValue<{}> 585 | Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } 586 | Unexpected property: whatever 587 | And unable to assign {hello: \\"world\\", whatever: true} to ParsedValue<{}> 588 | ParsedValue<{}> does not support Runtype.test" 589 | `); 590 | 591 | // The basic parsing works 592 | expect(JsonType.safeParse(`{"hello": "world"}`)).toEqual({ 593 | success: true, 594 | value: { hello: 'world' }, 595 | }); 596 | // Parsing also works even if the test would fail because it is not 597 | // implemented 598 | expect(JsonType.safeParse(`{}`)).toEqual({ 599 | success: true, 600 | value: undefined, 601 | }); 602 | 603 | // We used Sealed, so extra properties are not allowed 604 | expect(showError(JsonType.safeParse(`{"hello": "world", "whatever": true}`) as any)) 605 | .toMatchInlineSnapshot(` 606 | "Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } | ParsedValue<{}> 607 | Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } 608 | Unexpected property: whatever 609 | And unable to assign {hello: \\"world\\", whatever: true} to {} 610 | Unexpected property: hello 611 | Unexpected property: whatever" 612 | `); 613 | 614 | // We can serialize the normal object 615 | expect(JsonType.safeSerialize({ hello: 'world' })).toEqual({ 616 | success: true, 617 | value: `{"hello":"world"}`, 618 | }); 619 | // We cannot serialize undefined because we didn't implement serialize for that value in the union 620 | expect(JsonType.safeSerialize(undefined)).toEqual({ 621 | success: false, 622 | message: 'Expected { hello: "world"; }, but was undefined', 623 | }); 624 | // We used Sealed, so extra properties are not allowed 625 | expect(showError(JsonType.safeSerialize({ hello: 'world', whatever: true } as any) as any)) 626 | .toMatchInlineSnapshot(` 627 | "Unable to assign {hello: \\"world\\", whatever: true} to { hello: \\"world\\"; } 628 | Unexpected property: whatever" 629 | `); 630 | 631 | // We still apply normal tests post-parse, so you can still use the `test` to add 632 | // extra constraints 633 | const evenString = ParsedValue(String, { 634 | test: Number.withConstraint( 635 | value => (value % 2 === 0 ? true : `Expected an even number but got ${value}`), 636 | { name: `EvenNumber` }, 637 | ), 638 | parse(value) { 639 | if (!/^\d+$/.test(value)) { 640 | return { 641 | success: false, 642 | message: `Expected an even integer but got ${JSON.stringify(value)}`, 643 | }; 644 | } 645 | return { success: true, value: parseInt(value, 10) }; 646 | }, 647 | name: `EvenString`, 648 | }); 649 | expect(evenString.safeParse('10')).toEqual({ 650 | success: true, 651 | value: 10, 652 | }); 653 | expect(showError(evenString.safeParse('9') as any)).toMatchInlineSnapshot(` 654 | "Unable to assign 9 to EvenNumber 655 | Expected an even number but got 9" 656 | `); 657 | }); 658 | -------------------------------------------------------------------------------- /src/types/ParsedValue.ts: -------------------------------------------------------------------------------- 1 | import { failure, Result } from '../result'; 2 | import { 3 | RuntypeBase, 4 | Static, 5 | create, 6 | Codec, 7 | mapValidationPlaceholder, 8 | assertRuntype, 9 | innerGuard, 10 | createGuardVisitedState, 11 | } from '../runtype'; 12 | import show from '../show'; 13 | import { Never } from './never'; 14 | 15 | export interface ParsedValue, TParsed> 16 | extends Codec { 17 | readonly tag: 'parsed'; 18 | readonly underlying: TUnderlying; 19 | readonly config: ParsedValueConfig; 20 | } 21 | 22 | export interface ParsedValueConfig, TParsed> { 23 | name?: string; 24 | parse: (value: Static) => Result; 25 | serialize?: (value: TParsed) => Result>; 26 | test?: RuntypeBase; 27 | } 28 | export function ParsedValue, TParsed>( 29 | underlying: TUnderlying, 30 | config: ParsedValueConfig, 31 | ): ParsedValue { 32 | assertRuntype(underlying); 33 | return create>( 34 | 'parsed', 35 | { 36 | p: (value, _innerValidate, innerValidateToPlaceholder) => { 37 | return mapValidationPlaceholder( 38 | innerValidateToPlaceholder(underlying, value), 39 | source => config.parse(source), 40 | config.test, 41 | ); 42 | }, 43 | t(value, internalTest, _sealed, isOptionalTest) { 44 | return config.test 45 | ? internalTest(config.test, value) 46 | : isOptionalTest 47 | ? undefined 48 | : failure( 49 | `${config.name || `ParsedValue<${show(underlying)}>`} does not support Runtype.test`, 50 | ); 51 | }, 52 | s(value, _internalSerialize, internalSerializeToPlaceholder, _getFields, sealed) { 53 | if (!config.serialize) { 54 | return failure( 55 | `${ 56 | config.name || `ParsedValue<${show(underlying)}>` 57 | } does not support Runtype.serialize`, 58 | ); 59 | } 60 | const testResult = config.test 61 | ? innerGuard(config.test, value, createGuardVisitedState(), sealed, true) 62 | : undefined; 63 | 64 | if (testResult) { 65 | return testResult; 66 | } 67 | 68 | const serialized = config.serialize(value); 69 | 70 | if (!serialized.success) { 71 | return serialized; 72 | } 73 | 74 | return internalSerializeToPlaceholder(underlying, serialized.value, false); 75 | }, 76 | u(mode) { 77 | switch (mode) { 78 | case 'p': 79 | return underlying; 80 | case 't': 81 | return config.test; 82 | case 's': 83 | return config.serialize ? config.test : Never; 84 | } 85 | }, 86 | }, 87 | { 88 | underlying, 89 | config, 90 | 91 | show() { 92 | return config.name || `ParsedValue<${show(underlying, false)}>`; 93 | }, 94 | }, 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/types/Readonly.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ta from 'type-assertions'; 2 | import * as ft from '..'; 3 | 4 | test('Readonly(Record)', () => { 5 | const dictionary = ft.Record(ft.String, ft.Number); 6 | ta.assert, { [key in string]?: number }>>(); 7 | const rDictionary = ft.Readonly(dictionary); 8 | ta.assert< 9 | ta.Equal, { readonly [key in string]?: number }> 10 | >(); 11 | expect(rDictionary.safeParse({ foo: 1, bar: 2 })).toMatchInlineSnapshot(` 12 | Object { 13 | "success": true, 14 | "value": Object { 15 | "bar": 2, 16 | "foo": 1, 17 | }, 18 | } 19 | `); 20 | }); 21 | 22 | test('Readonly(Object)', () => { 23 | const obj = ft.Object({ whatever: ft.Number }); 24 | expect(obj.isReadonly).toBe(false); 25 | ta.assert, { whatever: number }>>(); 26 | const rObj = ft.Readonly(obj); 27 | expect(rObj.isReadonly).toBe(true); 28 | ta.assert, { readonly whatever: number }>>(); 29 | expect(rObj.safeParse({ whatever: 2 })).toMatchInlineSnapshot(` 30 | Object { 31 | "success": true, 32 | "value": Object { 33 | "whatever": 2, 34 | }, 35 | } 36 | `); 37 | expect(obj.asPartial().isReadonly).toBe(false); 38 | expect(rObj.asPartial().isReadonly).toBe(true); 39 | }); 40 | 41 | test('Readonly(Tuple)', () => { 42 | const tuple = ft.Tuple(ft.Number, ft.String); 43 | ta.assert, [number, string]>>(); 44 | const rTuple = ft.Readonly(tuple); 45 | ta.assert, readonly [number, string]>>(); 46 | expect(rTuple.safeParse([10, `world`])).toMatchInlineSnapshot(` 47 | Object { 48 | "success": true, 49 | "value": Array [ 50 | 10, 51 | "world", 52 | ], 53 | } 54 | `); 55 | }); 56 | 57 | test('Readonly(Array)', () => { 58 | const array = ft.Array(ft.Number); 59 | ta.assert, number[]>>(); 60 | const rArray = ft.Readonly(array); 61 | ta.assert, readonly number[]>>(); 62 | expect(rArray.safeParse([10, 3])).toMatchInlineSnapshot(` 63 | Object { 64 | "success": true, 65 | "value": Array [ 66 | 10, 67 | 3, 68 | ], 69 | } 70 | `); 71 | }); 72 | -------------------------------------------------------------------------------- /src/types/Readonly.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase } from '../runtype'; 2 | import { Array as Arr, ReadonlyArray } from './array'; 3 | import { InternalRecord, RecordFields } from './Object'; 4 | import { Record, KeyRuntypeBase, ReadonlyRecord } from './Record'; 5 | import { Tuple, ReadonlyTuple } from './tuple'; 6 | 7 | export type Readonly = T extends InternalRecord< 8 | infer TFields, 9 | infer TPartial, 10 | false 11 | > 12 | ? InternalRecord 13 | : T extends Arr 14 | ? ReadonlyArray 15 | : T extends Tuple 16 | ? ReadonlyTuple 17 | : T extends Record 18 | ? ReadonlyRecord 19 | : unknown; 20 | 21 | export function Readonly( 22 | input: InternalRecord, 23 | ): InternalRecord; 24 | export function Readonly( 25 | input: Arr, 26 | ): ReadonlyArray; 27 | export function Readonly< 28 | TElements extends readonly RuntypeBase[] = readonly RuntypeBase[], 29 | >(input: Tuple): ReadonlyTuple; 30 | export function Readonly>( 31 | record: Record, 32 | ): ReadonlyRecord; 33 | export function Readonly(input: any): any { 34 | const result = { ...input }; 35 | result.isReadonly = true; 36 | for (const m of [`asPartial`, `pick`, `omit`]) { 37 | if (typeof input[m] === 'function') { 38 | result[m] = (...args: any[]) => Readonly(input[m](...args)); 39 | } 40 | } 41 | return result; 42 | } 43 | -------------------------------------------------------------------------------- /src/types/Record.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ta from 'type-assertions'; 2 | import { Record, String, Number, Literal, Union, Object as ObjectType } from '..'; 3 | 4 | const recordType = ObjectType({ value: Literal(42) }); 5 | const record = { value: 42 }; 6 | 7 | test('StringRecord', () => { 8 | const dictionary = Record(String, recordType); 9 | ta.assert< 10 | ta.Equal, { [key in string]?: { value: 42 } }> 11 | >(); 12 | expect(dictionary.safeParse({ foo: record, bar: record })).toMatchInlineSnapshot(` 13 | Object { 14 | "success": true, 15 | "value": Object { 16 | "bar": Object { 17 | "value": 42, 18 | }, 19 | "foo": Object { 20 | "value": 42, 21 | }, 22 | }, 23 | } 24 | `); 25 | expect(dictionary.safeParse({ foo: record, bar: { value: 24 } })).toMatchInlineSnapshot(` 26 | Object { 27 | "fullError": Array [ 28 | "The types of bar are not compatible", 29 | Array [ 30 | "Unable to assign {value: 24} to { value: 42; }", 31 | Array [ 32 | "The types of \\"value\\" are not compatible", 33 | Array [ 34 | "Expected literal 42, but was 24", 35 | ], 36 | ], 37 | ], 38 | ], 39 | "key": "bar.value", 40 | "message": "Expected literal 42, but was 24", 41 | "success": false, 42 | } 43 | `); 44 | }); 45 | 46 | test('NumberRecord', () => { 47 | const dictionary = Record(Number, recordType); 48 | ta.assert< 49 | ta.Equal, { [key in number]?: { value: 42 } }> 50 | >(); 51 | expect(dictionary.safeParse({ 4: record, 3.14: record })).toMatchInlineSnapshot(` 52 | Object { 53 | "success": true, 54 | "value": Object { 55 | "3.14": Object { 56 | "value": 42, 57 | }, 58 | "4": Object { 59 | "value": 42, 60 | }, 61 | }, 62 | } 63 | `); 64 | expect(dictionary.safeParse({ foo: record, bar: record })).toMatchInlineSnapshot(` 65 | Object { 66 | "message": "Expected record key to be a number, but was \\"foo\\"", 67 | "success": false, 68 | } 69 | `); 70 | }); 71 | 72 | test('Using Object.create', () => { 73 | const dictionary = Record(String, recordType); 74 | ta.assert< 75 | ta.Equal, { [key in string]?: { value: 42 } }> 76 | >(); 77 | const record = Object.create(null); 78 | record.value = 42; 79 | const outer = Object.create(null); 80 | outer.foo = record; 81 | outer.bar = record; 82 | expect(dictionary.safeParse(outer)).toMatchInlineSnapshot(` 83 | Object { 84 | "success": true, 85 | "value": Object { 86 | "bar": Object { 87 | "value": 42, 88 | }, 89 | "foo": Object { 90 | "value": 42, 91 | }, 92 | }, 93 | } 94 | `); 95 | const outer2 = Object.create(null); 96 | outer2.foo = record; 97 | outer2.bar = { value: 24 }; 98 | expect(dictionary.safeParse(outer2)).toMatchInlineSnapshot(` 99 | Object { 100 | "fullError": Array [ 101 | "The types of bar are not compatible", 102 | Array [ 103 | "Unable to assign {value: 24} to { value: 42; }", 104 | Array [ 105 | "The types of \\"value\\" are not compatible", 106 | Array [ 107 | "Expected literal 42, but was 24", 108 | ], 109 | ], 110 | ], 111 | ], 112 | "key": "bar.value", 113 | "message": "Expected literal 42, but was 24", 114 | "success": false, 115 | } 116 | `); 117 | }); 118 | 119 | test('IntegerRecord', () => { 120 | const dictionary = Record( 121 | Number.withConstraint(v => v === Math.floor(v), { name: 'Integer' }), 122 | recordType, 123 | ); 124 | ta.assert< 125 | ta.Equal, { [key in number]?: { value: 42 } }> 126 | >(); 127 | expect(dictionary.safeParse({ 4: record, 2: record })).toMatchInlineSnapshot(` 128 | Object { 129 | "success": true, 130 | "value": Object { 131 | "2": Object { 132 | "value": 42, 133 | }, 134 | "4": Object { 135 | "value": 42, 136 | }, 137 | }, 138 | } 139 | `); 140 | expect(dictionary.safeParse({ 4: record, 3.14: record })).toMatchInlineSnapshot(` 141 | Object { 142 | "message": "Expected record key to be Integer, but was \\"3.14\\"", 143 | "success": false, 144 | } 145 | `); 146 | }); 147 | 148 | test('UnionRecord - strings', () => { 149 | const dictionary = Record(Union(Literal('foo'), Literal('bar')), recordType); 150 | ta.assert< 151 | ta.Equal, { [key in 'foo' | 'bar']?: { value: 42 } }> 152 | >(); 153 | expect(dictionary.safeParse({ foo: record, bar: record })).toMatchInlineSnapshot(` 154 | Object { 155 | "success": true, 156 | "value": Object { 157 | "bar": Object { 158 | "value": 42, 159 | }, 160 | "foo": Object { 161 | "value": 42, 162 | }, 163 | }, 164 | } 165 | `); 166 | expect(dictionary.safeParse({ 10: record })).toMatchInlineSnapshot(` 167 | Object { 168 | "message": "Expected record key to be \\"foo\\" | \\"bar\\", but was \\"10\\"", 169 | "success": false, 170 | } 171 | `); 172 | }); 173 | test('UnionRecord - numbers', () => { 174 | const dictionary = Record(Union(Literal(24), Literal(42)), recordType); 175 | ta.assert< 176 | ta.Equal, { [key in 24 | 42]?: { value: 42 } }> 177 | >(); 178 | expect(dictionary.safeParse({ 24: record, 42: record })).toMatchInlineSnapshot(` 179 | Object { 180 | "success": true, 181 | "value": Object { 182 | "24": Object { 183 | "value": 42, 184 | }, 185 | "42": Object { 186 | "value": 42, 187 | }, 188 | }, 189 | } 190 | `); 191 | expect(dictionary.safeParse({ 10: record })).toMatchInlineSnapshot(` 192 | Object { 193 | "message": "Expected record key to be 24 | 42, but was \\"10\\"", 194 | "success": false, 195 | } 196 | `); 197 | }); 198 | test('UnionRecord - mixed', () => { 199 | const dictionary = Record(Union(Literal('foo'), Literal(42)), recordType); 200 | ta.assert< 201 | ta.Equal, { [key in 'foo' | 42]?: { value: 42 } }> 202 | >(); 203 | expect(dictionary.safeParse({ foo: record, 42: record })).toMatchInlineSnapshot(` 204 | Object { 205 | "success": true, 206 | "value": Object { 207 | "42": Object { 208 | "value": 42, 209 | }, 210 | "foo": Object { 211 | "value": 42, 212 | }, 213 | }, 214 | } 215 | `); 216 | expect(dictionary.safeParse({ foo: record, bar: record })).toMatchInlineSnapshot(` 217 | Object { 218 | "message": "Expected record key to be \\"foo\\" | 42, but was \\"bar\\"", 219 | "success": false, 220 | } 221 | `); 222 | }); 223 | -------------------------------------------------------------------------------- /src/types/Record.ts: -------------------------------------------------------------------------------- 1 | import { 2 | create, 3 | Static, 4 | RuntypeBase, 5 | Codec, 6 | createValidationPlaceholder, 7 | assertRuntype, 8 | } from '../runtype'; 9 | import show from '../show'; 10 | import { String, Number } from './primative'; 11 | import { Literal } from './literal'; 12 | import { Constraint } from './constraint'; 13 | import { lazyValue } from './lazy'; 14 | import { Union } from './union'; 15 | import { expected, failure, Result, typesAreNotCompatible } from '../result'; 16 | 17 | export type KeyRuntypeBaseWithoutUnion = 18 | | Pick 19 | | Pick 20 | | Pick, 'value' | keyof RuntypeBase> 21 | | Pick, 'underlying' | keyof RuntypeBase>; 22 | 23 | export type KeyRuntypeBase = 24 | | KeyRuntypeBaseWithoutUnion 25 | | Pick, 'alternatives' | keyof RuntypeBase>; 26 | 27 | function getExpectedBaseType(key: KeyRuntypeBase): 'string' | 'number' | 'mixed' { 28 | switch (key.tag) { 29 | case 'string': 30 | return 'string'; 31 | case 'number': 32 | return 'number'; 33 | case 'literal': 34 | return typeof key.value as 'string' | 'number'; 35 | case 'union': 36 | const baseTypes = key.alternatives.map(getExpectedBaseType); 37 | return baseTypes.reduce((a, b) => (a === b ? a : 'mixed'), baseTypes[0]); 38 | case 'constraint': 39 | return getExpectedBaseType(key.underlying); 40 | } 41 | } 42 | 43 | export interface Record> 44 | extends Codec<{ [_ in Static]?: Static }> { 45 | readonly tag: 'record'; 46 | readonly key: K; 47 | readonly value: V; 48 | readonly isReadonly: false; 49 | } 50 | 51 | export interface ReadonlyRecord> 52 | extends Codec<{ readonly [_ in Static]?: Static }> { 53 | readonly tag: 'record'; 54 | readonly key: K; 55 | readonly value: V; 56 | readonly isReadonly: true; 57 | } 58 | 59 | /** 60 | * Construct a runtype for arbitrary dictionaries. 61 | */ 62 | export function Record>( 63 | key: K, 64 | value: V, 65 | ): Record { 66 | assertRuntype(key, value); 67 | const expectedBaseType = lazyValue(() => getExpectedBaseType(key)); 68 | const runtype: Record = create>( 69 | 'record', 70 | (x, innerValidate, _innerValidateToPlaceholder, _getFields, sealed) => { 71 | if (x === null || x === undefined || typeof x !== 'object') { 72 | return expected(runtype, x); 73 | } 74 | 75 | if (Object.getPrototypeOf(x) !== Object.prototype && Object.getPrototypeOf(x) !== null) { 76 | if (!Array.isArray(x)) { 77 | return failure(`Expected ${show(runtype)}, but was ${Object.getPrototypeOf(x)}`); 78 | } 79 | return failure('Expected Record, but was Array'); 80 | } 81 | 82 | return createValidationPlaceholder<{ [_ in Static]?: Static }>( 83 | Object.create(null), 84 | placeholder => { 85 | for (const k in x) { 86 | let keyValidation: Result | null = null; 87 | if (expectedBaseType() === 'number') { 88 | if (isNaN(+k)) return expected(`record key to be a number`, k); 89 | keyValidation = innerValidate(key, +k, false); 90 | } else if (expectedBaseType() === 'string') { 91 | keyValidation = innerValidate(key, k, false); 92 | } else { 93 | keyValidation = innerValidate(key, k, false); 94 | if (!keyValidation.success && !isNaN(+k)) { 95 | keyValidation = innerValidate(key, +k, false); 96 | } 97 | } 98 | if (!keyValidation.success) { 99 | return expected(`record key to be ${show(key)}`, k); 100 | } 101 | 102 | const validated = innerValidate( 103 | value, 104 | (x as any)[k], 105 | sealed && sealed.deep ? { deep: true } : false, 106 | ); 107 | if (!validated.success) { 108 | return failure(validated.message, { 109 | key: validated.key ? `${k}.${validated.key}` : k, 110 | fullError: typesAreNotCompatible(k, validated.fullError ?? [validated.message]), 111 | }); 112 | } 113 | (placeholder as any)[keyValidation.value] = validated.value; 114 | } 115 | }, 116 | ); 117 | }, 118 | { 119 | key, 120 | value, 121 | isReadonly: false, 122 | show() { 123 | return `{ [_: ${show(key, false)}]: ${show(value, false)} }`; 124 | }, 125 | }, 126 | ); 127 | return runtype; 128 | } 129 | 130 | export function ReadonlyRecord>( 131 | key: K, 132 | value: V, 133 | ): ReadonlyRecord { 134 | const record: any = Record(key, value); 135 | record.isReadonly = true; 136 | return record; 137 | } 138 | -------------------------------------------------------------------------------- /src/types/Sealed.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Array, 3 | String, 4 | Partial, 5 | ParsedValue, 6 | Object, 7 | Constraint, 8 | Codec, 9 | Intersect, 10 | Named, 11 | Lazy, 12 | Sealed, 13 | Undefined, 14 | Union, 15 | Number, 16 | showType, 17 | Tuple, 18 | Literal, 19 | Record, 20 | ReadonlyArray, 21 | ReadonlyTuple, 22 | ReadonlyRecord, 23 | ReadonlyObject, 24 | Brand, 25 | } from '..'; 26 | import { Unknown } from './unknown'; 27 | 28 | const SealableTypes: ( 29 | | Codec<{ hello: string; world: string | undefined }> 30 | | Codec<{ hello: string; world?: string }> 31 | )[] = [ 32 | Object({ hello: String, world: Union(String, Undefined) }), 33 | Intersect(Object({ hello: String }), Partial({ world: String })), 34 | ]; 35 | const BaseSealableTypes = SealableTypes.slice(); 36 | 37 | for (const t of BaseSealableTypes) { 38 | SealableTypes.push(Named(`HelloWorld`, t)); 39 | } 40 | 41 | for (const t of BaseSealableTypes) { 42 | SealableTypes.push(Constraint(t, () => true)); 43 | } 44 | 45 | for (const t of BaseSealableTypes) { 46 | SealableTypes.push(Lazy(() => t)); 47 | } 48 | 49 | for (const t of BaseSealableTypes) { 50 | SealableTypes.push(Brand(`x`, t) as any); 51 | } 52 | 53 | for (const t of BaseSealableTypes) { 54 | SealableTypes.push( 55 | ParsedValue(Intersect(Object({ hello: String }), Partial({ world: String, other: String })), { 56 | test: t, 57 | parse(v) { 58 | return { success: true, value: v }; 59 | }, 60 | serialize(v) { 61 | return { success: true, value: v }; 62 | }, 63 | }), 64 | ); 65 | } 66 | 67 | for (const t of SealableTypes) { 68 | test(`SealableTypes - ${showType(t)}`, () => { 69 | const s = Sealed(t); 70 | for (const obj of [{ hello: 'a', world: 'b' }, { hello: 'a' }]) { 71 | expect(s.safeParse(obj)).toEqual({ 72 | success: true, 73 | value: obj, 74 | }); 75 | expect(s.safeSerialize(obj)).toEqual({ 76 | success: true, 77 | value: obj, 78 | }); 79 | expect(s.test(obj)).toBe(true); 80 | } 81 | for (const obj of [ 82 | { hello: 'a', world: 'b', otherProperty: 'c' }, 83 | { hello: 'a', otherProperty: 'c' }, 84 | { hello: 'a', world: 'b', otherProperty: 'c', secondOtherProperty: 'd' }, 85 | ] as any[]) { 86 | expect(s.safeSerialize(obj).success).toBe(false); 87 | expect(s.safeParse(obj).success).toBe(false); 88 | expect(s.test(obj)).toBe(false); 89 | } 90 | }); 91 | } 92 | 93 | test(`Sealed - Union`, () => { 94 | const s = Sealed(Union(Object({ hello: String }), Object({ world: Number }))); 95 | expect(showType(s)).toMatchInlineSnapshot(`"Sealed<{ hello: string; } | { world: number; }>"`); 96 | expect(s.safeParse({ hello: 'a' })).toEqual({ 97 | success: true, 98 | value: { hello: 'a' }, 99 | }); 100 | expect(s.safeParse({ world: 42 })).toEqual({ 101 | success: true, 102 | value: { world: 42 }, 103 | }); 104 | expect(s.safeParse({ hello: 'a', world: 42 })).toMatchInlineSnapshot(` 105 | Object { 106 | "fullError": Array [ 107 | "Unable to assign {hello: \\"a\\", world: 42} to { hello: string; } | { world: number; }", 108 | Array [ 109 | "Unable to assign {hello: \\"a\\", world: 42} to { hello: string; }", 110 | Array [ 111 | "Unexpected property: world", 112 | ], 113 | ], 114 | Array [ 115 | "And unable to assign {hello: \\"a\\", world: 42} to { world: number; }", 116 | Array [ 117 | "Unexpected property: hello", 118 | ], 119 | ], 120 | ], 121 | "message": "Expected { hello: string; } | { world: number; }, but was {hello: \\"a\\", world: 42}", 122 | "success": false, 123 | } 124 | `); 125 | expect(() => s.assert({ hello: 'a', world: 42 })).toThrowErrorMatchingInlineSnapshot(` 126 | "Unable to assign {hello: \\"a\\", world: 42} to { hello: string; } | { world: number; } 127 | Unable to assign {hello: \\"a\\", world: 42} to { hello: string; } 128 | Unexpected property: world 129 | And unable to assign {hello: \\"a\\", world: 42} to { world: number; } 130 | Unexpected property: hello" 131 | `); 132 | }); 133 | 134 | test(`Sealed - Complex Union`, () => { 135 | const s = Sealed( 136 | Intersect( 137 | Object({ hello: String }), 138 | Union( 139 | Object({ kind: Literal('rectangle'), height: Number, width: Number }), 140 | Object({ kind: Literal('circle'), radius: Number }), 141 | Intersect(Object({ kind: Literal('squirkle') }), Unknown), 142 | ), 143 | Object({ world: String }), 144 | ), 145 | ); 146 | expect(s.safeParse({ hello: 'a', world: 'b', kind: 'circle', radius: 42 })).toEqual({ 147 | success: true, 148 | value: { hello: 'a', world: 'b', kind: 'circle', radius: 42 }, 149 | }); 150 | expect(s.safeParse({ hello: 'a', world: 'b', kind: 'circle', radius: 42, width: 5 })) 151 | .toMatchInlineSnapshot(` 152 | Object { 153 | "fullError": Array [ 154 | "Unable to assign {hello: \\"a\\", world: \\"b\\", kind: \\"circle\\" ... } to { kind: \\"rectangle\\"; height: number; width: number; } | { kind: \\"circle\\"; radius: number; } | ({ kind: \\"squirkle\\"; } & unknown)", 155 | Array [ 156 | "Unable to assign {hello: \\"a\\", world: \\"b\\", kind: \\"circle\\" ... } to { kind: \\"circle\\"; radius: number; }", 157 | Array [ 158 | "Unexpected property: width", 159 | ], 160 | ], 161 | ], 162 | "key": ".width", 163 | "message": "Unexpected property: width", 164 | "success": false, 165 | } 166 | `); 167 | expect(() => s.assert({ hello: 'a', world: 'b', kind: 'circle', radius: 42, width: 5 })) 168 | .toThrowErrorMatchingInlineSnapshot(` 169 | "Unable to assign {hello: \\"a\\", world: \\"b\\", kind: \\"circle\\" ... } to { kind: \\"rectangle\\"; height: number; width: number; } | { kind: \\"circle\\"; radius: number; } | ({ kind: \\"squirkle\\"; } & unknown) 170 | Unable to assign {hello: \\"a\\", world: \\"b\\", kind: \\"circle\\" ... } to { kind: \\"circle\\"; radius: number; } 171 | Unexpected property: width" 172 | `); 173 | 174 | expect(s.safeParse({ hello: 'a', world: 'b', kind: 'squirkle', leftish: 10, upish: 14 })).toEqual( 175 | { 176 | success: true, 177 | value: { hello: 'a', world: 'b', kind: 'squirkle', leftish: 10, upish: 14 }, 178 | }, 179 | ); 180 | }); 181 | 182 | test(`Sealed - Unbounded Union`, () => { 183 | const s = Sealed( 184 | Intersect( 185 | Union( 186 | Object({ kind: Literal('rectangle'), height: Number, width: Number }), 187 | Object({ kind: Literal('circle'), radius: Number }), 188 | ), 189 | Unknown, 190 | ), 191 | ); 192 | expect(s.safeParse({ kind: 'circle', radius: 42, whatever: 'hello world' })).toEqual({ 193 | success: true, 194 | value: { 195 | kind: 'circle', 196 | radius: 42, 197 | whatever: 'hello world', 198 | }, 199 | }); 200 | }); 201 | 202 | test(`Sealed - Intersected Unions`, () => { 203 | const s = Sealed( 204 | Intersect( 205 | Union( 206 | Object({ kind: Literal('rectangle'), height: Number, width: Number }), 207 | Object({ kind: Literal('circle'), radius: Number }), 208 | ), 209 | Union( 210 | Object({ dimension: Literal('2d') }), 211 | Object({ dimension: Literal('3d'), zIndex: Number }), 212 | ), 213 | ), 214 | ); 215 | 216 | // Valid inputs: 217 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '2d' })).toMatchInlineSnapshot(` 218 | Object { 219 | "success": true, 220 | "value": Object { 221 | "dimension": "2d", 222 | "kind": "circle", 223 | "radius": 42, 224 | }, 225 | } 226 | `); 227 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '3d', zIndex: 10 })) 228 | .toMatchInlineSnapshot(` 229 | Object { 230 | "success": true, 231 | "value": Object { 232 | "dimension": "3d", 233 | "kind": "circle", 234 | "radius": 42, 235 | "zIndex": 10, 236 | }, 237 | } 238 | `); 239 | 240 | // Invalid inputs: 241 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '2d', zIndex: 10 })) 242 | .toMatchInlineSnapshot(` 243 | Object { 244 | "fullError": Array [ 245 | "Unable to assign {kind: \\"circle\\", radius: 42, dimension: \\"2d\\" ... } to { dimension: \\"2d\\"; } | { dimension: \\"3d\\"; zIndex: number; }", 246 | Array [ 247 | "Unable to assign {kind: \\"circle\\", radius: 42, dimension: \\"2d\\" ... } to { dimension: \\"2d\\"; }", 248 | Array [ 249 | "Unexpected property: zIndex", 250 | ], 251 | ], 252 | ], 253 | "key": ".zIndex", 254 | "message": "Unexpected property: zIndex", 255 | "success": false, 256 | } 257 | `); 258 | expect(s.safeParse({ kind: 'rectangle', height: 42, width: 42, radius: 42, dimension: '2d' })) 259 | .toMatchInlineSnapshot(` 260 | Object { 261 | "fullError": Array [ 262 | "Unable to assign {kind: \\"rectangle\\", height: 42 ... } to { kind: \\"rectangle\\"; height: number; width: number; } | { kind: \\"circle\\"; radius: number; }", 263 | Array [ 264 | "Unable to assign {kind: \\"rectangle\\", height: 42 ... } to { kind: \\"rectangle\\"; height: number; width: number; }", 265 | Array [ 266 | "Unexpected property: radius", 267 | ], 268 | ], 269 | ], 270 | "key": ".radius", 271 | "message": "Unexpected property: radius", 272 | "success": false, 273 | } 274 | `); 275 | }); 276 | 277 | test(`Sealed - Intersected Unbounded Unions`, () => { 278 | const s = Sealed( 279 | Intersect( 280 | Union( 281 | Object({ kind: Literal('rectangle'), height: Number, width: Number }), 282 | Object({ kind: Literal('circle'), radius: Number }), 283 | ), 284 | Union( 285 | Object({ dimension: Literal('2d') }), 286 | Intersect(Object({ dimension: Literal('3d') }), Unknown), 287 | ), 288 | ), 289 | ); 290 | 291 | // Valid inputs: 292 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '2d' })).toMatchInlineSnapshot(` 293 | Object { 294 | "success": true, 295 | "value": Object { 296 | "dimension": "2d", 297 | "kind": "circle", 298 | "radius": 42, 299 | }, 300 | } 301 | `); 302 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '3d', zIndex: 10 })) 303 | .toMatchInlineSnapshot(` 304 | Object { 305 | "success": true, 306 | "value": Object { 307 | "dimension": "3d", 308 | "kind": "circle", 309 | "radius": 42, 310 | "zIndex": 10, 311 | }, 312 | } 313 | `); 314 | 315 | // Invalid inputs: 316 | expect(s.safeParse({ kind: 'circle', radius: 42, dimension: '2d', zIndex: 10 })) 317 | .toMatchInlineSnapshot(` 318 | Object { 319 | "fullError": Array [ 320 | "Unable to assign {kind: \\"circle\\", radius: 42, dimension: \\"2d\\" ... } to { dimension: \\"2d\\"; } | ({ dimension: \\"3d\\"; } & unknown)", 321 | Array [ 322 | "Unable to assign {kind: \\"circle\\", radius: 42, dimension: \\"2d\\" ... } to { dimension: \\"2d\\"; }", 323 | Array [ 324 | "Unexpected property: zIndex", 325 | ], 326 | ], 327 | ], 328 | "key": ".zIndex", 329 | "message": "Unexpected property: zIndex", 330 | "success": false, 331 | } 332 | `); 333 | 334 | // Extra valid input - we are not smart enough to detect that the 335 | // 2d option in one union means the other union could stop being unbounded: 336 | expect(s.safeParse({ kind: 'rectangle', height: 42, width: 42, radius: 42, dimension: '2d' })) 337 | .toMatchInlineSnapshot(` 338 | Object { 339 | "success": true, 340 | "value": Object { 341 | "dimension": "2d", 342 | "height": 42, 343 | "kind": "rectangle", 344 | "width": 42, 345 | }, 346 | } 347 | `); 348 | }); 349 | 350 | test(`Sealed - Lazy Cycle`, () => { 351 | interface T { 352 | children: ({ value: string } | T)[]; 353 | } 354 | const s: Codec = Sealed( 355 | Lazy(() => Object({ children: Array(Union(leaf, s)) })), 356 | { deep: true }, 357 | ); 358 | const leaf = Object({ value: String }); 359 | expect(s.safeParse({ children: [{ value: 'a' }, { value: 'b' }] })).toEqual({ 360 | success: true, 361 | value: { children: [{ value: 'a' }, { value: 'b' }] }, 362 | }); 363 | }); 364 | 365 | test(`Sealed - Deep`, () => { 366 | expect(Sealed(Array(Object({ a: String }))).safeParse([{ a: 'A', b: 'B' }])) 367 | .toMatchInlineSnapshot(` 368 | Object { 369 | "success": true, 370 | "value": Array [ 371 | Object { 372 | "a": "A", 373 | }, 374 | ], 375 | } 376 | `); 377 | expect(Sealed(Array(Object({ a: String })), { deep: true }).safeParse([{ a: 'A', b: 'B' }])) 378 | .toMatchInlineSnapshot(` 379 | Object { 380 | "fullError": Array [ 381 | "Unable to assign [{a: \\"A\\", b: \\"B\\"}] to { a: string; }[]", 382 | Array [ 383 | "The types of [0] are not compatible", 384 | Array [ 385 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 386 | Array [ 387 | "Unexpected property: b", 388 | ], 389 | ], 390 | ], 391 | ], 392 | "key": "[0].b", 393 | "message": "Unexpected property: b", 394 | "success": false, 395 | } 396 | `); 397 | expect( 398 | Sealed(ReadonlyArray(Object({ a: String })), { deep: true }).safeParse([{ a: 'A', b: 'B' }]), 399 | ).toMatchInlineSnapshot(` 400 | Object { 401 | "fullError": Array [ 402 | "Unable to assign [{a: \\"A\\", b: \\"B\\"}] to readonly { a: string; }[]", 403 | Array [ 404 | "The types of [0] are not compatible", 405 | Array [ 406 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 407 | Array [ 408 | "Unexpected property: b", 409 | ], 410 | ], 411 | ], 412 | ], 413 | "key": "[0].b", 414 | "message": "Unexpected property: b", 415 | "success": false, 416 | } 417 | `); 418 | expect(Sealed(Tuple(Object({ a: String }))).safeParse([{ a: 'A', b: 'B' }])) 419 | .toMatchInlineSnapshot(` 420 | Object { 421 | "success": true, 422 | "value": Array [ 423 | Object { 424 | "a": "A", 425 | }, 426 | ], 427 | } 428 | `); 429 | expect(Sealed(Tuple(Object({ a: String })), { deep: true }).safeParse([{ a: 'A', b: 'B' }])) 430 | .toMatchInlineSnapshot(` 431 | Object { 432 | "fullError": Array [ 433 | "Unable to assign [{a: \\"A\\", b: \\"B\\"}] to [{ a: string; }]", 434 | Array [ 435 | "The types of [0] are not compatible", 436 | Array [ 437 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 438 | Array [ 439 | "Unexpected property: b", 440 | ], 441 | ], 442 | ], 443 | ], 444 | "key": "[0].b", 445 | "message": "Unexpected property: b", 446 | "success": false, 447 | } 448 | `); 449 | expect( 450 | Sealed(ReadonlyTuple(Object({ a: String })), { deep: true }).safeParse([{ a: 'A', b: 'B' }]), 451 | ).toMatchInlineSnapshot(` 452 | Object { 453 | "fullError": Array [ 454 | "Unable to assign [{a: \\"A\\", b: \\"B\\"}] to readonly [{ a: string; }]", 455 | Array [ 456 | "The types of [0] are not compatible", 457 | Array [ 458 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 459 | Array [ 460 | "Unexpected property: b", 461 | ], 462 | ], 463 | ], 464 | ], 465 | "key": "[0].b", 466 | "message": "Unexpected property: b", 467 | "success": false, 468 | } 469 | `); 470 | 471 | expect(Sealed(Record(String, Object({ a: String }))).safeParse({ x: { a: 'A', b: 'B' } })) 472 | .toMatchInlineSnapshot(` 473 | Object { 474 | "success": true, 475 | "value": Object { 476 | "x": Object { 477 | "a": "A", 478 | }, 479 | }, 480 | } 481 | `); 482 | expect( 483 | Sealed(Record(String, Object({ a: String })), { deep: true }).safeParse({ 484 | x: { a: 'A', b: 'B' }, 485 | }), 486 | ).toMatchInlineSnapshot(` 487 | Object { 488 | "fullError": Array [ 489 | "The types of x are not compatible", 490 | Array [ 491 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 492 | Array [ 493 | "Unexpected property: b", 494 | ], 495 | ], 496 | ], 497 | "key": "x.b", 498 | "message": "Unexpected property: b", 499 | "success": false, 500 | } 501 | `); 502 | expect( 503 | Sealed(ReadonlyRecord(String, Object({ a: String })), { deep: true }).safeParse({ 504 | x: { a: 'A', b: 'B' }, 505 | }), 506 | ).toMatchInlineSnapshot(` 507 | Object { 508 | "fullError": Array [ 509 | "The types of x are not compatible", 510 | Array [ 511 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 512 | Array [ 513 | "Unexpected property: b", 514 | ], 515 | ], 516 | ], 517 | "key": "x.b", 518 | "message": "Unexpected property: b", 519 | "success": false, 520 | } 521 | `); 522 | 523 | expect(Sealed(Object({ x: Object({ a: String }) })).safeParse({ x: { a: 'A', b: 'B' } })) 524 | .toMatchInlineSnapshot(` 525 | Object { 526 | "success": true, 527 | "value": Object { 528 | "x": Object { 529 | "a": "A", 530 | }, 531 | }, 532 | } 533 | `); 534 | expect( 535 | Sealed(Object({ x: Object({ a: String }) }), { deep: true }).safeParse({ 536 | x: { a: 'A', b: 'B' }, 537 | }), 538 | ).toMatchInlineSnapshot(` 539 | Object { 540 | "fullError": Array [ 541 | "Unable to assign {x: {a: \\"A\\", b: \\"B\\"}} to { x: { a: string; }; }", 542 | Array [ 543 | "The types of \\"x\\" are not compatible", 544 | Array [ 545 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { a: string; }", 546 | Array [ 547 | "Unexpected property: b", 548 | ], 549 | ], 550 | ], 551 | ], 552 | "key": "x.b", 553 | "message": "Unexpected property: b", 554 | "success": false, 555 | } 556 | `); 557 | expect( 558 | Sealed(Object({ x: ReadonlyObject({ a: String }) }), { deep: true }).safeParse({ 559 | x: { a: 'A', b: 'B' }, 560 | }), 561 | ).toMatchInlineSnapshot(` 562 | Object { 563 | "fullError": Array [ 564 | "Unable to assign {x: {a: \\"A\\", b: \\"B\\"}} to { x: { readonly a: string; }; }", 565 | Array [ 566 | "The types of \\"x\\" are not compatible", 567 | Array [ 568 | "Unable to assign {a: \\"A\\", b: \\"B\\"} to { readonly a: string; }", 569 | Array [ 570 | "Unexpected property: b", 571 | ], 572 | ], 573 | ], 574 | ], 575 | "key": "x.b", 576 | "message": "Unexpected property: b", 577 | "success": false, 578 | } 579 | `); 580 | 581 | const deepParsed = Sealed( 582 | Array( 583 | Object({ 584 | a: String, 585 | b: ParsedValue(Object({ x: Number }), { 586 | parse({ x }) { 587 | return { success: true, value: { x, y: x } }; 588 | }, 589 | }), 590 | }), 591 | ), 592 | { 593 | deep: true, 594 | }, 595 | ); 596 | expect(deepParsed.safeParse([{ a: 'A', b: { x: 5 } }])).toMatchInlineSnapshot(` 597 | Object { 598 | "success": true, 599 | "value": Array [ 600 | Object { 601 | "a": "A", 602 | "b": Object { 603 | "x": 5, 604 | "y": 5, 605 | }, 606 | }, 607 | ], 608 | } 609 | `); 610 | expect(deepParsed.safeParse([{ a: 'A', b: { x: 5, y: 10 } }])).toMatchInlineSnapshot(` 611 | Object { 612 | "fullError": Array [ 613 | "Unable to assign [{a: \\"A\\", b: {x: 5, y: 10}}] to { a: string; b: ParsedValue<{ x: number; }>; }[]", 614 | Array [ 615 | "The types of [0] are not compatible", 616 | Array [ 617 | "Unable to assign {a: \\"A\\", b: {x: 5, y: 10}} to { a: string; b: ParsedValue<{ x: number; }>; }", 618 | Array [ 619 | "The types of \\"b\\" are not compatible", 620 | Array [ 621 | "Unable to assign {x: 5, y: 10} to { x: number; }", 622 | Array [ 623 | "Unexpected property: y", 624 | ], 625 | ], 626 | ], 627 | ], 628 | ], 629 | ], 630 | "key": "[0].b.y", 631 | "message": "Unexpected property: y", 632 | "success": false, 633 | } 634 | `); 635 | expect(deepParsed.safeParse([{ a: 'A', b: { x: 5 }, c: 'C' }])).toMatchInlineSnapshot(` 636 | Object { 637 | "fullError": Array [ 638 | "Unable to assign [{a: \\"A\\", b: {x: 5}, c: \\"C\\"}] to { a: string; b: ParsedValue<{ x: number; }>; }[]", 639 | Array [ 640 | "The types of [0] are not compatible", 641 | Array [ 642 | "Unable to assign {a: \\"A\\", b: {x: 5}, c: \\"C\\"} to { a: string; b: ParsedValue<{ x: number; }>; }", 643 | Array [ 644 | "Unexpected property: c", 645 | ], 646 | ], 647 | ], 648 | ], 649 | "key": "[0].c", 650 | "message": "Unexpected property: c", 651 | "success": false, 652 | } 653 | `); 654 | 655 | const unionParsed = Sealed( 656 | Intersect( 657 | Partial({ x: Number }), 658 | Union( 659 | ParsedValue(Object({ hello: String, world: String }), { 660 | parse(value) { 661 | return { success: true, value: { hello: value.hello } }; 662 | }, 663 | }), 664 | Object({ hello: String }), 665 | ), 666 | ), 667 | ); 668 | 669 | expect(unionParsed.safeParse({ hello: 'a' })).toMatchInlineSnapshot(` 670 | Object { 671 | "success": true, 672 | "value": Object { 673 | "hello": "a", 674 | }, 675 | } 676 | `); 677 | expect(unionParsed.safeParse({ hello: 'a', world: 'b' })).toMatchInlineSnapshot(` 678 | Object { 679 | "success": true, 680 | "value": Object { 681 | "hello": "a", 682 | }, 683 | } 684 | `); 685 | expect(unionParsed.safeParse({ hello: 'a', world: 'b', other: 'c' })).toMatchInlineSnapshot(` 686 | Object { 687 | "fullError": Array [ 688 | "Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to { x?: number; }", 689 | Array [ 690 | "Unexpected property: other", 691 | ], 692 | ], 693 | "key": "other", 694 | "message": "Unexpected property: other", 695 | "success": false, 696 | } 697 | `); 698 | 699 | expect(unionParsed.safeSerialize({ hello: 'a' })).toMatchInlineSnapshot(` 700 | Object { 701 | "success": true, 702 | "value": Object { 703 | "hello": "a", 704 | }, 705 | } 706 | `); 707 | expect(unionParsed.safeSerialize({ hello: 'a', world: 'b' } as any)).toMatchInlineSnapshot(` 708 | Object { 709 | "fullError": Array [ 710 | "Unable to assign {hello: \\"a\\", world: \\"b\\"} to { x?: number; }", 711 | Array [ 712 | "Unexpected property: world", 713 | ], 714 | ], 715 | "key": "world", 716 | "message": "Unexpected property: world", 717 | "success": false, 718 | } 719 | `); 720 | expect(unionParsed.safeSerialize({ hello: 'a', world: 'b', other: 'c' } as any)) 721 | .toMatchInlineSnapshot(` 722 | Object { 723 | "fullError": Array [ 724 | "Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to { x?: number; }", 725 | Array [ 726 | "Unexpected property: world", 727 | ], 728 | Array [ 729 | "Unexpected property: other", 730 | ], 731 | ], 732 | "key": "world", 733 | "message": "Unexpected property: world", 734 | "success": false, 735 | } 736 | `); 737 | 738 | (unionParsed as any).assert({ hello: 'a' }); 739 | expect(() => unionParsed.assert({ hello: 'a', world: 'b' })).toThrowErrorMatchingInlineSnapshot(` 740 | "Unable to assign {hello: \\"a\\", world: \\"b\\"} to ParsedValue<{ hello: string; world: string; }> | { hello: string; } 741 | Unable to assign {hello: \\"a\\", world: \\"b\\"} to ParsedValue<{ hello: string; world: string; }> 742 | ParsedValue<{ hello: string; world: string; }> does not support Runtype.test 743 | And unable to assign {hello: \\"a\\", world: \\"b\\"} to { hello: string; } 744 | Unexpected property: world" 745 | `); 746 | expect(() => unionParsed.assert({ hello: 'a', world: 'b', other: 'c' })) 747 | .toThrowErrorMatchingInlineSnapshot(` 748 | "Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to ParsedValue<{ hello: string; world: string; }> | { hello: string; } 749 | Unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to ParsedValue<{ hello: string; world: string; }> 750 | ParsedValue<{ hello: string; world: string; }> does not support Runtype.test 751 | And unable to assign {hello: \\"a\\", world: \\"b\\", other: \\"c\\"} to { hello: string; } 752 | Unexpected property: world 753 | Unexpected property: other" 754 | `); 755 | }); 756 | -------------------------------------------------------------------------------- /src/types/Sealed.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase, Static, create, Codec, assertRuntype } from '../runtype'; 2 | import show from '../show'; 3 | 4 | export interface Sealed> 5 | extends Codec> { 6 | readonly tag: 'sealed'; 7 | readonly underlying: TUnderlying; 8 | readonly deep: boolean; 9 | } 10 | 11 | export interface SealedConfig { 12 | readonly deep?: boolean; 13 | } 14 | export function Sealed>( 15 | underlying: TUnderlying, 16 | { deep = false }: SealedConfig = {}, 17 | ): Sealed { 18 | assertRuntype(underlying); 19 | 20 | return create>( 21 | 'sealed', 22 | { 23 | p: (value, _innerValidate, innerParseToPlaceholder) => { 24 | return innerParseToPlaceholder>( 25 | underlying as RuntypeBase>, 26 | value, 27 | { deep }, 28 | ); 29 | }, 30 | u: () => underlying, 31 | }, 32 | { 33 | underlying, 34 | deep, 35 | show: () => `Sealed<${show(underlying, false)}>`, 36 | }, 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/types/array.spec.ts: -------------------------------------------------------------------------------- 1 | import * as ta from 'type-assertions'; 2 | import { Array, ReadonlyArray, Literal, Object } from '..'; 3 | 4 | const recordType = Object({ value: Literal(42) }); 5 | const record = { value: 42 }; 6 | 7 | test('Array', () => { 8 | const dictionary = Array(recordType); 9 | ta.assert, { value: 42 }[]>>(); 10 | expect(dictionary.safeParse([record, record, record])).toMatchInlineSnapshot(` 11 | Object { 12 | "success": true, 13 | "value": Array [ 14 | Object { 15 | "value": 42, 16 | }, 17 | Object { 18 | "value": 42, 19 | }, 20 | Object { 21 | "value": 42, 22 | }, 23 | ], 24 | } 25 | `); 26 | expect(dictionary.safeParse([record, 10, record])).toMatchInlineSnapshot(` 27 | Object { 28 | "fullError": Array [ 29 | "Unable to assign [{value: 42}, 10, {value: 42}] to { value: 42; }[]", 30 | Array [ 31 | "The types of [1] are not compatible", 32 | Array [ 33 | "Expected { value: 42; }, but was 10", 34 | ], 35 | ], 36 | ], 37 | "key": "[1]", 38 | "message": "Expected { value: 42; }, but was 10", 39 | "success": false, 40 | } 41 | `); 42 | }); 43 | 44 | test('Array.asReadonly', () => { 45 | const dictionary = Array(recordType).asReadonly(); 46 | ta.assert, readonly { value: 42 }[]>>(); 47 | expect(dictionary.safeParse([record, record, record])).toMatchInlineSnapshot(` 48 | Object { 49 | "success": true, 50 | "value": Array [ 51 | Object { 52 | "value": 42, 53 | }, 54 | Object { 55 | "value": 42, 56 | }, 57 | Object { 58 | "value": 42, 59 | }, 60 | ], 61 | } 62 | `); 63 | expect(dictionary.safeParse([record, 10, record])).toMatchInlineSnapshot(` 64 | Object { 65 | "fullError": Array [ 66 | "Unable to assign [{value: 42}, 10, {value: 42}] to readonly { value: 42; }[]", 67 | Array [ 68 | "The types of [1] are not compatible", 69 | Array [ 70 | "Expected { value: 42; }, but was 10", 71 | ], 72 | ], 73 | ], 74 | "key": "[1]", 75 | "message": "Expected { value: 42; }, but was 10", 76 | "success": false, 77 | } 78 | `); 79 | }); 80 | 81 | test('ReadonlyArray', () => { 82 | const dictionary = ReadonlyArray(recordType); 83 | ta.assert, readonly { value: 42 }[]>>(); 84 | expect(dictionary.safeParse([record, record, record])).toMatchInlineSnapshot(` 85 | Object { 86 | "success": true, 87 | "value": Array [ 88 | Object { 89 | "value": 42, 90 | }, 91 | Object { 92 | "value": 42, 93 | }, 94 | Object { 95 | "value": 42, 96 | }, 97 | ], 98 | } 99 | `); 100 | expect(dictionary.safeParse([record, 10, record])).toMatchInlineSnapshot(` 101 | Object { 102 | "fullError": Array [ 103 | "Unable to assign [{value: 42}, 10, {value: 42}] to readonly { value: 42; }[]", 104 | Array [ 105 | "The types of [1] are not compatible", 106 | Array [ 107 | "Expected { value: 42; }, but was 10", 108 | ], 109 | ], 110 | ], 111 | "key": "[1]", 112 | "message": "Expected { value: 42; }, but was 10", 113 | "success": false, 114 | } 115 | `); 116 | }); 117 | -------------------------------------------------------------------------------- /src/types/array.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expected, 3 | failure, 4 | Failure, 5 | FullError, 6 | typesAreNotCompatible, 7 | unableToAssign, 8 | } from '../result'; 9 | import { 10 | Static, 11 | create, 12 | RuntypeBase, 13 | Codec, 14 | createValidationPlaceholder, 15 | assertRuntype, 16 | } from '../runtype'; 17 | import show from '../show'; 18 | 19 | export interface ReadonlyArray = RuntypeBase> 20 | extends Codec[]> { 21 | readonly tag: 'array'; 22 | readonly element: E; 23 | readonly isReadonly: true; 24 | } 25 | 26 | export { Arr as Array }; 27 | interface Arr = RuntypeBase> extends Codec[]> { 28 | readonly tag: 'array'; 29 | readonly element: E; 30 | readonly isReadonly: false; 31 | asReadonly(): ReadonlyArray; 32 | } 33 | 34 | /** 35 | * Construct an array runtype from a runtype for its elements. 36 | */ 37 | function InternalArr, IsReadonly extends boolean>( 38 | element: TElement, 39 | isReadonly: IsReadonly, 40 | ): IsReadonly extends true ? ReadonlyArray : Arr { 41 | assertRuntype(element); 42 | const result = create | Arr>( 43 | 'array', 44 | (xs, innerValidate, _innerValidateToPlaceholder, _getFields, sealed) => { 45 | if (!Array.isArray(xs)) { 46 | return expected('an Array', xs); 47 | } 48 | 49 | return createValidationPlaceholder([...xs], placeholder => { 50 | let fullError: FullError | undefined = undefined; 51 | let firstError: Failure | undefined; 52 | for (let i = 0; i < xs.length; i++) { 53 | const validated = innerValidate( 54 | element, 55 | xs[i], 56 | sealed && sealed.deep ? { deep: true } : false, 57 | ); 58 | if (!validated.success) { 59 | if (!fullError) { 60 | fullError = unableToAssign(xs, result); 61 | } 62 | fullError.push(typesAreNotCompatible(`[${i}]`, validated)); 63 | firstError = 64 | firstError || 65 | failure(validated.message, { 66 | key: validated.key ? `[${i}].${validated.key}` : `[${i}]`, 67 | fullError: fullError, 68 | }); 69 | } else { 70 | placeholder[i] = validated.value; 71 | } 72 | } 73 | return firstError; 74 | }); 75 | }, 76 | { 77 | isReadonly, 78 | element, 79 | show() { 80 | return `${isReadonly ? 'readonly ' : ''}${show(element, true)}[]`; 81 | }, 82 | }, 83 | ); 84 | if (!isReadonly) { 85 | (result as any).asReadonly = () => InternalArr(element, true); 86 | } 87 | return result as any; 88 | } 89 | 90 | function Arr>(element: TElement): Arr { 91 | return InternalArr(element, false); 92 | } 93 | export function ReadonlyArray>( 94 | element: TElement, 95 | ): ReadonlyArray { 96 | return InternalArr(element, true); 97 | } 98 | -------------------------------------------------------------------------------- /src/types/brand.ts: -------------------------------------------------------------------------------- 1 | import { RuntypeBase, Static, create, Codec, assertRuntype } from '../runtype'; 2 | import show from '../show'; 3 | 4 | export const RuntypeName = Symbol('RuntypeName'); 5 | 6 | export interface Brand> 7 | extends Codec< 8 | Static & { 9 | [RuntypeName]: B; 10 | } 11 | > { 12 | readonly tag: 'brand'; 13 | readonly brand: B; 14 | readonly entity: A; 15 | } 16 | 17 | export function Brand>(brand: B, entity: A) { 18 | assertRuntype(entity); 19 | return create>( 20 | 'brand', 21 | { 22 | p: (value, _innerValidate, innerValidateToPlaceholder) => 23 | innerValidateToPlaceholder(entity, value) as any, 24 | u: () => entity, 25 | }, 26 | { 27 | brand, 28 | entity, 29 | show(needsParens) { 30 | return show(entity, needsParens); 31 | }, 32 | }, 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/types/constraint.spec.ts: -------------------------------------------------------------------------------- 1 | import { Constraint, String, ParsedValue, InstanceOf } from '..'; 2 | 3 | test('Regression https://github.com/ForbesLindesay/funtypes/issues/62', () => { 4 | const DateSchema = ParsedValue(String, { 5 | test: InstanceOf(Date), 6 | parse: value => { 7 | return { success: true, value: new Date(value) }; 8 | }, 9 | serialize: value => { 10 | return { success: true, value: value.toISOString() }; 11 | }, 12 | }); 13 | let value: unknown; 14 | const ConstrainedDate = Constraint(DateSchema, v => { 15 | value = v; 16 | return true; 17 | }); 18 | 19 | value = undefined; 20 | expect(ConstrainedDate.test(new Date(0))).toEqual(true); 21 | expect(value).toEqual(new Date(0)); 22 | 23 | value = undefined; 24 | expect(ConstrainedDate.safeParse(new Date(0).toISOString())).toEqual({ 25 | success: true, 26 | value: new Date(0), 27 | }); 28 | expect(value).toEqual(new Date(0)); 29 | 30 | value = undefined; 31 | expect(ConstrainedDate.safeSerialize(new Date(0))).toEqual({ 32 | success: true, 33 | value: new Date(0).toISOString(), 34 | }); 35 | expect(value).toEqual(new Date(0)); 36 | }); 37 | -------------------------------------------------------------------------------- /src/types/constraint.ts: -------------------------------------------------------------------------------- 1 | import { failure, success, unableToAssign } from '../result'; 2 | import { RuntypeBase, Static, create, Codec, assertRuntype } from '../runtype'; 3 | import show from '../show'; 4 | import showValue from '../showValue'; 5 | import { Unknown } from './unknown'; 6 | 7 | export type ConstraintCheck> = (x: Static) => boolean | string; 8 | 9 | export interface Constraint< 10 | TUnderlying extends RuntypeBase, 11 | TConstrained extends Static = Static, 12 | TArgs = unknown, 13 | > extends Codec { 14 | readonly tag: 'constraint'; 15 | readonly underlying: TUnderlying; 16 | // See: https://github.com/Microsoft/TypeScript/issues/19746 for why this isn't just 17 | // `constraint: ConstraintCheck` 18 | constraint(x: Static): boolean | string; 19 | readonly name?: string; 20 | readonly args?: TArgs; 21 | } 22 | 23 | export function Constraint< 24 | TUnderlying extends RuntypeBase, 25 | TConstrained extends Static = Static, 26 | TArgs = unknown, 27 | >( 28 | underlying: TUnderlying, 29 | constraint: ConstraintCheck, 30 | options?: { name?: string; args?: TArgs }, 31 | ): Constraint { 32 | assertRuntype(underlying); 33 | const runtype: Constraint = create< 34 | Constraint 35 | >( 36 | 'constraint', 37 | { 38 | p(value, innerValidate, _, mode) { 39 | const name = options && options.name; 40 | const validated = innerValidate(underlying, value); 41 | 42 | if (!validated.success) { 43 | return validated; 44 | } 45 | 46 | const result = constraint(mode === 'p' ? (validated.value as any) : value); 47 | if (!result || typeof result === 'string') { 48 | const message = 49 | typeof result === 'string' 50 | ? result 51 | : `${showValue(value)} failed ${name || 'constraint'} check`; 52 | return failure(message, { 53 | fullError: unableToAssign(value, runtype, message), 54 | }); 55 | } 56 | return success(validated.value as TConstrained); 57 | }, 58 | u: () => underlying, 59 | }, 60 | { 61 | underlying, 62 | constraint, 63 | name: options && options.name, 64 | args: options && options.args, 65 | 66 | show(needsParens) { 67 | return (options && options.name) || `WithConstraint<${show(underlying, needsParens)}>`; 68 | }, 69 | }, 70 | ); 71 | return runtype; 72 | } 73 | 74 | export interface Guard 75 | extends Constraint {} 76 | export const Guard = ( 77 | test: (x: unknown) => x is T, 78 | options?: { name?: string; args?: K }, 79 | ): Guard => Unknown.withGuard(test, options); 80 | -------------------------------------------------------------------------------- /src/types/instanceof.ts: -------------------------------------------------------------------------------- 1 | import { expected, success } from '../result'; 2 | import { create, Codec } from '../runtype'; 3 | 4 | export interface Constructor { 5 | new (...args: any[]): V; 6 | } 7 | 8 | export interface InstanceOf extends Codec { 9 | readonly tag: 'instanceof'; 10 | readonly ctor: Constructor; 11 | } 12 | 13 | export function InstanceOf(ctor: Constructor): InstanceOf { 14 | return create>( 15 | 'instanceof', 16 | value => (value instanceof ctor ? success(value) : expected(`${(ctor as any).name}`, value)), 17 | { 18 | ctor: ctor, 19 | show() { 20 | return `InstanceOf<${(ctor as any).name}>`; 21 | }, 22 | }, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/types/intersect.spec.ts: -------------------------------------------------------------------------------- 1 | import { Intersect, ParsedValue, Object, String, Tuple, Unknown } from '..'; 2 | import { success } from '../result'; 3 | 4 | // This is a super odd/unhelpful type that just JSON.stringify's whatever you 5 | // attempt to parse 6 | const ConvertIntoJSON = Unknown.withParser({ 7 | name: 'ConvertIntoJSON', 8 | parse(value) { 9 | return success(JSON.stringify(value)); 10 | }, 11 | }); 12 | 13 | test('Intersect can handle object keys being converted', () => { 14 | const URLString = ParsedValue(String, { 15 | name: 'URLString', 16 | parse(value) { 17 | try { 18 | return success(new URL(value)); 19 | } catch (ex) { 20 | return { success: false, message: `Expected a valid URL but got '${value}'` }; 21 | } 22 | }, 23 | }); 24 | const NameRecord = Object({ name: String }); 25 | const UrlRecord = Object({ url: URLString }); 26 | const NamedURL = Intersect(NameRecord, UrlRecord); 27 | 28 | expect(NamedURL.safeParse({ name: 'example', url: 'http://example.com/foo/../' })) 29 | .toMatchInlineSnapshot(` 30 | Object { 31 | "success": true, 32 | "value": Object { 33 | "name": "example", 34 | "url": "http://example.com/", 35 | }, 36 | } 37 | `); 38 | 39 | expect(NamedURL.safeParse({ name: 'example', url: 'not a url' })).toMatchInlineSnapshot(` 40 | Object { 41 | "fullError": Array [ 42 | "Unable to assign {name: \\"example\\", url: \\"not a url\\"} to { url: URLString; }", 43 | Array [ 44 | "The types of \\"url\\" are not compatible", 45 | Array [ 46 | "Expected a valid URL but got 'not a url'", 47 | ], 48 | ], 49 | ], 50 | "key": "url", 51 | "message": "Expected a valid URL but got 'not a url'", 52 | "success": false, 53 | } 54 | `); 55 | 56 | expect( 57 | Intersect(NamedURL, ConvertIntoJSON).safeParse({ 58 | name: 'example', 59 | url: 'http://example.com/foo/../', 60 | }), 61 | ).toMatchInlineSnapshot(` 62 | Object { 63 | "message": "The validator ConvertIntoJSON attempted to convert the type of this value from an object to something else. That conversion is not valid as the child of an intersect", 64 | "success": false, 65 | } 66 | `); 67 | }); 68 | 69 | test('Intersect can handle tuple entries being converted', () => { 70 | const URLString = ParsedValue(String, { 71 | name: 'URLString', 72 | parse(value) { 73 | try { 74 | return success(new URL(value)); 75 | } catch (ex) { 76 | return { success: false, message: `Expected a valid URL but got '${value}'` }; 77 | } 78 | }, 79 | }); 80 | const NameRecord = Tuple(String, Unknown); 81 | const UrlRecord = Tuple(Unknown, URLString); 82 | const NamedURL = Intersect(NameRecord, UrlRecord); 83 | expect(NamedURL.safeParse(['example', 'http://example.com/foo/../'])).toMatchInlineSnapshot(` 84 | Object { 85 | "success": true, 86 | "value": Array [ 87 | "example", 88 | "http://example.com/", 89 | ], 90 | } 91 | `); 92 | expect(NamedURL.safeParse(['example', 'not a url'])).toMatchInlineSnapshot(` 93 | Object { 94 | "fullError": Array [ 95 | "Unable to assign [\\"example\\", \\"not a url\\"] to [unknown, URLString]", 96 | Array [ 97 | "The types of [1] are not compatible", 98 | Array [ 99 | "Expected a valid URL but got 'not a url'", 100 | ], 101 | ], 102 | ], 103 | "key": "[1]", 104 | "message": "Expected a valid URL but got 'not a url'", 105 | "success": false, 106 | } 107 | `); 108 | 109 | expect(Intersect(NamedURL, ConvertIntoJSON).safeParse(['example', 'http://example.com/foo/../'])) 110 | .toMatchInlineSnapshot(` 111 | Object { 112 | "message": "The validator ConvertIntoJSON attempted to convert the type of this value from an array to something else. That conversion is not valid as the child of an intersect", 113 | "success": false, 114 | } 115 | `); 116 | }); 117 | 118 | test('Intersect can handle String + Brand', () => { 119 | expect(Intersect(String, Unknown.withBrand('my_brand')).safeParse('hello world')) 120 | .toMatchInlineSnapshot(` 121 | Object { 122 | "success": true, 123 | "value": "hello world", 124 | } 125 | `); 126 | expect(Intersect(String, Unknown.withBrand('my_brand')).safeParse(42)).toMatchInlineSnapshot(` 127 | Object { 128 | "message": "Expected string, but was 42", 129 | "success": false, 130 | } 131 | `); 132 | }); 133 | 134 | test('Intersect validates its inputs', () => { 135 | expect(() => Intersect([String, Unknown] as any)).toThrowErrorMatchingInlineSnapshot( 136 | `"Expected Runtype but got [Runtype, Runtype]"`, 137 | ); 138 | }); 139 | -------------------------------------------------------------------------------- /src/types/intersect.ts: -------------------------------------------------------------------------------- 1 | import { failure, success } from '../result'; 2 | import { 3 | Static, 4 | create, 5 | RuntypeBase, 6 | Codec, 7 | createValidationPlaceholder, 8 | assertRuntype, 9 | SealedState, 10 | getFields, 11 | } from '../runtype'; 12 | import show, { parenthesize } from '../show'; 13 | import { lazyValue } from './lazy'; 14 | 15 | // We use the fact that a union of functions is effectively an intersection of parameters 16 | // e.g. to safely call (({x: 1}) => void | ({y: 2}) => void) you must pass {x: 1, y: 2} 17 | export type StaticIntersect[]> = { 18 | [key in keyof TIntersectees]: TIntersectees[key] extends RuntypeBase 19 | ? (parameter: Static) => any 20 | : unknown; 21 | }[number] extends (k: infer I) => void 22 | ? I 23 | : never; 24 | 25 | export interface Intersect< 26 | TIntersectees extends readonly [RuntypeBase, ...RuntypeBase[]], 27 | > extends Codec> { 28 | readonly tag: 'intersect'; 29 | readonly intersectees: TIntersectees; 30 | } 31 | 32 | export function isIntersectRuntype( 33 | runtype: RuntypeBase, 34 | ): runtype is Intersect<[RuntypeBase, ...RuntypeBase[]]> { 35 | return ( 36 | 'tag' in runtype && (runtype as Intersect<[RuntypeBase, ...RuntypeBase[]]>).tag === 'intersect' 37 | ); 38 | } 39 | 40 | /** 41 | * Construct an intersection runtype from runtypes for its alternatives. 42 | */ 43 | export function Intersect< 44 | TIntersectees extends readonly [RuntypeBase, ...RuntypeBase[]], 45 | >(...intersectees: TIntersectees): Intersect { 46 | assertRuntype(...intersectees); 47 | const allFieldInfoForMode = (mode: 'p' | 't' | 's') => { 48 | const intresecteesWithOwnFields = intersectees.map(intersectee => ({ 49 | i: intersectee, 50 | f: getFields(intersectee, mode), 51 | })); 52 | const intersecteesWithOtherFields = new Map( 53 | intersectees.map(intersectee => { 54 | const allFields = new Set(); 55 | for (const { i, f: fields } of intresecteesWithOwnFields) { 56 | if (i !== intersectee) { 57 | if (fields === undefined) return [intersectee, undefined] as const; 58 | for (const field of fields) { 59 | allFields.add(field); 60 | } 61 | } 62 | } 63 | return [intersectee, allFields] as const; 64 | }), 65 | ); 66 | 67 | const allFields = new Set(); 68 | for (const { f: fields } of intresecteesWithOwnFields) { 69 | if (fields === undefined) return { intersecteesWithOtherFields, allFields: undefined }; 70 | for (const field of fields) { 71 | allFields.add(field); 72 | } 73 | } 74 | return { intersecteesWithOtherFields, allFields }; 75 | }; 76 | // use lazy value here so that: 77 | // 1. If this is never used in a `Sealed` context, we can skip evaluating it 78 | // 2. Circular references using `Lazy` don't break. 79 | const allFieldInfo = { 80 | p: lazyValue(() => allFieldInfoForMode(`p`)), 81 | t: lazyValue(() => allFieldInfoForMode(`t`)), 82 | s: lazyValue(() => allFieldInfoForMode(`s`)), 83 | }; 84 | return create>( 85 | 'intersect', 86 | { 87 | p: (value, innerValidate, _innerValidateToPlaceholder, mode, sealed) => { 88 | const getSealed = sealed 89 | ? (targetType: RuntypeBase): SealedState => { 90 | const i = allFieldInfo[mode]().intersecteesWithOtherFields.get(targetType); 91 | if (i === undefined) return false; 92 | else return { keysFromIntersect: i, deep: sealed.deep }; 93 | } 94 | : (_i: RuntypeBase): SealedState => false; 95 | if (Array.isArray(value)) { 96 | return createValidationPlaceholder([...value], placeholder => { 97 | for (const targetType of intersectees) { 98 | let validated = innerValidate(targetType, placeholder, getSealed(targetType)); 99 | if (!validated.success) { 100 | return validated; 101 | } 102 | if (!Array.isArray(validated.value)) { 103 | return failure( 104 | `The validator ${show( 105 | targetType, 106 | )} attempted to convert the type of this value from an array to something else. That conversion is not valid as the child of an intersect`, 107 | ); 108 | } 109 | placeholder.splice(0, placeholder.length, ...validated.value); 110 | } 111 | }); 112 | } else if (value && typeof value === 'object') { 113 | return createValidationPlaceholder(Object.create(null), placeholder => { 114 | for (const targetType of intersectees) { 115 | let validated = innerValidate(targetType, value, getSealed(targetType)); 116 | if (!validated.success) { 117 | return validated; 118 | } 119 | if (!(validated.value && typeof validated.value === 'object')) { 120 | return failure( 121 | `The validator ${show( 122 | targetType, 123 | )} attempted to convert the type of this value from an object to something else. That conversion is not valid as the child of an intersect`, 124 | ); 125 | } 126 | Object.assign(placeholder, validated.value); 127 | } 128 | }); 129 | } 130 | let result = value; 131 | for (const targetType of intersectees) { 132 | let validated = innerValidate(targetType, result, getSealed(targetType)); 133 | if (!validated.success) { 134 | return validated; 135 | } 136 | result = validated.value; 137 | } 138 | return success(result); 139 | }, 140 | f: mode => allFieldInfo[mode]().allFields, 141 | }, 142 | { 143 | intersectees, 144 | show(needsParens) { 145 | return parenthesize(`${intersectees.map(v => show(v, true)).join(' & ')}`, needsParens); 146 | }, 147 | }, 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /src/types/lazy.ts: -------------------------------------------------------------------------------- 1 | import { create, RuntypeBase, Codec, Static } from '../runtype'; 2 | import show from '../show'; 3 | 4 | export interface Lazy> extends Codec> { 5 | readonly tag: 'lazy'; 6 | readonly underlying: () => TUnderlying; 7 | } 8 | 9 | export function lazyValue(fn: () => T) { 10 | let value: T; 11 | return () => { 12 | return value || (value = fn()); 13 | }; 14 | } 15 | 16 | /** 17 | * Construct a possibly-recursive Runtype. 18 | */ 19 | export function Lazy>( 20 | delayed: () => TUnderlying, 21 | ): Lazy { 22 | const underlying = lazyValue(delayed); 23 | 24 | return create>( 25 | 'lazy', 26 | { 27 | p: (value, _innerValidate, innerValidateToPlaceholder) => 28 | innerValidateToPlaceholder(underlying(), value) as any, 29 | u: underlying, 30 | }, 31 | { 32 | underlying, 33 | show(needsParens) { 34 | return show(underlying(), needsParens); 35 | }, 36 | }, 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/types/literal.ts: -------------------------------------------------------------------------------- 1 | import { failure, success } from '../result'; 2 | import { RuntypeBase, create, Codec } from '../runtype'; 3 | import showValue from '../showValue'; 4 | 5 | /** 6 | * The super type of all literal types. 7 | */ 8 | export type LiteralValue = undefined | null | boolean | number | string; 9 | 10 | export interface Literal 11 | extends Codec { 12 | readonly tag: 'literal'; 13 | readonly value: TLiteralValue; 14 | } 15 | 16 | export function isLiteralRuntype(runtype: RuntypeBase): runtype is Literal { 17 | return 'tag' in runtype && (runtype as Literal).tag === 'literal'; 18 | } 19 | 20 | /** 21 | * Construct a runtype for a type literal. 22 | */ 23 | export function Literal(valueBase: A): Literal { 24 | return create>( 25 | 'literal', 26 | value => 27 | value === valueBase 28 | ? success(value) 29 | : failure( 30 | `Expected literal ${showValue(valueBase)}, but was ${showValue(value)}${ 31 | typeof value !== typeof valueBase ? ` (i.e. a ${typeof value})` : `` 32 | }`, 33 | ), 34 | { 35 | value: valueBase, 36 | show() { 37 | return showValue(valueBase); 38 | }, 39 | }, 40 | ); 41 | } 42 | 43 | /** 44 | * An alias for Literal(undefined). 45 | */ 46 | export const Undefined = Literal(undefined); 47 | 48 | /** 49 | * An alias for Literal(null). 50 | */ 51 | export const Null = Literal(null); 52 | -------------------------------------------------------------------------------- /src/types/never.ts: -------------------------------------------------------------------------------- 1 | import { expected } from '../result'; 2 | import { Codec, create } from '../runtype'; 3 | 4 | export interface Never extends Codec { 5 | readonly tag: 'never'; 6 | } 7 | 8 | /** 9 | * Validates nothing (unknown fails). 10 | */ 11 | export const Never: Never = create( 12 | 'never', 13 | { p: value => expected('nothing', value), f: () => new Set() }, 14 | {}, 15 | ) as any; 16 | -------------------------------------------------------------------------------- /src/types/primative.spec.ts: -------------------------------------------------------------------------------- 1 | import { BigInt as BigIntCodec, Boolean } from '..'; 2 | 3 | test('BigInt', () => { 4 | expect(BigIntCodec.safeParse(BigInt('123'))).toMatchInlineSnapshot(` 5 | Object { 6 | "success": true, 7 | "value": 123n, 8 | } 9 | `); 10 | }); 11 | test('BigInt with string literal', () => { 12 | expect(BigIntCodec.safeParse('123')).toMatchInlineSnapshot(` 13 | Object { 14 | "message": "Expected bigint, but was \\"123\\" (i.e. a string literal)", 15 | "success": false, 16 | } 17 | `); 18 | }); 19 | test('BigInt with number literal', () => { 20 | expect(BigIntCodec.safeParse(123)).toMatchInlineSnapshot(` 21 | Object { 22 | "message": "Expected bigint, but was 123", 23 | "success": false, 24 | } 25 | `); 26 | }); 27 | 28 | test('Boolean with string literal', () => { 29 | expect(Boolean.safeParse('true')).toMatchInlineSnapshot(` 30 | Object { 31 | "message": "Expected boolean, but was \\"true\\" (i.e. a string literal)", 32 | "success": false, 33 | } 34 | `); 35 | }); 36 | -------------------------------------------------------------------------------- /src/types/primative.ts: -------------------------------------------------------------------------------- 1 | import { failure, success } from '../result'; 2 | import { create, Codec } from '../runtype'; 3 | import { showValueNonString } from '../showValue'; 4 | 5 | export interface Boolean extends Codec { 6 | readonly tag: 'boolean'; 7 | } 8 | 9 | export interface Function extends Codec<(...args: any[]) => any> { 10 | readonly tag: 'function'; 11 | } 12 | 13 | export interface Number extends Codec { 14 | readonly tag: 'number'; 15 | } 16 | 17 | export interface String extends Codec { 18 | readonly tag: 'string'; 19 | } 20 | 21 | interface Sym extends Codec { 22 | readonly tag: 'symbol'; 23 | } 24 | 25 | export interface BigInt extends Codec { 26 | readonly tag: 'bigint'; 27 | } 28 | 29 | function createPrimative< 30 | TType extends 'boolean' | 'function' | 'number' | 'string' | 'symbol' | 'bigint', 31 | TValue, 32 | >(type: TType): Codec & { readonly tag: TType } { 33 | return create & { readonly tag: TType }>( 34 | type, 35 | value => 36 | typeof value === type 37 | ? success(value) 38 | : failure(`Expected ${type}, but was ${showValueNonString(value)}`), 39 | {}, 40 | ); 41 | } 42 | 43 | /** 44 | * Validates that a value is a boolean. 45 | */ 46 | export const Boolean: Boolean = createPrimative('boolean'); 47 | 48 | /** 49 | * Validates that a value is a function. 50 | */ 51 | export const Function: Function = createPrimative('function'); 52 | 53 | /** 54 | * Validates that a value is a number. 55 | */ 56 | export const Number: Number = createPrimative('number'); 57 | 58 | /** 59 | * Validates that a value is a string. 60 | */ 61 | export const String: String = createPrimative('string'); 62 | 63 | /** 64 | * Validates that a value is a symbol. 65 | */ 66 | const Sym: Sym = createPrimative('symbol'); 67 | export { Sym as Symbol }; 68 | 69 | /** 70 | * Validates that a value is a BigInt. 71 | */ 72 | export const BigInt: BigInt = createPrimative('bigint'); 73 | -------------------------------------------------------------------------------- /src/types/tuple.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expected, 3 | failure, 4 | Failure, 5 | FullError, 6 | typesAreNotCompatible, 7 | unableToAssign, 8 | } from '../result'; 9 | import { create, RuntypeBase, Codec, createValidationPlaceholder, assertRuntype } from '../runtype'; 10 | import show from '../show'; 11 | 12 | export type StaticTuple[]> = { 13 | [key in keyof TElements]: TElements[key] extends RuntypeBase ? E : unknown; 14 | }; 15 | export type ReadonlyStaticTuple[]> = { 16 | readonly [key in keyof TElements]: TElements[key] extends RuntypeBase ? E : unknown; 17 | }; 18 | 19 | export interface Tuple< 20 | TElements extends readonly RuntypeBase[] = readonly RuntypeBase[], 21 | > extends Codec> { 22 | readonly tag: 'tuple'; 23 | readonly components: TElements; 24 | readonly isReadonly: false; 25 | } 26 | 27 | export interface ReadonlyTuple< 28 | TElements extends readonly RuntypeBase[] = readonly RuntypeBase[], 29 | > extends Codec> { 30 | readonly tag: 'tuple'; 31 | readonly components: TElements; 32 | readonly isReadonly: true; 33 | } 34 | 35 | export function isTupleRuntype(runtype: RuntypeBase): runtype is Tuple { 36 | return 'tag' in runtype && (runtype as Tuple).tag === 'tuple'; 37 | } 38 | 39 | /** 40 | * Construct a tuple runtype from runtypes for each of its elements. 41 | */ 42 | export function Tuple< 43 | T extends readonly [RuntypeBase, ...RuntypeBase[]] | readonly [], 44 | >(...components: T): Tuple { 45 | assertRuntype(...components); 46 | const result = create>( 47 | 'tuple', 48 | (x, innerValidate, _innerValidateToPlaceholder, _getFields, sealed) => { 49 | if (!Array.isArray(x)) { 50 | return expected(`tuple to be an array`, x); 51 | } 52 | 53 | if (x.length !== components.length) { 54 | return expected(`an array of length ${components.length}`, x.length); 55 | } 56 | 57 | return createValidationPlaceholder([...x] as any, placeholder => { 58 | let fullError: FullError | undefined = undefined; 59 | let firstError: Failure | undefined; 60 | for (let i = 0; i < components.length; i++) { 61 | let validatedComponent = innerValidate( 62 | components[i], 63 | x[i], 64 | sealed && sealed.deep ? { deep: true } : false, 65 | ); 66 | 67 | if (!validatedComponent.success) { 68 | if (!fullError) { 69 | fullError = unableToAssign(x, result); 70 | } 71 | fullError.push(typesAreNotCompatible(`[${i}]`, validatedComponent)); 72 | firstError = 73 | firstError || 74 | failure(validatedComponent.message, { 75 | key: validatedComponent.key ? `[${i}].${validatedComponent.key}` : `[${i}]`, 76 | fullError: fullError, 77 | }); 78 | } else { 79 | placeholder[i] = validatedComponent.value; 80 | } 81 | } 82 | return firstError; 83 | }); 84 | }, 85 | { 86 | components, 87 | isReadonly: false, 88 | show() { 89 | return `${this.isReadonly ? `readonly ` : ``}[${( 90 | components as readonly RuntypeBase[] 91 | ) 92 | .map(e => show(e, false)) 93 | .join(', ')}]`; 94 | }, 95 | }, 96 | ); 97 | return result; 98 | } 99 | 100 | export function ReadonlyTuple< 101 | T extends readonly [RuntypeBase, ...RuntypeBase[]] | readonly [], 102 | >(...components: T): ReadonlyTuple { 103 | const tuple: any = Tuple(...components); 104 | tuple.isReadonly = true; 105 | return tuple; 106 | } 107 | -------------------------------------------------------------------------------- /src/types/union.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Codec, 3 | Static, 4 | create, 5 | RuntypeBase, 6 | InnerValidateHelper, 7 | innerValidate, 8 | createVisitedState, 9 | OpaqueVisitedState, 10 | assertRuntype, 11 | unwrapRuntype, 12 | getFields, 13 | } from '../runtype'; 14 | import show, { parenthesize } from '../show'; 15 | import { LiteralValue, isLiteralRuntype } from './literal'; 16 | import { lazyValue } from './lazy'; 17 | import { isObjectRuntype } from './Object'; 18 | import { 19 | andError, 20 | expected, 21 | failure, 22 | FullError, 23 | Result, 24 | success, 25 | typesAreNotCompatible, 26 | unableToAssign, 27 | } from '../result'; 28 | import { isTupleRuntype } from './tuple'; 29 | import showValue from '../showValue'; 30 | import { isIntersectRuntype } from './intersect'; 31 | 32 | export type StaticUnion[]> = { 33 | [key in keyof TAlternatives]: TAlternatives[key] extends RuntypeBase 34 | ? Static 35 | : unknown; 36 | }[number]; 37 | 38 | export interface Union[]> 39 | extends Codec> { 40 | readonly tag: 'union'; 41 | readonly alternatives: TAlternatives; 42 | match: Match; 43 | } 44 | 45 | export function isUnionType(runtype: RuntypeBase): runtype is Union[]> { 46 | return 'tag' in runtype && (runtype as Union[]>).tag === 'union'; 47 | } 48 | 49 | function mapGet(map: Map) { 50 | return (key: TKey, fn: () => TValue) => { 51 | const existing = map.get(key); 52 | if (existing !== undefined) return existing; 53 | const fresh = fn(); 54 | map.set(key, fresh); 55 | return fresh; 56 | }; 57 | } 58 | 59 | function findFields( 60 | alternative: RuntypeBase, 61 | mode: 'p' | 's' | 't', 62 | ): [string, RuntypeBase][] { 63 | const underlying = unwrapRuntype(alternative, mode); 64 | const fields: [string, RuntypeBase][] = []; 65 | const pushField = (fieldName: string, type: RuntypeBase) => { 66 | const f = unwrapRuntype(type, mode); 67 | if (isUnionType(f)) { 68 | for (const type of f.alternatives) { 69 | pushField(fieldName, type); 70 | } 71 | } else { 72 | fields.push([fieldName, f]); 73 | } 74 | }; 75 | if (isObjectRuntype(underlying) && !underlying.isPartial) { 76 | for (const fieldName of Object.keys(underlying.fields)) { 77 | pushField(fieldName, underlying.fields[fieldName]); 78 | } 79 | } else if (isTupleRuntype(underlying)) { 80 | underlying.components.forEach((type, i) => { 81 | pushField(`${i}`, type); 82 | }); 83 | } else if (isIntersectRuntype(underlying)) { 84 | for (const type of underlying.intersectees) { 85 | fields.push(...findFields(type, mode)); 86 | } 87 | } else if (isUnionType(underlying)) { 88 | const alternatives = underlying.alternatives.map(type => findFields(type, mode)); 89 | const fieldNames = intersect(alternatives.map(v => new Set(v.map(([fieldName]) => fieldName)))); 90 | for (const v of alternatives) { 91 | for (const [fieldName, type] of v) { 92 | if (fieldNames.has(fieldName)) { 93 | pushField(fieldName, type); 94 | } 95 | } 96 | } 97 | } 98 | return fields; 99 | } 100 | 101 | function intersect(sets: Set[]) { 102 | const result = new Set(sets[0]); 103 | for (const s of sets) { 104 | for (const v of result) { 105 | if (!s.has(v)) { 106 | result.delete(v); 107 | } 108 | } 109 | } 110 | return result; 111 | } 112 | interface Discriminant { 113 | largestDiscriminant: number; 114 | fieldTypes: Map>>; 115 | } 116 | function createDiscriminant(): Discriminant { 117 | return { largestDiscriminant: 0, fieldTypes: new Map() }; 118 | } 119 | function findDiscriminator( 120 | recordAlternatives: readonly (readonly [RuntypeBase, [string, RuntypeBase][]])[], 121 | ) { 122 | const commonFieldNames = intersect( 123 | recordAlternatives.map(([, fields]) => new Set(fields.map(([fieldName]) => fieldName))), 124 | ); 125 | 126 | const commonLiteralFields = new Map>( 127 | // we want to always check these props first, in case there are multiple possible keys 128 | // that can be used to discriminate 129 | ['type', 'kind', 'tag', 'version'].map(fieldName => [fieldName, createDiscriminant()]), 130 | ); 131 | for (const [type, fields] of recordAlternatives) { 132 | for (const [fieldName, field] of fields) { 133 | if (commonFieldNames.has(fieldName)) { 134 | if (isLiteralRuntype(field)) { 135 | const discriminant = mapGet(commonLiteralFields)(fieldName, createDiscriminant); 136 | const typesForThisDiscriminant = discriminant.fieldTypes.get(field.value); 137 | if (typesForThisDiscriminant) { 138 | typesForThisDiscriminant.add(type); 139 | discriminant.largestDiscriminant = Math.max( 140 | discriminant.largestDiscriminant, 141 | typesForThisDiscriminant.size, 142 | ); 143 | } else { 144 | discriminant.largestDiscriminant = Math.max(discriminant.largestDiscriminant, 1); 145 | discriminant.fieldTypes.set(field.value, new Set([type])); 146 | } 147 | } else { 148 | commonFieldNames.delete(fieldName); 149 | } 150 | } 151 | } 152 | } 153 | let bestDiscriminatorSize = Infinity; 154 | for (const [fieldName, { largestDiscriminant }] of commonLiteralFields) { 155 | if (commonFieldNames.has(fieldName)) { 156 | bestDiscriminatorSize = Math.min(bestDiscriminatorSize, largestDiscriminant); 157 | } 158 | } 159 | if (bestDiscriminatorSize >= recordAlternatives.length) { 160 | return undefined; 161 | } 162 | for (const [fieldName, { fieldTypes, largestDiscriminant }] of commonLiteralFields) { 163 | if (largestDiscriminant === bestDiscriminatorSize && commonFieldNames.has(fieldName)) { 164 | return [ 165 | fieldName, 166 | new Map( 167 | Array.from(fieldTypes).map(([fieldValue, types]) => [fieldValue, Array.from(types)]), 168 | ), 169 | ] as const; 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Construct a union runtype from runtypes for its alternatives. 176 | */ 177 | export function Union< 178 | TAlternatives extends readonly [RuntypeBase, ...RuntypeBase[]], 179 | >(...alternatives: TAlternatives): Union { 180 | assertRuntype(...alternatives); 181 | type TResult = StaticUnion; 182 | type InnerValidate = (x: any, innerValidate: InnerValidateHelper) => Result; 183 | const flatAlternatives: RuntypeBase[] = []; 184 | for (const a of alternatives) { 185 | if (isUnionType(a)) { 186 | flatAlternatives.push(...(a.alternatives as any)); 187 | } else { 188 | flatAlternatives.push(a as any); 189 | } 190 | } 191 | function validateWithKey( 192 | tag: string, 193 | types: Map[]>, 194 | ): InnerValidate { 195 | const typeStrings = new Set(); 196 | for (const t of types.values()) { 197 | for (const v of t) { 198 | typeStrings.add(show(v, true)); 199 | } 200 | } 201 | const typesString = Array.from(typeStrings).join(' | '); 202 | return (value, innerValidate) => { 203 | if (!value || typeof value !== 'object') { 204 | return expected(typesString, value); 205 | } 206 | const validator = types.get(value[tag]); 207 | if (validator) { 208 | if (validator.length === 1) { 209 | const result = innerValidate(validator[0], value); 210 | if (!result.success) { 211 | return failure(result.message, { 212 | key: `<${/^\d+$/.test(tag) ? `[${tag}]` : tag}: ${showValue(value[tag])}>${ 213 | result.key ? `.${result.key}` : `` 214 | }`, 215 | fullError: unableToAssign(value, typesString, result), 216 | }); 217 | } 218 | return result; 219 | } 220 | 221 | return validateWithoutKeyInner(validator, value, innerValidate); 222 | } else { 223 | const err = expected( 224 | Array.from(types.keys()) 225 | .map(v => (typeof v === 'string' ? `'${v}'` : v)) 226 | .join(' | '), 227 | value[tag], 228 | { 229 | key: /^\d+$/.test(tag) ? `[${tag}]` : tag, 230 | }, 231 | ); 232 | err.fullError = unableToAssign( 233 | value, 234 | typesString, 235 | typesAreNotCompatible(/^\d+$/.test(tag) ? `[${tag}]` : `"${tag}"`, err.message), 236 | ); 237 | return err; 238 | } 239 | }; 240 | } 241 | 242 | function validateWithoutKeyInner( 243 | alternatives: readonly RuntypeBase[], 244 | value: any, 245 | innerValidate: InnerValidateHelper, 246 | ): Result { 247 | let fullError: FullError | undefined; 248 | for (const targetType of alternatives) { 249 | const result = innerValidate(targetType, value); 250 | if (result.success) { 251 | return result as Result; 252 | } 253 | if (!fullError) { 254 | fullError = unableToAssign( 255 | value, 256 | runtype, 257 | result.fullError || unableToAssign(value, targetType, result), 258 | ); 259 | } else { 260 | fullError.push(andError(result.fullError || unableToAssign(value, targetType, result))); 261 | } 262 | } 263 | 264 | return expected(runtype, value, { 265 | fullError, 266 | }); 267 | } 268 | function validateWithoutKey(alternatives: readonly RuntypeBase[]): InnerValidate { 269 | return (value, innerValidate) => validateWithoutKeyInner(alternatives, value, innerValidate); 270 | } 271 | function validateOnlyOption(innerType: RuntypeBase): InnerValidate { 272 | return (value, innerValidate) => innerValidate(innerType, value); 273 | } 274 | 275 | // This must be lazy to avoid eagerly evaluating any circular references 276 | const validatorOf = (mode: 'p' | 's' | 't'): InnerValidate => { 277 | const nonNeverAlternatives = flatAlternatives.filter( 278 | a => unwrapRuntype(a, mode).tag !== 'never', 279 | ); 280 | if (nonNeverAlternatives.length === 1) { 281 | return validateOnlyOption(nonNeverAlternatives[0]); 282 | } 283 | const withFields = nonNeverAlternatives.map(a => [a, findFields(a, mode)] as const); 284 | const withAtLeastOneField = withFields.filter(a => a[1].length !== 0); 285 | const withNoFields = withFields.filter(a => a[1].length === 0); 286 | const discriminant = findDiscriminator(withAtLeastOneField); 287 | 288 | if (discriminant && withNoFields.length) { 289 | const withKey = discriminant && validateWithKey(discriminant[0], discriminant[1]); 290 | const withoutKey = validateWithoutKey(withNoFields.map(v => v[0])); 291 | return (value, innerValidate) => { 292 | const resultWithKey = withKey(value, innerValidate); 293 | if (resultWithKey.success) { 294 | return resultWithKey; 295 | } 296 | const resultWithoutKey = withoutKey(value, innerValidate); 297 | if (!resultWithoutKey.success) { 298 | resultWithoutKey.fullError!.push( 299 | andError(resultWithKey.fullError ?? unableToAssign(value, `Object`)), 300 | ); 301 | } 302 | return resultWithoutKey; 303 | }; 304 | } else if (discriminant) { 305 | return validateWithKey(discriminant[0], discriminant[1]); 306 | } else { 307 | return validateWithoutKey(flatAlternatives); 308 | } 309 | }; 310 | const innerValidator = lazyValue(() => ({ 311 | p: validatorOf('p'), 312 | s: validatorOf('s'), 313 | t: validatorOf('t'), 314 | })); 315 | 316 | const getFieldsForMode = (mode: 'p' | 't' | 's') => { 317 | const fields = new Set(); 318 | for (const a of alternatives) { 319 | const aFields = getFields(a, mode); 320 | if (aFields === undefined) return undefined; 321 | for (const f of aFields) { 322 | fields.add(f); 323 | } 324 | } 325 | return fields; 326 | }; 327 | const fields = { 328 | p: lazyValue(() => getFieldsForMode(`p`)), 329 | t: lazyValue(() => getFieldsForMode(`t`)), 330 | s: lazyValue(() => getFieldsForMode(`s`)), 331 | }; 332 | 333 | const runtype: Union = create>( 334 | 'union', 335 | { 336 | p: (value, visited) => { 337 | return innerValidator().p(value, visited); 338 | }, 339 | s: (value, visited) => { 340 | return innerValidator().s(value, visited); 341 | }, 342 | t: (value, visited) => { 343 | const result = innerValidator().t(value, (t, v) => visited(t, v) || success(v as any)); 344 | return result.success ? undefined : result; 345 | }, 346 | f: mode => fields[mode](), 347 | }, 348 | { 349 | alternatives: flatAlternatives as any, 350 | match: match as any, 351 | show(needsParens) { 352 | return parenthesize(`${flatAlternatives.map(v => show(v, true)).join(' | ')}`, needsParens); 353 | }, 354 | }, 355 | ); 356 | 357 | return runtype; 358 | 359 | function match(...cases: any[]) { 360 | return (x: any) => { 361 | const visited: OpaqueVisitedState = createVisitedState(); 362 | for (let i = 0; i < alternatives.length; i++) { 363 | const input = innerValidate(alternatives[i], x, visited, false); 364 | if (input.success) { 365 | return cases[i](input.value); 366 | } 367 | } 368 | // if none of the types matched, we should fail with an assertion error 369 | runtype.assert(x); 370 | }; 371 | } 372 | } 373 | 374 | export interface Match[]> { 375 | ( 376 | ...a: { [key in keyof A]: A[key] extends RuntypeBase ? Case : never } 377 | ): Matcher; 378 | } 379 | 380 | export type Case, Result> = (v: Static) => Result; 381 | 382 | export type Matcher[], Z> = ( 383 | x: { 384 | [key in keyof A]: A[key] extends RuntypeBase ? Type : unknown; 385 | }[number], 386 | ) => Z; 387 | -------------------------------------------------------------------------------- /src/types/unknown.ts: -------------------------------------------------------------------------------- 1 | import { success } from '../result'; 2 | import { Codec, create } from '../runtype'; 3 | 4 | export interface Unknown extends Codec { 5 | readonly tag: 'unknown'; 6 | } 7 | 8 | /** 9 | * Validates anything, but provides no new type information about it. 10 | */ 11 | export const Unknown: Unknown = create('unknown', value => success(value), {}); 12 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Type test to determine if an object has a given key 2 | // If this feature gets implemented, we can use `in` instead: https://github.com/Microsoft/TypeScript/issues/10485 3 | export function hasKey(k: K, o: {}): o is { [_ in K]: {} } { 4 | return typeof o === 'object' && k in o; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "module": "ES2015", 5 | "moduleResolution": "node", 6 | "target": "ES2019", 7 | "lib": ["es2019", "dom"], 8 | "strict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "declaration": true, 12 | "sourceMap": true, 13 | "outDir": "lib", 14 | "forceConsistentCasingInFileNames": true, 15 | "experimentalDecorators": true 16 | }, 17 | "exclude": ["./lib/**/*", "./examples/**/*", "./test-output/**/*"] 18 | } 19 | --------------------------------------------------------------------------------