├── .github └── workflows │ ├── branches.yml │ └── master.yml ├── .gitignore ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.2.4.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── babel.config.cjs ├── index.ts ├── lib ├── .DS_Store ├── __snapshots__ │ ├── annotation.spec.ts.snap │ └── traverse.spec.ts.snap ├── annotation.spec.ts ├── annotation.ts ├── error.spec.ts ├── error.ts ├── location.spec.ts ├── location.ts ├── simplifications │ ├── const-enum.spec.ts │ ├── const-enum.ts │ ├── intersect-const-enum.spec.ts │ ├── intersect-const-enum.ts │ └── single.ts ├── simplify.spec.ts ├── simplify.ts ├── traverse.spec.ts ├── traverse.ts ├── types.ts ├── util.spec.ts ├── util.ts ├── validate.spec.ts └── validate.ts ├── package.json ├── tsconfig.json ├── tsconfig.prod.json └── yarn.lock /.github/workflows/branches.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Branches 5 | 6 | on: 7 | push: 8 | branches-ignore: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: 19 | - 14.x 20 | - 16.x 21 | - 18.x 22 | - 19.x 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: yarn 30 | - run: yarn build 31 | - run: yarn test 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Master 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: 18 | - 14.x 19 | - 16.x 20 | - 18.x 21 | - 19.x 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: yarn 29 | - run: yarn build 30 | - run: yarn test 31 | env: 32 | CI: true 33 | 34 | release: 35 | name: Release 36 | runs-on: ubuntu-latest 37 | needs: build 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v1 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v1 43 | with: 44 | node-version: 18 45 | - run: yarn 46 | - run: yarn build 47 | - run: yarn test 48 | env: 49 | CI: true 50 | - name: Coveralls 51 | uses: coverallsapp/github-action@master 52 | with: 53 | github-token: ${{ secrets.GITHUB_TOKEN }} 54 | - name: Release 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 58 | run: npx semantic-release 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .yarn/ 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.2.4.cjs 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Gustaf Räntilä 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 | [![npm version][npm-image]][npm-url] 2 | [![downloads][downloads-image]][npm-url] 3 | [![build status][build-image]][build-url] 4 | [![coverage status][coverage-image]][coverage-url] 5 | [![Node.JS version][node-version]][node-url] 6 | 7 | 8 | # core-types 9 | 10 | This package provides TypeScript types describing core types useful in TypeScript, JavaScript, JSON, JSON Schema etc. It also contains functions for simplifying unnecessarily complex types, as well helper utilities for other packages converting to/from core-types and another type system. 11 | 12 | Using core-types, e.g. implementing conversions to other type systems is easy, since core-types is relatively small and well defined. 13 | 14 | * [See](#see) use cases and other packages using this package 15 | * [Usage](#usage) 16 | * [simplify](#simplify) types, merge, flatten and remove unncessary types 17 | * [validate](#validate) types 18 | * [traverse](#traverse) type tree 19 | * [some](#some) *"Array.prototype.some"* for a type tree 20 | * [helpers](#helpers) for package implementors 21 | * [Specification](#specification) of the types in this package are: 22 | * [any](#any-type) (any of the below types) 23 | * [null](#null-type) 24 | * [boolean](#boolean-type) (`true`, `false`) 25 | * [string](#string-type) 26 | * [number](#number-type) and integer; distinguished in JSON Schema, equivalent in TypeScript 27 | * [object](#object-type); key-value of core types where the key is a string 28 | * [array](#array-type); list of arbitrary length of a specific core type 29 | * [tuple](#tuple-type); list of specific length with distinguished core types in each position 30 | * [ref](#ref-type) a reference to a named type 31 | * unions and intersections of the above 32 | * [union](#union-type); `{ or: [ core-types... ] }` 33 | * [intersection](#intersection-type); `{ and: [ core-types... ] }` 34 | 35 | The above describes JSON completely, and is a lowest common denominator for describing types useful for JSON, JSON Schema and TypeScript. Think of it as an *extremely* simplified version of JSON Schema. 36 | 37 | 38 | # See 39 | 40 | This package is used by: 41 | * [`core-types-json-schema`][core-types-json-schema-github-url] [![npmjs][core-types-json-schema-npm-image]][core-types-json-schema-npm-url] converting to and from JSON Schema / Open API 42 | * [`core-types-ts`][core-types-ts-github-url] [![npmjs][core-types-ts-npm-image]][core-types-ts-npm-url] converting to and from TypeScript types/interfaces 43 | * [`core-types-graphql`][core-types-graphql-github-url] [![npmjs][core-types-graphql-npm-image]][core-types-graphql-npm-url] converting to and from GraphQL 44 | * [`core-types-suretype`][core-types-suretype-github-url] [![npmjs][core-types-suretype-npm-image]][core-types-suretype-npm-url] converting to and from SureType validator schemas 45 | * [`typeconv`][typeconv-github-url] [![npmjs][typeconv-npm-image]][typeconv-npm-url] conversion between JSON Schema, TypeScript, GraphQL and Open API through core-types 46 | 47 | 48 | # Versions 49 | 50 | Since 3.0 this package is pure ESM and requires Node 14.13.1 or later. 51 | 52 | 53 | # Usage 54 | 55 | To create a core-types type, just cast it to `NodeType`. 56 | 57 | ```ts 58 | import type { NodeType } from 'core-types' 59 | 60 | const myStringType: NodeType = { type: 'string' }; 61 | ``` 62 | 63 | For more information on the specific types, see the [Specification](#specification). 64 | 65 | 66 | ## simplify 67 | 68 | The function `simplify` can take a type, or an array of types, and returns simplified type definitions. 69 | 70 | Examples of simpifications performed: 71 | * An empty `and` or `or` will often be removed. 72 | * A union (e.g. of `any` and `string`), except for usages `const` and `enum`, can be simplified as just `any`. 73 | * An intersection of `any` and `string` can be simplified to `string`. 74 | * An `or` containing child `or`s, will be flattened to the parent `or`. 75 | * and more... 76 | 77 | The simplify function is type-wise *lossless*, but can remove annotations (e.g. descriptions). It is however usually recommended to perform a simplification after a type has been converted *to* core-types before converting to another type system. 78 | 79 | ```ts 80 | import { simplify } from 'core-types' 81 | 82 | const simplified = simplify( myType ); 83 | 84 | simplify( { 85 | type: 'or', 86 | or: [ 87 | { type: 'or', or: [ { type: 'string' } ] }, 88 | { type: 'any', const: 'foo' } 89 | ] 90 | } ); // { type: 'string', const: 'foo' } 91 | ``` 92 | 93 | 94 | ## validate 95 | 96 | The `validate` function validates that a `NodeType` type tree is valid. 97 | 98 | It ensures e.g. 99 | * Non-negative *integer* `minItems` 100 | * Non-mismatching enums and const if both are specified. 101 | 102 | ```ts 103 | import { validate } from 'core-types' 104 | 105 | validate( myType ); // Throws error if not valid 106 | ``` 107 | 108 | 109 | ## traverse 110 | 111 | The `traverse` function traverses a type tree and calls a callback function for every node it finds. 112 | 113 | The callback function gets an object as argument on the following form: 114 | 115 | ```ts 116 | interface TraverseCallbackArgument 117 | { 118 | node: NodeType; 119 | rootNode: NodeType; 120 | path: Array< string | number >; 121 | parentProperty?: string; 122 | parentNode?: NodeType; 123 | index?: string | number; 124 | required?: boolean; 125 | } 126 | ``` 127 | 128 | ```ts 129 | import { traverse } from 'core-types' 130 | 131 | traverse( rootNode, ( { node } ) => { 132 | if ( !node.title ) 133 | node.title = "This is a dummy title"; 134 | } ); 135 | ``` 136 | 137 | 138 | ## some 139 | 140 | The `some` function is similar to `traverse` but the callback can return a boolean. If the callback returns true, `some` returns true, otherwise false. 141 | 142 | This is useful to quickly find if a node satisifes a certain criteria, and is similar to `Array.prototype.some`. 143 | 144 | ```ts 145 | import { some } from 'core-types' 146 | 147 | const hasRefNode = some( rootNode, ( { node: { type } } ) => type === 'ref' ); 148 | ``` 149 | 150 | 151 | ## helpers 152 | 153 | When implementing conversions to and from core-types, the following helper functions may come in handy: 154 | 155 | * `ensureArray` converts values to arrays of such values, or returns arrays as-is. null and undefined become empty array 156 | * `isPrimitiveType` returns true for primitive `NodeType`s 157 | * `hasConstEnum` returns true for `NodeType`s which has (or can have) `const` and `enum` properties. 158 | * `isEqual` deep-equal comparison (of JSON compatible non-recursive types) 159 | * `intersection` returns an array of values found in both of two arrays. Handles primitives as well as arrays and objects (uses `isEqual`) 160 | * `union` returns an array of unique values from two arrays. Handles primitives as well as arrays and objects (uses `isEqual`) 161 | * `isNonNullable` 162 | * `isCoreTypesError` 163 | * `decorateErrorMeta` 164 | * `decorateError` 165 | * `getPositionOffset` 166 | * `mergeLocations` 167 | 168 | ### Annotations 169 | 170 | * `mergeAnnotations` 171 | * `extractAnnotations` 172 | * `stringifyAnnotations` 173 | * `stripAnnotations` 174 | * `stringify` 175 | 176 | ### Conversion 177 | 178 | When converting, a conversion package is recommended to return a `ConversionResult`, i.e. the data as property `data` in an object which also contains information about the conversion: 179 | 180 | ```ts 181 | interface ConversionResult< T = string > 182 | { 183 | data: T; 184 | convertedTypes: Array< string >; 185 | notConvertedTypes: Array< string >; 186 | } 187 | ``` 188 | 189 | 190 | # Specification 191 | 192 | The main type is called `NodeType` and is a union of the specific types. A `NodeType` always has a `type` property of the type `Types`. The `Types` is defined as: 193 | 194 | ```ts 195 | type Types = 196 | | 'any' 197 | | 'null' 198 | | 'boolean' 199 | | 'string' 200 | | 'number' 201 | | 'integer' 202 | | 'object' 203 | | 'array' 204 | | 'tuple' 205 | | 'ref' 206 | | 'and' 207 | | 'or'; 208 | ``` 209 | 210 | Depending on which type is used, other properties in `NodeType` will be required. In fact, the `NodeType` is defined as: 211 | 212 | ```ts 213 | type NodeType = 214 | | AnyType 215 | | NullType 216 | | BooleanType 217 | | StringType 218 | | NumberType 219 | | IntegerType 220 | | ObjectType 221 | | ArrayType 222 | | TupleType 223 | | RefType 224 | | AndType 225 | | OrType; 226 | ``` 227 | 228 | These types have an optional `name` (string) property which can be converted to be *required* using `NamedType`. This is useful when converting to other type systems where at least the top-most types must have names (like JSON Schema definitions or exported TypeScript types/interfaces), and is used by the `NodeDocument`, which is what conversion packages should use: 229 | 230 | ```ts 231 | interface NodeDocument 232 | { 233 | version: 1; // core-types only has version 1 so far 234 | types: Array< NamedType >; 235 | } 236 | ``` 237 | 238 | The types also have optional annotation properties `title` (string), `description` (string), `examples` (string or array of strings), `default` (string), `see` (string or array of strings) and `comment` (string). 239 | 240 | All types except `NullType`, `AndType` and `OrType` can have two properties `const` (of type `T`) or `enum` (of type `Array`). The `T` depends on the `NodeType`. These have the same semantics as in JSON Schema, meaning a `const` value is equivalent of an `enum` with only that value. The `enum` can be seen as a type literal union in TypeScript. 241 | 242 | 243 | ## any type 244 | 245 | The `AnyType` matches any type. Its `const` and `enum` properties have the element type `T` set to `unknown`. 246 | 247 | This corresponds to `any` or `unknown` in TypeScript, and the empty schema `{}` in JSON Schema. 248 | 249 | Example: `{ type: 'any' }` 250 | 251 | 252 | ## null type 253 | 254 | The `NullType` is simply equivalent to the TypeScript, JavaScript and JSON type `null`. 255 | 256 | Example: `{ type: 'null' }` 257 | 258 | ## boolean type 259 | 260 | The `BooleanType` is equivalent to the TypeScript, JavaScript and JSON `Boolean` (`true` and `false`). 261 | 262 | The element type `T` for `const` and `enum` is `boolean`. 263 | 264 | Example: `{ type: 'boolean', const: false }` 265 | 266 | 267 | ## string type 268 | 269 | The `StringType` is equivalent to the TypeScript, JavaScript and JSON type `String`. 270 | 271 | The element type `T` for `const` and `enum` is `string`. 272 | 273 | Example: `{ type: 'string', enum: [ "foo", "bar" ] }` 274 | 275 | 276 | ## number type 277 | 278 | core-types distinguishes between `NumberType` and `IntegerType`. 279 | 280 | In TypeScript, JavaScript and JSON they are both equivalent to `Number`. In JSON Schema however, `integer` is a separate type, and can therefore be converted to `core-types` with maintained type information. 281 | 282 | The element type `T` for `const` and `enum` is `number`. 283 | 284 | Example: `{ type: 'number', enum: [ 17, 42 ] }` 285 | 286 | 287 | ## object type 288 | 289 | The `ObjectType` is used to describe the TypeScript type `Record` and the JavaScript and JSON type `Object`. In TypeScript or JavaScript, the keys must only be strings, not numbers or symbols. 290 | 291 | The element type `T` for `const` and `enum` is `Record`, i.e. plain objects. 292 | 293 | Two more properties are required for an `ObjectType`, `properties` and `additionalProperties`. 294 | 295 | `properties` is defined as `Record`. 296 | 297 | `additionalProperties` is defined as `boolean | NodeType`. When this is `false`, no additional properties apart from those defined in `properties` are allowed, and if `true` properties are allowed of any type (`AnyType`). Otherwise additional properties are allowed of the defined `NodeType`. 298 | 299 | Example: 300 | ```ts 301 | { 302 | type: 'object', 303 | properties: { 304 | name: { node: { type: 'string' }, required: true }, 305 | age: { node: { type: 'number' }, required: true }, 306 | level: { node: { type: 'string', enum: [ 'novice', 'proficient', 'expert' ] }, required: false }, 307 | }, 308 | additionalProperties: false, 309 | } 310 | ``` 311 | 312 | 313 | ## array type 314 | 315 | The `ArrayType` is used to describe the TypeScript type `Array` and the JavaScript and JSON type `Array`. 316 | 317 | The element type `T` for `const` and `enum` is `Array`, i.e. arrays of JSON-compatible types defined by the `NodeType` in `elementType`. 318 | 319 | The extra and required property `elementType` is of type `NodeType` and defines what types the array can hold. 320 | 321 | Example: 322 | ```ts 323 | { 324 | type: 'array', 325 | elementType: { type: 'string' }, 326 | } 327 | ``` 328 | 329 | 330 | ## tuple type 331 | 332 | The `TupleType` describes specific-length arrays where each position has a specific type. It matches the tuple type `[A, B, ...]` in `TypeScript` and is an `Array` in JavaScript and JSON. 333 | 334 | The element type `T` for `const` and `enum` is `[...any-json-types]`, i.e. tuples of JSON-compatible types defined by the `NodeType` in the required `elementTypes` and `additionalItems`. 335 | 336 | The extra and required properties for `TupleType` are `elementTypes`, `minItems` and `additionalItems`. 337 | 338 | `elementTypes` is defined as `[...NodeType]` and describes the valid types for each position in the tuple for the required and individually typed optional tuple elements. 339 | 340 | `minItems` is an integer (TypeScript/JavaScript number) defining the minimum required elements and must not be negative. If this is greater than the number of `elementTypes`, although valid in core-types per se, some conversions will limit it to the size of `elementTypes`. 341 | 342 | `additionalProperties` is used to describe optional extra elemenents. It is defined as `boolean | NodeType`. When this is `false`, no additional elements are allowed, and if `true` elements are allowed of any type (`AnyType`). Otherwise additional elemenets are allowed of the defined `NodeType`. 343 | 344 | Example: 345 | ```ts 346 | { 347 | type: 'tuple', 348 | elementTypes: [ 349 | { type: 'string' }, 350 | { type: 'boolean' }, // Optional, because minItems is 1 351 | ], 352 | minItems: 1, 353 | additionalItems: { type: 'number' }, 354 | } 355 | ``` 356 | 357 | 358 | ## ref type 359 | 360 | The `RefType` describes references to other named types. Exactly what this means is up to the implementation of the user of core-types, but it is recommended that a reference type in a list of `NodeType`s refers to a named type within that list. This corresponds to TypeScript named types being referred to in the same file as in which the type is defined, or JSON Schema `$ref` references only referring to `#/definitions/*` types. 361 | 362 | A `RefType` has a required property `ref` which is a string corresponding to the name of the reference. 363 | 364 | Example: 365 | ```ts 366 | [ 367 | { 368 | name: 'User', 369 | type: 'object', 370 | properties: { 371 | name: { node: { type: 'string' }, required: true }, 372 | id: { node: { type: 'number' }, required: true }, 373 | }, 374 | additionalProperties: true, 375 | }, 376 | { 377 | name: 'UserList', 378 | type: 'array', 379 | elementType: { type: 'ref', ref: 'User' }, 380 | }, 381 | ] 382 | ``` 383 | 384 | 385 | ## union type 386 | 387 | The `OrType` describes a union of other types. This is equivalent to union types in TypeScript (e.g. `number | string`) and `anyOf` in JSON Schema. 388 | 389 | An `OrType` has a required property `or` which is defined as `Array`. 390 | 391 | Example: 392 | ```ts 393 | { 394 | type: 'or', 395 | or: [ 396 | { type: 'string' }, 397 | { type: 'number' }, 398 | { type: 'ref', ref: 'IdType' }, // Defined somewhere... 399 | }, 400 | } 401 | ``` 402 | 403 | 404 | ## intersection type 405 | 406 | The `AndType` describes an intersection of other types. This is equivalent to intersection types in TypeScript (e.g. `A & B`) and `allOf` in JSON Schema. 407 | 408 | An `AndType` has a required property `and` which is defined as `Array`. 409 | 410 | Example: 411 | ```ts 412 | [ 413 | { 414 | name: 'CommentWithId', 415 | type: 'and', 416 | and: [ 417 | { type: 'ref', ref: 'Comment' }, 418 | { type: 'ref', ref: 'WithId' }, 419 | }, 420 | }, 421 | { 422 | name: 'Comment', 423 | type: 'object', 424 | properties: { 425 | line: { node: { type: 'string' }, required: true }, 426 | user: { node: { type: 'ref', ref: 'User' }, required: true }, 427 | }, 428 | additionalProperties: false, 429 | }, 430 | { 431 | name: 'WithId', 432 | type: 'object', 433 | properties: { 434 | id: { node: { type: 'string' }, required: true }, 435 | }, 436 | additionalProperties: false, 437 | }, 438 | ] 439 | ``` 440 | 441 | 442 | 443 | [npm-image]: https://img.shields.io/npm/v/core-types.svg 444 | [npm-url]: https://npmjs.org/package/core-types 445 | [downloads-image]: https://img.shields.io/npm/dm/core-types.svg 446 | [build-image]: https://img.shields.io/github/actions/workflow/status/grantila/core-types/master.yml?branch=master 447 | [build-url]: https://github.com/grantila/core-types/actions?query=workflow%3AMaster 448 | [coverage-image]: https://coveralls.io/repos/github/grantila/core-types/badge.svg?branch=master 449 | [coverage-url]: https://coveralls.io/github/grantila/core-types?branch=master 450 | [node-version]: https://img.shields.io/node/v/core-types 451 | [node-url]: https://nodejs.org/en/ 452 | 453 | [core-types-json-schema-npm-image]: https://img.shields.io/npm/v/core-types-json-schema.svg 454 | [core-types-json-schema-npm-url]: https://npmjs.org/package/core-types-json-schema 455 | [core-types-json-schema-github-url]: https://github.com/grantila/core-types-json-schema 456 | [core-types-ts-npm-image]: https://img.shields.io/npm/v/core-types-ts.svg 457 | [core-types-ts-npm-url]: https://npmjs.org/package/core-types-ts 458 | [core-types-ts-github-url]: https://github.com/grantila/core-types-ts 459 | [core-types-suretype-npm-image]: https://img.shields.io/npm/v/core-types-suretype.svg 460 | [core-types-suretype-npm-url]: https://npmjs.org/package/core-types-suretype 461 | [core-types-suretype-github-url]: https://github.com/grantila/core-types-suretype 462 | [core-types-graphql-npm-image]: https://img.shields.io/npm/v/core-types-graphql.svg 463 | [core-types-graphql-npm-url]: https://npmjs.org/package/core-types-graphql 464 | [core-types-graphql-github-url]: https://github.com/grantila/core-types-graphql 465 | [typeconv-npm-image]: https://img.shields.io/npm/v/typeconv.svg 466 | [typeconv-npm-url]: https://npmjs.org/package/typeconv 467 | [typeconv-github-url]: https://github.com/grantila/typeconv 468 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | modules: false, 7 | targets: { 8 | node: 'current', 9 | }, 10 | }, 11 | ], 12 | '@babel/preset-typescript', 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/types.js' 2 | export { simplify } from './lib/simplify.js' 3 | export { validate } from './lib/validate.js' 4 | export { 5 | type Comparable, 6 | type ComparableArray, 7 | type ComparableObject, 8 | type ComparablePrimitives, 9 | ensureArray, 10 | hasConstEnum, 11 | intersection, 12 | isEqual, 13 | isNonNullable, 14 | isPrimitiveType, 15 | union, 16 | } from './lib/util.js' 17 | export { 18 | extractAnnotations, 19 | formatDefault, 20 | formatExamples, 21 | formatSee, 22 | mergeAnnotations, 23 | stringify, 24 | stringifyAnnotations, 25 | stripAnnotations, 26 | } from './lib/annotation.js' 27 | export { 28 | type CoreTypesErrorMeta, 29 | type WarnFunction, 30 | decorateError, 31 | decorateErrorMeta, 32 | isCoreTypesError, 33 | MalformedTypeError, 34 | MissingReferenceError, 35 | RelatedError, 36 | throwRelatedError, 37 | throwUnsupportedError, 38 | UnsupportedError, 39 | } from './lib/error.js' 40 | export { 41 | getPositionOffset, 42 | locationToLineColumn, 43 | mergeLocations, 44 | positionToLineColumn, 45 | } from './lib/location.js' 46 | export { 47 | type SomeCallback, 48 | type TraverseCallback, 49 | type TraverseCallbackArgument, 50 | some, 51 | traverse, 52 | } from './lib/traverse.js' 53 | -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grantila/core-types/b8ce2c58563b632b023a521073c09b55c6c1c064/lib/.DS_Store -------------------------------------------------------------------------------- /lib/__snapshots__/annotation.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`annotation stringifyAnnotations should stringify everything corrently with arrays 1`] = ` 4 | "The description 5 | on multiple lines... 6 | 7 | @example 8 | Ex1 9 | @example 10 | Ex2 is here 11 | 12 | @default 13 | Joe 14 | 15 | @see This thing 16 | @see and this too" 17 | `; 18 | 19 | exports[`annotation stringifyAnnotations should stringify everything corrently with singles 1`] = ` 20 | "The description 21 | on multiple lines... 22 | 23 | @example 24 | The example 25 | 26 | @default 27 | Joe 28 | 29 | @see Interesting" 30 | `; 31 | -------------------------------------------------------------------------------- /lib/__snapshots__/traverse.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`traverse should traverse deeply 1`] = ` 4 | [ 5 | { 6 | "index": undefined, 7 | "parentProperty": undefined, 8 | "path": [], 9 | "required": undefined, 10 | "title": "root", 11 | }, 12 | { 13 | "index": undefined, 14 | "parentProperty": "additionalProperties", 15 | "path": [ 16 | "additionalProperties", 17 | ], 18 | "required": undefined, 19 | "title": "o-ap", 20 | }, 21 | { 22 | "index": "and", 23 | "parentProperty": "properties", 24 | "path": [ 25 | "properties", 26 | "and", 27 | ], 28 | "required": true, 29 | "title": "o-and", 30 | }, 31 | { 32 | "index": "any", 33 | "parentProperty": "properties", 34 | "path": [ 35 | "properties", 36 | "any", 37 | ], 38 | "required": true, 39 | "title": "o-any", 40 | }, 41 | { 42 | "index": "array", 43 | "parentProperty": "properties", 44 | "path": [ 45 | "properties", 46 | "array", 47 | ], 48 | "required": false, 49 | "title": "o-array", 50 | }, 51 | { 52 | "index": "boolean", 53 | "parentProperty": "properties", 54 | "path": [ 55 | "properties", 56 | "boolean", 57 | ], 58 | "required": true, 59 | "title": "o-boolean", 60 | }, 61 | { 62 | "index": "integer", 63 | "parentProperty": "properties", 64 | "path": [ 65 | "properties", 66 | "integer", 67 | ], 68 | "required": false, 69 | "title": "o-integer", 70 | }, 71 | { 72 | "index": "null", 73 | "parentProperty": "properties", 74 | "path": [ 75 | "properties", 76 | "null", 77 | ], 78 | "required": true, 79 | "title": "o-null", 80 | }, 81 | { 82 | "index": "number", 83 | "parentProperty": "properties", 84 | "path": [ 85 | "properties", 86 | "number", 87 | ], 88 | "required": true, 89 | "title": "o-number", 90 | }, 91 | { 92 | "index": "object", 93 | "parentProperty": "properties", 94 | "path": [ 95 | "properties", 96 | "object", 97 | ], 98 | "required": true, 99 | "title": "o-object", 100 | }, 101 | { 102 | "index": "or", 103 | "parentProperty": "properties", 104 | "path": [ 105 | "properties", 106 | "or", 107 | ], 108 | "required": true, 109 | "title": "o-or", 110 | }, 111 | { 112 | "index": "ref", 113 | "parentProperty": "properties", 114 | "path": [ 115 | "properties", 116 | "ref", 117 | ], 118 | "required": true, 119 | "title": "o-ref", 120 | }, 121 | { 122 | "index": "string", 123 | "parentProperty": "properties", 124 | "path": [ 125 | "properties", 126 | "string", 127 | ], 128 | "required": true, 129 | "title": "o-string", 130 | }, 131 | { 132 | "index": "tuple", 133 | "parentProperty": "properties", 134 | "path": [ 135 | "properties", 136 | "tuple", 137 | ], 138 | "required": true, 139 | "title": "o-tuple", 140 | }, 141 | { 142 | "index": undefined, 143 | "parentProperty": "elementType", 144 | "path": [ 145 | "properties", 146 | "array", 147 | "elementType", 148 | ], 149 | "required": undefined, 150 | "title": "o-array-null", 151 | }, 152 | { 153 | "index": undefined, 154 | "parentProperty": "additionalProperties", 155 | "path": [ 156 | "properties", 157 | "object", 158 | "additionalProperties", 159 | ], 160 | "required": undefined, 161 | "title": "o-o-ap", 162 | }, 163 | { 164 | "index": undefined, 165 | "parentProperty": "additionalItems", 166 | "path": [ 167 | "properties", 168 | "tuple", 169 | "additionalItems", 170 | ], 171 | "required": undefined, 172 | "title": "o-tuple-any", 173 | }, 174 | { 175 | "index": 0, 176 | "parentProperty": "and", 177 | "path": [ 178 | "properties", 179 | "and", 180 | "and", 181 | 0, 182 | ], 183 | "required": undefined, 184 | "title": "o-and-number", 185 | }, 186 | { 187 | "index": "foo", 188 | "parentProperty": "properties", 189 | "path": [ 190 | "properties", 191 | "object", 192 | "properties", 193 | "foo", 194 | ], 195 | "required": false, 196 | "title": "o-o-foo-integer", 197 | }, 198 | { 199 | "index": 0, 200 | "parentProperty": "or", 201 | "path": [ 202 | "properties", 203 | "or", 204 | "or", 205 | 0, 206 | ], 207 | "required": undefined, 208 | "title": "o-or-number", 209 | }, 210 | { 211 | "index": 1, 212 | "parentProperty": "elementTypes", 213 | "path": [ 214 | "properties", 215 | "tuple", 216 | "elementTypes", 217 | 1, 218 | ], 219 | "required": undefined, 220 | "title": "o-tuple-0-boolean", 221 | }, 222 | { 223 | "index": 0, 224 | "parentProperty": "elementTypes", 225 | "path": [ 226 | "properties", 227 | "tuple", 228 | "elementTypes", 229 | 0, 230 | ], 231 | "required": undefined, 232 | "title": "o-tuple-0-number", 233 | }, 234 | ] 235 | `; 236 | 237 | exports[`traverse should traverse without additionals 1`] = ` 238 | [ 239 | { 240 | "index": undefined, 241 | "parentProperty": undefined, 242 | "path": [], 243 | "required": undefined, 244 | "title": "", 245 | }, 246 | { 247 | "index": "p", 248 | "parentProperty": "properties", 249 | "path": [ 250 | "properties", 251 | "p", 252 | ], 253 | "required": false, 254 | "title": "{"type":"object","properties":{"foo":{"required":true,"node":{"type":"boolean"}}},"additionalProperties":false}", 255 | }, 256 | { 257 | "index": "tup", 258 | "parentProperty": "properties", 259 | "path": [ 260 | "properties", 261 | "tup", 262 | ], 263 | "required": true, 264 | "title": "{"type":"tuple","elementTypes":[{"type":"null"}],"minItems":0,"additionalItems":false}", 265 | }, 266 | { 267 | "index": "foo", 268 | "parentProperty": "properties", 269 | "path": [ 270 | "properties", 271 | "p", 272 | "properties", 273 | "foo", 274 | ], 275 | "required": true, 276 | "title": "{"type":"boolean"}", 277 | }, 278 | { 279 | "index": 0, 280 | "parentProperty": "elementTypes", 281 | "path": [ 282 | "properties", 283 | "tup", 284 | "elementTypes", 285 | 0, 286 | ], 287 | "required": undefined, 288 | "title": "{"type":"null"}", 289 | }, 290 | ] 291 | `; 292 | -------------------------------------------------------------------------------- /lib/annotation.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | arrayOrSingle, 3 | mergeAnnotations, 4 | stringifyAnnotations, 5 | stripAnnotations, 6 | wrapWhitespace, 7 | } from './annotation.js' 8 | import type { CoreTypeAnnotations, NodeType, ObjectProperty } from './types.js' 9 | 10 | 11 | const exampleAnnotation1: Readonly< CoreTypeAnnotations > = Object.freeze( { 12 | title: 'foo', 13 | comment: 'comment', 14 | description: 'description', 15 | default: 'def', 16 | examples: [ 'these', 'are', 'examples' ], 17 | loc: { start: 17, end: 42 }, 18 | name: 'name', 19 | see: 'see this', 20 | } ); 21 | 22 | const exampleAnnotation2: Readonly< CoreTypeAnnotations > = Object.freeze( { 23 | title: 'bar', 24 | comment: 'comment2', 25 | description: 'description2', 26 | default: 'def2', 27 | examples: [ 'more', 'examples', 'yay' ], 28 | loc: { start: 32, end: 64 }, 29 | name: 'name2', 30 | see: 'see this too', 31 | } ); 32 | 33 | describe( "annotation", ( ) => 34 | { 35 | describe( "mergeAnnotations", ( ) => 36 | { 37 | it ( "should handle empty array", ( ) => 38 | { 39 | expect( mergeAnnotations( [ ] ) ).toStrictEqual( { } ); 40 | } ); 41 | 42 | it ( "should handle empty documents", ( ) => 43 | { 44 | expect( mergeAnnotations( [ { } ] ) ).toStrictEqual( { } ); 45 | } ); 46 | 47 | it ( "should handle one non-empty document", ( ) => 48 | { 49 | expect( mergeAnnotations( [ exampleAnnotation1 ] ) ) 50 | .toStrictEqual( exampleAnnotation1 ); 51 | } ); 52 | 53 | it ( "should handle one empty and one non-empty document", ( ) => 54 | { 55 | expect( mergeAnnotations( [ { }, exampleAnnotation1 ] ) ) 56 | .toStrictEqual( exampleAnnotation1 ); 57 | } ); 58 | 59 | it ( "should handle two identical documents", ( ) => 60 | { 61 | expect( 62 | mergeAnnotations( [ exampleAnnotation1, exampleAnnotation1 ] ) 63 | ) 64 | .toStrictEqual( exampleAnnotation1 ); 65 | exampleAnnotation2; 66 | } ); 67 | 68 | it ( "should handle two non-identical documents", ( ) => 69 | { 70 | const ex1 = exampleAnnotation1; 71 | const ex2 = exampleAnnotation2; 72 | expect( 73 | mergeAnnotations( [ ex1, ex2 ] ) 74 | ) 75 | .toStrictEqual( { 76 | title: `${ex1.title}, ${ex2.title}`, 77 | comment: `${ex1.comment}\n${ex2.comment}`, 78 | description: `${ex1.description}\n${ex2.description}`, 79 | default: `${ex1.default}\n${ex2.default}`, 80 | examples: [ ...new Set( [ 81 | ...ex1.examples as any[], ...ex2.examples as any[] 82 | ] ) ], 83 | see: [ ex1.see, ex2.see ], 84 | name: 'name', 85 | loc: { start: 17, end: 64 }, 86 | } as CoreTypeAnnotations ); 87 | } ); 88 | } ); 89 | 90 | describe( "wrapWhitespace", ( ) => 91 | { 92 | it ( "should handle empty comment", ( ) => 93 | { 94 | expect( wrapWhitespace( "" ) ).toBe( " " ); 95 | } ); 96 | 97 | it ( "should handle single line comment", ( ) => 98 | { 99 | expect( wrapWhitespace( "foo" ) ).toBe( " foo" ); 100 | } ); 101 | 102 | it ( "should handle single line comment with leading space", ( ) => 103 | { 104 | expect( wrapWhitespace( " foo" ) ).toBe( " foo" ); 105 | } ); 106 | 107 | it ( "should handle multi line comment", ( ) => 108 | { 109 | expect( wrapWhitespace( "foo\nbar" ) ) 110 | .toBe( "*\n * foo\n * bar\n " ); 111 | } ); 112 | } ); 113 | 114 | describe( "arrayOrSingle", ( ) => 115 | { 116 | it ( "should handle empty array", ( ) => 117 | { 118 | expect( arrayOrSingle( [ ] ) ).toStrictEqual( [ ] ); 119 | } ); 120 | 121 | it ( "should handle one-sized array", ( ) => 122 | { 123 | expect( arrayOrSingle( [ "foo" ] ) ).toStrictEqual( "foo" ); 124 | } ); 125 | 126 | it ( "should handle multi-sized array", ( ) => 127 | { 128 | expect( arrayOrSingle( [ 1, 2 ] ) ).toStrictEqual( [ 1, 2 ] ); 129 | } ); 130 | } ); 131 | 132 | describe( "stripAnnotations", ( ) => 133 | { 134 | it ( "should strip everything", ( ) => 135 | { 136 | let shouldAnnotate = true; 137 | const annotate = ( node: NodeType, force = false ): NodeType => ( { 138 | ...node, 139 | ...( 140 | ( shouldAnnotate || force ) 141 | ? { 142 | comment: 'cmt', 143 | default: 'def', 144 | description: 'descr', 145 | see: 'see', 146 | title: 'title', 147 | examples: 'ex', 148 | } 149 | : { } 150 | ), 151 | } ); 152 | 153 | const prop = ( node: NodeType ): ObjectProperty => ( { 154 | required: false, 155 | node: annotate( node ), 156 | } ); 157 | 158 | const makeNode = ( ): NodeType => ( { 159 | type: 'object', 160 | properties: { 161 | nul: prop( { type: 'null' } ), 162 | str: prop( { type: 'string' } ), 163 | int: prop( { type: 'integer' } ), 164 | num: prop( { type: 'number' } ), 165 | bool: prop( { type: 'boolean' } ), 166 | arr: prop( { 167 | type: 'array', 168 | elementType: annotate( { type: 'any' } ), 169 | // These are value literals, not types 170 | // (although these particular ones may fool you) 171 | const: [ annotate( { type: 'boolean' }, true ) ], 172 | enum: [ [ annotate( { type: 'integer' }, true ) ] ], 173 | } ), 174 | tup: prop( { 175 | type: 'tuple', 176 | minItems: 2, 177 | elementTypes: [ 178 | annotate( { type: 'number' } ), 179 | annotate( { type: 'integer' } ), 180 | ], 181 | additionalItems: annotate( { type: 'string' } ) 182 | } ), 183 | ref: prop( { type: 'ref', ref: 'R2' } ), 184 | and: prop( { type: 'and', and: [ 185 | annotate( { type: 'string' } ), 186 | annotate( { type: 'boolean' } ), 187 | ] } ), 188 | or: prop( { type: 'or', or: [ 189 | annotate( { type: 'number' } ), 190 | annotate( { type: 'integer' } ), 191 | ] } ), 192 | }, 193 | additionalProperties: annotate( { type: 'ref', ref: 'R' } ), 194 | } ); 195 | 196 | const before = makeNode( ); 197 | shouldAnnotate = false; 198 | const after = makeNode( ); 199 | 200 | expect( stripAnnotations( before ) ).toStrictEqual( after ); 201 | } ); 202 | } ); 203 | 204 | describe( "stringifyAnnotations", ( ) => 205 | { 206 | const getAnnotations = ( multi: boolean ): CoreTypeAnnotations => ( { 207 | name: "Name", 208 | title: "Title", 209 | description: "The description\non multiple lines...", 210 | examples: multi ? [ "Ex1", "Ex2 is here" ] : 'The example', 211 | default: "Joe", 212 | see: multi ? [ "This thing", "and this too" ] : 'Interesting', 213 | comment: "Should be secret by default", 214 | } ); 215 | 216 | it( "should stringify everything corrently with arrays", ( ) => 217 | { 218 | const text = stringifyAnnotations( getAnnotations( true ) ); 219 | expect( text ).toMatchSnapshot( ); 220 | } ); 221 | 222 | it( "should stringify everything corrently with singles", ( ) => 223 | { 224 | const text = stringifyAnnotations( getAnnotations( false ) ); 225 | expect( text ).toMatchSnapshot( ); 226 | } ); 227 | } ); 228 | } ); 229 | -------------------------------------------------------------------------------- /lib/annotation.ts: -------------------------------------------------------------------------------- 1 | import { mergeLocations } from './location.js' 2 | import type { CoreTypeAnnotations, NodeType } from './types.js' 3 | import { ensureArray, uniq } from './util.js' 4 | 5 | 6 | export function mergeAnnotations( nodes: Array< CoreTypeAnnotations > ) 7 | : CoreTypeAnnotations 8 | { 9 | const nonEmpty = < T >( t: T ): t is NonNullable< T > => !!t; 10 | const join = < T >( t: Array< T >, separator = '\n' ) => 11 | uniq( t.filter( nonEmpty ) ).join( separator ).trim( ); 12 | 13 | const name = nodes.find( n => n.name )?.name; 14 | const title = join( nodes.map( n => n.title ), ', ' ); 15 | const description = join( nodes.map( n => n.description ) ); 16 | const examples = uniq( 17 | ( [ ] as Array< string > ).concat( 18 | ...nodes.map( n => ensureArray( n.examples ) ) 19 | ) 20 | .filter( nonEmpty ) 21 | ); 22 | const _default = join( nodes.map( n => n.default ) ); 23 | const see = uniq( 24 | ( [ ] as Array< string > ).concat( 25 | ...nodes.map( n => ensureArray( n.see ) ) 26 | ) 27 | .filter( nonEmpty ) 28 | ); 29 | const comment = join( nodes.map( n => n.comment ) ); 30 | const loc = mergeLocations( nodes.map( n => n.loc ) ); 31 | 32 | return { 33 | ...( name ? { name } : { } ), 34 | ...( title ? { title } : { } ), 35 | ...( description ? { description } : { } ), 36 | ...( 37 | examples.length > 0 38 | ? { examples: arrayOrSingle( examples ) } 39 | : { } 40 | ), 41 | ...( _default ? { default: _default } : { } ), 42 | ...( 43 | see.length > 0 44 | ? { see: arrayOrSingle( see ) } 45 | : { } 46 | ), 47 | ...( comment ? { comment } : { } ), 48 | ...( loc ? { loc } : { } ), 49 | }; 50 | } 51 | 52 | export function extractAnnotations( node: CoreTypeAnnotations ) 53 | : CoreTypeAnnotations 54 | { 55 | const { 56 | title, 57 | description, 58 | examples, 59 | default: 60 | _default, 61 | comment, 62 | see, 63 | } = node; 64 | 65 | return { 66 | ...( title ? { title } : { } ), 67 | ...( description ? { description } : { } ), 68 | ...( examples ? { examples } : { } ), 69 | ...( _default ? { default: _default } : { } ), 70 | ...( comment ? { comment } : { } ), 71 | ...( see ? { see } : { } ), 72 | }; 73 | } 74 | 75 | export interface StringifyAnnotationsOptions 76 | { 77 | /** 78 | * Include the comment part of the annotation 79 | */ 80 | includeComment?: boolean; 81 | 82 | /** 83 | * Format whitespace so that the comment can be either wrapped within 84 | * a \/* and *\/ boundary or prefixed with // 85 | */ 86 | formatWhitespace?: boolean; 87 | } 88 | 89 | export function wrapWhitespace( text: string ): string 90 | { 91 | if ( !text.includes( "\n" ) ) 92 | return text.startsWith( " " ) ? text : ` ${text}`; 93 | 94 | return [ 95 | "*", 96 | text.split( "\n" ).map( line => ` * ${line}` ).join( "\n" ), 97 | " " 98 | ].join( "\n" ); 99 | } 100 | 101 | function makeSafeComment( text: string ): string 102 | { 103 | return text.replace( /\*\//g, '*\\/' ); 104 | } 105 | 106 | export function stringifyAnnotations( 107 | node: CoreTypeAnnotations, 108 | { 109 | includeComment = false, 110 | formatWhitespace = false, 111 | }: StringifyAnnotationsOptions = { } 112 | ) 113 | : string 114 | { 115 | const { description, examples, default: _default, comment, see } = node; 116 | const fullComment = makeSafeComment( 117 | [ 118 | description, 119 | ...( examples == undefined ? [ ] : [ 120 | formatExamples( ensureArray( examples ) ) 121 | ] ), 122 | ...( _default === undefined ? [ ] : [ 123 | formatDefault( _default ) 124 | ] ), 125 | ...( see == undefined ? [ ] : [ 126 | formatSee( ensureArray( see ) ) 127 | ] ), 128 | ...( includeComment ? [ comment ] : [ ] ), 129 | ] 130 | .filter( v => v ) 131 | .join( "\n\n" ) 132 | .trim( ) 133 | ); 134 | 135 | return formatWhitespace && fullComment 136 | ? wrapWhitespace( fullComment ) 137 | : fullComment; 138 | } 139 | 140 | export function stripAnnotations< T extends NodeType >( 141 | node: T, 142 | recursive = true 143 | ) 144 | : T 145 | { 146 | const { 147 | comment, 148 | description, 149 | default: _default, 150 | examples, 151 | see, 152 | title, 153 | ...rest 154 | } = node; 155 | 156 | const filteredNode = rest as NodeType & T; 157 | 158 | if ( recursive ) 159 | { 160 | if ( filteredNode.type === 'and' ) 161 | return { 162 | ...filteredNode, 163 | and: filteredNode.and.map( n => stripAnnotations( n, true ) ), 164 | }; 165 | else if ( filteredNode.type === 'or' ) 166 | return { 167 | ...filteredNode, 168 | or: filteredNode.or.map( n => stripAnnotations( n, true ) ), 169 | }; 170 | else if ( filteredNode.type === 'array' ) 171 | return { 172 | ...filteredNode, 173 | elementType: 174 | stripAnnotations( filteredNode.elementType, true ), 175 | }; 176 | else if ( filteredNode.type === 'tuple' ) 177 | return { 178 | ...filteredNode, 179 | elementTypes: filteredNode.elementTypes.map( n => 180 | stripAnnotations( n, true ) 181 | ), 182 | additionalItems: 183 | typeof filteredNode.additionalItems === 'object' 184 | ? stripAnnotations( filteredNode.additionalItems, true ) 185 | : filteredNode.additionalItems, 186 | }; 187 | else if ( filteredNode.type === 'object' ) 188 | return { 189 | ...filteredNode, 190 | properties: Object.fromEntries( 191 | Object.keys( filteredNode.properties ).map( key => 192 | [ 193 | key, 194 | { 195 | ...filteredNode.properties[ key ], 196 | node: stripAnnotations( 197 | filteredNode.properties[ key ].node, 198 | true 199 | ), 200 | } 201 | ] 202 | ) 203 | ), 204 | additionalProperties: 205 | typeof filteredNode.additionalProperties === 'object' 206 | ? stripAnnotations( 207 | filteredNode.additionalProperties, 208 | true 209 | ) 210 | : filteredNode.additionalProperties, 211 | }; 212 | } 213 | 214 | return filteredNode; 215 | } 216 | 217 | export function arrayOrSingle< T >( arr: Array< T > ): T | Array< T > 218 | { 219 | if ( arr.length === 1 ) 220 | return arr[ 0 ]; 221 | return arr; 222 | } 223 | 224 | export function formatExamples( examples: Array< string > ): string 225 | { 226 | const lines = 227 | examples.map( example => 228 | "@example\n" + indent( stringify( example ).split( "\n" ), 4 ) 229 | ) 230 | .join( "\n" ); 231 | 232 | return lines.trim( ); 233 | } 234 | 235 | export function formatDefault( _default: string ): string 236 | { 237 | const lines = [ 238 | "@default", 239 | indent( stringify( _default ).split( "\n" ), 4 ) 240 | ] 241 | .join( "\n" ); 242 | 243 | return lines.trim( ); 244 | } 245 | 246 | export function formatSee( see: Array< string > ): string 247 | { 248 | const lines = 249 | see.map( see => "@see " + stringify( see ) ) 250 | .join( "\n" ); 251 | 252 | return lines.trim( ); 253 | } 254 | 255 | export function stringify( value: any ) 256 | { 257 | return typeof value === "string" 258 | ? value 259 | : JSON.stringify( value, null, 2 ); 260 | } 261 | 262 | function indent( lines: Array< string >, indent: number, bullet = false ) 263 | { 264 | return lines 265 | .map( ( line, index ) => 266 | { 267 | const prefix = 268 | index === 0 && bullet 269 | ? ( ' '.repeat( indent - 2 ) + "* " ) 270 | : ' '.repeat( indent ); 271 | return prefix + line; 272 | } ) 273 | .join( "\n" ); 274 | } 275 | -------------------------------------------------------------------------------- /lib/error.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CoreTypesError, 3 | type CoreTypesErrorMeta, 4 | decorateError, 5 | decorateErrorMeta, 6 | isCoreTypesError, 7 | MalformedTypeError, 8 | MissingReferenceError, 9 | RelatedError, 10 | throwRelatedError, 11 | throwUnsupportedError, 12 | UnsupportedError, 13 | } from './error.js' 14 | 15 | 16 | const catchError = < T = CoreTypesError >( thrower: ( ) => any ): T => 17 | { 18 | try 19 | { 20 | thrower( ); 21 | } 22 | catch ( err ) 23 | { 24 | return err as T; 25 | } 26 | throw new Error( "No error thrown" ); 27 | } 28 | 29 | describe( "errors", ( ) => 30 | { 31 | describe( "MalformedTypeError", ( ) => 32 | { 33 | it( "should contain the blob", ( ) => 34 | { 35 | const err = new MalformedTypeError( "msg", { blob: { foo: 42 } } ); 36 | expect( err.message ).toEqual( "msg" ); 37 | expect( err.blob ).toStrictEqual( { foo: 42 } ); 38 | } ); 39 | } ); 40 | 41 | describe( "MissingReferenceError", ( ) => 42 | { 43 | it( "should contain the blob", ( ) => 44 | { 45 | const err = 46 | new MissingReferenceError( "MyType", { blob: { foo: 42 } } ); 47 | expect( err.message ).toMatch( /missing.*MyType/ ); 48 | expect( err.blob ).toStrictEqual( { foo: 42 } ); 49 | } ); 50 | } ); 51 | 52 | describe( "UnsupportedError", ( ) => 53 | { 54 | it( "should contain the blob", ( ) => 55 | { 56 | const err = new UnsupportedError( "msg", { blob: { foo: 42 } } ); 57 | expect( err.message ).toEqual( "msg" ); 58 | expect( err.blob ).toStrictEqual( { foo: 42 } ); 59 | } ); 60 | } ); 61 | 62 | describe( "throwUnsupportedError", ( ) => 63 | { 64 | it( "should forward data properly", ( ) => 65 | { 66 | const err = catchError( 67 | ( ) => 68 | throwUnsupportedError( 69 | "msg", 70 | { type: 'string' }, 71 | [ 'a', 'b' ] 72 | ) 73 | ); 74 | expect( err ).toBeInstanceOf( UnsupportedError ); 75 | expect( err.message ).toEqual( "msg" ); 76 | expect( err.blob ).toStrictEqual( { type: 'string' } ); 77 | expect( err.path ).toEqual( [ 'a', 'b' ] ); 78 | } ); 79 | } ); 80 | 81 | describe( "throwRelatedError", ( ) => 82 | { 83 | it( "should forward data properly", ( ) => 84 | { 85 | const related = new Error( "foo" ); 86 | const err = catchError( 87 | ( ) => 88 | throwRelatedError( 89 | related, 90 | { 91 | source: 'src', 92 | filename: 'file', 93 | }, 94 | ) 95 | ); 96 | expect( err ).toBeInstanceOf( RelatedError ); 97 | expect( err.message ).toEqual( "foo" ); 98 | expect( err.relatedError ).toBe( related ); 99 | expect( err.source ).toEqual( 'src' ); 100 | expect( err.filename ).toEqual( 'file' ); 101 | } ); 102 | } ); 103 | 104 | describe( "isCoreTypesError", ( ) => 105 | { 106 | it( "should be true for CoreTypesErrors", ( ) => 107 | { 108 | const err1 = catchError< unknown >( 109 | ( ) => throwUnsupportedError( "foo", { type: 'string' } ) 110 | ); 111 | expect( isCoreTypesError( err1 ) ).toBe( true ); 112 | 113 | const err2 = catchError< unknown >( 114 | ( ) => throwRelatedError( err1 as CoreTypesError ) 115 | ); 116 | expect( isCoreTypesError( err2 ) ).toBe( true ); 117 | } ); 118 | 119 | it( "should be false for non-CoreTypesErrors", ( ) => 120 | { 121 | expect( isCoreTypesError( new Error( ) ) ).toBe( false ); 122 | } ); 123 | } ); 124 | 125 | describe( "decorateError", ( ) => 126 | { 127 | it( "should", ( ) => 128 | { 129 | const err = catchError( 130 | ( ) => 131 | throwRelatedError( new Error( 'rel' ), { source: 'src' } ) 132 | ); 133 | decorateError( err, { filename: 'file' } ); 134 | 135 | expect( err.filename ).toEqual( 'file' ); 136 | } ); 137 | } ); 138 | 139 | describe( "decorateErrorMeta", ( ) => 140 | { 141 | it( "should", ( ) => 142 | { 143 | const target: CoreTypesErrorMeta = { 144 | blob: { blobby: true }, 145 | path: [ 'a' ], 146 | loc: { start: { offset: 4, line: 1, column: 4 } }, 147 | source: 'src', 148 | filename: 'file', 149 | }; 150 | const bak = JSON.parse( JSON.stringify( target ) ); 151 | const out = decorateErrorMeta( 152 | target, 153 | { 154 | blob: { blobby: false }, 155 | path: [ 'b' ], 156 | loc: { start: { offset: 6, line: 1, column: 6 } }, 157 | source: 'src-data', 158 | filename: 'file.x', 159 | } 160 | ); 161 | 162 | expect( out ).toBe( target ); 163 | expect( target ).toStrictEqual( bak ); 164 | } ); 165 | } ); 166 | } ); 167 | -------------------------------------------------------------------------------- /lib/error.ts: -------------------------------------------------------------------------------- 1 | import type { Location, NodePath, NodeType } from './types.js' 2 | 3 | 4 | export interface CoreTypesErrorMeta 5 | { 6 | blob?: any; 7 | path?: NodePath; 8 | loc?: Location; 9 | source?: string; 10 | filename?: string; 11 | relatedError?: Error; 12 | } 13 | 14 | export class CoreTypesError extends Error implements CoreTypesErrorMeta 15 | { 16 | public blob?: any; 17 | public path?: NodePath; 18 | public loc?: Location; 19 | public source?: string; 20 | public filename?: string; 21 | public relatedError?: Error; 22 | 23 | constructor( message: string, meta: CoreTypesErrorMeta = { } ) 24 | { 25 | super( message ); 26 | Object.setPrototypeOf( this, CoreTypesError.prototype ); 27 | 28 | this.blob = meta.blob; 29 | this.path = meta.path; 30 | this.loc = meta.loc; 31 | this.source = meta.source; 32 | this.filename = meta.filename; 33 | this.relatedError = meta.relatedError; 34 | } 35 | } 36 | 37 | export class MalformedTypeError extends CoreTypesError 38 | { 39 | constructor( message: string, meta: CoreTypesErrorMeta = { } ) 40 | { 41 | super( message, meta ); 42 | Object.setPrototypeOf( this, MalformedTypeError.prototype ); 43 | } 44 | } 45 | 46 | export class MissingReferenceError extends CoreTypesError 47 | { 48 | constructor( ref: string, meta: CoreTypesErrorMeta = { } ) 49 | { 50 | super( `Reference to missing type "${ref}"`, meta ); 51 | Object.setPrototypeOf( this, MissingReferenceError.prototype ); 52 | } 53 | } 54 | 55 | export class UnsupportedError extends CoreTypesError 56 | { 57 | constructor( message: string, meta: CoreTypesErrorMeta = { } ) 58 | { 59 | super( message, meta ); 60 | Object.setPrototypeOf( this, UnsupportedError.prototype ); 61 | } 62 | } 63 | 64 | export class RelatedError extends CoreTypesError 65 | { 66 | constructor( err: Error, meta: CoreTypesErrorMeta = { } ) 67 | { 68 | super( err.message, { ...meta, relatedError: err } ); 69 | Object.setPrototypeOf( this, RelatedError.prototype ); 70 | } 71 | } 72 | 73 | export function throwUnsupportedError( 74 | msg: string, 75 | node: NodeType, 76 | path?: NodePath 77 | ) 78 | : never 79 | { 80 | throw new UnsupportedError( 81 | msg, 82 | { 83 | blob: node, 84 | ...( node.loc ? { loc: node.loc } : { } ), 85 | ...( path ? { path } : { } ), 86 | } 87 | ); 88 | } 89 | 90 | export function throwRelatedError( 91 | err: Error, 92 | meta?: Omit< CoreTypesErrorMeta, 'relatedError' > 93 | ) 94 | : never 95 | { 96 | throw new RelatedError( err, meta ); 97 | } 98 | 99 | export function isCoreTypesError( err: unknown | Error | CoreTypesError ) 100 | : err is CoreTypesError 101 | { 102 | return err instanceof CoreTypesError; 103 | } 104 | 105 | export function decorateErrorMeta( 106 | target: CoreTypesErrorMeta, 107 | source: Partial< CoreTypesErrorMeta > 108 | ) 109 | : CoreTypesErrorMeta 110 | { 111 | if ( source.blob ) 112 | target.blob ??= source.blob; 113 | if ( source.path ) 114 | target.path ??= source.path; 115 | if ( source.loc ) 116 | target.loc ??= source.loc; 117 | if ( source.source ) 118 | target.source ??= source.source; 119 | if ( source.filename ) 120 | target.filename ??= source.filename; 121 | return target; 122 | } 123 | 124 | export function decorateError< T extends Error >( 125 | err: T, 126 | meta: Partial< CoreTypesErrorMeta > 127 | ) 128 | : T 129 | { 130 | if ( isCoreTypesError( err ) ) 131 | decorateErrorMeta( err as CoreTypesErrorMeta, meta ); 132 | 133 | return err; 134 | } 135 | 136 | export type WarnFunction = ( msg: string, meta?: CoreTypesErrorMeta ) => void; 137 | -------------------------------------------------------------------------------- /lib/location.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPositionOffset, 3 | locationToLineColumn, 4 | mergeLocations, 5 | positionToLineColumn, 6 | } from './location.js' 7 | import type { LocationWithLineColumn } from './types.js' 8 | 9 | 10 | describe( "location", ( ) => 11 | { 12 | describe( "positionToLineColumn", ( ) => 13 | { 14 | it( "empty string, pos 0", ( ) => 15 | { 16 | expect( positionToLineColumn( "", 0 ) ).toStrictEqual( { 17 | line: 1, 18 | column: 0, 19 | offset: 0, 20 | } ); 21 | } ); 22 | } ); 23 | 24 | describe( "locationToLineColumn", ( ) => 25 | { 26 | it( "should return location if start is an object", ( ) => 27 | { 28 | const loc = { start: { } } as LocationWithLineColumn; 29 | expect( locationToLineColumn( '', loc ) ).toBe( loc ); 30 | } ); 31 | 32 | it( "should return location undefined if undefined", ( ) => 33 | { 34 | const loc = { start: undefined } as LocationWithLineColumn; 35 | expect( locationToLineColumn( '', loc ) ).toStrictEqual( loc ); 36 | } ); 37 | 38 | it( "should return only start properly", ( ) => 39 | { 40 | const loc = { start: 4 }; 41 | expect( locationToLineColumn( 'hello world', loc ) ) 42 | .toStrictEqual( { start: { offset: 4, line: 1, column: 4 } } ); 43 | } ); 44 | 45 | it( "should return start and end properly", ( ) => 46 | { 47 | const loc = { start: 4, end: 6 }; 48 | expect( locationToLineColumn( 'hello world', loc ) ) 49 | .toStrictEqual( { 50 | start: { offset: 4, line: 1, column: 4 }, 51 | end: { offset: 6, line: 1, column: 6 }, 52 | } ); 53 | } ); 54 | } ); 55 | 56 | describe( "getPositionOffset", ( ) => 57 | { 58 | it( "undefined", ( ) => 59 | { 60 | expect( getPositionOffset( undefined ) ).toBe( undefined ); 61 | } ); 62 | 63 | it( "number", ( ) => 64 | { 65 | expect( getPositionOffset( 47 ) ).toBe( 47 ); 66 | } ); 67 | 68 | it( "object", ( ) => 69 | { 70 | expect( getPositionOffset( { offset: 42, line: 2, column: 4 } ) ) 71 | .toBe( 42 ); 72 | } ); 73 | } ); 74 | 75 | describe( "mergeLocations", ( ) => 76 | { 77 | it( "should merge", ( ) => 78 | { 79 | expect( 80 | mergeLocations( [ 81 | undefined, 82 | { 83 | start: 5, 84 | end: 4, 85 | }, 86 | { 87 | start: 3, 88 | } 89 | ] ) 90 | ).toStrictEqual( { start: 3, end: 4 } ); 91 | } ); 92 | 93 | it( "should pick lowest start and highest end", ( ) => 94 | { 95 | expect( 96 | mergeLocations( [ 97 | undefined, 98 | { 99 | start: 5, 100 | end: 10, 101 | }, 102 | { 103 | start: 7, 104 | end: 12, 105 | } 106 | ] ) 107 | ).toStrictEqual( { start: 5, end: 12 } ); 108 | } ); 109 | } ); 110 | } ); 111 | -------------------------------------------------------------------------------- /lib/location.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LineColumn, 3 | LocationOffset, 4 | LocationWithLineColumn, 5 | } from './types.js' 6 | import { isNonNullable } from './util.js' 7 | 8 | 9 | export function positionToLineColumn( text: string, pos: number ) 10 | : LineColumn 11 | { 12 | const line = text.slice( 0, pos ).split( "\n" ).length; 13 | const columnIndex = text.lastIndexOf( "\n", pos ); 14 | return columnIndex === -1 15 | ? { offset: pos, line, column: pos } 16 | : { offset: pos, line, column: pos - columnIndex }; 17 | } 18 | 19 | export function locationToLineColumn( 20 | text: string, 21 | loc: LocationOffset | LocationWithLineColumn 22 | ) 23 | : LocationWithLineColumn 24 | { 25 | if ( typeof loc.start === 'object' ) 26 | return loc as LocationWithLineColumn; 27 | 28 | return { 29 | start: typeof loc.start === 'undefined' 30 | ? undefined 31 | : positionToLineColumn( text, loc.start ), 32 | ...( 33 | loc.end == null 34 | ? { } 35 | : { end: positionToLineColumn( text, loc.end as number ) } 36 | ), 37 | }; 38 | } 39 | 40 | export function getPositionOffset< T extends LineColumn | number | undefined >( 41 | pos: T 42 | ) 43 | : T extends undefined ? undefined : number 44 | { 45 | type Ret = T extends undefined ? undefined : number; 46 | 47 | if ( typeof pos === 'undefined' ) 48 | return pos as undefined as Ret; 49 | else if ( typeof pos === 'number' ) 50 | return pos as number as Ret; 51 | return pos.offset as Ret; 52 | } 53 | 54 | /** 55 | * Use the smallest {start} and the biggest {end} to make a range consiting of 56 | * all locations 57 | */ 58 | export function mergeLocations( 59 | locations: Array< LocationOffset | LocationWithLineColumn | undefined > 60 | ) 61 | : LocationOffset | LocationWithLineColumn | undefined 62 | { 63 | interface Boundary { 64 | location: 65 | | LocationOffset[ 'start' ] 66 | | LocationWithLineColumn[ 'end' ] 67 | | undefined; 68 | offset: number; 69 | } 70 | let low: Boundary | undefined; 71 | let high: Boundary | undefined; 72 | 73 | const getOffset = 74 | ( loc: LocationOffset[ 'end' ] | LocationWithLineColumn[ 'end' ] ) => 75 | typeof loc === 'number' ? loc : loc?.offset; 76 | 77 | locations 78 | .filter( isNonNullable ) 79 | .forEach( ( { start, end } ) => 80 | { 81 | const startOffset = getOffset( start ); 82 | const endOffset = getOffset( end ); 83 | if ( startOffset !== undefined ) 84 | { 85 | if ( 86 | !low 87 | || 88 | typeof low.location === 'number' 89 | && 90 | low.location === startOffset 91 | || 92 | low.offset > startOffset 93 | ) 94 | low = { 95 | location: start, 96 | offset: startOffset, 97 | }; 98 | } 99 | if ( endOffset !== undefined ) 100 | { 101 | if ( 102 | !high 103 | || 104 | typeof high.location === 'number' 105 | && 106 | high.location === startOffset 107 | || 108 | high.offset < endOffset 109 | ) 110 | high = { 111 | location: end, 112 | offset: endOffset, 113 | }; 114 | } 115 | } ); 116 | 117 | const start = low?.location; 118 | const end = high?.location; 119 | 120 | if ( typeof start === 'undefined' && typeof end === 'undefined' ) 121 | return undefined; 122 | 123 | if ( 124 | typeof ( start as LineColumn )?.offset !== 'undefined' 125 | && 126 | ( 127 | typeof ( end as LineColumn )?.offset !== 'undefined' 128 | || 129 | typeof end === 'undefined' 130 | ) 131 | ) 132 | return { 133 | start: start as LineColumn, 134 | end: end as LineColumn | undefined, 135 | }; 136 | 137 | return { 138 | start: getOffset( start ) ?? 0, 139 | end: getOffset( end ), 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /lib/simplifications/const-enum.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combineConstAndEnum, 3 | mergeConstEnumUnion, 4 | simplifyEnumAndConst, 5 | } from './const-enum.js' 6 | 7 | 8 | describe( "combineConstAndEnum", ( ) => 9 | { 10 | it( "no const or enum", ( ) => 11 | { 12 | expect( combineConstAndEnum( { } ) ).toStrictEqual( [ ] ); 13 | } ); 14 | 15 | it( "only const", ( ) => 16 | { 17 | expect( 18 | combineConstAndEnum( { const: 'foo' } ) 19 | ).toStrictEqual( 20 | [ 'foo' ] 21 | ); 22 | } ); 23 | 24 | it( "only enum", ( ) => 25 | { 26 | expect( 27 | combineConstAndEnum( { enum: [ 'foo', 'bar' ] } ) 28 | ).toStrictEqual( 29 | [ 'foo', 'bar' ] 30 | ); 31 | } ); 32 | 33 | it( "both const and enum", ( ) => 34 | { 35 | expect( 36 | combineConstAndEnum( { const: 'baz', enum: [ 'foo', 'bar' ] } ) 37 | ).toStrictEqual( 38 | [ 'baz', 'foo', 'bar' ] 39 | ); 40 | } ); 41 | } ); 42 | 43 | describe( "mergeConstEnumUnion", ( ) => 44 | { 45 | it( "no types", ( ) => 46 | { 47 | expect( mergeConstEnumUnion( [ ] ) ).toStrictEqual( [ ] ); 48 | } ); 49 | 50 | it( "one empty type", ( ) => 51 | { 52 | expect( mergeConstEnumUnion( [ { type: 'string' } ] ) ) 53 | .toStrictEqual( [ ] ); 54 | } ); 55 | 56 | it( "one empty two other", ( ) => 57 | { 58 | expect( mergeConstEnumUnion( [ 59 | { type: 'string' }, 60 | { type: 'string', const: 'foo' }, 61 | { type: 'string', enum: [ 'foo', 'bar', 'baz' ] }, 62 | ] ) ).toStrictEqual( [ ] ); 63 | } ); 64 | 65 | it( "one const, one enum", ( ) => 66 | { 67 | expect( mergeConstEnumUnion( [ 68 | { type: 'string', const: 'foo' }, 69 | { type: 'string', enum: [ 'bar', 'foo', 'baz' ] }, 70 | ] ) ).toStrictEqual( [ 'foo', 'bar', 'baz' ] ); 71 | } ); 72 | 73 | // it( "only const", ( ) => 74 | // { 75 | // expect( 76 | // mergeConstEnumUnion( { const: 'foo' } ) 77 | // ).toStrictEqual( 78 | // [ 'foo' ] 79 | // ); 80 | // } ); 81 | 82 | // it( "only enum", ( ) => 83 | // { 84 | // expect( 85 | // mergeConstEnumUnion( { enum: [ 'foo', 'bar' ] } ) 86 | // ).toStrictEqual( 87 | // [ 'foo', 'bar' ] 88 | // ); 89 | // } ); 90 | 91 | // it( "both const and enum", ( ) => 92 | // { 93 | // expect( 94 | // mergeConstEnumUnion( { const: 'baz', enum: [ 'foo', 'bar' ] } ) 95 | // ).toStrictEqual( 96 | // [ 'baz', 'foo', 'bar' ] 97 | // ); 98 | // } ); 99 | } ); 100 | 101 | describe( "simplifyEnumAndConst", ( ) => 102 | { 103 | describe( "boolean", ( ) => 104 | { 105 | it( "without enum/const", ( ) => 106 | { 107 | expect( 108 | simplifyEnumAndConst( { type: 'boolean' } ) 109 | ).toStrictEqual( 110 | { type: 'boolean' } 111 | ); 112 | } ); 113 | 114 | it( "with 1 const", ( ) => 115 | { 116 | expect( 117 | simplifyEnumAndConst( { type: 'boolean', const: false } ) 118 | ).toStrictEqual( 119 | { type: 'boolean', const: false } 120 | ); 121 | } ); 122 | 123 | it( "with 1 enum", ( ) => 124 | { 125 | expect( 126 | simplifyEnumAndConst( { type: 'boolean', enum: [ false ] } ) 127 | ).toStrictEqual( 128 | { type: 'boolean', const: false } 129 | ); 130 | } ); 131 | 132 | it( "with 2 enum", ( ) => 133 | { 134 | expect( 135 | simplifyEnumAndConst( { type: 'boolean', enum: [ false, true ] } ) 136 | ).toStrictEqual( 137 | { type: 'boolean' } 138 | ); 139 | } ); 140 | } ); 141 | 142 | describe( "string", ( ) => 143 | { 144 | it( "without enum/const", ( ) => 145 | { 146 | expect( 147 | simplifyEnumAndConst( { type: 'string' } ) 148 | ).toStrictEqual( 149 | { type: 'string' } 150 | ); 151 | } ); 152 | 153 | it( "with 1 const", ( ) => 154 | { 155 | expect( 156 | simplifyEnumAndConst( { type: 'string', const: 'foo' } ) 157 | ).toStrictEqual( 158 | { type: 'string', const: 'foo' } 159 | ); 160 | } ); 161 | 162 | it( "with 1 enum", ( ) => 163 | { 164 | expect( 165 | simplifyEnumAndConst( { type: 'string', enum: [ 'foo' ] } ) 166 | ).toStrictEqual( 167 | { type: 'string', const: 'foo' } 168 | ); 169 | } ); 170 | 171 | it( "with 2 enum", ( ) => 172 | { 173 | expect( 174 | simplifyEnumAndConst( 175 | { type: 'string', enum: [ 'foo', 'bar' ] } 176 | ) 177 | ).toStrictEqual( 178 | { type: 'string', enum: [ 'foo', 'bar' ] } 179 | ); 180 | } ); 181 | 182 | it( "with duplicate enum and const", ( ) => 183 | { 184 | const { enum: _enum, ...rest } = simplifyEnumAndConst( 185 | { 186 | type: 'string', 187 | const: 'bar', 188 | enum: [ 'foo', 'bar', 'foo' ] 189 | } 190 | ); 191 | 192 | expect( rest ).toStrictEqual( { type: 'string' } ); 193 | expect( _enum?.sort( ) ).toStrictEqual( [ 'foo', 'bar' ].sort( ) ); 194 | } ); 195 | } ); 196 | } ); 197 | -------------------------------------------------------------------------------- /lib/simplifications/const-enum.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GenericTypeInfo, 3 | NodeType, 4 | NodeWithConstEnum, 5 | TypeMap, 6 | } from '../types.js' 7 | import { type Comparable, uniq } from '../util.js' 8 | 9 | 10 | export function simplifyEnumAndConst< T extends NodeType, U >( 11 | node: T & GenericTypeInfo< U > 12 | ) 13 | : T & GenericTypeInfo< U > 14 | { 15 | const { const: _const, enum: _enum, ...rest } = node; 16 | type ItemType = UnknownAsComparable< TypeMap[ T[ 'type' ] ] >; 17 | 18 | const combined = 19 | combineConstAndEnum( node as GenericTypeInfo< ItemType > ); 20 | 21 | if ( combined.length === 0 ) 22 | return rest as T & GenericTypeInfo< U >; 23 | else if ( combined.length === 1 ) 24 | return { ...( rest as typeof node ), const: combined[ 0 ] } as 25 | T & GenericTypeInfo< U >; 26 | else 27 | { 28 | if ( 29 | node.type === 'boolean' 30 | && 31 | ( combined as Array< unknown > ).includes( false ) 32 | && 33 | ( combined as Array< unknown > ).includes( true ) 34 | ) 35 | // This enum can be removed in favor of generic boolean 36 | return { ...rest } as T & GenericTypeInfo< U >; 37 | else 38 | return { ...rest, enum: combined } as T & GenericTypeInfo< U >; 39 | } 40 | } 41 | 42 | type UnknownAsComparable< T > = T extends unknown 43 | ? Comparable 44 | : T; 45 | 46 | export function mergeConstEnumUnion< T extends NodeWithConstEnum >( 47 | nodes: Array< T > 48 | ) 49 | : Array< TypeMap[ T[ 'type' ] ] > 50 | { 51 | type ItemType = UnknownAsComparable< TypeMap[ T[ 'type' ] ] >; 52 | 53 | const arrays = nodes.map( node => 54 | combineConstAndEnum( node as GenericTypeInfo< ItemType > ) as 55 | Array< TypeMap[ T[ 'type' ] ] > 56 | ); 57 | 58 | if ( arrays.some( arr => arr.length === 0 ) ) 59 | // One of the nodes doesn't have const or enum, so all other const and 60 | // enums are irrelevant in a union. 61 | return [ ]; 62 | 63 | type ArrType = Array< UnknownAsComparable< TypeMap[ T[ 'type' ] ] > >; 64 | 65 | return uniq( 66 | ( [ ] as ArrType ).concat( ...( arrays as Array< ArrType > ) ) 67 | ) as Array< TypeMap[ T[ 'type' ] ] >; 68 | } 69 | 70 | 71 | // TODO: This shouldn't union but _intersect_ enum and const 72 | export function combineConstAndEnum< T extends Comparable >( 73 | pseudoNode: GenericTypeInfo< T > 74 | ) 75 | : Array< T > 76 | { 77 | return uniq( [ 78 | ...( pseudoNode.const != null ? [ pseudoNode.const ] : [ ] ), 79 | ...( pseudoNode.enum != null ? pseudoNode.enum : [ ] ), 80 | ] ); 81 | } 82 | -------------------------------------------------------------------------------- /lib/simplifications/intersect-const-enum.spec.ts: -------------------------------------------------------------------------------- 1 | import { intersectConstEnum } from './intersect-const-enum.js' 2 | 3 | 4 | describe( "intersectConstEnum", ( ) => 5 | { 6 | it( "should fail if no types", ( ) => 7 | { 8 | const thrower = ( ) => intersectConstEnum( [ ] ); 9 | expect( thrower ).toThrowError( /empty/ ); 10 | } ); 11 | 12 | it( "should return the only type if only one", ( ) => 13 | { 14 | const theOnlyType = { type: 'string', title: 'Foo' } as const; 15 | expect( intersectConstEnum( [ theOnlyType ] ) ).toBe( theOnlyType ); 16 | } ); 17 | 18 | it( "should intersect const even if enum", ( ) => 19 | { 20 | const result = intersectConstEnum( [ 21 | { type: 'string', const: 'foo', enum: [ 'fee' ] }, 22 | { type: 'string', const: 'bar', enum: [ 'baz', 'foo' ] }, 23 | ] ) 24 | expect( result ).toStrictEqual( { 25 | type: 'string', 26 | enum: [ ], 27 | } ); 28 | } ); 29 | 30 | it( "should intersect const and nothing", ( ) => 31 | { 32 | const result = intersectConstEnum( [ 33 | { type: 'string', const: 'foo', enum: [ 'fee' ] }, 34 | { type: 'string' }, 35 | ] ) 36 | expect( result ).toStrictEqual( { 37 | type: 'string', 38 | const: 'foo', 39 | } ); 40 | } ); 41 | 42 | it( "should intersect const and enum", ( ) => 43 | { 44 | const result = intersectConstEnum( [ 45 | { type: 'string', const: 'foo', enum: [ 'fee' ] }, 46 | { type: 'string', enum: [ 'baz', 'foo' ] }, 47 | ] ) 48 | expect( result ).toStrictEqual( { 49 | type: 'string', 50 | const: 'foo', 51 | } ); 52 | } ); 53 | 54 | it( "should intersect enums", ( ) => 55 | { 56 | const result = intersectConstEnum( [ 57 | { type: 'string', enum: [ 'foo', 'fee', 'bar', 'baz' ] }, 58 | { type: 'string', enum: [ 'baz', 'foo' ] }, 59 | ] ) 60 | expect( result ).toStrictEqual( { 61 | type: 'string', 62 | enum: [ 'foo', 'baz' ], 63 | } ); 64 | } ); 65 | 66 | it( "should intersect annotations", ( ) => 67 | { 68 | const result = intersectConstEnum( [ 69 | { 70 | type: 'string', 71 | enum: [ 'foo', 'fee', 'bar', 'baz' ], 72 | title: 'Foo', 73 | }, 74 | { 75 | type: 'string', 76 | enum: [ 'baz', 'foo' ], 77 | title: 'Bar', 78 | description: 'Bars', 79 | }, 80 | ] ) 81 | expect( result ).toStrictEqual( { 82 | type: 'string', 83 | enum: [ 'foo', 'baz' ], 84 | title: 'Foo, Bar', 85 | description: 'Bars' 86 | } ); 87 | } ); 88 | } ); 89 | -------------------------------------------------------------------------------- /lib/simplifications/intersect-const-enum.ts: -------------------------------------------------------------------------------- 1 | import { mergeAnnotations } from '../annotation.js' 2 | import type { CoreTypeAnnotations, NodeWithConstEnum } from '../types.js' 3 | import { type Comparable, intersection } from '../util.js' 4 | 5 | 6 | export function intersectConstEnum< T extends NodeWithConstEnum >( 7 | nodes: Array< T > 8 | ) 9 | : Pick< T, 'type' | 'const' | 'enum' | keyof CoreTypeAnnotations > 10 | { 11 | if ( nodes.length === 0 ) 12 | throw new Error( 13 | "Cannot intersect const and enum from an empty array of nodes" 14 | ); 15 | 16 | if ( nodes.length === 1 ) 17 | return nodes[ 0 ]; 18 | 19 | const elements = nodes 20 | .map( ( node ): Array< Comparable > | undefined => 21 | typeof node.const !== 'undefined' 22 | ? [ node.const as Comparable ] 23 | : typeof node.enum !== 'undefined' 24 | ? node.enum as Array< Comparable > 25 | : undefined 26 | ) 27 | .filter( < T >( v: T ): v is NonNullable< T > => !!v ); 28 | 29 | const constEnum = elements.slice( 1 ).reduce( 30 | ( prev, cur ) => intersection( prev, cur ), 31 | elements[ 0 ] 32 | ); 33 | 34 | return { 35 | type: nodes[ 0 ].type, 36 | ...( constEnum.length === 1 ? { const: constEnum[ 0 ] } : { } ), 37 | ...( constEnum.length !== 1 ? { enum: constEnum } : { } ), 38 | ...mergeAnnotations( nodes ), 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /lib/simplifications/single.ts: -------------------------------------------------------------------------------- 1 | import { NodeType, AndType, OrType } from '../types.js' 2 | import { simplifyEnumAndConst } from './const-enum.js' 3 | 4 | 5 | export function simplifySingle< 6 | T extends Exclude< NodeType, OrType | AndType > 7 | >( node: T ): T 8 | { 9 | if ( 10 | node.type === 'boolean' || 11 | node.type === 'integer' || 12 | node.type === 'number' || 13 | node.type === 'string' 14 | ) 15 | return simplifyEnumAndConst( node ); 16 | else 17 | return node; 18 | } 19 | -------------------------------------------------------------------------------- /lib/simplify.spec.ts: -------------------------------------------------------------------------------- 1 | import type { NodeDocument, NodeType, OrType } from './types.js' 2 | import { simplify } from './simplify.js' 3 | 4 | 5 | describe( "simplify", ( ) => 6 | { 7 | it( "document", ( ) => 8 | { 9 | const result = simplify( { 10 | version: 1, 11 | types: [ 12 | { 13 | name: 'x', 14 | type: 'string', 15 | const: 'foo', 16 | }, 17 | ], 18 | } ); 19 | 20 | expect( result ).toStrictEqual( { 21 | version: 1, 22 | types: [ 23 | { 24 | name: 'x', 25 | type: 'string', 26 | const: 'foo', 27 | }, 28 | ], 29 | } ); 30 | } ); 31 | 32 | it( "array of types", ( ) => 33 | { 34 | const result = simplify( [ 35 | { 36 | type: 'string', 37 | const: 'foo', 38 | }, 39 | { 40 | type: 'number', 41 | const: 3, 42 | }, 43 | ] ); 44 | 45 | expect( result ).toStrictEqual( [ 46 | { 47 | type: 'string', 48 | const: 'foo', 49 | }, 50 | { 51 | type: 'number', 52 | const: 3, 53 | }, 54 | ] ); 55 | } ); 56 | 57 | it( "primitive type", ( ) => 58 | { 59 | const result = simplify( { 60 | type: 'string', 61 | const: 'foo', 62 | } ); 63 | 64 | expect( result ).toStrictEqual( { 65 | type: 'string', 66 | const: 'foo', 67 | } ); 68 | } ); 69 | 70 | it( "union of refs", ( ) => 71 | { 72 | const result = simplify( { 73 | type: 'or', 74 | or: [ 75 | { 76 | type: 'ref', 77 | ref: 'foo', 78 | }, 79 | { 80 | type: 'ref', 81 | ref: 'bar', 82 | }, 83 | ] 84 | } ); 85 | 86 | expect( result ).toStrictEqual( { 87 | type: 'or', 88 | or: [ 89 | { 90 | type: 'ref', 91 | ref: 'foo', 92 | }, 93 | { 94 | type: 'ref', 95 | ref: 'bar', 96 | }, 97 | ] 98 | } ); 99 | } ); 100 | 101 | it( "bool union true and false", ( ) => 102 | { 103 | const result = simplify( { 104 | type: 'or', 105 | or: [ 106 | { 107 | type: 'boolean', 108 | const: true, 109 | }, 110 | { 111 | type: 'boolean', 112 | const: false, 113 | } 114 | ], 115 | } ); 116 | 117 | expect( result ).toStrictEqual( { type: 'boolean' } ); 118 | } ); 119 | 120 | it( "bool union empty enums", ( ) => 121 | { 122 | const result = simplify( { 123 | type: 'or', 124 | or: [ 125 | { type: 'boolean' }, 126 | { type: 'boolean' } 127 | ], 128 | } ); 129 | 130 | expect( result ).toStrictEqual( { type: 'boolean' } ); 131 | } ); 132 | 133 | it( "or union multiple", ( ) => 134 | { 135 | const result = simplify( { 136 | type: 'or', 137 | or: [ 138 | { 139 | type: 'or', 140 | or: [ { type: 'string' }, { type: 'number' } ], 141 | }, 142 | { 143 | type: 'or', 144 | or: [ { type: 'boolean' }, { type: 'integer' } ], 145 | } 146 | ], 147 | } ); 148 | 149 | expect( result ).toStrictEqual( { 150 | type: 'or', 151 | or: [ 152 | { type: 'string' }, 153 | { type: 'number' }, 154 | { type: 'boolean' }, 155 | { type: 'integer' }, 156 | ], 157 | } ); 158 | } ); 159 | 160 | it( "and types without sub-ands", ( ) => 161 | { 162 | const node: NodeType = { 163 | type: 'and', 164 | and: [ 165 | { 166 | type: 'string', 167 | const: "foo", 168 | }, 169 | { 170 | type: 'number', 171 | const: 42, 172 | }, 173 | ] 174 | }; 175 | const result = simplify( node ); 176 | 177 | expect( result ).toStrictEqual( node ); 178 | } ); 179 | 180 | it( "and types with single sub-and and single sub-or", ( ) => 181 | { 182 | const node: NodeType = { 183 | type: 'and', 184 | and: [ 185 | { 186 | type: 'string', 187 | const: "foo", 188 | }, 189 | { 190 | type: 'and', 191 | and: [ 192 | { 193 | type: 'number', 194 | const: 42, 195 | } 196 | ] 197 | }, 198 | { 199 | type: 'or', 200 | or: [ 201 | { 202 | type: 'boolean', 203 | const: true, 204 | } 205 | ] 206 | }, 207 | ] 208 | }; 209 | const result = simplify( node ); 210 | 211 | expect( result ).toStrictEqual( { 212 | type: 'and', 213 | and: [ 214 | { 215 | type: 'string', 216 | const: "foo", 217 | }, 218 | { 219 | type: 'number', 220 | const: 42, 221 | }, 222 | { 223 | type: 'boolean', 224 | const: true, 225 | }, 226 | ] 227 | } ); 228 | } ); 229 | 230 | it( "should flatten single ors and ands", ( ) => 231 | { 232 | const node: Array< NodeType > = [ 233 | { 234 | type: 'or', 235 | or: [ 236 | { 237 | type: 'and', 238 | and: [ 239 | { 240 | type: 'or', 241 | or: [ 242 | { type: 'string' } 243 | ] 244 | } 245 | ] 246 | } 247 | ] 248 | }, 249 | { 250 | type: 'and', 251 | and: [ 252 | { 253 | type: 'or', 254 | or: [ 255 | { 256 | type: 'and', 257 | and: [ 258 | { type: 'number' } 259 | ] 260 | } 261 | ] 262 | } 263 | ] 264 | }, 265 | ]; 266 | 267 | expect( simplify( node ) ).toStrictEqual( [ 268 | { type: 'string' }, 269 | { type: 'number' }, 270 | ] ); 271 | } ); 272 | 273 | it( "should remove empty ors and ands", ( ) => 274 | { 275 | const node: NodeDocument = { 276 | version: 1, 277 | types: [ 278 | { 279 | name: 'ors', 280 | type: 'or', 281 | or: [ 282 | { type: 'and', and: [ ] }, 283 | { type: 'string' }, 284 | { type: 'or', or: [ ] }, 285 | ] 286 | }, 287 | { 288 | name: 'ands', 289 | type: 'and', 290 | and: [ 291 | { type: 'and', and: [ ] }, 292 | { type: 'number' }, 293 | { type: 'or', or: [ ] }, 294 | ] 295 | }, 296 | ] 297 | }; 298 | 299 | expect( simplify( node ) ).toStrictEqual( { 300 | version: 1, 301 | types: [ 302 | { name: 'ors', type: 'string' }, 303 | { name: 'ands', type: 'number' }, 304 | ] 305 | } ); 306 | } ); 307 | 308 | describe( "should remove empty ors and ands in nested structures", ( ) => 309 | { 310 | const bloatedAndsAndOrs: OrType = { 311 | type: 'or', 312 | title: 'Main or', 313 | or: [ 314 | { 315 | type: 'or', 316 | or: [ 317 | { type: 'and', and: [ ] }, 318 | { type: 'string' }, 319 | { type: 'or', or: [ ] }, 320 | ] 321 | }, 322 | { 323 | type: 'and', 324 | and: [ 325 | { type: 'and', and: [ ] }, 326 | { type: 'number' }, 327 | { type: 'or', or: [ ] }, 328 | ] 329 | }, 330 | ], 331 | }; 332 | const simpleAndsAndOrs: OrType = { 333 | type: 'or', 334 | title: 'Main or', 335 | or: [ 336 | { type: 'string' }, 337 | { type: 'number' }, 338 | ], 339 | }; 340 | 341 | it( "inside an object", ( ) => 342 | { 343 | const node: NodeDocument = { 344 | version: 1, 345 | types: [ 346 | { 347 | name: 'o', 348 | type: 'object', 349 | additionalProperties: false, 350 | properties: { 351 | foo: { required: true, node: bloatedAndsAndOrs }, 352 | }, 353 | }, 354 | ] 355 | }; 356 | 357 | expect( simplify( node ) ).toStrictEqual( { 358 | version: 1, 359 | types: [ 360 | { 361 | name: 'o', 362 | type: 'object', 363 | additionalProperties: false, 364 | properties: { 365 | foo: { required: true, node: simpleAndsAndOrs }, 366 | }, 367 | }, 368 | ] 369 | } ); 370 | } ); 371 | 372 | it( "inside an object w/ additionalProperties", ( ) => 373 | { 374 | const node: NodeDocument = { 375 | version: 1, 376 | types: [ 377 | { 378 | name: 'o', 379 | type: 'object', 380 | additionalProperties: bloatedAndsAndOrs, 381 | properties: { 382 | foo: { required: true, node: bloatedAndsAndOrs }, 383 | }, 384 | }, 385 | ] 386 | }; 387 | 388 | expect( simplify( node ) ).toStrictEqual( { 389 | version: 1, 390 | types: [ 391 | { 392 | name: 'o', 393 | type: 'object', 394 | additionalProperties: simpleAndsAndOrs, 395 | properties: { 396 | foo: { required: true, node: simpleAndsAndOrs }, 397 | }, 398 | }, 399 | ] 400 | } ); 401 | } ); 402 | 403 | it( "inside an tuple", ( ) => 404 | { 405 | const node: NodeDocument = { 406 | version: 1, 407 | types: [ 408 | { 409 | name: 'tup', 410 | type: 'tuple', 411 | additionalItems: false, 412 | elementTypes: [ bloatedAndsAndOrs ], 413 | minItems: 1, 414 | }, 415 | ] 416 | }; 417 | 418 | expect( simplify( node ) ).toStrictEqual( { 419 | version: 1, 420 | types: [ 421 | { 422 | name: 'tup', 423 | type: 'tuple', 424 | additionalItems: false, 425 | elementTypes: [ simpleAndsAndOrs ], 426 | minItems: 1, 427 | }, 428 | ] 429 | } ); 430 | } ); 431 | 432 | it( "inside an tuple w/ additionalItems", ( ) => 433 | { 434 | const node: NodeDocument = { 435 | version: 1, 436 | types: [ 437 | { 438 | name: 'tup', 439 | type: 'tuple', 440 | additionalItems: bloatedAndsAndOrs, 441 | elementTypes: [ bloatedAndsAndOrs ], 442 | minItems: 1, 443 | }, 444 | ] 445 | }; 446 | 447 | expect( simplify( node ) ).toStrictEqual( { 448 | version: 1, 449 | types: [ 450 | { 451 | name: 'tup', 452 | type: 'tuple', 453 | additionalItems: simpleAndsAndOrs, 454 | elementTypes: [ simpleAndsAndOrs ], 455 | minItems: 1, 456 | }, 457 | ] 458 | } ); 459 | } ); 460 | 461 | it( "inside an array", ( ) => 462 | { 463 | const node: NodeDocument = { 464 | version: 1, 465 | types: [ 466 | { 467 | name: 'arr', 468 | type: 'array', 469 | elementType: bloatedAndsAndOrs, 470 | }, 471 | ] 472 | }; 473 | 474 | expect( simplify( node ) ).toStrictEqual( { 475 | version: 1, 476 | types: [ 477 | { 478 | name: 'arr', 479 | type: 'array', 480 | elementType: simpleAndsAndOrs, 481 | }, 482 | ] 483 | } ); 484 | } ); 485 | 486 | it( "deep inside an array", ( ) => 487 | { 488 | const node: NodeDocument = { 489 | version: 1, 490 | types: [ 491 | { 492 | name: 'arr', 493 | type: 'array', 494 | elementType: { 495 | type: 'or', 496 | or: [ 497 | { 498 | name: 'tup', 499 | type: 'tuple', 500 | additionalItems: bloatedAndsAndOrs, 501 | elementTypes: [ bloatedAndsAndOrs ], 502 | minItems: 1, 503 | } 504 | ] 505 | }, 506 | }, 507 | ] 508 | }; 509 | 510 | expect( simplify( node ) ).toStrictEqual( { 511 | version: 1, 512 | types: [ 513 | { 514 | name: 'arr', 515 | type: 'array', 516 | elementType: { 517 | name: 'tup', 518 | type: 'tuple', 519 | additionalItems: simpleAndsAndOrs, 520 | elementTypes: [ simpleAndsAndOrs ], 521 | minItems: 1, 522 | }, 523 | }, 524 | ] 525 | } ); 526 | } ); 527 | } ); 528 | 529 | it( "should remove empty ors and ands and prefer 'any' in 'or'", ( ) => 530 | { 531 | const node: NodeDocument = { 532 | version: 1, 533 | types: [ 534 | { 535 | type: "and", 536 | and: [ 537 | { type: "or", or: [ ] }, 538 | { 539 | type: "or", 540 | or: [ 541 | { 542 | type: "string" 543 | }, 544 | { 545 | type: "any" 546 | } 547 | ], 548 | }, 549 | { type: "and", and: [ ] } 550 | ], 551 | name: "StringOrAny" 552 | } 553 | ] 554 | }; 555 | 556 | expect( simplify( node ) ).toStrictEqual( { 557 | version: 1, 558 | types: [ 559 | { name: 'StringOrAny', type: 'any' }, 560 | ] 561 | } ); 562 | } ); 563 | 564 | it( "and types with multiple sub-and and multiple sub-or", ( ) => 565 | { 566 | const node: NodeType = { 567 | type: 'and', 568 | and: [ 569 | { 570 | type: 'and', 571 | and: [ 572 | { 573 | type: 'string', 574 | const: "foo", 575 | }, 576 | { 577 | type: 'string', 578 | enum: [ "foo", "bar" ], 579 | }, 580 | ] 581 | }, 582 | { 583 | type: 'and', 584 | and: [ 585 | { 586 | type: 'number', 587 | enum: [ 17, 42 ], 588 | }, 589 | { 590 | type: 'integer', 591 | const: 17, 592 | }, 593 | ] 594 | }, 595 | { 596 | type: 'or', 597 | or: [ 598 | { 599 | type: 'boolean', 600 | const: true, 601 | }, 602 | { 603 | type: 'boolean', 604 | const: false, 605 | }, 606 | ] 607 | }, 608 | ] 609 | }; 610 | const result = simplify( node ); 611 | 612 | expect( result ).toStrictEqual( { 613 | type: 'and', 614 | and: [ 615 | { 616 | type: 'string', 617 | const: "foo", 618 | }, 619 | { 620 | type: 'number', 621 | const: 17, 622 | }, 623 | { 624 | type: 'boolean', 625 | }, 626 | ] 627 | } ); 628 | } ); 629 | 630 | it( "or types with single sub-and and single sub-or", ( ) => 631 | { 632 | const node: NodeType = { 633 | type: 'or', 634 | or: [ 635 | { 636 | type: 'string', 637 | const: "foo", 638 | }, 639 | { 640 | type: 'and', 641 | and: [ 642 | { 643 | type: 'number', 644 | const: 42, 645 | } 646 | ] 647 | }, 648 | { 649 | type: 'or', 650 | or: [ 651 | { 652 | type: 'boolean', 653 | const: true, 654 | } 655 | ] 656 | }, 657 | ] 658 | }; 659 | const result = simplify( node ); 660 | 661 | expect( result ).toStrictEqual( { 662 | type: 'or', 663 | or: [ 664 | { 665 | type: 'string', 666 | const: "foo", 667 | }, 668 | { 669 | type: 'number', 670 | const: 42, 671 | }, 672 | { 673 | type: 'boolean', 674 | const: true, 675 | }, 676 | ] 677 | } ); 678 | } ); 679 | 680 | it( "or types with multiple const of same type (become enum)", ( ) => 681 | { 682 | const node: NodeType = { 683 | type: 'or', 684 | or: [ 685 | { 686 | type: 'string', 687 | const: 'foo', 688 | }, 689 | { 690 | type: 'and', 691 | and: [ 692 | { 693 | type: 'number', 694 | const: 42, 695 | } 696 | ] 697 | }, 698 | { 699 | type: 'or', 700 | or: [ 701 | { 702 | type: 'string', 703 | const: 'bar', 704 | } 705 | ] 706 | }, 707 | ] 708 | }; 709 | const result = simplify( node ); 710 | 711 | expect( result ).toStrictEqual( { 712 | type: 'or', 713 | or: [ 714 | { 715 | type: 'string', 716 | enum: [ 'foo', 'bar' ], 717 | }, 718 | { 719 | type: 'number', 720 | const: 42, 721 | }, 722 | ] 723 | } ); 724 | } ); 725 | 726 | it( "or types with multiple const or enum (become enum)", ( ) => 727 | { 728 | const node: NodeType = { 729 | type: 'or', 730 | or: [ 731 | { 732 | type: 'string', 733 | const: 'foo', 734 | }, 735 | { 736 | type: 'and', 737 | and: [ 738 | { 739 | type: 'string', 740 | enum: [ 'bar', 'baz' ], 741 | } 742 | ] 743 | }, 744 | { 745 | type: 'or', 746 | or: [ 747 | { 748 | type: 'string', 749 | const: 'bak', 750 | } 751 | ] 752 | }, 753 | ] 754 | }; 755 | const result = simplify( node ); 756 | 757 | expect( result ).toStrictEqual( { 758 | type: 'string', 759 | enum: [ 'foo', 'bar', 'baz', 'bak' ], 760 | } ); 761 | } ); 762 | 763 | it( "or types with const/enum and generic (become generic)", ( ) => 764 | { 765 | const node: NodeType = { 766 | type: 'or', 767 | or: [ 768 | { 769 | type: 'string', 770 | const: 'foo', 771 | description: 'this is a foo' 772 | }, 773 | { 774 | type: 'and', 775 | and: [ 776 | { 777 | type: 'string', 778 | enum: [ 'bar', 'baz' ], 779 | title: 'this has a title', 780 | description: 'and a bar-baz description', 781 | } 782 | ] 783 | }, 784 | { 785 | type: 'or', 786 | or: [ 787 | { 788 | type: 'string', 789 | see: 'me', 790 | } 791 | ] 792 | }, 793 | ] 794 | }; 795 | const result = simplify( node ); 796 | 797 | expect( result ).toStrictEqual( { 798 | type: 'string', 799 | description: 'this is a foo\nand a bar-baz description', 800 | title: 'this has a title', 801 | see: 'me', 802 | } ); 803 | } ); 804 | 805 | it( "maintain or-order", ( ) => 806 | { 807 | const node1: NodeType = { 808 | type: 'or', 809 | or: [ 810 | { 811 | type: 'string', 812 | }, 813 | { 814 | type: 'number', 815 | }, 816 | ] 817 | }; 818 | const result1 = simplify( node1 ); 819 | expect( result1 ).toStrictEqual( node1 ); 820 | 821 | // This will ensure issue #2 is resolved 822 | const node2: NodeType = { 823 | type: 'or', 824 | or: [ 825 | { 826 | type: 'number', 827 | }, 828 | { 829 | type: 'string', 830 | }, 831 | ] 832 | }; 833 | const result2 = simplify( node2 ); 834 | expect( result2 ).toStrictEqual( node2 ); 835 | } ); 836 | 837 | it( "and with any", ( ) => 838 | { 839 | const node: NodeType = { 840 | type: 'and', 841 | and: [ 842 | { 843 | type: 'string', 844 | }, 845 | { 846 | type: 'any', 847 | }, 848 | ] 849 | }; 850 | const result = simplify( node ); 851 | 852 | expect( result ).toStrictEqual( { 853 | type: 'string', 854 | } ); 855 | } ); 856 | 857 | describe( "merge objects", ( ) => 858 | { 859 | it( "should not merge if there are non-objects", ( ) => 860 | { 861 | const node: NodeType = { 862 | type: 'and', 863 | and: [ 864 | { 865 | type: 'string', 866 | }, 867 | { 868 | type: 'object', 869 | properties: { 870 | a: { node: { type: 'number' }, required: false }, 871 | }, 872 | additionalProperties: false, 873 | }, 874 | { 875 | type: 'object', 876 | properties: { 877 | b: { node: { type: 'boolean' }, required: false }, 878 | }, 879 | additionalProperties: false, 880 | }, 881 | ] 882 | }; 883 | const result = simplify( node, { mergeObjects: true } ); 884 | 885 | expect( result ).toStrictEqual( node ); 886 | } ); 887 | 888 | it( "should be able to simply merge two objects", ( ) => 889 | { 890 | const node: NodeType = { 891 | type: 'and', 892 | and: [ 893 | { 894 | type: 'object', 895 | properties: { 896 | a: { node: { type: 'number' }, required: false }, 897 | }, 898 | additionalProperties: false, 899 | }, 900 | { 901 | type: 'object', 902 | properties: { 903 | b: { node: { type: 'boolean' }, required: false }, 904 | }, 905 | additionalProperties: false, 906 | }, 907 | ] 908 | }; 909 | const result = simplify( node, { mergeObjects: true } ); 910 | 911 | expect( result ).toStrictEqual( { 912 | type: 'object', 913 | properties: { 914 | a: { node: { type: 'number' }, required: false }, 915 | b: { node: { type: 'boolean' }, required: false }, 916 | }, 917 | additionalProperties: false, 918 | } ); 919 | } ); 920 | 921 | it( "should prefer additionalProperties with node over false", ( ) => 922 | { 923 | const node: NodeType = { 924 | type: 'and', 925 | and: [ 926 | { 927 | type: 'object', 928 | properties: { 929 | a: { node: { type: 'number' }, required: false }, 930 | }, 931 | additionalProperties: false, 932 | }, 933 | { 934 | type: 'object', 935 | properties: { 936 | b: { node: { type: 'boolean' }, required: false }, 937 | }, 938 | additionalProperties: { type: 'string' }, 939 | }, 940 | ] 941 | }; 942 | const result = simplify( node, { mergeObjects: true } ); 943 | 944 | expect( result ).toStrictEqual( { 945 | type: 'object', 946 | properties: { 947 | a: { node: { type: 'number' }, required: false }, 948 | b: { node: { type: 'boolean' }, required: false }, 949 | }, 950 | additionalProperties: { type: 'string' }, 951 | } ); 952 | } ); 953 | 954 | it( "should prefer additionalProperties with true over node", ( ) => 955 | { 956 | const node: NodeType = { 957 | type: 'and', 958 | and: [ 959 | { 960 | type: 'object', 961 | properties: { 962 | a: { node: { type: 'number' }, required: false }, 963 | }, 964 | additionalProperties: true, 965 | }, 966 | { 967 | type: 'object', 968 | properties: { 969 | b: { node: { type: 'boolean' }, required: false }, 970 | }, 971 | additionalProperties: { type: 'string' }, 972 | }, 973 | ] 974 | }; 975 | const result = simplify( node, { mergeObjects: true } ); 976 | 977 | expect( result ).toStrictEqual( { 978 | type: 'object', 979 | properties: { 980 | a: { node: { type: 'number' }, required: false }, 981 | b: { node: { type: 'boolean' }, required: false }, 982 | }, 983 | additionalProperties: true, 984 | } ); 985 | } ); 986 | 987 | it( "should prefer prefer required on conflicting property", ( ) => 988 | { 989 | const node: NodeType = { 990 | type: 'and', 991 | and: [ 992 | { 993 | type: 'object', 994 | properties: { 995 | a: { node: { type: 'number' }, required: false }, 996 | }, 997 | additionalProperties: false, 998 | }, 999 | { 1000 | type: 'object', 1001 | properties: { 1002 | a: { node: { type: 'boolean' }, required: true }, 1003 | }, 1004 | additionalProperties: false, 1005 | }, 1006 | ] 1007 | }; 1008 | const result = simplify( node, { mergeObjects: true } ); 1009 | 1010 | expect( result ).toStrictEqual( { 1011 | type: 'object', 1012 | properties: { 1013 | a: { 1014 | node: { 1015 | type: 'and', 1016 | and: [ { type: 'number' }, { type: 'boolean' } ], 1017 | }, 1018 | required: true, 1019 | }, 1020 | }, 1021 | additionalProperties: false, 1022 | } ); 1023 | } ); 1024 | 1025 | it( "should handle refs and sub-intersections", ( ) => 1026 | { 1027 | const node: NodeDocument = { 1028 | version: 1, 1029 | types: [ 1030 | { 1031 | name: 'root', 1032 | type: 'and', 1033 | and: [ 1034 | { 1035 | type: 'object', 1036 | properties: { 1037 | a: { 1038 | node: { type: 'number' }, 1039 | required: false, 1040 | }, 1041 | x: { 1042 | node: { type: 'number' }, 1043 | required: false, 1044 | }, 1045 | }, 1046 | additionalProperties: false, 1047 | }, 1048 | { 1049 | type: 'and', 1050 | and: [ 1051 | { 1052 | type: 'object', 1053 | properties: { 1054 | b: { 1055 | node: { type: 'boolean' }, 1056 | required: false, 1057 | }, 1058 | x: { 1059 | node: { type: 'boolean' }, 1060 | required: false, 1061 | }, 1062 | }, 1063 | additionalProperties: false, 1064 | }, 1065 | { 1066 | type: 'object', 1067 | properties: { 1068 | c: { 1069 | node: { type: 'string' }, 1070 | required: false, 1071 | }, 1072 | x: { 1073 | node: { type: 'string' }, 1074 | required: false, 1075 | }, 1076 | }, 1077 | additionalProperties: false, 1078 | }, 1079 | { 1080 | type: 'ref', 1081 | ref: 'ext', 1082 | }, 1083 | ], 1084 | }, 1085 | ] 1086 | }, 1087 | { 1088 | name: 'ext', 1089 | type: 'and', 1090 | and: [ { 1091 | type: 'and', 1092 | and: [ 1093 | { 1094 | type: 'object', 1095 | properties: { 1096 | d: { 1097 | node: { type: 'null' }, 1098 | required: false 1099 | }, 1100 | x: { 1101 | node: { type: 'null' }, 1102 | required: false 1103 | }, 1104 | }, 1105 | additionalProperties: false, 1106 | } 1107 | ], 1108 | } ], 1109 | }, 1110 | ], 1111 | }; 1112 | const result = simplify( node, { mergeObjects: true } ); 1113 | 1114 | expect( result ).toStrictEqual( { 1115 | version: 1, 1116 | types: [ 1117 | { 1118 | name: 'root', 1119 | type: 'object', 1120 | properties: { 1121 | a: { 1122 | node: { type: 'number' }, 1123 | required: false, 1124 | }, 1125 | b: { 1126 | node: { type: 'boolean' }, 1127 | required: false, 1128 | }, 1129 | c: { 1130 | node: { type: 'string' }, 1131 | required: false, 1132 | }, 1133 | d: { 1134 | node: { type: 'null' }, 1135 | required: false 1136 | }, 1137 | x: { 1138 | node: { 1139 | type: 'and', 1140 | and: [ 1141 | { type: 'number' }, 1142 | { type: 'boolean' }, 1143 | { type: 'string' }, 1144 | { type: 'null' }, 1145 | ], 1146 | }, 1147 | required: false, 1148 | }, 1149 | }, 1150 | additionalProperties: false, 1151 | }, 1152 | { 1153 | name: 'ext', 1154 | type: 'object', 1155 | properties: { 1156 | d: { 1157 | node: { type: 'null' }, 1158 | required: false 1159 | }, 1160 | x: { 1161 | node: { type: 'null' }, 1162 | required: false 1163 | }, 1164 | }, 1165 | additionalProperties: false, 1166 | }, 1167 | ], 1168 | } satisfies NodeDocument ); 1169 | } ); 1170 | } ); 1171 | } ); 1172 | -------------------------------------------------------------------------------- /lib/simplify.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AndType, 3 | NamedType, 4 | NodeDocument, 5 | NodeType, 6 | NodeTypeMap, 7 | NodeWithConstEnum, 8 | ObjectProperty, 9 | ObjectType, 10 | OrType, 11 | TypeMap, 12 | Types, 13 | } from './types.js' 14 | import { simplifySingle } from './simplifications/single.js' 15 | import { mergeConstEnumUnion } from './simplifications/const-enum.js' 16 | import { intersectConstEnum } from './simplifications/intersect-const-enum.js' 17 | import { MalformedTypeError } from './error.js' 18 | import { extractAnnotations, mergeAnnotations } from './annotation.js' 19 | import { 20 | copyName, 21 | firstSplitTypeIndex, 22 | flattenSplitTypeValues, 23 | isNodeDocument, 24 | NodeWithOrder, 25 | splitTypes, 26 | } from './util.js' 27 | 28 | 29 | const enumableTypeNames = [ 30 | 'any', 31 | 'string', 32 | 'number', 33 | 'integer', 34 | 'boolean', 35 | ]; 36 | 37 | export interface SimplifyOptions 38 | { 39 | /** 40 | * If true, and-types of objects are merged into one type. 41 | * This will also include ref-types that reference objects, they will all be 42 | * merged into one object. 43 | * 44 | * @default false 45 | */ 46 | mergeObjects?: boolean; 47 | } 48 | 49 | export function simplify< T extends NamedType >( 50 | node: T, 51 | options?: SimplifyOptions 52 | ): NamedType; 53 | export function simplify< T extends NamedType >( 54 | node: Array< T >, 55 | options?: SimplifyOptions 56 | ): Array< NamedType >; 57 | export function simplify< T extends NodeType >( 58 | node: T, 59 | options?: SimplifyOptions 60 | ): NodeType; 61 | export function simplify< T extends NodeType >( 62 | node: Array< T >, 63 | options?: SimplifyOptions 64 | ): NodeType; 65 | export function simplify< T extends NodeType >( 66 | node: NodeDocument< 1, T >, 67 | options?: SimplifyOptions 68 | ): NodeDocument< 1, NodeType >; 69 | 70 | export function simplify( 71 | node: NodeDocument | NodeType | Array< NodeType >, 72 | { mergeObjects = false }: SimplifyOptions = { } 73 | ) 74 | : typeof node 75 | { 76 | const ctx: Required< SimplifyOptions > & SimplifyContext = { 77 | mergeObjects, 78 | refs: new Map( ), 79 | }; 80 | 81 | if ( Array.isArray( node ) ) 82 | return node.map( node => simplifyImpl( node, ctx ) ); 83 | 84 | if ( isNodeDocument( node ) ) 85 | { 86 | const simplified = { 87 | ...node, 88 | types: simplify( ( node as NodeDocument ).types, ctx ), 89 | } as NodeDocument; 90 | 91 | if ( !mergeObjects ) 92 | return simplified; 93 | 94 | // Run simplification again, with the named refs available to merge 95 | // further, if necessary 96 | 97 | simplified.types.forEach( type => 98 | { 99 | ctx.refs.set( type.name, type ); 100 | } ); 101 | simplified.types = 102 | simplified.types.map( node => simplifyImpl( node, ctx ) ); 103 | 104 | return simplified; 105 | } 106 | 107 | return simplifyImpl( node, ctx ); 108 | } 109 | 110 | interface SimplifyContext 111 | { 112 | refs: Map< string, NodeType >; 113 | } 114 | 115 | export function simplifyImpl< Type extends NodeType | NamedType >( 116 | node: Type, 117 | ctx: Required< SimplifyOptions > & SimplifyContext 118 | ) 119 | : Type 120 | { 121 | const wrapName = ( newNode: NodeType ) => copyName( node, newNode ); 122 | 123 | if ( node.type === 'tuple' ) 124 | { 125 | return { 126 | ...node, 127 | elementTypes: node.elementTypes.map( type => simplify( type ) ), 128 | ...( 129 | node.additionalItems && 130 | typeof node.additionalItems === 'object' 131 | ? { additionalItems: simplify( node.additionalItems ) } 132 | : { } 133 | ), 134 | }; 135 | } 136 | else if ( node.type === 'array' ) 137 | { 138 | return { 139 | ...node, 140 | elementType: simplify( node.elementType ) 141 | }; 142 | } 143 | else if ( node.type === 'object' ) 144 | { 145 | return { 146 | ...node, 147 | properties: Object.fromEntries( 148 | Object.entries( node.properties ) 149 | .map( ( [ name, { node, required } ] ) => 150 | [ name, { node: simplify( node ), required } ] 151 | ) 152 | ), 153 | ...( 154 | node.additionalProperties && 155 | typeof node.additionalProperties === 'object' 156 | ? { 157 | additionalProperties: simplify( node.additionalProperties ) 158 | } 159 | : { } 160 | ), 161 | }; 162 | } 163 | else if ( node.type !== 'and' && node.type !== 'or' ) 164 | return wrapName( simplifySingle( node ) ); 165 | else if ( node.type === 'and' ) 166 | { 167 | const and = maybeMergeObjects( 168 | simplifyIntersection( 169 | ( [ ] as NodeType[ ] ).concat( 170 | ...node.and.map( node => 171 | { 172 | const simplifiedNode = simplify( node ); 173 | return ( simplifiedNode as AndType ).and 174 | ? ( simplifiedNode as AndType ).and 175 | : [ simplifiedNode ]; 176 | } ) 177 | ) 178 | ), 179 | ctx 180 | ); 181 | 182 | if ( and.length === 1 ) 183 | return wrapName( { 184 | ...and[ 0 ], 185 | ...mergeAnnotations( [ extractAnnotations( node ), and[ 0 ] ] ) 186 | } ); 187 | 188 | return wrapName( { type: 'and', and, ...extractAnnotations( node ) } ); 189 | } 190 | else if ( node.type === 'or' ) 191 | { 192 | const or = simplifyUnion( 193 | ( [ ] as NodeType[ ] ).concat( 194 | ...node.or.map( node => 195 | { 196 | const simplifiedNode = simplify( node ); 197 | return ( simplifiedNode as OrType ).or 198 | ? ( simplifiedNode as OrType ).or 199 | : [ simplifiedNode ]; 200 | } ) 201 | ) 202 | ); 203 | 204 | if ( or.length === 1 ) 205 | return wrapName( { 206 | ...or[ 0 ], 207 | ...mergeAnnotations( [ extractAnnotations( node ), or[ 0 ] ] ) 208 | } ); 209 | return wrapName( { type: 'or', or, ...extractAnnotations( node ) } ); 210 | } 211 | else 212 | { 213 | // istanbul ignore next 214 | throw new MalformedTypeError( "Invalid node", node ); 215 | } 216 | } 217 | 218 | function maybeMergeObjects( 219 | nodes: Array< NodeType >, 220 | ctx: Required< SimplifyOptions > & SimplifyContext 221 | ) 222 | : Array< NodeType > 223 | { 224 | const { mergeObjects, refs } = ctx; 225 | 226 | if ( !mergeObjects || nodes.length < 2 ) 227 | return nodes; 228 | 229 | const visited = new Set< NodeType >( ); 230 | 231 | const expandNode = ( node: NodeType ): Array< NodeType > => 232 | { 233 | if ( visited.has( node ) ) 234 | throw new Error( `Cyclic dependency detected` ); 235 | visited.add( node ); 236 | 237 | if ( node.type === 'object' ) 238 | return [ node ]; 239 | 240 | else if ( node.type === 'ref' ) 241 | { 242 | const ref = refs.get( node.ref ); 243 | if ( !ref ) 244 | // Return the node itself if the ref wasn't found - we'll not 245 | // try to merge this tree if there are missing refs. 246 | return [ node ]; 247 | 248 | return expandNode( ref ); 249 | } 250 | 251 | else if ( node.type === 'and' ) 252 | return node.and.flatMap( node => expandNode( node ) ); 253 | 254 | return [ node ]; 255 | }; 256 | 257 | const expanded = nodes.flatMap( node => expandNode( node ) ); 258 | 259 | if ( expanded.some( node => node.type !== 'object' ) ) 260 | // Any of the nodes was not an object, so won't try to merge. 261 | return nodes; 262 | 263 | const objects = expanded as Array< ObjectType >; 264 | 265 | const mergedAnnotations = 266 | mergeAnnotations( objects.map( obj => extractAnnotations( obj ) ) ); 267 | 268 | // Pick the loosest value 269 | const additionalProperties: boolean | NodeType = objects.reduce( 270 | ( prev, cur ) => 271 | prev === false 272 | ? cur.additionalProperties 273 | : prev === true 274 | ? prev 275 | : cur.additionalProperties 276 | , 277 | false as boolean | NodeType 278 | ); 279 | 280 | const allProperties = new Map< string, ObjectProperty[ ] >( ); 281 | objects.forEach( obj => 282 | { 283 | Object.entries( obj.properties ).forEach( ( [ name, prop ] ) => 284 | { 285 | const props = allProperties.get( name ) ?? [ ]; 286 | props.push( prop ); 287 | allProperties.set( name, props ); 288 | } ); 289 | } ); 290 | 291 | const properties = Object.fromEntries( 292 | [ ...allProperties.entries( ) ] 293 | .map( ( [ name, props ] ): [ string, ObjectProperty ] => 294 | { 295 | // If any is required, it's required 296 | const required = props.reduce( 297 | ( prev, cur ) => prev || cur.required, 298 | false 299 | ); 300 | 301 | const node = simplifyImpl( 302 | { 303 | type: 'and', 304 | and: props.map( prop => prop.node ), 305 | }, 306 | ctx 307 | ); 308 | 309 | return [ name, { node, required } ]; 310 | } ) 311 | ); 312 | 313 | return [ { 314 | type: 'object', 315 | properties, 316 | additionalProperties, 317 | ...mergedAnnotations, 318 | } ]; 319 | } 320 | 321 | // Combine types/nodes where one is more generic than some other, or where 322 | // they can be combined to fewer nodes. 323 | function simplifyUnion( nodes: Array< NodeType > ): Array< NodeType > 324 | { 325 | const typeMap = splitTypes( nodes ); 326 | 327 | if ( typeMap.any.length > 0 ) 328 | { 329 | const enums = mergeConstEnumUnion( 330 | typeMap.any.map( ( { node } ) => node ) 331 | ); 332 | if ( enums.length === 0 ) 333 | // If any type in a set of types is an "any" type, without const 334 | // or enum, the whole union is "any". 335 | return [ { 336 | type: 'any', 337 | ...mergeAnnotations( typeMap.any.map( ( { node } ) => node ) ), 338 | } ]; 339 | } 340 | 341 | for ( const [ _typeName, _types ] of Object.entries( typeMap ) ) 342 | { 343 | type ThisType = Array< NodeWithOrder< NodeWithConstEnum > >; 344 | 345 | const typeName = _typeName as keyof TypeMap; 346 | 347 | if ( !enumableTypeNames.includes( typeName ) || !_types.length ) 348 | continue; 349 | 350 | const orderedTypes = 351 | _types as NodeWithOrder< NodeWithConstEnum >[ ]; 352 | 353 | const types = orderedTypes.map( ( { node } ) => node ); 354 | 355 | const merged = mergeConstEnumUnion( types ); 356 | 357 | if ( merged.length === 0 ) 358 | ( typeMap[ typeName ] as ThisType ) = [ { 359 | node: { 360 | type: typeName, 361 | ...mergeAnnotations( types ), 362 | } as NodeWithConstEnum, 363 | order: firstSplitTypeIndex( orderedTypes ), 364 | } ]; 365 | else 366 | ( typeMap[ typeName ] as ThisType ) = [ { 367 | node: simplifySingle( { 368 | type: typeName, 369 | enum: merged, 370 | ...mergeAnnotations( types ), 371 | } as NodeWithConstEnum ), 372 | order: firstSplitTypeIndex( orderedTypes ), 373 | } ]; 374 | } 375 | 376 | if ( typeMap.or.length > 0 ) 377 | typeMap.or = typeMap.or.filter( ( { node } ) => node.or.length > 0 ); 378 | 379 | if ( typeMap.and.length > 0 ) 380 | typeMap.and = typeMap.and 381 | .filter( ( { node } ) => node.and.length > 0 ); 382 | 383 | return flattenSplitTypeValues( typeMap ); 384 | } 385 | 386 | // Combine types/nodes and exclude types, const and enum where other are 387 | // narrower/stricter. 388 | function simplifyIntersection( nodes: Array< NodeType > ): Array< NodeType > 389 | { 390 | const typeMap = splitTypes( nodes ); 391 | 392 | if ( typeMap.any.length > 0 ) 393 | { 394 | if ( 395 | typeMap.and.length === 0 && 396 | typeMap.or.length === 0 && 397 | typeMap.ref.length === 0 && 398 | typeMap.null.length === 0 && 399 | typeMap.string.length === 0 && 400 | typeMap.number.length === 0 && 401 | typeMap.integer.length === 0 && 402 | typeMap.boolean.length === 0 && 403 | typeMap.object.length === 0 && 404 | typeMap.array.length === 0 && 405 | typeMap.tuple.length === 0 406 | ) 407 | return [ { 408 | type: 'any', 409 | ...mergeAnnotations( typeMap.any.map( ( { node } ) => node ) ), 410 | } ]; 411 | else 412 | // A more precise type will supercede this 413 | typeMap.any = [ ]; 414 | } 415 | 416 | const cast = 417 | < T extends Types >( nodes: Array< NodeWithOrder< unknown > > ) => 418 | nodes.map( ( { node } ) => node ) as Array< NodeTypeMap[ T ] >; 419 | 420 | if ( typeMap.boolean.length > 1 ) 421 | typeMap.boolean = [ { 422 | node: intersectConstEnum( [ 423 | ...typeMap.boolean.map( ( { node } ) => node ), 424 | ...cast< 'boolean' >( typeMap.any ), 425 | ] ), 426 | order: firstSplitTypeIndex( typeMap.boolean ), 427 | } ]; 428 | 429 | if ( typeMap.string.length > 1 ) 430 | typeMap.string = [ { 431 | node: intersectConstEnum( [ 432 | ...typeMap.string.map( ( { node } ) => node ), 433 | ...cast< 'string' >( typeMap.any ), 434 | ] ), 435 | order: firstSplitTypeIndex( typeMap.string ), 436 | } ]; 437 | 438 | if ( typeMap.number.length > 0 && typeMap.integer.length > 0 ) 439 | { 440 | typeMap.number = [ { 441 | node: intersectConstEnum( [ 442 | ...typeMap.number.map( ( { node } ) => node ), 443 | ...cast< 'number' >( typeMap.integer ), 444 | ...cast< 'number' >( typeMap.any ), 445 | ] ), 446 | order: firstSplitTypeIndex( typeMap.number ), 447 | } ]; 448 | typeMap.integer = [ ]; 449 | } 450 | else if ( typeMap.number.length > 1 ) 451 | typeMap.number = [ { 452 | node: intersectConstEnum( [ 453 | ...typeMap.number.map( ( { node } ) => node ), 454 | ...cast< 'number' >( typeMap.any ), 455 | ] ), 456 | order: firstSplitTypeIndex( typeMap.number ), 457 | } ]; 458 | else if ( typeMap.integer.length > 1 ) 459 | typeMap.integer = [ { 460 | node: intersectConstEnum( [ 461 | ...typeMap.integer.map( ( { node } ) => node ), 462 | ...cast< 'integer' >( typeMap.any ), 463 | ] ), 464 | order: firstSplitTypeIndex( typeMap.integer ), 465 | } ]; 466 | 467 | if ( typeMap.or.length > 0 ) 468 | typeMap.or = typeMap.or.filter( ( { node } ) => node.or.length > 0 ); 469 | 470 | if ( typeMap.and.length > 0 ) 471 | typeMap.and = typeMap.and 472 | .filter( ( { node } ) => node.and.length > 0 ); 473 | 474 | return flattenSplitTypeValues( typeMap ); 475 | } 476 | -------------------------------------------------------------------------------- /lib/traverse.spec.ts: -------------------------------------------------------------------------------- 1 | import { type TraverseCallbackArgument, some, traverse } from './traverse.js' 2 | import type { NodeType } from './types.js' 3 | 4 | 5 | const root: NodeType = { 6 | type: 'object', 7 | title: 'root', 8 | additionalProperties: { type: 'string', title: 'o-ap' }, 9 | properties: { 10 | any: { required: true, node: { 11 | title: 'o-any', 12 | type: 'any' 13 | }, }, 14 | null: { required: true, node: { 15 | title: 'o-null', 16 | type: 'null' 17 | }, }, 18 | string: { required: true, node: { 19 | title: 'o-string', 20 | type: 'string' 21 | }, }, 22 | number: { required: true, node: { 23 | title: 'o-number', 24 | type: 'number' 25 | }, }, 26 | integer: { required: false, node: { 27 | title: 'o-integer', 28 | type: 'integer' 29 | }, }, 30 | boolean: { required: true, node: { 31 | title: 'o-boolean', 32 | type: 'boolean' 33 | }, }, 34 | and: { 35 | required: true, 36 | node: { 37 | title: 'o-and', 38 | type: 'and', 39 | and: [ { type: 'number', title: 'o-and-number' } ], 40 | }, 41 | }, 42 | or: { 43 | required: true, 44 | node: { 45 | title: 'o-or', 46 | type: 'or', 47 | or: [ { type: 'number', title: 'o-or-number' } ], 48 | }, 49 | }, 50 | ref: { 51 | required: true, 52 | node: { 53 | title: 'o-ref', 54 | type: 'ref', 55 | ref: 'the-ref', 56 | }, 57 | }, 58 | object: { 59 | required: true, 60 | node: { 61 | title: 'o-object', 62 | type: 'object', 63 | properties: { 64 | foo: { 65 | required: false, 66 | node: { 67 | type: 'integer', 68 | title: 'o-o-foo-integer', 69 | }, 70 | }, 71 | }, 72 | additionalProperties: { type: 'any', title: 'o-o-ap' }, 73 | }, 74 | }, 75 | array: { 76 | required: false, 77 | node: { 78 | title: 'o-array', 79 | type: 'array', 80 | elementType: { type: 'null', title: 'o-array-null' }, 81 | }, 82 | }, 83 | tuple: { 84 | required: true, 85 | node: { 86 | title: 'o-tuple', 87 | type: 'tuple', 88 | elementTypes: [ 89 | { type: 'number', title: 'o-tuple-0-number' }, 90 | { type: 'boolean', title: 'o-tuple-0-boolean' }, 91 | ], 92 | additionalItems: { type: 'any', title: 'o-tuple-any' }, 93 | minItems: 1, 94 | }, 95 | }, 96 | }, 97 | }; 98 | 99 | describe( "traverse", ( ) => 100 | { 101 | type TestObject = 102 | { title?: string } 103 | & 104 | Pick< 105 | TraverseCallbackArgument, 106 | | 'path' 107 | | 'required' 108 | | 'parentProperty' 109 | | 'index' 110 | > 111 | & 112 | Partial< Pick< TraverseCallbackArgument, 'rootNode' > >; 113 | 114 | const testObjectSorter = ( a: TestObject, b: TestObject ) => 115 | { 116 | if ( a.path.length < b.path.length ) 117 | return -1; 118 | else if ( a.path.length > b.path.length ) 119 | return 1; 120 | return JSON.stringify( a ).localeCompare( JSON.stringify( b ) ); 121 | }; 122 | 123 | it( "should traverse deeply", ( ) => 124 | { 125 | const visited: Array< TestObject > = [ ]; 126 | traverse( 127 | root, 128 | ( { node, path, required, parentProperty, index, rootNode } ) => 129 | { 130 | const { title } = node; 131 | visited.push( { 132 | title, 133 | path, 134 | required, 135 | parentProperty, 136 | index, 137 | rootNode, 138 | } ); 139 | } 140 | ); 141 | 142 | for ( const elem of visited ) 143 | { 144 | expect( elem.rootNode ).toBe( root ); 145 | delete elem.rootNode; 146 | } 147 | 148 | visited.sort( testObjectSorter ); 149 | 150 | expect( visited ).toMatchSnapshot( ); 151 | } ); 152 | 153 | it( "should traverse without additionals", ( ) => 154 | { 155 | const visited: Array< TestObject > = [ ]; 156 | const root: NodeType = { 157 | type: 'object', 158 | properties: { 159 | p: { required: false, node: { 160 | type: 'object', 161 | properties: { 162 | foo: { required: true, node: { type: 'boolean' } }, 163 | }, 164 | additionalProperties: false, 165 | } }, 166 | tup: { required: true, node: { 167 | type: 'tuple', 168 | elementTypes: [ { type: 'null' } ], 169 | minItems: 0, 170 | additionalItems: false, 171 | }, }, 172 | }, 173 | additionalProperties: true, 174 | }; 175 | traverse( 176 | root, 177 | ( { node, path, required, parentProperty, index } ) => 178 | { 179 | visited.push( { 180 | title: node === root ? '' : JSON.stringify( node ), 181 | path, 182 | required, 183 | parentProperty, 184 | index, 185 | } ); 186 | } 187 | ); 188 | 189 | visited.sort( testObjectSorter ); 190 | 191 | expect( visited ).toMatchSnapshot( ); 192 | } ); 193 | } ); 194 | 195 | describe( "some", ( ) => 196 | { 197 | it( "should not find what's not there", ( ) => 198 | { 199 | const found = some( 200 | root, 201 | ( { node } ) => 202 | node.title === 'o-ref' && node.type === 'integer' 203 | ); 204 | 205 | expect( found ).toBe( false ); 206 | } ); 207 | 208 | it( "should not find what's actually there", ( ) => 209 | { 210 | const found = some( 211 | root, 212 | ( { node } ) => 213 | node.title === 'o-ref' && node.type === 'ref' 214 | ); 215 | 216 | expect( found ).toBe( true ); 217 | } ); 218 | 219 | it( "should forward errors", ( ) => 220 | { 221 | const find = ( ) => some( 222 | root, 223 | ( { node } ) => 224 | { 225 | if ( node.title === 'o-ref' && node.type === 'ref' ) 226 | throw new Error( 'some error' ); 227 | return false; 228 | } 229 | ); 230 | 231 | expect( find ).toThrowError( 'some error' ); 232 | } ); 233 | } ); 234 | -------------------------------------------------------------------------------- /lib/traverse.ts: -------------------------------------------------------------------------------- 1 | import type { NodePath, NodeType } from './types.js' 2 | 3 | export interface TraverseCallbackArgument 4 | { 5 | node: NodeType; 6 | rootNode: NodeType; 7 | path: NodePath; 8 | parentProperty?: string; 9 | parentNode?: NodeType; 10 | index?: string | number; 11 | required?: boolean; 12 | } 13 | 14 | export type TraverseCallback = ( arg: TraverseCallbackArgument ) => void; 15 | export type SomeCallback = ( arg: TraverseCallbackArgument ) => boolean; 16 | 17 | class StopError extends Error { } 18 | 19 | export function traverse( node: NodeType, cb: TraverseCallback ) 20 | { 21 | function makeNewArg< T extends NodeType >( 22 | arg: TraverseCallbackArgument, 23 | parentNode: T, 24 | parentProperty: keyof T, 25 | index?: string | number, 26 | required?: boolean, 27 | newNode?: NodeType 28 | ) 29 | : TraverseCallbackArgument 30 | { 31 | const node = 32 | newNode !== undefined 33 | ? newNode 34 | : index === undefined 35 | ? parentNode[ parentProperty ] 36 | : ( parentNode[ parentProperty ] as any)[ index ]; 37 | 38 | const newPath: NodePath = [ 39 | ...arg.path, 40 | parentProperty as string, 41 | ...( index === undefined ? [ ] : [ index ] ), 42 | ]; 43 | 44 | const newValues: Partial< TraverseCallbackArgument > = { 45 | node, 46 | path: newPath, 47 | parentNode, 48 | parentProperty: parentProperty as string, 49 | index, 50 | required, 51 | }; 52 | 53 | return Object.assign( { }, arg, newValues ); 54 | } 55 | 56 | function recurse( arg: TraverseCallbackArgument, cb: TraverseCallback ) 57 | { 58 | cb( arg ); 59 | 60 | const { node } = arg; 61 | 62 | if ( node.type === 'array' ) 63 | recurse( 64 | makeNewArg< typeof node >( arg, node, 'elementType' ), 65 | cb 66 | ); 67 | else if ( node.type === 'tuple' ) 68 | { 69 | node.elementTypes.forEach( ( _, i ) => 70 | recurse( 71 | makeNewArg< typeof node >( arg, node, 'elementTypes', i ), 72 | cb 73 | ) 74 | ); 75 | if ( typeof node.additionalItems === 'object' ) 76 | recurse( 77 | makeNewArg< typeof node >( arg, node, 'additionalItems' ), 78 | cb 79 | ); 80 | } 81 | else if ( node.type === 'object' ) 82 | { 83 | for ( const prop of Object.keys( node.properties ) ) 84 | recurse( 85 | makeNewArg< typeof node >( 86 | arg, 87 | node, 88 | 'properties', 89 | prop, 90 | node.properties[ prop ].required, 91 | node.properties[ prop ].node 92 | ), 93 | cb 94 | ); 95 | 96 | if ( typeof node.additionalProperties === 'object' ) 97 | recurse( 98 | makeNewArg< typeof node >( 99 | arg, 100 | node, 101 | 'additionalProperties' 102 | ), 103 | cb 104 | ); 105 | } 106 | else if ( node.type === 'and' ) 107 | node.and.forEach( ( _, i ) => 108 | recurse( 109 | makeNewArg< typeof node >( arg, node, 'and', i ), 110 | cb 111 | ) 112 | ); 113 | else if ( node.type === 'or' ) 114 | node.or.forEach( ( _, i ) => 115 | recurse( 116 | makeNewArg< typeof node >( arg, node, 'or', i ), 117 | cb 118 | ) 119 | ); 120 | } 121 | 122 | const arg: TraverseCallbackArgument = { 123 | node, 124 | rootNode: node, 125 | path: [ ], 126 | }; 127 | 128 | recurse( arg, cb ); 129 | } 130 | 131 | export function some( node: NodeType, cb: SomeCallback ) 132 | { 133 | try 134 | { 135 | traverse( node, arg => 136 | { 137 | if ( cb( arg ) ) 138 | throw new StopError( ); 139 | } ); 140 | } 141 | catch ( err ) 142 | { 143 | if ( err instanceof StopError ) 144 | return true; 145 | throw err; 146 | } 147 | return false; 148 | } 149 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface LineColumn 3 | { 4 | line: number; 5 | column: number; 6 | offset: number; 7 | } 8 | 9 | export interface LocationWithLineColumn { 10 | start?: LineColumn; 11 | end?: LineColumn; 12 | } 13 | 14 | export interface LocationOffset 15 | { 16 | start?: number; 17 | end?: number; 18 | } 19 | 20 | export type Location = 21 | | LocationWithLineColumn 22 | | LocationOffset; 23 | 24 | export interface CoreTypeAnnotations 25 | { 26 | name?: string; 27 | title?: string; 28 | description?: string; 29 | examples?: string | Array< string >; 30 | default?: string; 31 | see?: string | Array< string >; 32 | comment?: string; 33 | loc?: LocationOffset | LocationWithLineColumn; 34 | } 35 | 36 | export interface AndType extends CoreTypeAnnotations 37 | { 38 | type: 'and'; 39 | and: NodeType[ ]; 40 | } 41 | 42 | export interface OrType extends CoreTypeAnnotations 43 | { 44 | type: 'or'; 45 | or: NodeType[ ]; 46 | } 47 | 48 | export interface TypeMap 49 | { 50 | and: void; 51 | or: void; 52 | ref: string; 53 | any: unknown; 54 | null: null; 55 | string: string; 56 | number: number; 57 | integer: number; 58 | boolean: boolean; 59 | object: object; 60 | array: Array< unknown >; 61 | tuple: Array< unknown >; 62 | } 63 | 64 | export type Types = keyof TypeMap; 65 | 66 | export interface Const< T > 67 | { 68 | const?: T; 69 | } 70 | 71 | export interface Enum< T > 72 | { 73 | enum?: Array< T >; 74 | } 75 | 76 | export type GenericTypeInfo< T > = 77 | & Const< T > 78 | & Enum< T >; 79 | 80 | export interface NodePrimitiveCoreType< 81 | Type extends 'any' | 'null' | 'string' | 'number' | 'integer' | 'boolean' 82 | > 83 | { 84 | type: Type; 85 | } 86 | 87 | export type NodePrimitiveType< 88 | Type extends 'any' | 'null' | 'string' | 'number' | 'integer' | 'boolean' 89 | > = 90 | & NodePrimitiveCoreType< Type > 91 | & GenericTypeInfo< TypeMap[ Type ] >; 92 | 93 | export type AnyType = NodePrimitiveType< 'any' > & CoreTypeAnnotations; 94 | export type NullType = NodePrimitiveCoreType< 'null' > & CoreTypeAnnotations; 95 | export type StringType = NodePrimitiveType< 'string' > & CoreTypeAnnotations; 96 | export type NumberType = NodePrimitiveType< 'number' > & CoreTypeAnnotations; 97 | export type IntegerType = NodePrimitiveType< 'integer' > & CoreTypeAnnotations; 98 | export type BooleanType = NodePrimitiveType< 'boolean' > & CoreTypeAnnotations; 99 | 100 | export type PrimitiveType = 101 | | NullType 102 | | StringType 103 | | NumberType 104 | | IntegerType 105 | | BooleanType; 106 | 107 | export interface NodeRefCoreType 108 | { 109 | type: 'ref'; 110 | ref: string; 111 | } 112 | 113 | export type RefType = 114 | & NodeRefCoreType 115 | & GenericTypeInfo< unknown > 116 | & CoreTypeAnnotations; 117 | 118 | export interface ObjectProperty 119 | { 120 | required: boolean; 121 | node: NodeType; 122 | } 123 | 124 | export interface NodeObjectCoreType 125 | { 126 | type: 'object'; 127 | properties: { 128 | [ name: string ]: ObjectProperty; 129 | }; 130 | additionalProperties: boolean | NodeType; 131 | } 132 | 133 | export type ObjectType = 134 | & NodeObjectCoreType 135 | & GenericTypeInfo< object > 136 | & CoreTypeAnnotations; 137 | 138 | export interface NodeArrayCoreType 139 | { 140 | type: 'array'; 141 | elementType: NodeType; 142 | } 143 | 144 | export type ArrayType< T = unknown > = 145 | & NodeArrayCoreType 146 | & GenericTypeInfo< Array< T > > 147 | & CoreTypeAnnotations; 148 | 149 | export interface NodeTupleCoreType 150 | { 151 | type: 'tuple'; 152 | elementTypes: Array< NodeType >; 153 | minItems: number; 154 | additionalItems: boolean | NodeType; 155 | } 156 | 157 | export type TupleType< T extends unknown[ ] = unknown[ ] > = 158 | & NodeTupleCoreType 159 | & GenericTypeInfo< T > 160 | & CoreTypeAnnotations; 161 | 162 | export interface NodeTypeMap 163 | { 164 | and: AndType; 165 | or: OrType; 166 | ref: RefType; 167 | any: AnyType; 168 | null: NullType; 169 | string: StringType; 170 | number: NumberType; 171 | integer: IntegerType; 172 | boolean: BooleanType; 173 | object: ObjectType; 174 | array: ArrayType; 175 | tuple: TupleType; 176 | } 177 | 178 | export type NodeType = NodeTypeMap[ keyof NodeTypeMap ]; 179 | 180 | export type NamedType< T extends NodeType = NodeType > = T & { name: string; }; 181 | 182 | export type NodeWithConstEnum = 183 | | AnyType 184 | | StringType 185 | | NumberType 186 | | IntegerType 187 | | BooleanType 188 | | ObjectType 189 | | ArrayType 190 | | TupleType 191 | | RefType; 192 | 193 | export type NodePath = Array< string | number >; 194 | 195 | export interface NodeDocument< 196 | Version extends number = 1, 197 | T extends NodeType = NodeType 198 | > 199 | { 200 | version: Version; 201 | types: Array< NamedType< T > >; 202 | } 203 | 204 | export interface ConversionResult< T = string > 205 | { 206 | data: T; 207 | convertedTypes: Array< string >; 208 | notConvertedTypes: Array< string >; 209 | } 210 | -------------------------------------------------------------------------------- /lib/util.spec.ts: -------------------------------------------------------------------------------- 1 | import type { NodeType, Types } from './types.js' 2 | import { 3 | ensureArray, 4 | hasConstEnum, 5 | intersection, 6 | isEqual, 7 | isPrimitiveType, 8 | union, 9 | uniq, 10 | } from './util.js' 11 | 12 | 13 | const nodeify = ( type: Types ): NodeType => ( { type } ) as NodeType; 14 | 15 | describe( "utils", ( ) => 16 | { 17 | describe( "uniq", ( ) => 18 | { 19 | it( "should work on empty arrays", ( ) => 20 | { 21 | expect( uniq( [ ] ) ).toStrictEqual( [ ] ); 22 | } ); 23 | 24 | it( "should work on numerics", ( ) => 25 | { 26 | expect( uniq( [ 2, 4, 2, 3 ] ) ).toStrictEqual( [ 2, 4, 3 ] ); 27 | } ); 28 | 29 | it( "should work on strings", ( ) => 30 | { 31 | expect( uniq( [ "a", "b", "a", "c" ] ) ) 32 | .toStrictEqual( [ "a", "b", "c" ] ); 33 | } ); 34 | 35 | it( "should work on objects", ( ) => 36 | { 37 | expect( 38 | uniq( [ { a: 1 }, { b: 2 }, { a: 1 }, { a: 2 }, { c: 3 } ] ) 39 | ).toStrictEqual( 40 | [ { a: 1 }, { b: 2 }, { a: 2 }, { c: 3 } ] 41 | ); 42 | } ); 43 | 44 | it( "should work on arrays", ( ) => 45 | { 46 | expect( 47 | uniq( [ [ 1, 2 ], [ 1, 3 ], [ 1, 2 ], [ 1, 4 ] ] ) 48 | ).toStrictEqual( 49 | [ [ 1, 2 ], [ 1, 3 ], [ 1, 4 ] ] 50 | ); 51 | } ); 52 | } ); 53 | 54 | describe( "ensureArray", ( ) => 55 | { 56 | it( "should turn null to empty array", ( ) => 57 | { 58 | expect( 59 | ensureArray( null ) 60 | ).toStrictEqual( 61 | [ ] 62 | ); 63 | } ); 64 | 65 | it( "should turn undefined to empty array", ( ) => 66 | { 67 | expect( 68 | ensureArray( undefined ) 69 | ).toStrictEqual( 70 | [ ] 71 | ); 72 | } ); 73 | 74 | it( "should turn string into array of string", ( ) => 75 | { 76 | expect( 77 | ensureArray( "foo" ) 78 | ).toStrictEqual( 79 | [ "foo" ] 80 | ); 81 | } ); 82 | 83 | it( "should return same empty array", ( ) => 84 | { 85 | const arr = [ ] as Array< never >; 86 | expect( ensureArray( arr ) ).toBe( arr ); 87 | } ); 88 | 89 | it( "should return same non-empty array", ( ) => 90 | { 91 | const arr = [ "foo", "bar" ] as Array< never >; 92 | expect( ensureArray( arr ) ).toBe( arr ); 93 | } ); 94 | } ); 95 | 96 | describe( "isPrimitiveType", ( ) => 97 | { 98 | it( "should return true for null", ( ) => 99 | { 100 | expect( isPrimitiveType( nodeify( "null" ) ) ).toBe( true ); 101 | } ); 102 | 103 | it( "should return true for string", ( ) => 104 | { 105 | expect( isPrimitiveType( nodeify( "string" ) ) ).toBe( true ); 106 | } ); 107 | 108 | it( "should return true for number", ( ) => 109 | { 110 | expect( isPrimitiveType( nodeify( "number" ) ) ).toBe( true ); 111 | } ); 112 | 113 | it( "should return true for integer", ( ) => 114 | { 115 | expect( isPrimitiveType( nodeify( "integer" ) ) ).toBe( true ); 116 | } ); 117 | 118 | it( "should return true for boolean", ( ) => 119 | { 120 | expect( isPrimitiveType( nodeify( "boolean" ) ) ).toBe( true ); 121 | } ); 122 | 123 | it( "should return true for and", ( ) => 124 | { 125 | expect( isPrimitiveType( nodeify( "and" ) ) ).toBe( false ); 126 | } ); 127 | 128 | it( "should return true for or", ( ) => 129 | { 130 | expect( isPrimitiveType( nodeify( "or" ) ) ).toBe( false ); 131 | } ); 132 | 133 | it( "should return true for ref", ( ) => 134 | { 135 | expect( isPrimitiveType( nodeify( "ref" ) ) ).toBe( false ); 136 | } ); 137 | 138 | it( "should return true for any", ( ) => 139 | { 140 | expect( isPrimitiveType( nodeify( "any" ) ) ).toBe( false ); 141 | } ); 142 | 143 | it( "should return true for object", ( ) => 144 | { 145 | expect( isPrimitiveType( nodeify( "object" ) ) ).toBe( false ); 146 | } ); 147 | 148 | it( "should return true for array", ( ) => 149 | { 150 | expect( isPrimitiveType( nodeify( "array" ) ) ).toBe( false ); 151 | } ); 152 | 153 | it( "should return true for tuple", ( ) => 154 | { 155 | expect( isPrimitiveType( nodeify( "tuple" ) ) ).toBe( false ); 156 | } ); 157 | } ); 158 | 159 | describe( "hasConstEnum", ( ) => 160 | { 161 | it( "should return false for and", ( ) => 162 | { 163 | expect( hasConstEnum( nodeify( "and" ) ) ).toBe( false ); 164 | } ); 165 | 166 | it( "should return false for or", ( ) => 167 | { 168 | expect( hasConstEnum( nodeify( "or" ) ) ).toBe( false ); 169 | } ); 170 | 171 | it( "should return true for any", ( ) => 172 | { 173 | expect( hasConstEnum( nodeify( "any" ) ) ).toBe( true ); 174 | } ); 175 | 176 | it( "should return true for string", ( ) => 177 | { 178 | expect( hasConstEnum( nodeify( "string" ) ) ).toBe( true ); 179 | } ); 180 | 181 | it( "should return true for number", ( ) => 182 | { 183 | expect( hasConstEnum( nodeify( "number" ) ) ).toBe( true ); 184 | } ); 185 | 186 | it( "should return true for integer", ( ) => 187 | { 188 | expect( hasConstEnum( nodeify( "integer" ) ) ).toBe( true ); 189 | } ); 190 | 191 | it( "should return true for boolean", ( ) => 192 | { 193 | expect( hasConstEnum( nodeify( "boolean" ) ) ).toBe( true ); 194 | } ); 195 | 196 | it( "should return true for object", ( ) => 197 | { 198 | expect( hasConstEnum( nodeify( "object" ) ) ).toBe( true ); 199 | } ); 200 | 201 | it( "should return true for array", ( ) => 202 | { 203 | expect( hasConstEnum( nodeify( "array" ) ) ).toBe( true ); 204 | } ); 205 | 206 | it( "should return true for tuple", ( ) => 207 | { 208 | expect( hasConstEnum( nodeify( "tuple" ) ) ).toBe( true ); 209 | } ); 210 | 211 | it( "should return true for ref", ( ) => 212 | { 213 | expect( hasConstEnum( nodeify( "ref" ) ) ).toBe( true ); 214 | } ); 215 | } ); 216 | 217 | describe( "isEqual", ( ) => 218 | { 219 | it( "not same typeof", ( ) => 220 | { 221 | expect( isEqual( 4, '4' ) ).toBe( false ); 222 | } ); 223 | 224 | it( "not same nullish", ( ) => 225 | { 226 | expect( isEqual( { }, null ) ).toBe( false ); 227 | } ); 228 | 229 | it( "both null", ( ) => 230 | { 231 | expect( isEqual( null, null ) ).toBe( true ); 232 | } ); 233 | 234 | it( "arrays of different length", ( ) => 235 | { 236 | expect( isEqual( [ 3 ], [ ] ) ).toBe( false ); 237 | } ); 238 | 239 | it( "arrays of same length, but different content", ( ) => 240 | { 241 | expect( isEqual( [ 3 ], [ '3' ] ) ).toBe( false ); 242 | } ); 243 | 244 | it( "arrays of same length, with equal content", ( ) => 245 | { 246 | expect( isEqual( [ 3 ], [ 3 ] ) ).toBe( true ); 247 | } ); 248 | 249 | it( "one arrays, one not", ( ) => 250 | { 251 | expect( isEqual( [ ], { } ) ).toBe( false ); 252 | } ); 253 | } ); 254 | 255 | describe( "intersection", ( ) => 256 | { 257 | intersection; 258 | } ); 259 | 260 | describe( "union", ( ) => 261 | { 262 | it( "should union both empty", ( ) => 263 | { 264 | expect( union( [ ], [ ] ) ).toStrictEqual( [ ] ); 265 | } ); 266 | 267 | it( "should union one empty", ( ) => 268 | { 269 | expect( union( [ ], [ 2, 3 ] ) ).toStrictEqual( [ 2, 3 ] ); 270 | } ); 271 | 272 | it( "should union correctly", ( ) => 273 | { 274 | expect( union( [ 1, 2, 4, 5 ], [ 2, 3, 4 ] ) ) 275 | .toStrictEqual( [ 1, 2, 4, 5, 3 ] ); 276 | } ); 277 | } ); 278 | } ); 279 | -------------------------------------------------------------------------------- /lib/util.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NamedType, 3 | NodeDocument, 4 | NodeType, 5 | NodeTypeMap, 6 | NodeWithConstEnum, 7 | PrimitiveType, 8 | Types, 9 | } from './types.js' 10 | 11 | 12 | export function uniq< T extends Comparable | unknown >( arr: Array< T > ) 13 | : Array< T > 14 | { 15 | return arr 16 | .filter( ( t, index ) => 17 | { 18 | for ( let i = 0; i < index; ++i ) 19 | { 20 | const u = arr[ i ]; 21 | if ( isEqual( t as Comparable, u as Comparable ) ) 22 | return false; 23 | } 24 | return true; 25 | } ); 26 | } 27 | 28 | export function ensureArray< T >( t: T | Array< T > | undefined | null ) 29 | : Array< T > 30 | { 31 | if ( t == null ) 32 | return [ ]; 33 | return Array.isArray( t ) ? t : [ t ]; 34 | } 35 | 36 | export const isPrimitiveType = ( node: NodeType ): node is PrimitiveType => 37 | [ "null", "string", "number", "integer", "boolean" ].includes( node.type ); 38 | 39 | export const constEnumTypes = new Set< NodeType[ 'type' ] >( [ 40 | 'any', 41 | 'string', 42 | 'number', 43 | 'integer', 44 | 'boolean', 45 | 'object', 46 | 'array', 47 | 'tuple', 48 | 'ref' 49 | ] ); 50 | 51 | export const hasConstEnum = ( node: NodeType ): node is NodeWithConstEnum => 52 | constEnumTypes.has( node.type ); 53 | 54 | export type ComparablePrimitives = 55 | undefined | null | boolean | string | number; 56 | export type ComparableArray = Array< Comparable >; 57 | export type ComparableObject = { [ key: string ]: Comparable; }; 58 | export type Comparable = 59 | | ComparablePrimitives 60 | | ComparableArray 61 | | ComparableObject; 62 | 63 | export function isEqual< T extends Comparable >( a: T, b: T ): boolean; 64 | export function isEqual< T extends Comparable, U extends Comparable > 65 | ( a: T, b: U ): false; 66 | export function isEqual< T extends Comparable, U extends Comparable > 67 | ( a: T, b: U ) 68 | : boolean 69 | { 70 | if ( typeof a !== typeof b ) 71 | return false; 72 | else if ( ( a === null ) !== ( b === null ) ) 73 | return false; 74 | else if ( a === null ) 75 | return true; 76 | else if ( Array.isArray( a ) && Array.isArray( b ) ) 77 | { 78 | if ( a.length !== b.length ) 79 | return false; 80 | return !a.some( ( value, index ) => !isEqual( value, b[ index ] ) ); 81 | } 82 | else if ( Array.isArray( a ) !== Array.isArray( b ) ) 83 | return false; 84 | else if ( typeof a === 'object' ) 85 | { 86 | const keysA = Object.keys( a as NonNullable< typeof a > ).sort( ); 87 | const keysB = Object.keys( b as NonNullable< typeof b > ).sort( ); 88 | if ( !isEqual( keysA, keysB ) ) 89 | return false; 90 | 91 | return !keysA.some( key => 92 | !isEqual( 93 | ( a as ComparableObject )[ key ], 94 | ( b as ComparableObject )[ key ] 95 | ) 96 | ); 97 | } 98 | else 99 | return a === ( b as ComparablePrimitives ); 100 | } 101 | 102 | export function intersection< T extends Comparable >( 103 | a: Array< T >, 104 | b: Array< T > 105 | ) 106 | : Array< T > 107 | { 108 | const ret: Array< T > = [ ]; 109 | 110 | a.forEach( aItem => 111 | { 112 | b.forEach( bItem => { 113 | if ( isEqual( aItem, bItem ) ) 114 | ret.push( aItem ); 115 | } ); 116 | } ); 117 | 118 | return ret; 119 | } 120 | 121 | export function union< T extends Comparable >( 122 | a: Array< T >, 123 | b: Array< T > 124 | ) 125 | : Array< T > 126 | { 127 | const ret: Array< T > = [ ...a ]; 128 | 129 | b.forEach( aItem => 130 | { 131 | const unique = !a.some( bItem => isEqual( aItem, bItem ) ); 132 | if ( unique ) 133 | ret.push( aItem ); 134 | } ); 135 | 136 | return ret; 137 | } 138 | 139 | export interface NodeWithOrder< T > 140 | { 141 | node: T; 142 | order: number; 143 | } 144 | 145 | type SplitTypes = { 146 | [ T in Types ]: Array< NodeWithOrder< NodeTypeMap[ T ] > >; 147 | }; 148 | 149 | // Split a set of types into individual sets per-type 150 | export function splitTypes( nodes: Array< NodeType > ): SplitTypes 151 | { 152 | const ret: SplitTypes = { 153 | and: [ ], 154 | or: [ ], 155 | ref: [ ], 156 | any: [ ], 157 | null: [ ], 158 | string: [ ], 159 | number: [ ], 160 | integer: [ ], 161 | boolean: [ ], 162 | object: [ ], 163 | array: [ ], 164 | tuple: [ ], 165 | }; 166 | 167 | nodes.forEach( ( node, index ) => 168 | { 169 | if ( 170 | node.type !== 'and' && node.type !== 'or' 171 | || 172 | node.type === 'and' && node.and.length > 0 173 | || 174 | node.type === 'or' && node.or.length > 0 175 | ) 176 | { 177 | const nodeWithOrder: NodeWithOrder< typeof node > = { 178 | node, 179 | order: index, 180 | }; 181 | ret[ node.type ].push( nodeWithOrder as any ); 182 | } 183 | } ); 184 | 185 | return ret; 186 | } 187 | 188 | export function flattenSplitTypeValues( splitTypes: SplitTypes ) 189 | { 190 | return ( [ ] as Array< NodeType > ).concat( 191 | Object.values( splitTypes ) 192 | .flat( ) 193 | .sort( ( a, b ) => a.order - b.order ) 194 | .map( ( { node } ) => node ) 195 | ); 196 | } 197 | 198 | export function firstSplitTypeIndex( nodes: Array< NodeWithOrder< unknown > > ) 199 | { 200 | return Math.min( ...nodes.map( ( { order } ) => order ) ); 201 | } 202 | 203 | export function copyName( from: NamedType< any >, to: NamedType< any > ) 204 | : typeof to 205 | { 206 | return typeof from.name === 'undefined' ? to : { ...to, name: from.name }; 207 | } 208 | 209 | export function isNonNullable< T >( t: T ): t is NonNullable< T > 210 | { 211 | return t != null; 212 | } 213 | 214 | export function isNodeDocument( 215 | t: NodeDocument | NodeType | Array< NodeType > 216 | ) 217 | : t is NodeDocument 218 | { 219 | return Array.isArray( ( t as NodeDocument ).types ); 220 | } 221 | -------------------------------------------------------------------------------- /lib/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import { MalformedTypeError } from './error.js' 2 | import { validate } from './validate.js' 3 | 4 | 5 | describe( "validate", ( ) => 6 | { 7 | it( "should allow simple type", ( ) => 8 | { 9 | expect( validate( { type: 'string' } ) ).toBeUndefined( ); 10 | } ); 11 | 12 | it( "should allow 'and' array of simple types", ( ) => 13 | { 14 | expect( 15 | validate( { type: 'and', and: [ { type: 'string' } ] } ) 16 | ).toBeUndefined( ); 17 | } ); 18 | 19 | it( "should allow 'or' array of simple types", ( ) => 20 | { 21 | expect( 22 | validate( { type: 'or', or: [ { type: 'string' } ] } ) 23 | ).toBeUndefined( ); 24 | } ); 25 | 26 | it( "should disallow empty enum", ( ) => 27 | { 28 | expect( ( ) => validate( { type: 'string', enum: [ ] } ) ) 29 | .toThrowError( MalformedTypeError ); 30 | } ); 31 | 32 | it( "should disallow 'and' array of empty enum", ( ) => 33 | { 34 | expect( ( ) => 35 | validate( { type: 'and', and: [ { type: 'string', enum: [ ] } ] } ) 36 | ).toThrowError( MalformedTypeError ); 37 | } ); 38 | 39 | it( "should disallow 'or' array of empty enum", ( ) => 40 | { 41 | expect( ( ) => 42 | validate( { type: 'or', or: [ { type: 'string', enum: [ ] } ] } ) 43 | ).toThrowError( MalformedTypeError ); 44 | } ); 45 | 46 | it( "should disallow enum & const that mismatch", ( ) => 47 | { 48 | expect( ( ) => 49 | validate( { 50 | type: 'string', 51 | enum: [ 'foo', 'bar' ], const: 'baz' 52 | } ) 53 | ).toThrowError( MalformedTypeError ); 54 | } ); 55 | 56 | it( "should disallow 'and' array of enum & const that mismatch", ( ) => 57 | { 58 | expect( ( ) => 59 | validate( { 60 | type: 'and', 61 | and: [ 62 | { type: 'string', enum: [ 'foo', 'bar' ], const: 'baz' } 63 | ] 64 | } ) 65 | ).toThrowError( MalformedTypeError ); 66 | } ); 67 | 68 | it( "should disallow 'or' array of enum & const that mismatch", ( ) => 69 | { 70 | expect( ( ) => 71 | validate( { 72 | type: 'or', 73 | or: [ 74 | { type: 'string', enum: [ 'foo', 'bar' ], const: 'baz' } 75 | ] 76 | } ) 77 | ).toThrowError( MalformedTypeError ); 78 | } ); 79 | } ); 80 | -------------------------------------------------------------------------------- /lib/validate.ts: -------------------------------------------------------------------------------- 1 | import { MalformedTypeError } from './error.js' 2 | import type { NodeType, NodeWithConstEnum } from './types.js' 3 | import { hasConstEnum, isEqual } from './util.js' 4 | 5 | 6 | export function validate( node: NodeType ) 7 | { 8 | if ( hasConstEnum( node ) ) 9 | validateConstEnum( node ) 10 | 11 | if ( node.type === 'and' ) 12 | node.and.forEach( subNode => validate( subNode ) ); 13 | 14 | if ( node.type === 'or' ) 15 | node.or.forEach( subNode => validate( subNode ) ); 16 | } 17 | 18 | function validateConstEnum( node: NodeWithConstEnum ) 19 | { 20 | if ( node.enum && node.enum.length === 0 ) 21 | throw new MalformedTypeError( "Empty enum is not allowed", node ); 22 | 23 | if ( node.enum && node.const !== undefined ) 24 | { 25 | if ( !node.enum.some( entry => isEqual( entry, node.const as any ) ) ) 26 | throw new MalformedTypeError( 27 | "Enum and const are both set, but enum doesn't contain const", 28 | node 29 | ); 30 | } 31 | 32 | // TODO: Check data type of enum/const matching type 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "core-types", 3 | "version": "0.0.0-development", 4 | "description": "Generic type declarations for e.g. TypeScript, GraphQL and JSON Schema", 5 | "author": "Gustaf Räntilä", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/grantila/core-types/issues" 9 | }, 10 | "homepage": "https://github.com/grantila/core-types#readme", 11 | "main": "./dist/index.js", 12 | "types": "./dist/index.d.ts", 13 | "directories": {}, 14 | "type": "module", 15 | "sideEffects": false, 16 | "engines": { 17 | "node": ">=14.13.1 || >=16.0.0" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "build": "rimraf dist && tsc -p tsconfig.prod.json", 24 | "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", 25 | "cz": "git-cz" 26 | }, 27 | "pre-commit": [ 28 | "build", 29 | "test" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/grantila/core-types" 34 | }, 35 | "keywords": [ 36 | "type", 37 | "types", 38 | "generic", 39 | "typescript", 40 | "graphql", 41 | "json", 42 | "schema" 43 | ], 44 | "devDependencies": { 45 | "@babel/preset-env": "^7.21.4", 46 | "@babel/preset-typescript": "^7.21.4", 47 | "@types/jest": "^29.5.0", 48 | "cz-conventional-changelog": "^3.3.0", 49 | "jest": "^29.5.0", 50 | "pre-commit": "^1.2.2", 51 | "rimraf": "^4.4.1", 52 | "ts-jest-resolver": "^2.0.1", 53 | "typescript": "5.0.3" 54 | }, 55 | "config": { 56 | "commitizen": { 57 | "path": "./node_modules/cz-conventional-changelog" 58 | } 59 | }, 60 | "jest": { 61 | "resolver": "ts-jest-resolver", 62 | "extensionsToTreatAsEsm": [ 63 | ".ts" 64 | ], 65 | "testEnvironment": "node", 66 | "coverageReporters": [ 67 | "lcov", 68 | "text", 69 | "html" 70 | ], 71 | "collectCoverageFrom": [ 72 | "/lib/**" 73 | ], 74 | "coveragePathIgnorePatterns": [ 75 | "/node_modules/", 76 | "/__snapshots__/" 77 | ] 78 | }, 79 | "packageManager": "yarn@3.2.4" 80 | } 81 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "declaration": true, 5 | "sourceMap": true, 6 | "lib": [ "ES2020" ], 7 | "types": [ 8 | "node", 9 | "jest" 10 | ], 11 | "noEmit": true, 12 | "target": "ES2020", 13 | "module": "ES2020", 14 | "moduleResolution": "node", 15 | "noImplicitAny": true, 16 | "noUnusedLocals": true, 17 | "pretty": true, 18 | "strict": true, 19 | "alwaysStrict": true, 20 | }, 21 | "include": [ 22 | "lib", 23 | "index.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "outDir": "dist", 6 | "noEmit": false 7 | }, 8 | "exclude": [ 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | --------------------------------------------------------------------------------