├── .changeset ├── README.md └── config.json ├── .gitignore ├── .travis.yml ├── .vscode └── settings.example.json ├── LICENSE ├── README.md ├── biome.json ├── documentation ├── about-typeonly.md └── tutorial.md ├── package-lock.json ├── package.json └── packages ├── loader ├── .gitignore ├── .npmignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bundle-tsd │ └── bundle-tsd.js ├── package.json ├── scripts │ └── bundle-tsd.js ├── src │ ├── api.ts │ ├── helpers │ │ └── module-path-helpers.ts │ ├── loader │ │ ├── ModuleFactory.ts │ │ └── Project.ts │ └── typeonly-loader.d.ts ├── tests │ ├── loader-types.spec.ts │ └── tsconfig.json ├── tsconfig.json └── vitest.config.ts ├── typeonly ├── .gitignore ├── .npmignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── TypeOnlyLexer.g4 ├── TypeOnlyParser.g4 ├── package.json ├── scripts │ └── bundle-tsd.js ├── src │ ├── api.ts │ ├── ast.d.ts │ ├── cli.ts │ ├── helpers │ │ ├── fs-utils.ts │ │ ├── js-lib.ts │ │ └── module-path-helpers.ts │ ├── parser │ │ ├── AstExtractor.ts │ │ ├── CommentGrabber.ts │ │ ├── antlr4-defs.d.ts │ │ └── parse-typeonly.ts │ ├── rto-factory │ │ ├── AstImportTool.ts │ │ ├── InlineImportScanner.ts │ │ ├── ProjectInputOutput.ts │ │ ├── RtoModuleFactory.ts │ │ ├── RtoProject.ts │ │ └── internal-types.d.ts │ └── rto.d.ts ├── tests │ ├── ast-tests │ │ ├── ast-array.spec.ts │ │ ├── ast-comment.spec.ts │ │ ├── ast-composite.spec.ts │ │ ├── ast-function.spec.ts │ │ ├── ast-generic.spec.ts │ │ ├── ast-index-signature.spec.ts │ │ ├── ast-inline-import.spec.ts │ │ ├── ast-interface.spec.ts │ │ ├── ast-interface2.spec.ts │ │ ├── ast-key-of.spec.ts │ │ ├── ast-literal.spec.ts │ │ ├── ast-member-type.spec.ts │ │ ├── ast-named-import.spec.ts │ │ ├── ast-named-type.spec.ts │ │ ├── ast-nested-types.spec.ts │ │ ├── ast-precedence.spec.ts │ │ ├── ast-tuple.spec.ts │ │ └── ast-whitespaces.spec.ts │ ├── import-tests │ │ ├── import-tests.spec.ts │ │ └── test-proj01 │ │ │ ├── node_modules │ │ │ ├── @hello │ │ │ │ └── world │ │ │ │ │ ├── package.json │ │ │ │ │ └── types │ │ │ │ │ ├── main.d.ts │ │ │ │ │ └── other.d.ts │ │ │ └── simple-package │ │ │ │ ├── package.json │ │ │ │ └── types │ │ │ │ ├── main.d.ts │ │ │ │ └── simple-other.d.ts │ │ │ ├── package.json │ │ │ └── types │ │ │ └── proj01-types.d.ts │ ├── rto-tests │ │ ├── rto-comment.spec.ts │ │ ├── rto-import.spec.ts │ │ └── rto-types.spec.ts │ └── tsconfig.json ├── tsconfig.json ├── typeonly-language.md └── vitest.config.ts ├── validator-cli ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src │ └── cli.ts └── tsconfig.json └── validator ├── .gitignore ├── .npmignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bundle-tsd └── bundle-tsd.js ├── package.json ├── scripts └── bundle-tsd.js ├── src ├── Validator.ts ├── api.ts ├── error-message.ts └── helpers.ts ├── tests ├── interface.spec.ts ├── tsconfig.json └── types.spec.ts ├── tsconfig.json └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 12 | "onlyUpdatePeerDependentsWhenOutOfRange": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .npmrc 4 | .vscode/* 5 | !.vscode/settings.example.json 6 | /.DS_Store 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 12 5 | - 14 6 | cache: 7 | directories: 8 | - "node_modules" 9 | before_install: 10 | - wget https://www.antlr.org/download/antlr-4.13.2-complete.jar --directory-prefix typeonly 11 | # before_script: 12 | # - npm run build 13 | env: 14 | - SUB_PROJECT=typeonly 15 | - SUB_PROJECT=loader 16 | - SUB_PROJECT=validator 17 | - SUB_PROJECT=validator-cli 18 | script: cd $SUB_PROJECT && npm i && npm run prepublishOnly 19 | -------------------------------------------------------------------------------- /.vscode/settings.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.rulers": [100], 4 | "editor.tabSize": 2, 5 | "editor.wordWrap": "wordWrapColumn", 6 | "editor.wordWrapColumn": 100, 7 | "files.encoding": "utf8", 8 | "files.trimTrailingWhitespace": true, 9 | "javascript.preferences.quoteStyle": "double", 10 | "search.useIgnoreFiles": true, 11 | "javascript.format.semicolons": "insert", 12 | "typescript.locale": "en", 13 | "typescript.format.semicolons": "insert", 14 | "typescript.preferences.importModuleSpecifier": "relative", 15 | "typescript.preferences.quoteStyle": "double", 16 | "search.exclude": { 17 | "**/node_modules": true, 18 | "**/package-lock.json": true, 19 | "**/dist": true, 20 | "**/CHANGELOG.md": true 21 | }, 22 | "[typescript]": { 23 | "editor.codeActionsOnSave": { 24 | "source.fixAll": "explicit", 25 | "source.organizeImports": "explicit" 26 | }, 27 | "editor.defaultFormatter": "biomejs.biome", 28 | "editor.formatOnSave": true 29 | }, 30 | "[json]": { 31 | "editor.defaultFormatter": "biomejs.biome" 32 | }, 33 | "antlr4.format": { 34 | "continuationIndentWidth": 2, 35 | "indentWidth": 2, 36 | "tabWidth": 2, 37 | "useTab": false 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monorepo for TypeOnly 2 | 3 | [![Build Status](https://travis-ci.com/paroi-tech/typeonly.svg?branch=master)](https://travis-ci.com/paroi-tech/typeonly) 4 | 5 | **TypeOnly** is a lightweight validation library that uses TypeScript type definitions to validate JSON data. **[Learn more about TypeOnly here](https://github.com/paroi-tech/typeonly/tree/master/packages/typeonly)**. 6 | 7 | ## Projects 8 | 9 | * [typeonly](https://github.com/paroi-tech/typeonly/tree/master/packages/typeonly): Parses types and interfaces from TypeScript and stores them as JSON files; 10 | * [@typeonly/loader](https://github.com/paroi-tech/typeonly/tree/master/packages/loader): Brings types and interfaces from TypeScript at runtime; 11 | * [@typeonly/validator](https://github.com/paroi-tech/typeonly/tree/master/packages/validator): An API to validate JSON data or JavaScript objects, using TypeScript typing definitions; 12 | * [@typeonly/validator-cli](https://github.com/paroi-tech/typeonly/tree/master/packages/validator-cli): A CLI to validate JSON files, using TypeScript typing definitions. 13 | 14 | ## Contribute 15 | 16 | ### Install and Build 17 | 18 | We need a JVM (Java Virtual Machine) to build the parser because we use [ANTLR](https://www.antlr.org/), which is a Java program. So, at first, install a JVM on your system. 19 | 20 | In a terminal, open the cloned `typeonly/typeonly/` repository. Then: 21 | 22 | ```sh 23 | # Download once the ANTLR JAR file in the project's root directory 24 | wget https://www.antlr.org/download/antlr-4.13.2-complete.jar 25 | 26 | # Install once all Node.js dependencies 27 | npm install 28 | ``` 29 | 30 | ### Development environment 31 | 32 | With VS Code, our recommanded plugins are: 33 | 34 | - **Biome** from biomejs (biomejs.dev) 35 | - **ANTLR4 grammar syntax support** from Mike Lischke (`mike-lischke.vscode-antlr4`) 36 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "javascript": { 4 | "parser": { 5 | "unsafeParameterDecoratorsEnabled": true 6 | } 7 | }, 8 | "linter": { 9 | "enabled": true, 10 | "rules": { 11 | "recommended": true, 12 | "suspicious": { 13 | "noExplicitAny": "off", 14 | "useValidTypeof": "off" 15 | }, 16 | "complexity": { 17 | "noForEach": "off", 18 | "useSimplifiedLogicExpression": "off" 19 | }, 20 | "a11y": { 21 | "useKeyWithClickEvents": "warn", 22 | "noLabelWithoutControl": "off" 23 | }, 24 | "security": { 25 | "noDangerouslySetInnerHtml": "off" 26 | }, 27 | "correctness": { 28 | "noConstantCondition": "off", 29 | "noSwitchDeclarations": "off", 30 | "useExhaustiveDependencies": "off", 31 | "useImportExtensions": "error" 32 | } 33 | } 34 | }, 35 | "formatter": { 36 | "enabled": true, 37 | "formatWithErrors": false, 38 | "indentStyle": "space", 39 | "indentWidth": 2, 40 | "lineWidth": 100 41 | }, 42 | "files": { 43 | "include": ["*.ts", "*.json"], 44 | "ignore": ["dist", "node_modules", "scripts/declarations"] 45 | }, 46 | "organizeImports": { 47 | "enabled": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /documentation/about-typeonly.md: -------------------------------------------------------------------------------- 1 | # About TypeOnly 2 | 3 | ## What is TypeOnly? 4 | 5 | TypeOnly aims to be the pure typing part of TypeScript. See also: a [detailed description of the language](https://github.com/paroi-tech/typeonly/blob/master/typeonly/typeonly-language.md). 6 | 7 | TypeOnly is a new language but not a new syntax. TypeOnly aims to be and remain a strict subset of TypeScript: any code that compiles with TypeOnly will also compile with TypeScript. It is the "pure typing" part of TypeScript: only `interface` and `type` definitions. 8 | 9 | With TypeScript, types definitions are not available at runtime. Sometime this forces us to repeat ourselves, as in the following example: 10 | 11 | ```ts 12 | type ColorName = "red" | "green" | "blue"; 13 | 14 | function isColorName(name: string): name is ColorName { 15 | return ["red", "green", "blue"].includes(name); 16 | } 17 | ``` 18 | 19 | This kind of code is not ideal. There is an [issue](https://github.com/microsoft/TypeScript/issues/3628) on Github related to this subject, and the TypeScript team is not ready to provide a solution. 20 | 21 | The TypeOnly parser is implemented from scratch and does not require TypeScript as a dependency. It can be used outside a TypeScript project, such as in a JavaScript project, or to validate JSON data with a command line tool. 22 | 23 | ## How to use TypeOnly 24 | 25 | There are three packages built on top of TypeOnly. 26 | 27 | How to **load typing metadata at runtime**: use the package [@typeonly/loader](https://github.com/paroi-tech/typeonly/tree/master/packages/loader). 28 | 29 | How to **validate JSON data from the command line**: use the package [@typeonly/validator-cli](https://github.com/paroi-tech/typeonly/tree/master/packages/validator-cli). 30 | 31 | How to **validate JSON data or a JavaScript object using an API**: use the package [@typeonly/validator](https://github.com/paroi-tech/typeonly/tree/master/packages/validator). 32 | 33 | ## Known Limitations 34 | 35 | Generics are not implemented. 36 | 37 | There is some kind of source code that can currently be parsed without error with TypeOnly, although it is invalid in TypeScript. This is a temporary limitation of our implementation. Do not use it! TypeOnly will always remain a strict subset of TypeScript. If you write some code that is incompatible with TypeScript, then future versions of TypeOnly could break your code. 38 | 39 | An example of invalid TypeScript code that mistakenly can be parsed by the current version of TypeOnly: 40 | 41 | ```ts 42 | interface I1 { 43 | [name: string]: boolean; 44 | p1: number; // TS Error: Property 'p1' of type 'number' is not assignable to string index type 'boolean'. 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /documentation/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorials for TypeOnly 2 | 3 | ## Parse TypeScript definitions with the CLI 4 | 5 | In a new directory, install `typeonly` as a dependency: 6 | 7 | ```sh 8 | npm init 9 | npm install typeonly --save-dev 10 | ``` 11 | 12 | Edit the file `package.json` and add the following entry in the section `"scripts"`: 13 | 14 | ```json 15 | "scripts": { 16 | "typeonly": "typeonly --bundle dist/types.to.json --source-dir types" 17 | }, 18 | ``` 19 | 20 | Create a subdirectory `types/`, then create a file _"types/drawing.d.ts"_ with the following code: 21 | 22 | ```ts 23 | // types/drawing.d.ts 24 | 25 | export interface Drawing { 26 | color: ColorName; 27 | dashed?: boolean; 28 | shape: Rectangle | Circle; 29 | } 30 | 31 | export type ColorName = "red" | "green" | "blue"; 32 | 33 | export interface Rectangle { 34 | kind: "rectangle"; 35 | x: number; 36 | y: number; 37 | width: number; 38 | height: number; 39 | } 40 | 41 | export interface Circle { 42 | kind: "circle"; 43 | x: number; 44 | y: number; 45 | radius: number; 46 | } 47 | ``` 48 | 49 | Now we can execute the TypeOnly parser via our script: 50 | 51 | ```sh 52 | npm run typeonly 53 | ``` 54 | 55 | This command creates a file `dist/types.to.json`. A file with the `.to.json` extension is a bundle that contains metadata extracted from several `.d.ts` typing file. 56 | 57 | ## How to programmatically validate JSON data 58 | 59 | Now, add `@typeonly/validator` to the project: 60 | 61 | ```sh 62 | npm install @typeonly/validator 63 | ``` 64 | 65 | Create a file `src/validate-main.js` with the following content: 66 | 67 | ```ts 68 | // src/validate-main.js 69 | import { readFileSync } from "node:fs" 70 | import { createValidator } from "@typeonly/validator"; 71 | 72 | const data = { 73 | color: "green", 74 | shape: { 75 | kind: "circle", 76 | x: 100, 77 | y: 100, 78 | radius: 50 79 | } 80 | }; 81 | 82 | const validator = createValidator({ 83 | bundle: JSON.parse(readFileSync(`./types.to.json`)); 84 | }); 85 | const result = validator.validate("./drawing", "Drawing", data); 86 | console.log(result); 87 | ``` 88 | 89 | This code validates the `data` object using RTO files generated by the TypeOnly parser. 90 | 91 | Execute it: 92 | 93 | ```sh 94 | $ node src/validate-main.js 95 | { valid: true } 96 | ``` 97 | 98 | ## Tutorial: Load typing definitions at runtime 99 | 100 | Now, add `@typeonly/loader` to the project: 101 | 102 | ```sh 103 | npm install @typeonly/loader 104 | ``` 105 | 106 | Create a file `src/main.js` with the following content: 107 | 108 | ```ts 109 | // src/main.js 110 | import { readFileSync } from "node:fs" 111 | import { loadModules, literals } from "@typeonly/loader"; 112 | 113 | const modules = loadModules({ 114 | bundle: JSON.parse(readFileSync(`./types.to.json`)); 115 | }); 116 | 117 | const { ColorName } = modules["./drawing"].namedTypes; 118 | console.log("Color names:", literals(ColorName, "string")); 119 | ``` 120 | 121 | If you write this code in a TypeScript source file, simply replace the `require` syntax with a standard `import`. 122 | 123 | We can execute our program: 124 | 125 | ```sh 126 | $ node src/main.js 127 | Color names: [ 'red', 'green', 'blue' ] 128 | ``` 129 | 130 | Yes, it’s as easy as it seems: the list of color names is now available at runtime. 131 | 132 | Notice: The TypeOnly parser is used at build time. At runtime, our code only use `@typeonly/loader` which is a lightweight wrapper for `.to.json` files. 133 | 134 | ## How to validate the conformity of a JSON file using the CLI 135 | 136 | Create a file _"drawing.d.ts"_ with the following code: 137 | 138 | ```ts 139 | // drawing.d.ts 140 | 141 | export interface Drawing { 142 | color: ColorName 143 | dashed?: boolean 144 | shape: Rectangle | Circle 145 | } 146 | 147 | export type ColorName = "red" | "green" | "blue" 148 | 149 | export interface Rectangle { 150 | kind: "rectangle", 151 | x: number 152 | y: number 153 | width: number 154 | height: number 155 | } 156 | 157 | export interface Circle { 158 | kind: "circle", 159 | x: number 160 | y: number 161 | radius: number 162 | } 163 | ``` 164 | 165 | Then, create a JSON file _"drawing.json"_ that must be of type `Drawing`: 166 | 167 | ```json 168 | { 169 | "color": "green", 170 | "shape": { 171 | "kind": "circle", 172 | "x": 100, 173 | "y": 100, 174 | "radius": "wrong value" 175 | } 176 | } 177 | ``` 178 | 179 | We are ready to validate the JSON file: 180 | 181 | ```sh 182 | $ npx @typeonly/validator-cli -s drawing.d.ts -t "Drawing" drawing.json 183 | In property 'radius', value '"wrong value"' is not conform to number. 184 | ``` 185 | 186 | A mistake is detected in the JSON file. Fix it by replacing the value of the property `"radius"` with a valid number. For example: `"radius": 50`. And run the command again: 187 | 188 | ```sh 189 | $ npx @typeonly/validator-cli -s drawing.d.ts -t "Drawing" drawing.json 190 | ``` 191 | 192 | Good. The validator no longer complain. 193 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeonly-monorepo", 3 | "version": "0.0.0", 4 | "author": "Paroi Team", 5 | "private": true, 6 | "scripts": { 7 | "clear": "npm run clear --workspaces --if-present", 8 | "build": "npm run build --workspaces --if-present", 9 | "test": "npm run test --workspaces --if-present", 10 | "lint": "npm run lint --workspaces --if-present", 11 | "changeset": "changeset", 12 | "changeset:version": "changeset version", 13 | "changeset:publish": "changeset publish" 14 | }, 15 | "devDependencies": { 16 | "@biomejs/biome": "~1.9.4", 17 | "@changesets/cli": "~2.27.12" 18 | }, 19 | "workspaces": [ 20 | "packages/typeonly", 21 | "packages/loader", 22 | "packages/validator", 23 | "packages/validator-cli" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/loader/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | 4 | node_modules 5 | package-lock.json 6 | .npmrc 7 | 8 | /dist 9 | /scripts/declarations 10 | -------------------------------------------------------------------------------- /packages/loader/.npmignore: -------------------------------------------------------------------------------- 1 | /scripts/declarations 2 | -------------------------------------------------------------------------------- /packages/loader/.prettierignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /packages/loader/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @typeonly/loader 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 9759c6a: Documentation 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - ESM modules 14 | 15 | ## 0.6.0 16 | 17 | ### Minor Changes 18 | 19 | - upgrade dependencies & syntax 20 | -------------------------------------------------------------------------------- /packages/loader/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | 117 | -------------------------------------------------------------------------------- /packages/loader/README.md: -------------------------------------------------------------------------------- 1 | # @typeonly/loader 2 | 3 | [![Build Status](https://travis-ci.com/paroi-tech/typeonly.svg?branch=master)](https://travis-ci.com/paroi-tech/typeonly) 4 | [![npm](https://img.shields.io/npm/dm/@typeonly/loader)](https://www.npmjs.com/package/@typeonly/loader) 5 | ![Type definitions](https://img.shields.io/npm/types/@typeonly/loader) 6 | ![GitHub](https://img.shields.io/github/license/paroi-tech/typeonly) 7 | 8 | This package is part of **TypeOnly**, a lightweight validation library that uses TypeScript type definitions to validate JSON data. **[Learn more about TypeOnly here](https://www.npmjs.com/package/typeonly)**. 9 | -------------------------------------------------------------------------------- /packages/loader/bundle-tsd/bundle-tsd.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const bundleName = "loader" 10 | const srcDir = join(__dirname, "..", "src") 11 | const compiledDir = join(__dirname, "declarations") 12 | const packageDir = join(__dirname, "..") 13 | 14 | try { 15 | writeFileSync(join(packageDir, "dist", `${bundleName}.d.ts`), makeDefinitionsCode()) 16 | } catch (err) { 17 | console.log(err.message, err.stack) 18 | } 19 | 20 | function makeDefinitionsCode() { 21 | const defs = [ 22 | "// -- API Definitions --", 23 | cleanGeneratedCode( 24 | removeLocalImportsExports((readFileSync(join(compiledDir, "api.d.ts"), "utf-8")).trim()), 25 | ), 26 | "// -- TypeOnly Loader Definitions --", 27 | removeLocalImportsExports((readFileSync(join(srcDir, "typeonly-loader.d.ts"), "utf-8")).trim()), 28 | ] 29 | return defs.join("\n\n") 30 | } 31 | 32 | function removeLocalImportsExports(code) { 33 | const localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/ 34 | return code.split("\n").filter(line => { 35 | return !localImportExport.test(line) 36 | }).join("\n").trim() 37 | } 38 | 39 | function cleanGeneratedCode(code) { 40 | return code.replace(/;/g, "").replace(/ /g, " ") 41 | } 42 | -------------------------------------------------------------------------------- /packages/loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeonly/loader", 3 | "version": "1.0.1", 4 | "description": "Brings types and interfaces from TypeScript at runtime.", 5 | "author": "Paroi Team", 6 | "scripts": { 7 | "prepublishOnly": "npm run build && npm run test", 8 | "clear": "rimraf dist/* scripts/declarations/*", 9 | "tsc": "tsc", 10 | "tsc:watch": "tsc --watch", 11 | "bundle-tsd": "node scripts/bundle-tsd.js", 12 | "build": "npm run clear && npm run tsc && npm run bundle-tsd", 13 | "lint": "biome check . --json-formatter-enabled=false --organize-imports-enabled=false", 14 | "test:watch": "vitest", 15 | "test": "vitest run" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "22", 19 | "rimraf": "^6.0.1", 20 | "typeonly": "^1.0.1", 21 | "typescript": "^5.7.3", 22 | "vitest": "^3.0.5" 23 | }, 24 | "type": "module", 25 | "main": "dist/api.js", 26 | "types": "dist/loader.d.ts", 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/paroi-tech/typeonly.git" 30 | }, 31 | "homepage": "https://github.com/paroi-tech/typeonly/tree/master/packages/loader", 32 | "license": "CC0-1.0", 33 | "keywords": [ 34 | "typescript", 35 | "type", 36 | "interface", 37 | "runtime" 38 | ] 39 | } -------------------------------------------------------------------------------- /packages/loader/scripts/bundle-tsd.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const bundleName = "loader" 10 | const srcDir = join(__dirname, "..", "src") 11 | const compiledDir = join(__dirname, "declarations") 12 | const packageDir = join(__dirname, "..") 13 | 14 | try { 15 | writeFileSync(join(packageDir, "dist", `${bundleName}.d.ts`), makeDefinitionsCode()) 16 | } catch (err) { 17 | console.log(err.message, err.stack) 18 | } 19 | 20 | function makeDefinitionsCode() { 21 | const defs = [ 22 | "// -- API Definitions --", 23 | cleanGeneratedCode( 24 | removeLocalImportsExports((readFileSync(join(compiledDir, "api.d.ts"), "utf-8")).trim()), 25 | ), 26 | "// -- TypeOnly Loader Definitions --", 27 | removeLocalImportsExports((readFileSync(join(srcDir, "typeonly-loader.d.ts"), "utf-8")).trim()), 28 | ] 29 | return defs.join("\n\n") 30 | } 31 | 32 | function removeLocalImportsExports(code) { 33 | const localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/ 34 | return code.split("\n").filter(line => { 35 | return !localImportExport.test(line) 36 | }).join("\n").trim() 37 | } 38 | 39 | function cleanGeneratedCode(code) { 40 | return code.replace(/;/g, "").replace(/ /g, " ") 41 | } 42 | -------------------------------------------------------------------------------- /packages/loader/src/api.ts: -------------------------------------------------------------------------------- 1 | import { readFile, readdir } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import Project from "./loader/Project.js"; 4 | import type { Modules, Type } from "./typeonly-loader.d.ts"; 5 | 6 | export type ReadModulesOptions = SyncReadModulesOptions | AsyncReadModulesOptions; 7 | 8 | export interface SyncReadModulesOptions { 9 | /** 10 | * Optional when `"modules"` is defined. 11 | */ 12 | modulePaths?: string[]; 13 | /** 14 | * Of type: `RtoModules`. 15 | */ 16 | bundle: any; 17 | } 18 | 19 | export interface AsyncReadModulesOptions { 20 | /** 21 | * Optional when `"modules"` is defined. 22 | */ 23 | modulePaths: string[]; 24 | baseDir?: string; 25 | encoding?: BufferEncoding; 26 | rtoModuleProvider?: RtoModuleProvider; 27 | } 28 | 29 | /** 30 | * The returned type is `Promise | RtoModule`. 31 | */ 32 | export type RtoModuleProvider = (modulePath: string) => Promise | any; 33 | 34 | export function loadModules(options: SyncReadModulesOptions): Modules; 35 | export function loadModules(options: AsyncReadModulesOptions): Promise; 36 | export function loadModules(options: ReadModulesOptions): any { 37 | if (isSyncReadModulesOptions(options)) return loadModulesSync(options); 38 | return loadModulesAsync(options); 39 | } 40 | 41 | export function isSyncReadModulesOptions( 42 | options: ReadModulesOptions, 43 | ): options is SyncReadModulesOptions { 44 | return !!(options as any).bundle; 45 | } 46 | 47 | function loadModulesSync(options: SyncReadModulesOptions): Modules { 48 | let { modulePaths, bundle } = options; 49 | const rtoModuleProvider = (modulePath: string) => { 50 | const rtoModule = bundle[modulePath]; 51 | if (!rtoModule) throw new Error(`Unknown module: ${modulePath}`); 52 | return rtoModule; 53 | }; 54 | if (!modulePaths) modulePaths = Object.keys(bundle); 55 | const project = new Project({ rtoModuleProvider }); 56 | return project.parseModulesSync(modulePaths); 57 | } 58 | 59 | async function loadModulesAsync(options: AsyncReadModulesOptions): Promise { 60 | let { modulePaths, rtoModuleProvider } = options; 61 | if (rtoModuleProvider) { 62 | if (options.baseDir) 63 | throw new Error("Do not use 'baseDir' with 'rtoModuleProvider' or 'modules'"); 64 | if (!modulePaths) throw new Error("Missing parameter 'modulePaths'"); 65 | } else { 66 | if (!options.baseDir) 67 | throw new Error("An option 'baseDir', 'rtoModuleProvider' or 'modules' is required"); 68 | rtoModuleProvider = makeReadSourceFileRtoModuleProvider({ 69 | baseDir: options.baseDir, 70 | encoding: options.encoding || "utf8", 71 | }); 72 | if (!modulePaths) modulePaths = await getModulePathsInDir(options.baseDir); 73 | } 74 | const project = new Project({ rtoModuleProvider }); 75 | return await project.parseModulesAsync(modulePaths); 76 | } 77 | 78 | async function getModulePathsInDir(dir: string): Promise { 79 | const files = await readdir(dir); 80 | return files 81 | .filter((fileName) => fileName.endsWith(".rto.json")) 82 | .map((fileName) => `./${fileName}`); 83 | } 84 | 85 | function makeReadSourceFileRtoModuleProvider(options: { 86 | baseDir: string; 87 | encoding: BufferEncoding; 88 | }): RtoModuleProvider { 89 | return async (modulePath: string) => { 90 | const { baseDir, encoding } = options; 91 | const data = await readRtoFile(baseDir, modulePath, encoding); 92 | return JSON.parse(data); 93 | }; 94 | } 95 | 96 | async function readRtoFile(baseDir: string, modulePath: string, encoding: BufferEncoding) { 97 | const path = join(baseDir, modulePath); 98 | try { 99 | return await readFile(`${path}.rto.json`, encoding); 100 | } catch { 101 | throw new Error(`Cannot open module file: ${path}.rto.json`); 102 | } 103 | } 104 | 105 | export function literals(type: Type, only: "string"): string[]; 106 | export function literals(type: Type, only: "number"): number[]; 107 | export function literals(type: Type, only: "bigint"): Array; 108 | export function literals(type: Type, only: "boolean"): boolean[]; 109 | export function literals(type: Type): Array; 110 | export function literals(type: Type, only?: string): any[] { 111 | let children: Type[]; 112 | if (type.kind !== "composite" || type.op !== "union") { 113 | if (type.kind === "literal") children = [type]; 114 | else throw new Error("Should be a union"); 115 | } else children = type.types; 116 | return children.map((child) => { 117 | if (child.kind !== "literal") throw new Error(`Should be a 'literal': '${type.kind}'`); 118 | if (only && typeof child.literal !== only) 119 | throw new Error(`Literal should be a '${only}': '${typeof child.literal}'`); 120 | return child.literal; 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /packages/loader/src/helpers/module-path-helpers.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from "node:path"; 2 | 3 | export interface RelativeModulePath { 4 | from: string; 5 | relativeToModule?: string; 6 | } 7 | 8 | export interface ToModulePathOptions extends RelativeModulePath { 9 | removeExtensions?: string[]; 10 | } 11 | 12 | export function toModulePath(options: ToModulePathOptions): string { 13 | let from = options.removeExtensions 14 | ? removeExtensions(options.from, options.removeExtensions) 15 | : options.from; 16 | if (!from.startsWith("./") && !from.startsWith("../")) 17 | throw new Error("Module path must start with './' or '../'"); 18 | let parentDir = options.relativeToModule ? dirname(options.relativeToModule) : "."; 19 | while (true) { 20 | if (from.startsWith("./")) from = from.substr(2); 21 | else if (from.startsWith("../")) { 22 | const newDir = dirname(parentDir); 23 | if (newDir === parentDir) break; 24 | parentDir = newDir; 25 | from = from.substr(3); 26 | } else break; 27 | } 28 | if (from.startsWith("../")) return from; 29 | return `${parentDir}/${from}`; 30 | } 31 | 32 | function removeExtensions(path: string, extensions: string[]): string { 33 | for (const extension of extensions) { 34 | if (path.endsWith(extension)) return path.substr(0, path.length - extension.length); 35 | } 36 | return path; 37 | } 38 | -------------------------------------------------------------------------------- /packages/loader/src/loader/Project.ts: -------------------------------------------------------------------------------- 1 | import type { RtoModuleProvider } from "../api.js"; 2 | import { type RelativeModulePath, toModulePath } from "../helpers/module-path-helpers.js"; 3 | import type { Modules } from "../typeonly-loader.d.ts"; 4 | import ModuleFactory from "./ModuleFactory.js"; 5 | 6 | export type GetModuleFactory = (modulePath: RelativeModulePath) => ModuleFactory; 7 | 8 | export interface ProjectOptions { 9 | rtoModuleProvider: RtoModuleProvider; 10 | } 11 | 12 | export default class Project { 13 | private factories = new Map(); 14 | 15 | constructor(private options: ProjectOptions) {} 16 | 17 | parseModulesSync(paths: string[]): Modules { 18 | this.checkPaths(paths); 19 | for (const from of paths) this.loadRtoModuleSync({ from }); 20 | return this.createModules(); 21 | } 22 | 23 | async parseModulesAsync(paths: string[]): Promise { 24 | this.checkPaths(paths); 25 | for (const from of paths) await this.loadRtoModule({ from }); 26 | return this.createModules(); 27 | } 28 | 29 | private checkPaths(paths: string[]) { 30 | paths.forEach((path) => { 31 | if (!path.startsWith("./") && !path.startsWith("../")) 32 | throw new Error("A relative path is required for RTO module"); 33 | }); 34 | } 35 | 36 | private createModules() { 37 | const modules: Modules = {}; 38 | for (const factory of this.factories.values()) { 39 | const module = factory.createModule((modulePath) => this.getModuleFactory(modulePath)); 40 | if (!module.path) throw new Error("Missing path in module"); 41 | modules[module.path] = module; 42 | } 43 | return modules; 44 | } 45 | 46 | private getModuleFactory(relPath: RelativeModulePath): ModuleFactory { 47 | const modulePath = toModulePath({ 48 | ...relPath, 49 | removeExtensions: [".rto.json"], 50 | }); 51 | const factory = this.factories.get(modulePath); 52 | if (!factory) throw new Error(`Unknown module: ${modulePath}`); 53 | return factory; 54 | } 55 | 56 | private async loadRtoModule(relPath: RelativeModulePath) { 57 | const modulePath = toModulePath({ 58 | ...relPath, 59 | removeExtensions: [".rto.json"], 60 | }); 61 | let factory = this.factories.get(modulePath); 62 | if (!factory) { 63 | const rtoModule = await this.options.rtoModuleProvider(modulePath); 64 | factory = new ModuleFactory(rtoModule, modulePath); 65 | this.factories.set(modulePath, factory); 66 | await this.loadImports(factory, modulePath); 67 | } 68 | } 69 | 70 | private async loadImports(factory: ModuleFactory, modulePath: string) { 71 | if (factory.rtoModule.imports) { 72 | for (const { from } of factory.rtoModule.imports) 73 | await this.loadRtoModule({ from, relativeToModule: modulePath }); 74 | } 75 | if (factory.rtoModule.namespacedImports) { 76 | for (const { from } of factory.rtoModule.namespacedImports) 77 | await this.loadRtoModule({ from, relativeToModule: modulePath }); 78 | } 79 | } 80 | 81 | private loadRtoModuleSync(relPath: RelativeModulePath) { 82 | const modulePath = toModulePath({ 83 | ...relPath, 84 | removeExtensions: [".rto.json"], 85 | }); 86 | let factory = this.factories.get(modulePath); 87 | if (!factory) { 88 | const rtoModule = this.options.rtoModuleProvider(modulePath); 89 | if (rtoModule.then) throw new Error(`Cannot load module '${modulePath}' synchronously`); 90 | factory = new ModuleFactory(rtoModule, modulePath); 91 | this.factories.set(modulePath, factory); 92 | this.loadImportsSync(factory, modulePath); 93 | } 94 | } 95 | 96 | private loadImportsSync(factory: ModuleFactory, modulePath: string) { 97 | if (factory.rtoModule.imports) { 98 | for (const { from } of factory.rtoModule.imports) 99 | this.loadRtoModuleSync({ from, relativeToModule: modulePath }); 100 | } 101 | if (factory.rtoModule.namespacedImports) { 102 | for (const { from } of factory.rtoModule.namespacedImports) 103 | this.loadRtoModuleSync({ from, relativeToModule: modulePath }); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /packages/loader/src/typeonly-loader.d.ts: -------------------------------------------------------------------------------- 1 | export interface Modules { 2 | [modulePath: string]: Module; 3 | } 4 | 5 | /** 6 | * TypeOnly Module 7 | */ 8 | export interface Module { 9 | path?: string; 10 | imports?: Import[]; 11 | namespacedImports?: NamespacedImport[]; 12 | namedTypes: NamedTypes; 13 | } 14 | 15 | export interface Import { 16 | from: Module; 17 | namedMembers?: ImportNamedMembers; 18 | } 19 | 20 | export interface ImportNamedMembers { 21 | [as: string]: NamedType; 22 | } 23 | 24 | export interface NamespacedImport { 25 | from: Module; 26 | asNamespace: string; 27 | } 28 | 29 | export interface NamedTypes { 30 | [name: string]: NamedType; 31 | } 32 | 33 | export type NamedType = Type & BaseNamedType; 34 | 35 | export type NamedTypeName = TypeName & BaseNamedType; 36 | export type NamedGenericParameterName = GenericParameterName & BaseNamedType; 37 | export type NamedLocalTypeRef = LocalTypeRef & BaseNamedType; 38 | export type NamedImportedTypeRef = ImportedTypeRef & BaseNamedType; 39 | export type NamedLiteralType = LiteralType & BaseNamedType; 40 | export type NamedCompositeType = CompositeType & BaseNamedType; 41 | export type NamedTupleType = TupleType & BaseNamedType; 42 | export type NamedArrayType = ArrayType & BaseNamedType; 43 | export type NamedGenericInstance = GenericInstance & BaseNamedType; 44 | export type NamedFunctionType = FunctionType & BaseNamedType; 45 | export type NamedKeyofType = KeyofType & BaseNamedType; 46 | export type NamedMemberType = MemberType & BaseNamedType; 47 | export type NamedInterface = Interface & BaseNamedType; 48 | 49 | export type Type = 50 | | TypeName 51 | | GenericParameterName 52 | | LocalTypeRef 53 | | ImportedTypeRef 54 | | LiteralType 55 | | CompositeType 56 | | TupleType 57 | | ArrayType 58 | | GenericInstance 59 | | FunctionType 60 | | KeyofType 61 | | MemberType 62 | | Interface; 63 | 64 | export interface BaseNamedType extends Commentable { 65 | module: Module; 66 | exported: boolean; 67 | name: string; 68 | generic?: GenericParameter[]; 69 | } 70 | 71 | export interface GenericParameter { 72 | name: string; 73 | extendsType?: Type; 74 | defaultType?: Type; 75 | } 76 | 77 | export interface TypeName { 78 | kind: "name"; 79 | group: "ts" | "primitive" | "global"; 80 | refName: SpecialTypeName | PrimitiveTypeName | string; 81 | } 82 | 83 | export type SpecialTypeName = "any" | "unknown" | "object" | "void" | "never"; 84 | export type PrimitiveTypeName = 85 | | "string" 86 | | "number" 87 | | "bigint" 88 | | "boolean" 89 | | "undefined" 90 | | "null" 91 | | "symbol"; 92 | 93 | export interface GenericParameterName { 94 | kind: "genericParameterName"; 95 | genericParameterName: string; 96 | } 97 | 98 | export interface LocalTypeRef { 99 | kind: "localRef"; 100 | refName: string; 101 | ref: NamedType; 102 | } 103 | 104 | export interface ImportedTypeRef { 105 | kind: "importedRef"; 106 | refName: string; 107 | namespace?: string; 108 | ref: NamedType; 109 | } 110 | 111 | export interface LiteralType { 112 | kind: "literal"; 113 | literal: string | number | bigint | boolean; 114 | } 115 | 116 | export interface CompositeType { 117 | kind: "composite"; 118 | op: "union" | "intersection"; 119 | types: Type[]; 120 | } 121 | 122 | export interface TupleType { 123 | kind: "tuple"; 124 | itemTypes: Type[]; 125 | } 126 | 127 | export interface ArrayType { 128 | kind: "array"; 129 | itemType: Type; 130 | } 131 | 132 | export interface GenericInstance { 133 | kind: "genericInstance"; 134 | genericName: string; 135 | parameterTypes: Type[]; 136 | } 137 | 138 | export interface FunctionType { 139 | kind: "function"; 140 | parameters: FunctionParameter[]; 141 | returnType: Type; 142 | generic?: GenericParameter[]; 143 | } 144 | 145 | export interface FunctionParameter { 146 | name: string; 147 | type: Type; 148 | optional: boolean; 149 | } 150 | 151 | export interface KeyofType { 152 | kind: "keyof"; 153 | type: Type; 154 | } 155 | 156 | export interface MemberType { 157 | kind: "member"; 158 | parentType: Type; 159 | memberName: string | MemberNameLiteral; 160 | } 161 | 162 | export interface MemberNameLiteral { 163 | literal: string | number; 164 | } 165 | 166 | export interface Interface { 167 | kind: "interface"; 168 | indexSignature?: IndexSignature; 169 | mappedIndexSignature?: MappedIndexSignature; 170 | properties?: Properties; 171 | } 172 | 173 | export interface Properties { 174 | [name: string]: Property; 175 | } 176 | 177 | export interface Property extends Commentable { 178 | of: Interface; 179 | name: string; 180 | type: Type; 181 | optional: boolean; 182 | readonly: boolean; 183 | } 184 | 185 | export interface IndexSignature extends Commentable { 186 | of: Interface; 187 | keyName: string; 188 | keyType: "string" | "number"; 189 | type: Type; 190 | optional: boolean; 191 | readonly: boolean; 192 | } 193 | 194 | export interface MappedIndexSignature extends Commentable { 195 | of: Interface; 196 | keyName: string; 197 | keyInType: Type; 198 | type: Type; 199 | optional: boolean; 200 | readonly: boolean; 201 | } 202 | 203 | export interface Commentable { 204 | /** 205 | * A multiline string. 206 | */ 207 | docComment?: string; 208 | } 209 | -------------------------------------------------------------------------------- /packages/loader/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "noEmit": true 10 | }, 11 | "include": ["."] 12 | } 13 | -------------------------------------------------------------------------------- /packages/loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "outDir": "dist", 10 | "noEmitOnError": true, 11 | "declaration": true, 12 | "declarationDir": "scripts/declarations" 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/loader/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["**/src/**/*.spec.ts", "**/tests/**/*.spec.ts"], 6 | environment: "node", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/typeonly/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | 4 | node_modules 5 | package-lock.json 6 | .npmrc 7 | 8 | /dist 9 | /scripts/declarations 10 | 11 | /.antlr 12 | /antlr-*.jar 13 | antlr-parser 14 | 15 | !/tests/import-tests/**/node_modules -------------------------------------------------------------------------------- /packages/typeonly/.npmignore: -------------------------------------------------------------------------------- 1 | /scripts/declarations 2 | 3 | /.antlr 4 | /antlr-*.jar -------------------------------------------------------------------------------- /packages/typeonly/.prettierignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /packages/typeonly/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # typeonly 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 9759c6a: Documentation 8 | 9 | ## 1.0.0 10 | 11 | ### Major Changes 12 | 13 | - ESM modules 14 | 15 | ## 0.5.0 16 | 17 | ### Minor Changes 18 | 19 | - upgrade dependencies & syntax 20 | -------------------------------------------------------------------------------- /packages/typeonly/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | 117 | -------------------------------------------------------------------------------- /packages/typeonly/README.md: -------------------------------------------------------------------------------- 1 | # TypeOnly 2 | 3 | [![Build Status](https://travis-ci.com/paroi-tech/typeonly.svg?branch=master)](https://travis-ci.com/paroi-tech/typeonly) 4 | [![npm](https://img.shields.io/npm/dm/typeonly)](https://www.npmjs.com/package/typeonly) 5 | ![Type definitions](https://img.shields.io/npm/types/typeonly) 6 | [![GitHub](https://img.shields.io/github/license/paroi-tech/typeonly)](https://github.com/paroi-tech/typeonly) 7 | 8 | A lightweight library for validating JSON data using TypeScript type definitions. 9 | 10 | ## Getting Started 11 | 12 | ```sh 13 | npm install typeonly --save-dev 14 | npm install @typeonly/validator 15 | ``` 16 | 17 | Add an entry to the `"scripts"` section of your `package.json` (this example uses a `typeonly/` directory for input and a `dist/` directory for output): 18 | 19 | ```json 20 | "typeonly": "typeonly --bundle dist/types.to.json -s ./typeonly" 21 | ``` 22 | 23 | Create a type file with a `.d.ts` extension: 24 | 25 | ```ts 26 | // typeonly/my-types.d.ts 27 | 28 | export type Task = RedTask | BlueTask; 29 | 30 | export interface RedTask { 31 | color: "red"; 32 | priority: number | "max"; 33 | label: string; 34 | } 35 | 36 | export interface BlueTask { 37 | color: "blue"; 38 | label: string; 39 | } 40 | ``` 41 | 42 | _Important: TypeOnly uses its own parser and implements only a subset of TypeScript. Specifically, generics and template string types are not supported._ 43 | 44 | Generate the type bundle by running: 45 | 46 | ```sh 47 | npm run typeonly 48 | ``` 49 | 50 | A new file `dist/types.to.json` will be generated containing all the type definitions from the input directory. Being a JSON file, the parser is not needed at runtime. 51 | 52 | ### Validating JSON Data (ESM Version) 53 | 54 | ```js 55 | import { readFileSync } from "node:fs"; 56 | import { createValidator } from "@typeonly/validator"; 57 | 58 | const typeValidator = createValidator({ 59 | bundle: JSON.parse(readFileSync("./dist/types.to.json", "utf-8")), 60 | }); 61 | 62 | const result1 = typeValidator.validate("Task", { 63 | color: "red", 64 | priority: 2, 65 | label: "My urgent task" 66 | }); 67 | 68 | console.log(result1); // { valid: true } 69 | 70 | const result2 = typeValidator.validate("Task", { 71 | color: "red", 72 | priority: "abc", 73 | label: "My urgent task" 74 | }); 75 | 76 | console.log(result2); // { valid: false, error: 'Value...' } 77 | ``` 78 | 79 | The error message looks like: 80 | 81 | ``` 82 | Value {color: "red", priority: "abc", label: "My urgent ta…"} is not conform to Task: no matching type in: RedTask | BlueTask. 83 | In type 'RedTask', value {color: "red", priority: "abc", label: "My urgent ta…"} is not conform to RedTask. 84 | In property 'priority', value '"abc"' is not conform to union: no matching type in: number | "max". 85 | ``` 86 | 87 | ### Validating JSON Data (CommonJS Version) 88 | 89 | ```js 90 | const { readFileSync } = require("node:fs"); 91 | 92 | async function main() { 93 | const { createValidator } = await import("@typeonly/validator"); 94 | 95 | // … same code as in the ESM version … 96 | } 97 | 98 | main(); 99 | ``` 100 | 101 | ## Command Line Interface 102 | 103 | Compile a typing source file: 104 | 105 | ```sh 106 | npx typeonly --bundle dist/types.to.json --source-dir types 107 | ``` 108 | 109 | This command generates a compiled file `dist/types.to.json`. 110 | 111 | Available options: 112 | 113 | ``` 114 | -h, --help Print this help message. 115 | -o, --output-dir directory The output directory (optional). 116 | -s, --source-dir directory The source directory (optional when used with option --ast or with a single source file). 117 | -e, --encoding string Encoding for input and output file(s) (default is utf8). 118 | -b, --bundle string Generate a bundle file for RTO data (optional). 119 | --prettify Prettify RTO files (optional). 120 | --ast Generate AST files instead of RTO files (optional). 121 | --src file ... Input files to process (by default at last position). 122 | ``` 123 | 124 | ## Using as a Library 125 | 126 | Install as a dependency: 127 | 128 | ```sh 129 | npm install typeonly --save-dev 130 | ``` 131 | 132 | Then, use it: 133 | 134 | ```js 135 | import { generateRtoModules } from "typeonly"; 136 | 137 | const bundle = await generateRtoModules({ 138 | modulePaths: ["./file-name"], 139 | readFiles: { 140 | sourceDir: `../types` 141 | }, 142 | returnRtoModules: true 143 | }).catch(console.log); 144 | ``` 145 | -------------------------------------------------------------------------------- /packages/typeonly/TODO.md: -------------------------------------------------------------------------------- 1 | 2 | * Additional properties with an index signature must be conform to the index signature. 3 | -------------------------------------------------------------------------------- /packages/typeonly/TypeOnlyParser.g4: -------------------------------------------------------------------------------- 1 | parser grammar TypeOnlyParser; 2 | 3 | options { 4 | tokenVocab = TypeOnlyLexer; 5 | } 6 | 7 | declarations: typeSep? declaration* EOF; 8 | 9 | typeSep: (NL | SEMI_COLON)+; 10 | 11 | declaration: 12 | importDecl (typeSep | EOF) 13 | | namedInterface typeSep? 14 | | namedType (typeSep | EOF); 15 | 16 | /* 17 | * Import 18 | */ 19 | importDecl: classicImport | namespacedImport; 20 | 21 | classicImport: 22 | IMPORT TYPE? NL* (namedImportContent NL* FROM NL*)? STRING_LITERAL; 23 | 24 | namedImportContent: 25 | OPEN_BRACE NL* namedMember (NL* COMMA NL* namedMember)* NL* CLOSE_BRACE; 26 | 27 | namedMember: IDENTIFIER (NL* AS NL* IDENTIFIER)?; 28 | 29 | namespacedImport: 30 | IMPORT NL* STAR NL* AS NL* IDENTIFIER NL* FROM NL* STRING_LITERAL; 31 | 32 | /* 33 | * NamedInterface 34 | */ 35 | namedInterface: 36 | (EXPORT NL*)? INTERFACE IDENTIFIER (NL* genericParameters)? ( 37 | NL* interfaceExtends 38 | )? NL* anonymousInterface; 39 | 40 | interfaceExtends: 41 | EXTENDS NL* typeName (NL* COMMA NL* typeName)*; 42 | 43 | anonymousInterface: 44 | OPEN_BRACE (NL* interfaceEntries)? CLOSE_BRACE; 45 | 46 | interfaceEntries: 47 | interfaceEntry (propertySeparator interfaceEntry)* propertySeparator?; 48 | 49 | interfaceEntry: 50 | indexSignature 51 | | property 52 | | functionProperty 53 | | mappedIndexSignature; 54 | 55 | property: 56 | (READONLY NL*)? propertyName (NL* QUESTION_MARK)? NL* COLON NL* aType; 57 | 58 | functionProperty: 59 | (READONLY NL*)? propertyName (NL* QUESTION_MARK)? NL* ( 60 | NL* genericParameters 61 | )? OPEN_PARENTHESE ( 62 | NL* functionParameter (NL* COMMA NL* functionParameter)* 63 | )? NL* CLOSE_PARENTHESE (NL* COLON NL* aType)?; 64 | 65 | /* 66 | * IndexSignature and MappedIndexSignature 67 | */ 68 | indexSignature: 69 | (READONLY NL*)? OPEN_BRACKET IDENTIFIER COLON signatureType CLOSE_BRACKET ( 70 | NL* QUESTION_MARK 71 | )? COLON aType; 72 | signatureType: STRING | NUMBER; 73 | mappedIndexSignature: 74 | (READONLY NL*)? OPEN_BRACKET IDENTIFIER IN aType CLOSE_BRACKET ( 75 | NL* QUESTION_MARK 76 | )? COLON aType; 77 | 78 | propertySeparator: (NL+ (propertyExplicitSeparator NL*)?) | (propertyExplicitSeparator NL*); 79 | propertyExplicitSeparator: (SEMI_COLON (NL* SEMI_COLON)*) | COMMA; 80 | 81 | propertyName: IDENTIFIER | JS_KEYWORD | typeOnlyKeywords; 82 | typeOnlyKeywords: 83 | INTERFACE 84 | | TYPE 85 | | EXPORT 86 | | EXTENDS 87 | | READONLY 88 | | KEYOF 89 | | STRING 90 | | NUMBER 91 | | IN 92 | | AS 93 | | FROM 94 | | IMPORT; 95 | 96 | typeName: IDENTIFIER | IDENTIFIER DOT IDENTIFIER; 97 | 98 | /* 99 | * NamedType 100 | */ 101 | namedType: (EXPORT NL*)? TYPE IDENTIFIER NL* ( 102 | NL* genericParameters 103 | )? ASSIGN NL* aType; 104 | 105 | /* 106 | * Common rules for NamedInterface and NamedType 107 | */ 108 | aType: 109 | inlineImportType 110 | | typeName 111 | | signatureType 112 | | literal 113 | | tupleType 114 | | memberParentType = aType OPEN_BRACKET memberName CLOSE_BRACKET 115 | | arrayItemType = aType NL* OPEN_BRACKET NL* CLOSE_BRACKET 116 | | KEYOF aType 117 | | anonymousInterface 118 | | typeWithParenthesis 119 | | aType NL* INTERSECTION NL* aType 120 | | INTERSECTION NL* aType 121 | | aType NL* UNION NL* aType 122 | | UNION NL* aType 123 | | genericInstance 124 | | (NL* genericParameters)? OPEN_PARENTHESE ( 125 | NL* functionParameter (NL* COMMA NL* functionParameter)* 126 | )? NL* CLOSE_PARENTHESE NL* ARROW NL* returnType = aType; 127 | 128 | memberName: STRING_LITERAL | INTEGER_LITERAL | IDENTIFIER; 129 | 130 | genericParameters: LESS_THAN genericParameter+ MORE_THAN; 131 | genericParameter: 132 | IDENTIFIER (EXTENDS extendsType = aType)? ( 133 | ASSIGN defaultType = aType 134 | )?; 135 | genericInstance: 136 | typeName NL* LESS_THAN NL* aType (NL* COMMA NL* aType)* NL* MORE_THAN; 137 | inlineImportType: 138 | IMPORT OPEN_PARENTHESE stringLiteral CLOSE_PARENTHESE DOT IDENTIFIER; 139 | 140 | stringLiteral: STRING_LITERAL; // | TEMPLATE_STRING_LITERAL 141 | 142 | tupleType: 143 | OPEN_BRACKET (NL* aType (NL* COMMA NL* aType)*)? NL* CLOSE_BRACKET; 144 | typeWithParenthesis: 145 | OPEN_PARENTHESE NL* aType NL* CLOSE_PARENTHESE; 146 | functionParameter: IDENTIFIER (NL* QUESTION_MARK)? (NL* COLON NL* aType)?; 147 | 148 | /* 149 | * Literal 150 | */ 151 | literal: 152 | STRING_LITERAL 153 | // | TEMPLATE_STRING_LITERAL 154 | | BOOLEAN_LITERAL 155 | | BIG_INT_LITERAL 156 | | INTEGER_LITERAL 157 | | DECIMAL_LITERAL 158 | | HEX_INTEGER_LITERAL 159 | | OCTAL_INTEGER_LITERAL 160 | | BINARY_INTEGER_LITERAL; 161 | -------------------------------------------------------------------------------- /packages/typeonly/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeonly", 3 | "version": "1.0.1", 4 | "description": "Parses types and interfaces from TypeScript and stores them as JSON files.", 5 | "author": "Paroi", 6 | "scripts": { 7 | "prepublishOnly": "npm run lint && npm run build && npm run test", 8 | "antlr": "java -cp \"antlr-4.13.2-complete.jar\" org.antlr.v4.Tool -Dlanguage=JavaScript -o antlr-parser TypeOnlyParser.g4 TypeOnlyLexer.g4", 9 | "clear": "rimraf dist/* scripts/declarations/* antlr-parser/*", 10 | "tsc": "tsc", 11 | "tsc:watch": "tsc --watch", 12 | "bundle-tsd": "node scripts/bundle-tsd", 13 | "build": "npm run clear && npm run antlr && npm run tsc && npm run bundle-tsd", 14 | "lint": "biome check . --json-formatter-enabled=false --organize-imports-enabled=false", 15 | "test:watch": "vitest", 16 | "test": "vitest run" 17 | }, 18 | "dependencies": { 19 | "antlr4": "4.13.2", 20 | "command-line-args": "^6.0.1", 21 | "command-line-usage": "^7.0.3" 22 | }, 23 | "devDependencies": { 24 | "@types/command-line-args": "^5.2.3", 25 | "@types/command-line-usage": "^5.0.4", 26 | "@types/node": "22", 27 | "rimraf": "^6.0.1", 28 | "typescript": "^5.7.3", 29 | "vitest": "^3.0.5" 30 | }, 31 | "type": "module", 32 | "main": "dist/api.js", 33 | "types": "dist/typeonly.d.ts", 34 | "bin": "./dist/cli.js", 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/paroi-tech/typeonly.git" 38 | }, 39 | "homepage": "https://github.com/paroi-tech/typeonly/tree/master/packages/typeonly", 40 | "license": "CC0-1.0", 41 | "keywords": [ 42 | "typescript", 43 | "type", 44 | "interface", 45 | "runtime" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /packages/typeonly/scripts/bundle-tsd.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const bundleName = "typeonly" 10 | const srcDir = join(__dirname, "..", "src") 11 | const compiledDir = join(__dirname, "declarations") 12 | const packageDir = join(__dirname, "..") 13 | 14 | try { 15 | writeFileSync(join(packageDir, "dist", `${bundleName}.d.ts`), makeDefinitionsCode()) 16 | } catch (err) { 17 | console.log(err.message, err.stack) 18 | } 19 | 20 | function makeDefinitionsCode() { 21 | const defs = [ 22 | "// -- API definitions --", 23 | cleanGeneratedCode( 24 | removeLocalImportsExports((readFileSync(join(compiledDir, "api.d.ts"), "utf-8")).trim()), 25 | ), 26 | "// -- RTO definitions --", 27 | removeLocalImportsExports((readFileSync(join(srcDir, "rto.d.ts"), "utf-8")).trim()), 28 | "// -- AST definitions --", 29 | removeLocalImportsExports((readFileSync(join(srcDir, "ast.d.ts"), "utf-8")).trim()), 30 | ] 31 | return defs.join("\n\n") 32 | } 33 | 34 | function removeLocalImportsExports(code) { 35 | const localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/ 36 | return code.split("\n").filter(line => { 37 | return !localImportExport.test(line) 38 | }).join("\n").trim() 39 | } 40 | 41 | function cleanGeneratedCode(code) { 42 | return code.replace(/;/g, "").replace(/ /g, " ") 43 | } 44 | -------------------------------------------------------------------------------- /packages/typeonly/src/api.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOnlyAst } from "./ast.d.ts"; 2 | import { parseTypeOnlyToAst } from "./parser/parse-typeonly.js"; 3 | import { 4 | RtoProjectOutput, 5 | type WriteRtoFilesOptions, 6 | makeReadSourceFileAstProvider, 7 | } from "./rto-factory/ProjectInputOutput.js"; 8 | import RtoModuleFactory from "./rto-factory/RtoModuleFactory.js"; 9 | import RtoProject from "./rto-factory/RtoProject.js"; 10 | import type { RtoModule, RtoModules } from "./rto.d.ts"; 11 | 12 | export interface ParseTypeOnlyOptions { 13 | source: string; 14 | return?: { 15 | /** 16 | * Default value is `false`. 17 | */ 18 | freeze?: boolean; 19 | }; 20 | } 21 | 22 | export function parseTypeOnly(options: ParseTypeOnlyOptions): TypeOnlyAst { 23 | const ast = parseTypeOnlyToAst(options.source); 24 | if (options.return?.freeze) deepFreezePojo(ast); 25 | return ast; 26 | } 27 | 28 | export interface GenerateRtoModulesOptions { 29 | modulePaths: string[]; 30 | readFiles?: { 31 | sourceDir: string; 32 | encoding?: BufferEncoding; 33 | }; 34 | astProvider?: TypeOnlyAstProvider; 35 | defineGlobals?: (globals: Set) => Set; 36 | writeFiles?: 37 | | boolean 38 | | { 39 | encoding?: BufferEncoding; 40 | outputDir?: string; 41 | /** 42 | * The indentation parameter of `JSON.stringify`. 43 | */ 44 | prettify?: number | string; 45 | }; 46 | returnRtoModules?: 47 | | boolean 48 | | { 49 | /** 50 | * Default value is `false`. 51 | */ 52 | freeze?: boolean; 53 | }; 54 | } 55 | 56 | export type TypeOnlyAstProvider = (modulePath: string) => Promise | TypeOnlyAst; 57 | 58 | export async function generateRtoModules( 59 | options: GenerateRtoModulesOptions, 60 | ): Promise { 61 | let astProvider = options.astProvider; 62 | if (!astProvider) { 63 | if (!options.readFiles) 64 | throw new Error("A parameter 'readFiles' or 'astProvider' is required."); 65 | astProvider = makeReadSourceFileAstProvider( 66 | options.readFiles.sourceDir, 67 | options.readFiles.encoding ?? "utf8", 68 | ); 69 | } 70 | let wfOpt2: WriteRtoFilesOptions | undefined; 71 | if (options.writeFiles) { 72 | const wfOpt1 = options.writeFiles === true ? {} : options.writeFiles; 73 | const encoding = wfOpt1.encoding || "utf8"; 74 | const outputDir = 75 | wfOpt1.outputDir || 76 | (!options.astProvider && options.readFiles && options.readFiles.sourceDir); 77 | if (!outputDir) 78 | throw new Error( 79 | "Option 'writeFiles.outputDir' is required when 'readFiles.sourceDir' is not provided.", 80 | ); 81 | wfOpt2 = { 82 | encoding, 83 | outputDir, 84 | prettify: wfOpt1.prettify, 85 | }; 86 | } 87 | const output = new RtoProjectOutput({ 88 | returnRtoModules: !!options.returnRtoModules, 89 | writeFiles: wfOpt2, 90 | }); 91 | const project = new RtoProject({ 92 | astProvider, 93 | rtoModuleListener: output.listener, 94 | moduleFactoryOptions: { 95 | defineGlobals: options.defineGlobals, 96 | }, 97 | }); 98 | await project.addModules(options.modulePaths); 99 | if (options.returnRtoModules) { 100 | const rtoModules = output.getRtoModules(); 101 | if (options.returnRtoModules !== true && options.returnRtoModules.freeze) 102 | deepFreezePojo(rtoModules); 103 | return rtoModules; 104 | } 105 | } 106 | 107 | export interface CreateStandaloneRtoModuleOptions { 108 | ast: TypeOnlyAst; 109 | defineGlobals?: (globals: Set) => Set; 110 | /** 111 | * Default value is `false`. 112 | */ 113 | freeze?: boolean; 114 | } 115 | 116 | export function createStandaloneRtoModule(options: CreateStandaloneRtoModuleOptions): RtoModule { 117 | const factory = new RtoModuleFactory(options.ast, undefined, { 118 | defineGlobals: options.defineGlobals, 119 | }); 120 | const rtoModule = factory.createRtoModule(); 121 | if (options.freeze) deepFreezePojo(rtoModule); 122 | return rtoModule; 123 | } 124 | 125 | function deepFreezePojo(object: T): T { 126 | if (Object.isFrozen(object)) return object; 127 | Object.freeze(object); 128 | for (const key of Object.keys(object)) { 129 | const value = (object as any)[key]; 130 | if (value && typeof value === "object") deepFreezePojo(value); 131 | } 132 | return object; 133 | } 134 | -------------------------------------------------------------------------------- /packages/typeonly/src/ast.d.ts: -------------------------------------------------------------------------------- 1 | export interface TypeOnlyAst { 2 | declarations?: AstDeclaration[]; 3 | } 4 | 5 | export type AstDeclaration = AstImport | AstNamedInterface | AstNamedType | AstStandaloneComment; 6 | 7 | export type AstImport = AstClassicImport | AstNamespacedImport; 8 | 9 | export interface AstClassicImport extends AstCommentable { 10 | whichDeclaration: "import"; 11 | whichImport: "classic"; 12 | from: string; 13 | namedMembers?: AstImportNamedMember[]; 14 | } 15 | 16 | export interface AstImportNamedMember { 17 | name: string; 18 | as?: string; 19 | } 20 | 21 | export interface AstNamespacedImport extends AstCommentable { 22 | whichDeclaration: "import"; 23 | whichImport: "namespaced"; 24 | from: string; 25 | asNamespace: string; 26 | } 27 | 28 | export interface AstInterface { 29 | whichType: "interface"; 30 | entries?: AstInterfaceEntry[]; 31 | } 32 | 33 | export interface AstNamedInterface extends AstInterface, AstCommentable { 34 | whichDeclaration: "interface"; 35 | name: string; 36 | exported?: boolean; 37 | extends?: string[]; 38 | generic?: AstGenericParameter[]; 39 | } 40 | 41 | export type AstInterfaceEntry = 42 | | AstProperty 43 | | AstFunctionProperty 44 | | AstIndexSignature 45 | | AstMappedIndexSignature 46 | | AstStandaloneInterfaceComment; 47 | 48 | export interface AstProperty extends AstCommentable { 49 | whichEntry: "property"; 50 | optional?: boolean; 51 | readonly?: boolean; 52 | name: string; 53 | type: AstType; 54 | } 55 | 56 | export interface AstFunctionProperty extends AstCommentable { 57 | whichEntry: "functionProperty"; 58 | optional?: boolean; 59 | readonly?: boolean; 60 | name: string; 61 | parameters?: AstFunctionParameter[]; 62 | returnType?: AstType; 63 | generic?: AstGenericParameter[]; 64 | } 65 | 66 | export interface AstIndexSignature extends AstCommentable { 67 | whichEntry: "indexSignature"; 68 | keyName: string; 69 | keyType: "string" | "number"; 70 | type: AstType; 71 | optional?: boolean; 72 | readonly?: boolean; 73 | } 74 | 75 | export interface AstMappedIndexSignature extends AstCommentable { 76 | whichEntry: "mappedIndexSignature"; 77 | keyName: string; 78 | keyInType: AstType; 79 | type: AstType; 80 | optional?: boolean; 81 | readonly?: boolean; 82 | } 83 | 84 | export interface AstNamedType extends AstCommentable { 85 | whichDeclaration: "type"; 86 | name: string; 87 | type: AstType; 88 | exported?: boolean; 89 | generic?: AstGenericParameter[]; 90 | } 91 | 92 | export type AstType = 93 | | string 94 | | AstLiteralType 95 | | AstInterface 96 | | AstCompositeType 97 | | AstTupleType 98 | | AstArrayType 99 | | AstGenericInstance 100 | | AstFunctionType 101 | | AstKeyofType 102 | | AstMemberType 103 | | AstInlineImportType; 104 | 105 | export interface AstLiteralType { 106 | whichType: "literal"; 107 | literal: string | number | boolean | bigint; 108 | stringDelim?: '"' | "'"; 109 | } 110 | 111 | export interface AstCompositeType { 112 | whichType: "composite"; 113 | op: "union" | "intersection"; 114 | types: AstType[]; 115 | } 116 | 117 | export interface AstTupleType { 118 | whichType: "tuple"; 119 | itemTypes?: AstType[]; 120 | } 121 | 122 | export interface AstArrayType { 123 | whichType: "array"; 124 | itemType: AstType; 125 | genericSyntax?: boolean; 126 | } 127 | 128 | export interface AstGenericInstance { 129 | whichType: "genericInstance"; 130 | genericName: string; 131 | parameterTypes: AstType[]; 132 | } 133 | 134 | export interface AstFunctionType { 135 | whichType: "function"; 136 | parameters?: AstFunctionParameter[]; 137 | returnType: AstType; 138 | generic?: AstGenericParameter[]; 139 | } 140 | 141 | export interface AstFunctionParameter { 142 | name: string; 143 | optional?: boolean; 144 | type?: AstType; 145 | } 146 | 147 | export interface AstKeyofType { 148 | whichType: "keyof"; 149 | type: AstType; 150 | } 151 | 152 | export interface AstMemberType { 153 | whichType: "member"; 154 | parentType: AstType; 155 | memberName: string | AstMemberNameLiteral; 156 | } 157 | 158 | export interface AstMemberNameLiteral { 159 | literal: string | number; 160 | stringDelim?: '"' | "'"; 161 | } 162 | 163 | export interface AstInlineImportType { 164 | whichType: "inlineImport"; 165 | from: string; 166 | exportedName: string; 167 | } 168 | 169 | export interface AstGenericParameter { 170 | name: string; 171 | extendsType?: AstType; 172 | defaultType?: AstType; 173 | } 174 | 175 | export interface AstStandaloneComment { 176 | whichDeclaration: "comment"; 177 | /** 178 | * A multiline string. 179 | */ 180 | text: string; 181 | syntax: "inline" | "classic"; 182 | } 183 | 184 | export interface AstStandaloneInterfaceComment { 185 | whichEntry: "comment"; 186 | /** 187 | * A multiline string. 188 | */ 189 | text: string; 190 | syntax: "inline" | "classic"; 191 | } 192 | 193 | export interface AstCommentable { 194 | /** 195 | * A multiline string. 196 | */ 197 | docComment?: string; 198 | inlineComments?: AstInlineComment[]; 199 | } 200 | 201 | export interface AstInlineComment { 202 | /** 203 | * A single line string. 204 | */ 205 | text: string; 206 | syntax: "inline" | "classic"; 207 | } 208 | -------------------------------------------------------------------------------- /packages/typeonly/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { readFileSync, readdirSync, writeFileSync } from "node:fs"; 3 | import { basename, dirname, join, parse } from "node:path"; 4 | import commandLineArgs from "command-line-args"; 5 | import commandLineUsage from "command-line-usage"; 6 | import { generateRtoModules, parseTypeOnly } from "./api.js"; 7 | import type { TypeOnlyAst } from "./ast.d.ts"; 8 | import { ensureDirectory } from "./rto-factory/ProjectInputOutput.js"; 9 | import type { RtoModules } from "./rto.d.ts"; 10 | 11 | process.on("uncaughtException", (err) => { 12 | console.error("uncaughtException", err); 13 | process.exit(1); 14 | }); 15 | 16 | process.on("unhandledRejection", (err) => { 17 | console.trace("unhandledRejection", err); 18 | process.exit(1); 19 | }); 20 | 21 | class InvalidArgumentError extends Error { 22 | readonly causeCode = "invalidArgument"; 23 | } 24 | 25 | type OptionDefinition = commandLineUsage.OptionDefinition & commandLineArgs.OptionDefinition; 26 | 27 | const optionDefinitions: OptionDefinition[] = [ 28 | { 29 | name: "help", 30 | alias: "h", 31 | type: Boolean, 32 | description: "Print this help message.", 33 | }, 34 | { 35 | name: "output-dir", 36 | alias: "o", 37 | type: String, 38 | description: "The output directory (optional).", 39 | typeLabel: "{underline directory}", 40 | }, 41 | { 42 | name: "source-dir", 43 | alias: "s", 44 | type: String, 45 | description: 46 | "The source directory (optional when used with option {underline --ast} or with a single source file).", 47 | typeLabel: "{underline directory}", 48 | }, 49 | { 50 | name: "encoding", 51 | alias: "e", 52 | type: String, 53 | description: "Encoding for input and output file(s) (default is {underline utf8}).", 54 | }, 55 | { 56 | name: "bundle", 57 | alias: "b", 58 | type: String, 59 | description: "Generate a bundle file for RTO data (optional).", 60 | }, 61 | { 62 | name: "prettify", 63 | type: Boolean, 64 | description: "Prettify RTO files (optional).", 65 | }, 66 | { 67 | name: "ast", 68 | type: Boolean, 69 | description: "Generate AST files instead of RTO files (optional).", 70 | }, 71 | { 72 | name: "src", 73 | description: "Input files to process (by default at last position).", 74 | type: String, 75 | multiple: true, 76 | defaultOption: true, 77 | typeLabel: "{underline file} ...", 78 | }, 79 | ]; 80 | 81 | cli().catch((error) => { 82 | console.error(`Error: ${error.message}`, error); 83 | }); 84 | 85 | async function cli() { 86 | const options = parseOptions(); 87 | if (!options) return; 88 | if (options.help) { 89 | printHelp(); 90 | return; 91 | } 92 | 93 | try { 94 | await processFiles(options); 95 | } catch (error: any) { 96 | if (error.causeCode === "invalidArgument") { 97 | console.error(`Error: ${error.message}`); 98 | printHelp(); 99 | } else throw error; 100 | } 101 | } 102 | 103 | function printHelp() { 104 | const sections = [ 105 | { 106 | header: "TypeOnly", 107 | content: "TypeOnly Parser.", 108 | }, 109 | { 110 | header: "Synopsis", 111 | content: [ 112 | "$ npx typeonly {bold --source-dir} {underline src/} {underline file-name.d.ts}", 113 | "$ npx typeonly {bold --help}", 114 | ], 115 | }, 116 | { 117 | header: "Options", 118 | optionList: optionDefinitions, 119 | }, 120 | { 121 | content: "Project home: {underline https://github.com/paroi-tech/typeonly}", 122 | }, 123 | ]; 124 | const usage = commandLineUsage(sections); 125 | console.log(usage); 126 | } 127 | 128 | interface OptionsObject { 129 | [name: string]: unknown; 130 | } 131 | 132 | function parseOptions(): OptionsObject | undefined { 133 | try { 134 | return commandLineArgs(optionDefinitions); 135 | } catch (error: any) { 136 | console.log(`Error: ${error.message}`); 137 | printHelp(); 138 | } 139 | } 140 | 141 | async function processFiles(options: OptionsObject) { 142 | if (options.ast) { 143 | if (!options.src || !Array.isArray(options.src)) 144 | throw new InvalidArgumentError("Missing source file(s)."); 145 | options.src.map((file: string) => createAstJsonFile(file, options)); 146 | } else await createRtoJsonFiles(options); 147 | } 148 | 149 | function createAstJsonFile(file: string, options: OptionsObject) { 150 | const f = options["source-dir"] ? join(options["source-dir"] as string, file) : file; 151 | const encoding: BufferEncoding = (options.encoding as BufferEncoding) ?? "utf8"; 152 | let source: string; 153 | try { 154 | source = readFileSync(f, { encoding }); 155 | } catch (err) { 156 | throw new InvalidArgumentError(`Cannot read file: ${f}`); 157 | } 158 | 159 | let fileName = basename(f); 160 | if (fileName.endsWith(".ts")) 161 | fileName = fileName.substring(0, fileName.length - (fileName.endsWith(".d.ts") ? 5 : 3)); 162 | 163 | const ast: TypeOnlyAst = parseTypeOnly({ source }); 164 | const outFile = join( 165 | (options["output-dir"] as string | undefined) ?? dirname(f), 166 | `${fileName}.ast.json`, 167 | ); 168 | writeFileSync(outFile, JSON.stringify(ast, undefined, "\t"), encoding); 169 | } 170 | 171 | async function createRtoJsonFiles(options: OptionsObject) { 172 | let srcList = (options.src as string[] | undefined) ?? []; 173 | let sourceDir: string; 174 | if (!options["source-dir"]) { 175 | if (srcList.length === 1) sourceDir = dirname(srcList[0]); 176 | else throw new Error("Missing 'source-dir' option."); 177 | } else sourceDir = options["source-dir"] as string; 178 | sourceDir = normalizeDir(sourceDir); 179 | 180 | if (srcList.length === 0) srcList = getTypingFilesInDir(sourceDir); 181 | 182 | const modulePaths = normalizeModulePaths(srcList, sourceDir); 183 | const encoding: BufferEncoding = (options.encoding as BufferEncoding | undefined) ?? "utf8"; 184 | const prettify = options.prettify ? "\t" : undefined; 185 | let bundleName = options.bundle as string | undefined; 186 | 187 | if (bundleName) { 188 | if (!bundleName.endsWith(".to.json")) bundleName += ".to.json"; 189 | let outputDir = options["output-dir"] 190 | ? normalizeDir(options["output-dir"] as string) 191 | : undefined; 192 | const parsed = parse(bundleName); 193 | if (parsed.dir) { 194 | outputDir = outputDir ? join(outputDir, parsed.dir) : parsed.dir; 195 | bundleName = parsed.base; 196 | } 197 | if (outputDir) { 198 | await ensureDirectory(outputDir); 199 | bundleName = join(outputDir, bundleName); 200 | } 201 | const rtoModules = (await generateRtoModules({ 202 | modulePaths, 203 | readFiles: { 204 | sourceDir, 205 | encoding, 206 | }, 207 | returnRtoModules: true, 208 | })) as RtoModules; 209 | writeFileSync(bundleName, JSON.stringify(rtoModules, undefined, prettify), { encoding }); 210 | } else { 211 | const outputDir = normalizeDir((options["output-dir"] as string | undefined) ?? sourceDir); 212 | await generateRtoModules({ 213 | modulePaths, 214 | readFiles: { 215 | sourceDir, 216 | encoding, 217 | }, 218 | writeFiles: { 219 | outputDir, 220 | prettify, 221 | }, 222 | }); 223 | } 224 | } 225 | 226 | function getTypingFilesInDir(dir: string): string[] { 227 | const files = readdirSync(dir); 228 | return files.filter((fileName) => fileName.endsWith(".d.ts")); 229 | } 230 | 231 | function normalizeModulePaths(files: string[], sourceDir: string): string[] { 232 | const prefix = `${sourceDir}/`.replace(/\\/g, "/"); 233 | return files.map((file) => { 234 | let f = file.replace(/\\/g, "/"); 235 | if (f.startsWith(prefix)) f = `./${f.substring(prefix.length)}`; 236 | else if (!f.startsWith("./") && !f.startsWith("../")) f = `./${f}`; 237 | return f; 238 | }); 239 | } 240 | 241 | function normalizeDir(path: string): string { 242 | return path.replace(/\\/g, "/").replace(/\/+$/, ""); 243 | } 244 | -------------------------------------------------------------------------------- /packages/typeonly/src/helpers/fs-utils.ts: -------------------------------------------------------------------------------- 1 | import { type MakeDirectoryOptions, existsSync, mkdirSync, promises } from "node:fs"; 2 | 3 | const { access } = promises; 4 | 5 | export async function pathExists(path: string) { 6 | try { 7 | await access(path); 8 | return true; 9 | } catch { 10 | return false; 11 | } 12 | } 13 | 14 | export function ensureDirSync(dir: string, options: MakeDirectoryOptions = {}) { 15 | if (!existsSync(dir)) mkdirSync(dir, options); 16 | } 17 | -------------------------------------------------------------------------------- /packages/typeonly/src/helpers/js-lib.ts: -------------------------------------------------------------------------------- 1 | export function assertExists(val: T | undefined): asserts val is T { 2 | if (val === undefined) throw new Error("missing value"); 3 | } 4 | -------------------------------------------------------------------------------- /packages/typeonly/src/helpers/module-path-helpers.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { dirname, join, normalize, resolve } from "node:path"; 3 | import { pathExists } from "./fs-utils.js"; 4 | 5 | export interface RelativeModulePath { 6 | from: string; 7 | relativeToModule?: string; 8 | } 9 | 10 | export interface ToModulePathOptions extends RelativeModulePath { 11 | removeExtensions?: string[]; 12 | } 13 | 14 | export function toModulePath(options: ToModulePathOptions): string { 15 | if (parseExternalModuleName(options.from)) return options.from; 16 | let from = options.removeExtensions 17 | ? removeExtensions(options.from, options.removeExtensions) 18 | : options.from; 19 | if (!from.startsWith("./") && !from.startsWith("../")) 20 | throw new Error("Module path must start with './' or '../'"); 21 | let parentDir = options.relativeToModule ? dirname(options.relativeToModule) : "."; 22 | while (true) { 23 | if (from.startsWith("./")) from = from.substr(2); 24 | else if (from.startsWith("../")) { 25 | const newDir = dirname(parentDir); 26 | if (newDir === parentDir) break; 27 | parentDir = newDir; 28 | from = from.substr(3); 29 | } else break; 30 | } 31 | if (from.startsWith("../")) return normalize(from); 32 | const result = `${parentDir}/${from}`; 33 | if (result.startsWith("./")) return `./${normalize(result)}`; 34 | return normalize(result); 35 | } 36 | 37 | export interface ParsedExternalModuleName { 38 | packageName: string; 39 | additionalPath?: string; 40 | } 41 | 42 | export function parseExternalModuleName(modulePath: string): ParsedExternalModuleName | undefined { 43 | const result = /^((?:@[a-z0-9-~][a-z0-9-\._~]*\/)?[a-z0-9-~][a-z0-9-\._~]*)(\/.+)?$/.exec( 44 | modulePath, 45 | ); 46 | if (!result) return; 47 | return { 48 | packageName: result[1], 49 | additionalPath: result[2], 50 | }; 51 | } 52 | 53 | export async function getExternalModulePath( 54 | parsed: ParsedExternalModuleName, 55 | sourceDir: string, 56 | ): Promise { 57 | // console.log("==>", parsed, sourceDir) 58 | const mainPackageDir = await findParentPackageDir(sourceDir); 59 | // console.log("==. packageDir", mainPackageDir) 60 | if (!mainPackageDir) throw new Error(`Cannot find the package directory of '${sourceDir}'`); 61 | 62 | const depPackageDir = join(mainPackageDir, "node_modules", parsed.packageName); 63 | 64 | if (parsed.additionalPath) return join(depPackageDir, parsed.additionalPath); 65 | 66 | const content = JSON.parse(await readFile(join(depPackageDir, "package.json"), "utf8")); 67 | const additionalPath = content.types ?? content.typing; 68 | if (additionalPath) return join(depPackageDir, removeModuleNameExtension(additionalPath)); 69 | return join(depPackageDir, "index"); 70 | } 71 | 72 | function removeModuleNameExtension(fileName: string): string { 73 | if (!fileName.endsWith(".ts")) return fileName; 74 | return fileName.substring(0, fileName.length - (fileName.endsWith(".d.ts") ? 5 : 3)); 75 | } 76 | 77 | async function findParentPackageDir(dir: string): Promise { 78 | let resolvedDir = resolve(dir); 79 | while (true) { 80 | if (await pathExists(join(resolvedDir, "package.json"))) return resolvedDir; 81 | if (resolvedDir === "/" || resolvedDir === ".") return; 82 | resolvedDir = dirname(resolvedDir); 83 | } 84 | } 85 | 86 | function removeExtensions(path: string, extensions: string[]): string { 87 | for (const extension of extensions) { 88 | if (path.endsWith(extension)) return path.substr(0, path.length - extension.length); 89 | } 90 | return path; 91 | } 92 | -------------------------------------------------------------------------------- /packages/typeonly/src/parser/antlr4-defs.d.ts: -------------------------------------------------------------------------------- 1 | export interface AntlrParser { 2 | ruleNames: { [type: number]: string }; 3 | symbolicNames: { [type: number]: string }; 4 | } 5 | 6 | export interface AntlrTokenStream { 7 | tokens: AntlrToken[]; 8 | // getHiddenTokensToLeft(tokenIndex: number, channel: number): AntlrToken[] | null 9 | // getHiddenTokensToRight(tokenIndex: number, channel: number): AntlrToken[] | null 10 | } 11 | 12 | export type ParseTree = AntlrRuleContext | AntlrTerminalNode; 13 | 14 | export interface AntlrRuleContext { 15 | [childName: string]: any; 16 | parentCtx: AntlrRuleContext; 17 | parser: AntlrParser; 18 | ruleIndex: number; 19 | start: AntlrToken; 20 | stop: AntlrToken; 21 | getText(): string; 22 | getChild(index: number): ParseTree | null; 23 | getChildCount(): number; 24 | } 25 | 26 | export type AntlrAnyRuleContext = AntlrRuleContext & { 27 | [childrenName: string]: any; 28 | }; 29 | 30 | export interface AntlrTerminalNode { 31 | parentCtx: AntlrRuleContext; 32 | symbol: AntlrToken; 33 | getText(): string; 34 | } 35 | 36 | export interface AntlrToken { 37 | tokenIndex: number; 38 | channel: number; 39 | type: number; 40 | start: number; 41 | stop: number; 42 | line: number; 43 | column: number; 44 | } 45 | -------------------------------------------------------------------------------- /packages/typeonly/src/parser/parse-typeonly.ts: -------------------------------------------------------------------------------- 1 | import antlr4, { CommonTokenStream, InputStream, type Recognizer } from "antlr4"; 2 | import AstExtractor from "./AstExtractor.js"; 3 | 4 | const TypeOnlyLexer = (await import("../../antlr-parser/TypeOnlyLexer.js" as string)).default; 5 | const TypeOnlyParser = (await import("../../antlr-parser/TypeOnlyParser.js" as string)).default; 6 | 7 | export function parseTypeOnlyToAst(source: string) { 8 | const chars = new InputStream(source); 9 | const lexer = new TypeOnlyLexer(chars); 10 | const tokenStream = new CommonTokenStream(lexer); 11 | const parser = new TypeOnlyParser(tokenStream); 12 | 13 | parser.buildParseTrees = true; 14 | 15 | // console.log(debugTokensToText(tokenStream.tokens)) 16 | // function debugTokensToText(tokens) { 17 | // if (!tokens) 18 | // return "-no-tokens-" 19 | // return tokens.map(({ tokenIndex, type, start, stop }) => { 20 | // return `[${tokenIndex}] ${type}: ${source.substring(start, stop + 1).replace(/\n/g, "\u23ce")}` 21 | // }).join("\n") 22 | // } 23 | 24 | const errors: string[] = []; 25 | const errorListener = { 26 | syntaxError( 27 | _recognizer: any, 28 | _offendingSymbol: any, 29 | line: number, 30 | column: number, 31 | msg: string, 32 | _e: any, 33 | ) { 34 | errors.push(`Syntax error at line ${line}:${column}, ${msg}`); 35 | }, 36 | 37 | reportAmbiguity( 38 | recognizer: Recognizer, 39 | dfa: any, 40 | startIndex: number, 41 | stopIndex: number, 42 | exact: boolean, 43 | ambigAlts: any, 44 | configs: any, 45 | ) {}, 46 | 47 | reportAttemptingFullContext( 48 | recognizer: Recognizer, 49 | dfa: any, 50 | startIndex: number, 51 | stopIndex: number, 52 | conflictingAlts: any, 53 | configs: any, 54 | ) {}, 55 | 56 | reportContextSensitivity( 57 | recognizer: Recognizer, 58 | dfa: any, 59 | startIndex: number, 60 | stopIndex: number, 61 | prediction: number, 62 | configs: any, 63 | ) {}, 64 | }; 65 | lexer.removeErrorListeners(); 66 | lexer.addErrorListener(errorListener); 67 | parser.removeErrorListeners(); 68 | parser.addErrorListener(errorListener); 69 | 70 | const treeRoot = parser.declarations(); 71 | 72 | if (errors.length > 0) throw new Error(errors.join("\n")); 73 | 74 | const extractor = new AstExtractor({ 75 | source, 76 | tokenStream, 77 | tokenTypes: { 78 | SEMICOLON: TypeOnlyParser.SEMI_COLON, 79 | COMMA: TypeOnlyParser.COMMA, 80 | MULTILINE_COMMENT: TypeOnlyParser.MULTILINE_COMMENT, 81 | SINGLE_LINE_COMMENT: TypeOnlyParser.SINGLE_LINE_COMMENT, 82 | NEWLINE: TypeOnlyParser.NL, 83 | }, 84 | }); 85 | 86 | (antlr4 as any).tree.ParseTreeWalker.DEFAULT.walk(extractor, treeRoot); 87 | 88 | if (!extractor.ast) throw new Error("missing AST"); 89 | 90 | return extractor.ast; 91 | } 92 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto-factory/AstImportTool.ts: -------------------------------------------------------------------------------- 1 | import type { AstImport, AstInlineImportType } from "../ast.d.ts"; 2 | import type { RtoImport, RtoNamespacedImport } from "../rto.d.ts"; 3 | import type RtoModuleFactory from "./RtoModuleFactory.js"; 4 | import type { RtoModuleLoader } from "./internal-types.js"; 5 | 6 | export interface ImportRef { 7 | refName: string; 8 | namespace?: string; 9 | } 10 | 11 | interface ImportedFromModule { 12 | namedMembers: Array<{ name: string; as?: string }>; 13 | namespaces: string[]; 14 | inlineMembers: Set; 15 | } 16 | 17 | export default class AstImportTool { 18 | private fromModules = new Map(); 19 | private importedIdentifiers = new Set(); 20 | private importedNamespaces = new Set(); 21 | 22 | constructor( 23 | readonly path: string, 24 | private moduleLoader: RtoModuleLoader, 25 | ) {} 26 | 27 | addImport(astNode: AstImport) { 28 | const ifm = this.getImportedFromModule(astNode.from); 29 | if (astNode.whichImport === "namespaced") this.addNamespacedImport(astNode.asNamespace, ifm); 30 | else { 31 | if (astNode.namedMembers) { 32 | for (const member of astNode.namedMembers) this.addNamedImport({ ...member }, ifm); 33 | } 34 | } 35 | } 36 | 37 | addInlineImport(astNode: AstInlineImportType) { 38 | const ifm = this.getImportedFromModule(astNode.from); 39 | ifm.inlineMembers.add(astNode.exportedName); 40 | } 41 | 42 | async load() { 43 | for (const [from, ifm] of this.fromModules.entries()) { 44 | const factory = await this.moduleLoader({ from, relativeToModule: this.path }); 45 | this.checkExportedNames(ifm, factory); 46 | } 47 | } 48 | 49 | createRtoImports(): { imports?: RtoImport[]; namespacedImports?: RtoNamespacedImport[] } { 50 | const imports: RtoImport[] = []; 51 | const namespacedImports: RtoNamespacedImport[] = []; 52 | for (const [from, ifm] of this.fromModules.entries()) { 53 | const { namedMembers, namespaces } = ifm; 54 | if (namedMembers.length > 0) { 55 | imports.push({ 56 | from, 57 | namedMembers, 58 | }); 59 | } 60 | namespaces.forEach((asNamespace) => namespacedImports.push({ from, asNamespace })); 61 | } 62 | const result: { imports?: RtoImport[]; namespacedImports?: RtoNamespacedImport[] } = {}; 63 | if (imports.length > 0) result.imports = imports; 64 | if (namespacedImports.length > 0) result.namespacedImports = namespacedImports; 65 | return result; 66 | } 67 | 68 | findImportedMember(fullName: string): ImportRef | undefined { 69 | const dotIndex = fullName.indexOf("."); 70 | if (dotIndex !== -1) { 71 | const refName = fullName.slice(0, dotIndex); 72 | const namespace = fullName.slice(dotIndex + 1); 73 | if (!this.importedNamespaces.has(namespace)) 74 | throw new Error(`Unknown namespace: ${namespace}`); 75 | return { refName, namespace }; 76 | } 77 | return this.importedIdentifiers.has(fullName) ? { refName: fullName } : undefined; 78 | } 79 | 80 | inlineImport({ exportedName }: AstInlineImportType): ImportRef { 81 | return { refName: exportedName }; 82 | } 83 | 84 | private addNamespacedImport(asNamespace: string, ifm: ImportedFromModule) { 85 | if (this.importedNamespaces.has(asNamespace) || this.importedIdentifiers.has(asNamespace)) 86 | throw new Error(`Duplicate identifier '${asNamespace}'`); 87 | this.importedNamespaces.add(asNamespace); 88 | ifm.namespaces.push(asNamespace); 89 | } 90 | 91 | private addNamedImport(member: { name: string; as?: string }, ifm: ImportedFromModule) { 92 | const name = member.as || member.name; 93 | if (this.importedNamespaces.has(name) || this.importedIdentifiers.has(name)) 94 | throw new Error(`Duplicate identifier '${name}'`); 95 | this.importedIdentifiers.add(name); 96 | ifm.namedMembers.push({ ...member }); 97 | } 98 | 99 | private getImportedFromModule(from: string): ImportedFromModule { 100 | let ifm = this.fromModules.get(from); 101 | if (!ifm) { 102 | ifm = { 103 | namedMembers: [], 104 | namespaces: [], 105 | inlineMembers: new Set(), 106 | }; 107 | this.fromModules.set(from, ifm); 108 | } 109 | return ifm; 110 | } 111 | 112 | private checkExportedNames(ifm: ImportedFromModule, factory: RtoModuleFactory) { 113 | for (const { name } of ifm.namedMembers) { 114 | if (!factory.hasExportedNamedType(name)) 115 | throw new Error(`Module '${factory.getModulePath()}' has no exported member '${name}'.`); 116 | } 117 | for (const name of ifm.inlineMembers) { 118 | if (!factory.hasExportedNamedType(name)) 119 | throw new Error(`Module '${factory.getModulePath()}' has no exported member '${name}'.`); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto-factory/InlineImportScanner.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AstArrayType, 3 | AstCompositeType, 4 | AstFunctionParameter, 5 | AstFunctionProperty, 6 | AstFunctionType, 7 | AstGenericInstance, 8 | AstGenericParameter, 9 | AstIndexSignature, 10 | AstInlineImportType, 11 | AstInterface, 12 | AstKeyofType, 13 | AstMappedIndexSignature, 14 | AstMemberType, 15 | AstProperty, 16 | AstTupleType, 17 | AstType, 18 | } from "../ast.d.ts"; 19 | import type AstImportTool from "./AstImportTool.js"; 20 | 21 | export default class InlineImportScanner { 22 | private typeScans: { 23 | [K in Exclude["whichType"]]: (astNode: any) => void; 24 | } = { 25 | inlineImport: (astNode) => this.registerInlineImport(astNode), 26 | literal: () => {}, 27 | array: (astNode) => this.scanArrayType(astNode), 28 | tuple: (astNode) => this.scanTupleType(astNode), 29 | keyof: (astNode) => this.scanKeyofType(astNode), 30 | member: (astNode) => this.scanMemberType(astNode), 31 | composite: (astNode) => this.scanCompositeType(astNode), 32 | genericInstance: (astNode) => this.scanGenericInstance(astNode), 33 | function: (astNode) => this.scanFunctionType(astNode), 34 | interface: (astNode) => this.scanInterface(astNode), 35 | }; 36 | 37 | constructor(private importTool: AstImportTool) {} 38 | 39 | scan(astNode: AstType): void { 40 | if (typeof astNode === "string") return; 41 | const scan = this.typeScans[astNode.whichType]; 42 | if (!scan) throw new Error(`Unexpected whichType to scan: ${astNode.whichType}`); 43 | scan(astNode); 44 | } 45 | 46 | private registerInlineImport(astNode: AstInlineImportType) { 47 | this.importTool.addInlineImport(astNode); 48 | } 49 | 50 | private scanArrayType(astNode: AstArrayType) { 51 | this.scan(astNode.itemType); 52 | } 53 | 54 | private scanCompositeType(astNode: AstCompositeType) { 55 | astNode.types.forEach((child) => this.scan(child)); 56 | } 57 | 58 | private scanGenericInstance(astNode: AstGenericInstance) { 59 | astNode.parameterTypes.forEach((child) => this.scan(child)); 60 | } 61 | 62 | private scanKeyofType(astNode: AstKeyofType) { 63 | this.scan(astNode.type); 64 | } 65 | 66 | private scanMemberType(astNode: AstMemberType) { 67 | this.scan(astNode.parentType); 68 | } 69 | 70 | private scanTupleType(astNode: AstTupleType) { 71 | if (astNode.itemTypes) astNode.itemTypes.forEach((child) => this.scan(child)); 72 | } 73 | 74 | private scanFunctionType(astNode: AstFunctionType) { 75 | this.scan(astNode.returnType); 76 | if (astNode.parameters) this.scanFunctionParameters(astNode.parameters); 77 | if (astNode.generic) this.scanGenericParameters(astNode.generic); 78 | } 79 | 80 | private scanFunctionParameters(astNodes: AstFunctionParameter[]) { 81 | return astNodes.forEach(({ type }) => { 82 | if (type) this.scan(type); 83 | }); 84 | } 85 | 86 | private scanGenericParameters(astNodes: AstGenericParameter[]) { 87 | return astNodes.forEach(({ extendsType, defaultType }) => { 88 | if (extendsType) this.scan(extendsType); 89 | if (defaultType) this.scan(defaultType); 90 | }); 91 | } 92 | 93 | private scanInterface(astNode: AstInterface) { 94 | if (!astNode.entries) return; 95 | for (const entry of astNode.entries) { 96 | if (entry.whichEntry === "indexSignature") this.scanIndexSignature(entry); 97 | else if (entry.whichEntry === "mappedIndexSignature") this.scanMappedIndexSignature(entry); 98 | else if (entry.whichEntry === "property") this.scanProperty(entry); 99 | else if (entry.whichEntry === "functionProperty") 100 | this.scanPropertyFromFunctionProperty(entry); 101 | } 102 | } 103 | 104 | private scanProperty(entry: AstProperty) { 105 | this.scan(entry.type); 106 | } 107 | 108 | private scanPropertyFromFunctionProperty(entry: AstFunctionProperty) { 109 | if (entry.parameters) this.scanFunctionParameters(entry.parameters); 110 | if (entry.returnType) this.scan(entry.returnType); 111 | if (entry.generic) this.scanGenericParameters(entry.generic); 112 | } 113 | 114 | private scanIndexSignature(entry: AstIndexSignature) { 115 | this.scan(entry.type); 116 | } 117 | 118 | private scanMappedIndexSignature(entry: AstMappedIndexSignature) { 119 | this.scan(entry.keyInType); 120 | this.scan(entry.type); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto-factory/ProjectInputOutput.ts: -------------------------------------------------------------------------------- 1 | import { stat } from "node:fs"; 2 | import { mkdir, readFile, writeFile } from "node:fs/promises"; 3 | import { dirname, join } from "node:path"; 4 | import type { TypeOnlyAstProvider } from "../api.js"; 5 | import { getExternalModulePath, parseExternalModuleName } from "../helpers/module-path-helpers.js"; 6 | import { parseTypeOnlyToAst } from "../parser/parse-typeonly.js"; 7 | import type { RtoModule, RtoModules } from "../rto.js"; 8 | import type { RtoModuleListener } from "./RtoProject.js"; 9 | 10 | export interface RtoProjectOutputOptions { 11 | writeFiles?: WriteRtoFilesOptions; 12 | returnRtoModules: boolean; 13 | } 14 | 15 | export interface WriteRtoFilesOptions { 16 | encoding: BufferEncoding; 17 | outputDir: string; 18 | /** 19 | * The indentation parameter of `JSON.stringify`. 20 | */ 21 | prettify?: number | string; 22 | } 23 | 24 | export class RtoProjectOutput { 25 | readonly listener: RtoModuleListener; 26 | 27 | private modules = new Map(); 28 | 29 | constructor(options: RtoProjectOutputOptions) { 30 | this.listener = async (module: RtoModule, modulePath: string) => { 31 | if (options.writeFiles) await this.writeModuleFile(module, modulePath, options.writeFiles); 32 | if (options.returnRtoModules) this.modules.set(modulePath, module); 33 | }; 34 | } 35 | 36 | getRtoModules(): RtoModules { 37 | const result: RtoModules = {}; 38 | for (const [modulePath, module] of this.modules) result[modulePath] = module; 39 | return result; 40 | } 41 | 42 | private async writeModuleFile( 43 | module: RtoModule, 44 | modulePath: string, 45 | options: WriteRtoFilesOptions, 46 | ) { 47 | const data = JSON.stringify(module, undefined, options.prettify); 48 | const outputFile = `${join(options.outputDir, modulePath)}.rto.json`; 49 | await ensureDirectory(options.outputDir); 50 | await ensureDirectory(dirname(outputFile), { createIntermediate: true }); 51 | await writeFile(outputFile, data, { encoding: options.encoding }); 52 | } 53 | } 54 | 55 | export function makeReadSourceFileAstProvider( 56 | sourceDir: string, 57 | encoding: BufferEncoding, 58 | ): TypeOnlyAstProvider { 59 | return async (modulePath: string) => { 60 | const source = await readModuleFile(sourceDir, modulePath, encoding); 61 | return parseTypeOnlyToAst(source); 62 | }; 63 | } 64 | 65 | async function readModuleFile(sourceDir: string, modulePath: string, encoding: BufferEncoding) { 66 | const parsedExternalModule = parseExternalModuleName(modulePath); 67 | const path = parsedExternalModule 68 | ? await getExternalModulePath(parsedExternalModule, sourceDir) 69 | : join(sourceDir, modulePath); 70 | try { 71 | return await readFile(`${path}.d.ts`, encoding); 72 | } catch {} 73 | try { 74 | return await readFile(`${path}.ts`, encoding); 75 | } catch {} 76 | throw new Error(`Cannot open module file: ${path}.d.ts`); 77 | } 78 | 79 | export async function ensureDirectory(path: string, { createIntermediate = false } = {}) { 80 | if (!(await fsExists(path))) await mkdir(path, { recursive: createIntermediate }); 81 | } 82 | 83 | function fsExists(path: string): Promise { 84 | return new Promise((resolve) => { 85 | stat(path, (error) => resolve(!error)); 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto-factory/RtoProject.ts: -------------------------------------------------------------------------------- 1 | import type { TypeOnlyAstProvider } from "../api.js"; 2 | import { type RelativeModulePath, toModulePath } from "../helpers/module-path-helpers.js"; 3 | import type { RtoModule } from "../rto.d.ts"; 4 | import RtoModuleFactory, { type RtoModuleFactoryOptions } from "./RtoModuleFactory.js"; 5 | 6 | export interface RtoProjectOptions { 7 | astProvider: TypeOnlyAstProvider; 8 | rtoModuleListener: RtoModuleListener; 9 | moduleFactoryOptions?: RtoModuleFactoryOptions; 10 | } 11 | 12 | export type RtoModuleListener = (module: RtoModule, modulePath: string) => Promise | void; 13 | 14 | export default class RtoProject { 15 | private factories = new Map(); 16 | 17 | constructor(private options: RtoProjectOptions) {} 18 | 19 | async addModules(modulePaths: string[]) { 20 | for (const from of modulePaths) await this.importModule({ from }); 21 | for (const factory of this.factories.values()) 22 | await factory.loadImports((modulePath) => this.importModule(modulePath)); 23 | for (const factory of this.factories.values()) { 24 | const module = factory.createRtoModule(); 25 | await this.options.rtoModuleListener(module, factory.getModulePath()); 26 | } 27 | } 28 | 29 | private async importModule(relPath: RelativeModulePath): Promise { 30 | const modulePath = toModulePath({ 31 | ...relPath, 32 | removeExtensions: [".d.ts", ".ts"], 33 | }); 34 | let factory = this.factories.get(modulePath); 35 | if (!factory) { 36 | const ast = await this.options.astProvider(modulePath); 37 | factory = new RtoModuleFactory(ast, modulePath, this.options.moduleFactoryOptions); 38 | this.factories.set(modulePath, factory); 39 | } 40 | return factory; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto-factory/internal-types.d.ts: -------------------------------------------------------------------------------- 1 | import type { RelativeModulePath } from "../helpers/module-path-helpers.js"; 2 | import type RtoModuleFactory from "./RtoModuleFactory.d.ts"; 3 | 4 | export type RtoModuleLoader = (modulePath: RelativeModulePath) => Promise; 5 | -------------------------------------------------------------------------------- /packages/typeonly/src/rto.d.ts: -------------------------------------------------------------------------------- 1 | export interface RtoModules { 2 | [modulePath: string]: RtoModule; 3 | } 4 | 5 | /** 6 | * Raw TypeOnly Module 7 | */ 8 | export interface RtoModule { 9 | imports?: RtoImport[]; 10 | namespacedImports?: RtoNamespacedImport[]; 11 | namedTypes?: RtoNamedType[]; 12 | } 13 | 14 | export interface RtoImport { 15 | from: string; 16 | namedMembers?: RtoImportNamedMember[]; 17 | } 18 | 19 | export interface RtoImportNamedMember { 20 | name: string; 21 | as?: string; 22 | } 23 | 24 | export interface RtoNamespacedImport { 25 | from: string; 26 | asNamespace: string; 27 | } 28 | 29 | export type RtoNamedType = RtoType & RtoBaseNamedType; 30 | 31 | export type RtoNamedTypeName = RtoTypeName & RtoBaseNamedType; 32 | export type RtoNamedGenericParameterName = RtoGenericParameterName & RtoBaseNamedType; 33 | export type RtoNamedLocalTypeRef = RtoLocalTypeRef & RtoBaseNamedType; 34 | export type RtoNamedImportedTypeRef = RtoImportedTypeRef & RtoBaseNamedType; 35 | export type RtoNamedLiteralType = RtoLiteralType & RtoBaseNamedType; 36 | export type RtoNamedCompositeType = RtoCompositeType & RtoBaseNamedType; 37 | export type RtoNamedTupleType = RtoTupleType & RtoBaseNamedType; 38 | export type RtoNamedArrayType = RtoArrayType & RtoBaseNamedType; 39 | export type RtoNamedGenericInstance = RtoGenericInstance & RtoBaseNamedType; 40 | export type RtoNamedFunctionType = RtoFunctionType & RtoBaseNamedType; 41 | export type RtoNamedKeyofType = RtoKeyofType & RtoBaseNamedType; 42 | export type RtoNamedMemberType = RtoMemberType & RtoBaseNamedType; 43 | export type RtoNamedInterface = RtoInterface & RtoBaseNamedType; 44 | 45 | export type RtoType = 46 | | RtoTypeName 47 | | RtoGenericParameterName 48 | | RtoLocalTypeRef 49 | | RtoImportedTypeRef 50 | | RtoLiteralType 51 | | RtoCompositeType 52 | | RtoTupleType 53 | | RtoArrayType 54 | | RtoGenericInstance 55 | | RtoFunctionType 56 | | RtoKeyofType 57 | | RtoMemberType 58 | | RtoInterface; 59 | 60 | export interface RtoBaseNamedType extends RtoCommentable { 61 | exported?: boolean; 62 | name: string; 63 | generic?: RtoGenericParameter[]; 64 | } 65 | 66 | export interface RtoGenericParameter { 67 | name: string; 68 | extendsType?: RtoType; 69 | defaultType?: RtoType; 70 | } 71 | 72 | export interface RtoTypeName { 73 | kind: "name"; 74 | group: "ts" | "primitive" | "global"; 75 | refName: RtoSpecialTypeName | RtoPrimitiveTypeName | string; 76 | } 77 | 78 | export type RtoSpecialTypeName = "any" | "unknown" | "object" | "void" | "never"; 79 | export type RtoPrimitiveTypeName = 80 | | "string" 81 | | "number" 82 | | "bigint" 83 | | "boolean" 84 | | "symbol" 85 | | "undefined" 86 | | "null"; 87 | 88 | export interface RtoGenericParameterName { 89 | kind: "genericParameterName"; 90 | genericParameterName: string; 91 | } 92 | 93 | export interface RtoLocalTypeRef { 94 | kind: "localRef"; 95 | refName: string; 96 | } 97 | 98 | export interface RtoImportedTypeRef { 99 | kind: "importedRef"; 100 | refName: string; 101 | namespace?: string; 102 | } 103 | 104 | export interface RtoLiteralType { 105 | kind: "literal"; 106 | literal: string | number | bigint | boolean; 107 | } 108 | 109 | export interface RtoCompositeType { 110 | kind: "composite"; 111 | op: "union" | "intersection"; 112 | types: RtoType[]; 113 | } 114 | 115 | export interface RtoTupleType { 116 | kind: "tuple"; 117 | itemTypes?: RtoType[]; 118 | } 119 | 120 | export interface RtoArrayType { 121 | kind: "array"; 122 | itemType: RtoType; 123 | } 124 | 125 | export interface RtoGenericInstance { 126 | kind: "genericInstance"; 127 | genericName: string; 128 | parameterTypes: RtoType[]; 129 | } 130 | 131 | export interface RtoFunctionType { 132 | kind: "function"; 133 | parameters?: RtoFunctionParameter[]; 134 | returnType: RtoType; 135 | generic?: RtoGenericParameter[]; 136 | } 137 | 138 | export interface RtoFunctionParameter { 139 | name: string; 140 | type?: RtoType; 141 | optional?: boolean; 142 | } 143 | 144 | export interface RtoKeyofType { 145 | kind: "keyof"; 146 | type: RtoType; 147 | } 148 | 149 | export interface RtoMemberType { 150 | kind: "member"; 151 | parentType: RtoType; 152 | memberName: string | RtoMemberNameLiteral; 153 | } 154 | 155 | export interface RtoMemberNameLiteral { 156 | literal: string | number; 157 | } 158 | 159 | export interface RtoInterface { 160 | kind: "interface"; 161 | indexSignature?: RtoIndexSignature; 162 | mappedIndexSignature?: RtoMappedIndexSignature; 163 | properties?: RtoProperty[]; 164 | } 165 | 166 | export interface RtoProperty extends RtoCommentable { 167 | name: string; 168 | type: RtoType; 169 | optional?: boolean; 170 | readonly?: boolean; 171 | } 172 | 173 | export interface RtoIndexSignature extends RtoCommentable { 174 | keyName: string; 175 | keyType: "string" | "number"; 176 | type: RtoType; 177 | optional?: boolean; 178 | readonly?: boolean; 179 | } 180 | 181 | export interface RtoMappedIndexSignature extends RtoCommentable { 182 | keyName: string; 183 | keyInType: RtoType; 184 | type: RtoType; 185 | optional?: boolean; 186 | readonly?: boolean; 187 | } 188 | 189 | export interface RtoCommentable { 190 | /** 191 | * A multiline string. 192 | */ 193 | docComment?: string; 194 | } 195 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-array.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { 4 | AstArrayType, 5 | AstFunctionType, 6 | AstInterface, 7 | AstNamedType, 8 | AstProperty, 9 | } from "../../src/ast.d.ts"; 10 | 11 | describe("AST Specification for Array", () => { 12 | test("array with an identifier", () => { 13 | const source = ` 14 | type T1 = number[] 15 | `; 16 | const ast = parseTypeOnly({ source }); 17 | const namedType = ast.declarations?.[0] as AstNamedType; 18 | expect(namedType.type).toEqual({ 19 | whichType: "array", 20 | itemType: "number", 21 | } as AstArrayType); 22 | }); 23 | 24 | test("array with an interface", () => { 25 | const source = ` 26 | type T1 = {a: A}[] 27 | `; 28 | const ast = parseTypeOnly({ source }); 29 | const namedType = ast.declarations?.[0] as AstNamedType; 30 | const type = namedType.type as AstArrayType; 31 | expect(type.whichType).toBe("array"); 32 | expect(type.genericSyntax).toBe(undefined); 33 | const itemType = type.itemType as AstInterface; 34 | expect(itemType.whichType).toBe("interface"); 35 | expect((itemType.entries?.[0] as AstProperty).name).toBe("a"); 36 | }); 37 | 38 | test("array with a function", () => { 39 | const source = ` 40 | type T1 = (() => A)[] 41 | `; 42 | const ast = parseTypeOnly({ source }); 43 | const namedType = ast.declarations?.[0] as AstNamedType; 44 | const type = namedType.type as AstArrayType; 45 | expect(type.whichType).toBe("array"); 46 | expect(type.genericSyntax).toBe(undefined); 47 | const itemType = type.itemType as AstFunctionType; 48 | expect(itemType.whichType).toBe("function"); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-comment.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { 4 | AstInlineComment, 5 | AstNamedInterface, 6 | AstNamedType, 7 | AstStandaloneComment, 8 | } from "../../src/ast.d.ts"; 9 | 10 | describe("AST Specification for Comments", () => { 11 | function testStandaloneComment(source: string, text: string, syntax: "inline" | "classic") { 12 | test(`standalone comment: ${JSON.stringify(source).replace(/\\n/g, "\u23ce")}`, () => { 13 | const ast = parseTypeOnly({ source }); 14 | expect(ast.declarations?.[0]).toEqual({ 15 | whichDeclaration: "comment", 16 | text, 17 | syntax, 18 | } as AstStandaloneComment); 19 | }); 20 | } 21 | 22 | testStandaloneComment("// com 1", "com 1", "inline"); 23 | testStandaloneComment("//line 1\n// line 2", "line 1\nline 2", "inline"); 24 | testStandaloneComment("// line 1\n// line 2", " line 1\n line 2", "inline"); 25 | testStandaloneComment(" \n\n // com 1 \n \n ", "com 1", "inline"); 26 | testStandaloneComment("//\n// com 1\n//", "\ncom 1\n", "inline"); 27 | 28 | testStandaloneComment("/* com 1 */", "com 1", "classic"); 29 | testStandaloneComment("/* line 1\n line 2 */", " line 1\n line 2", "classic"); 30 | testStandaloneComment("/*\n * com 1\n */", "com 1", "classic"); 31 | testStandaloneComment(" \n\n /* com 1 */ \n \n ", "com 1", "classic"); 32 | testStandaloneComment("/*\ncom 1\n*/", "\ncom 1\n", "classic"); 33 | testStandaloneComment("/*\n *\n * com 1\n *\n */", "\ncom 1\n", "classic"); 34 | 35 | function testInlineComment(source: string, text: string, syntax: "inline" | "classic") { 36 | test(`inline comment: ${JSON.stringify(source).replace(/\\n/g, "\u23ce")}`, () => { 37 | const ast = parseTypeOnly({ source }); 38 | const namedType = ast.declarations?.[0] as AstNamedType; 39 | expect(namedType.inlineComments).toEqual([ 40 | { 41 | syntax, 42 | text, 43 | }, 44 | ] as AstInlineComment[]); 45 | }); 46 | } 47 | 48 | testInlineComment("type T1 = string // com 1", "com 1", "inline"); 49 | testInlineComment("type T1 = string /* com 1 */", "com 1", "classic"); 50 | testInlineComment("type T1 = /* com 1 */ string", "com 1", "classic"); 51 | testInlineComment("type T1 /* com 1 */ = string", "com 1", "classic"); 52 | testInlineComment("type /* com 1 */ T1 = string", "com 1", "classic"); 53 | 54 | function testDocCommentOnDeclaration(source: string, text: string) { 55 | test(`doc comment in declaration: ${JSON.stringify(source).replace(/\\n/g, "\u23ce")}`, () => { 56 | const ast = parseTypeOnly({ source }); 57 | const decl = ast.declarations?.[0] as AstNamedType | AstNamedInterface; 58 | expect(decl.docComment).toBe(text); 59 | }); 60 | } 61 | 62 | function testDocCommentsOnDeclaration(declaration: string) { 63 | testDocCommentOnDeclaration(`/** com 1 */${declaration}`, "com 1"); 64 | testDocCommentOnDeclaration(`/** com 1 */ ${declaration}`, "com 1"); 65 | testDocCommentOnDeclaration(`/** com 1 */\n${declaration}`, "com 1"); 66 | testDocCommentOnDeclaration(`/** line 1\n line 2 */\n${declaration}`, " line 1\n line 2"); 67 | testDocCommentOnDeclaration(`/**\n * line 1\n * line 2\n */\n${declaration}`, "line 1\nline 2"); 68 | } 69 | 70 | testDocCommentsOnDeclaration("type T1 = string"); 71 | testDocCommentsOnDeclaration("interface I1 {}"); 72 | 73 | test("multiple comments", () => { 74 | const source = ` 75 | // standalone 1, line 1 76 | // standalone 1, line 2 77 | 78 | // standalone 2 79 | 80 | /** 81 | * doc T1 82 | */ 83 | type T1 = A // inline T1 84 | 85 | // standalone 3 86 | `; 87 | const ast = parseTypeOnly({ source }); 88 | expect(ast.declarations?.[0]).toEqual({ 89 | whichDeclaration: "comment", 90 | text: "standalone 1, line 1\nstandalone 1, line 2", 91 | syntax: "inline", 92 | } as AstStandaloneComment); 93 | expect(ast.declarations?.[1]).toEqual({ 94 | whichDeclaration: "comment", 95 | text: "standalone 2", 96 | syntax: "inline", 97 | } as AstStandaloneComment); 98 | const namedType = ast.declarations?.[2] as AstNamedType; 99 | expect(namedType.whichDeclaration).toBe("type"); 100 | expect(namedType.docComment).toBe("doc T1"); 101 | expect(namedType.inlineComments).toEqual([ 102 | { 103 | syntax: "inline", 104 | text: "inline T1", 105 | }, 106 | ] as AstInlineComment[]); 107 | expect(ast.declarations?.[3]).toEqual({ 108 | whichDeclaration: "comment", 109 | text: "standalone 3", 110 | syntax: "inline", 111 | } as AstStandaloneComment); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-composite.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstCompositeType, AstInterface, AstNamedType, AstProperty } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Composite Types", () => { 6 | test("composite type with identifiers", () => { 7 | const source = "type T1 = number | string | undefined | null"; 8 | const ast = parseTypeOnly({ source }); 9 | const namedType = ast.declarations?.[0] as AstNamedType; 10 | const composite = namedType.type as AstCompositeType; 11 | expect(composite).toEqual({ 12 | whichType: "composite", 13 | op: "union", 14 | types: ["number", "string", "undefined", "null"], 15 | } as AstCompositeType); 16 | }); 17 | 18 | test("composite type with nested types", () => { 19 | const source = "type T1 = { a: string | number } & { b: boolean }"; 20 | const ast = parseTypeOnly({ source }); 21 | const namedType = ast.declarations?.[0] as AstNamedType; 22 | const composite = namedType.type as AstCompositeType; 23 | expect(composite.op).toBe("intersection"); 24 | 25 | const t1 = composite.types[0] as AstInterface; 26 | expect(t1.whichType).toBe("interface"); 27 | const t1Prop = (t1.entries?.[0] as AstProperty).type as AstCompositeType; 28 | expect(t1Prop).toEqual({ 29 | whichType: "composite", 30 | op: "union", 31 | types: ["string", "number"], 32 | } as AstCompositeType); 33 | 34 | const t2 = composite.types?.[1] as AstInterface; 35 | expect(t2.whichType).toBe("interface"); 36 | }); 37 | 38 | const withParenthesis = [ 39 | "A | B & C", 40 | "A | (B & C)", 41 | "(A) | ((B & C))", 42 | "(((A) | ((B & C))))", 43 | "A | B \n & \n C", 44 | ]; 45 | withParenthesis.forEach((inputType, index) => { 46 | test(`composite type with parenthesis #${index + 1}`, () => { 47 | const ast = parseTypeOnly({ source: `type T1 = ${inputType}` }); 48 | const namedType = ast.declarations?.[0] as AstNamedType; 49 | const composite = namedType.type as AstCompositeType; 50 | expect(composite.op).toBe("union"); 51 | expect(composite.types[0]).toBe("A"); 52 | 53 | const t2 = composite.types[1] as AstCompositeType; 54 | expect(t2).toEqual({ 55 | whichType: "composite", 56 | op: "intersection", 57 | types: ["B", "C"], 58 | } as AstCompositeType); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-function.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstFunctionType, AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Function Types", () => { 6 | test("empty function", () => { 7 | const source = "type T1 = () => void"; 8 | const ast = parseTypeOnly({ source }); 9 | const namedType = ast.declarations?.[0] as AstNamedType; 10 | expect(namedType.type).toEqual({ 11 | whichType: "function", 12 | returnType: "void", 13 | } as AstFunctionType); 14 | }); 15 | 16 | test("function with parameters", () => { 17 | const source = "type T1 = (p1: string, p2?: number) => void"; 18 | const ast = parseTypeOnly({ source }); 19 | const namedType = ast.declarations?.[0] as AstNamedType; 20 | expect(namedType.type).toEqual({ 21 | whichType: "function", 22 | parameters: [ 23 | { 24 | name: "p1", 25 | type: "string", 26 | optional: false, 27 | }, 28 | { 29 | name: "p2", 30 | type: "number", 31 | optional: true, 32 | }, 33 | ], 34 | returnType: "void", 35 | } as AstFunctionType); 36 | }); 37 | 38 | test("function with nested types", () => { 39 | const source = "type T1 = (p1: () => void) => () => void"; 40 | const ast = parseTypeOnly({ source }); 41 | const namedType = ast.declarations?.[0] as AstNamedType; 42 | const fnType = namedType.type as AstFunctionType; 43 | const emptyFnType: AstFunctionType = { 44 | whichType: "function", 45 | returnType: "void", 46 | }; 47 | expect(fnType.returnType).toEqual(emptyFnType); 48 | expect(fnType.parameters?.[0].type).toEqual(emptyFnType); 49 | }); 50 | 51 | test("function with nested types and parenthesis", () => { 52 | const source = "type T1 = (((p1: (() => void)) => (() => void)))"; 53 | const ast = parseTypeOnly({ source }); 54 | const namedType = ast.declarations?.[0] as AstNamedType; 55 | const fnType = namedType.type as AstFunctionType; 56 | const emptyFnType: AstFunctionType = { 57 | whichType: "function", 58 | returnType: "void", 59 | }; 60 | expect(fnType.returnType).toEqual(emptyFnType); 61 | expect(fnType.parameters?.[0].type).toEqual(emptyFnType); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-generic.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstGenericInstance, AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Generic", () => { 6 | test("a generic instance with identifier", () => { 7 | const source = ` 8 | type T1 = G 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toEqual({ 14 | whichType: "genericInstance", 15 | genericName: "G", 16 | parameterTypes: ["number", "string"], 17 | } as AstGenericInstance); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-index-signature.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstInterface, AstNamedInterface, AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Index Signature", () => { 6 | test("a index signature", () => { 7 | const source = `interface I1 { 8 | [index1: string] : {a: number} 9 | }`; 10 | const ast = parseTypeOnly({ source }); 11 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 12 | expect(namedInterface.whichDeclaration).toBe("interface"); 13 | expect(namedInterface.whichType).toBe("interface"); 14 | expect(namedInterface.name).toBe("I1"); 15 | expect(namedInterface.entries).toEqual([ 16 | { 17 | whichEntry: "indexSignature", 18 | keyName: "index1", 19 | keyType: "string", 20 | optional: false, 21 | readonly: false, 22 | type: { 23 | whichType: "interface", 24 | entries: [ 25 | { 26 | whichEntry: "property", 27 | name: "a", 28 | optional: false, 29 | readonly: false, 30 | type: "number", 31 | }, 32 | ], 33 | }, 34 | }, 35 | ]); 36 | }); 37 | 38 | test("a mapped index signature", () => { 39 | const source = `type T1 = { 40 | [index1 in string] : {a: number} 41 | }`; 42 | const ast = parseTypeOnly({ source }); 43 | const namedType = ast.declarations?.[0] as AstNamedType; 44 | expect(namedType.whichDeclaration).toBe("type"); 45 | expect(namedType.name).toBe("T1"); 46 | const type = namedType.type as AstInterface; 47 | expect(type.whichType).toBe("interface"); 48 | expect(type.entries).toEqual([ 49 | { 50 | whichEntry: "mappedIndexSignature", 51 | keyName: "index1", 52 | optional: false, 53 | readonly: false, 54 | keyInType: "string", 55 | type: { 56 | whichType: "interface", 57 | entries: [ 58 | { 59 | whichEntry: "property", 60 | name: "a", 61 | optional: false, 62 | readonly: false, 63 | type: "number", 64 | }, 65 | ], 66 | }, 67 | }, 68 | ]); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-inline-import.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for InlineImport", () => { 6 | test("a InlineImport", () => { 7 | const source = ` 8 | type T1 = import("./abc").Test 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toEqual({ 14 | whichType: "inlineImport", 15 | from: "./abc", 16 | exportedName: "Test", 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-interface.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstNamedInterface, AstProperty } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Interfaces", () => { 6 | const validIdentifiers = ["Abc12", "$_ab12", "_", "$", "əe"]; 7 | validIdentifiers.forEach((identifier) => { 8 | test(`valid identifier: ${identifier}`, () => { 9 | const source = `interface ${identifier} {}`; 10 | const ast = parseTypeOnly({ source }); 11 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 12 | expect(namedInterface.name).toBe(identifier); 13 | }); 14 | }); 15 | 16 | const invalidIdentifiers = ["2b", "a-b", "if", "for", "while", "do", "break", "continue"]; 17 | invalidIdentifiers.forEach((identifier) => { 18 | test(`invalid identifier: ${identifier}`, () => { 19 | const source = `interface ${identifier} {}`; 20 | expect(() => parseTypeOnly({ source })).toThrow(); 21 | }); 22 | }); 23 | 24 | test("declarations can be separated with semicolon or newline or nothing", () => { 25 | const source = ` 26 | interface I1 {}interface I2{} interface I3{} 27 | interface I4{}; interface I5{}; 28 | interface I6{} 29 | `; 30 | const ast = parseTypeOnly({ source }); 31 | expect(ast.declarations?.length).toBe(6); 32 | }); 33 | 34 | test("property separator can be a coma, a semicolon or a new line", () => { 35 | const source = ` 36 | interface I1 { 37 | a: string,aa: boolean, 38 | b: string;bb: boolean; 39 | ; 40 | c: string 41 | 42 | , d: string 43 | } 44 | `; 45 | const ast = parseTypeOnly({ source }); 46 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 47 | expect(namedInterface.entries?.length).toBe(6); 48 | }); 49 | 50 | test("inline interface", () => { 51 | const source = ` 52 | interface I1 { a: string, b: string; c: string } 53 | `; 54 | const ast = parseTypeOnly({ source }); 55 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 56 | expect(namedInterface.entries?.length).toBe(3); 57 | }); 58 | 59 | const validTypeNames = { 60 | primitive: ["number", "string", "boolean", "bigint", "symbol"], 61 | global: ["Number", "String", "Boolean", "Bigint", "Date", "Symbol"], 62 | typescript: ["any", "void", "object", "unknown", "never"], 63 | identifier: validIdentifiers, 64 | }; 65 | Object.entries(validTypeNames).forEach(([category, typeNames]) => { 66 | for (const typeName of typeNames) { 67 | test(`${category} type: ${typeName}`, () => { 68 | const source = `interface I1 { 69 | a: ${typeName} 70 | }`; 71 | const ast = parseTypeOnly({ source }); 72 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 73 | const property = namedInterface.entries?.[0] as AstProperty; 74 | expect(property.type).toBe(typeName); 75 | }); 76 | } 77 | }); 78 | 79 | test("extends", () => { 80 | const source = ` 81 | interface I1 extends I2, I3 { 82 | a: number 83 | } 84 | `; 85 | const ast = parseTypeOnly({ source }); 86 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 87 | expect(namedInterface.extends).toEqual(["I2", "I3"]); 88 | }); 89 | 90 | test("interface with optional property", () => { 91 | const source = ` 92 | interface I1 { 93 | a?: number 94 | } 95 | `; 96 | const ast = parseTypeOnly({ source }); 97 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 98 | const prop = namedInterface.entries?.[0] as AstProperty; 99 | expect(prop.optional).toBe(true); 100 | }); 101 | 102 | test("semicolons", () => { 103 | const source = ` 104 | interface I1 { 105 | a: number; 106 | b: number; 107 | } 108 | `; 109 | const ast = parseTypeOnly({ source }); 110 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 111 | expect(namedInterface.entries?.length).toBe(2); 112 | }); 113 | 114 | test("comas", () => { 115 | const source = ` 116 | interface I1 { 117 | a: number, 118 | b: number, 119 | } 120 | `; 121 | const ast = parseTypeOnly({ source }); 122 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 123 | expect(namedInterface.entries?.length).toBe(2); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-interface2.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { 4 | AstFunctionProperty, 5 | AstFunctionType, 6 | AstNamedInterface, 7 | AstProperty, 8 | } from "../../src/ast.d.ts"; 9 | 10 | describe("AST Specification for Interfaces (part 2)", () => { 11 | const testFunctionAsProperty = (source: string, parameters: any[], returnType: string) => { 12 | test(`a function as property, ${parameters.length === 0 ? "no parameter" : `with ${parameters.length} parameters`}`, () => { 13 | const ast = parseTypeOnly({ source }); 14 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 15 | const prop = namedInterface.entries?.[0] as AstProperty; 16 | expect(prop.whichEntry).toBe("property"); 17 | expect(prop.name).toBe("a"); 18 | const propType = prop.type as AstFunctionType; 19 | expect(propType.whichType).toBe("function"); 20 | expect(propType.parameters).toEqual(parameters.length === 0 ? undefined : parameters); 21 | expect(propType.returnType).toBe(returnType); 22 | }); 23 | }; 24 | 25 | const testFunctionProperty = (source: string, parameters: any[], returnType: string) => { 26 | test(`a function as functionProperty, ${parameters.length === 0 ? "no parameter" : `with ${parameters.length} parameters`}`, () => { 27 | const ast = parseTypeOnly({ source }); 28 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 29 | const prop = namedInterface.entries?.[0] as AstFunctionProperty; 30 | expect(prop.whichEntry).toBe("functionProperty"); 31 | expect(prop.name).toBe("a"); 32 | expect(prop.parameters).toEqual(parameters.length === 0 ? undefined : parameters); 33 | expect(prop.returnType).toBe(returnType); 34 | }); 35 | }; 36 | 37 | testFunctionProperty( 38 | ` 39 | interface I1 { 40 | a(): number 41 | }`, 42 | [], 43 | "number", 44 | ); 45 | 46 | testFunctionAsProperty( 47 | ` 48 | interface I1 { 49 | a: () => number 50 | }`, 51 | [], 52 | "number", 53 | ); 54 | 55 | const parameters = [ 56 | { 57 | name: "p1", 58 | type: "T1", 59 | optional: false, 60 | }, 61 | { 62 | name: "p2", 63 | type: "T2", 64 | optional: false, 65 | }, 66 | ]; 67 | 68 | testFunctionProperty( 69 | ` 70 | interface I1 { 71 | a(p1: T1, p2: T2): void 72 | }`, 73 | parameters, 74 | "void", 75 | ); 76 | 77 | testFunctionAsProperty( 78 | ` 79 | interface I1 { 80 | a: (p1: T1, p2: T2) => void 81 | }`, 82 | parameters, 83 | "void", 84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-key-of.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for KeyOf", () => { 6 | test("a keyOf with interface", () => { 7 | const source = ` 8 | type T1 = keyof {a:number} 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toEqual({ 14 | whichType: "keyof", 15 | type: { 16 | whichType: "interface", 17 | entries: [ 18 | { 19 | whichEntry: "property", 20 | name: "a", 21 | optional: false, 22 | readonly: false, 23 | type: "number", 24 | }, 25 | ], 26 | }, 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-literal.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstLiteralType, AstNamedType } from "../../src/ast.js"; 4 | 5 | describe("AST Specification for Literal Types", () => { 6 | const validStringLiterals = [ 7 | { literal: `"abc"`, result: "abc" }, 8 | { literal: `"a\\"b"`, result: 'a"b' }, 9 | { literal: `'a\\'b'`, result: "a'b" }, 10 | ]; 11 | 12 | validStringLiterals.forEach(({ literal, result }) => { 13 | test(`valid string literal: ${literal}`, () => { 14 | const source = `type T1 = ${literal}`; 15 | const ast = parseTypeOnly({ source }); 16 | const namedType = ast.declarations?.[0] as AstNamedType; 17 | expect(namedType.type).toEqual({ 18 | whichType: "literal", 19 | literal: result, 20 | stringDelim: literal[0], 21 | } as AstLiteralType); 22 | }); 23 | }); 24 | 25 | const validLiterals = [ 26 | { literal: "23n", result: 23n }, 27 | { literal: "12", result: 12 }, 28 | { literal: "2.3", result: 2.3 }, 29 | { literal: "false", result: false }, 30 | { literal: "true", result: true }, 31 | ]; 32 | validLiterals.forEach(({ literal, result }) => { 33 | test(`valid literal: ${literal}`, () => { 34 | const source = `type T1 = ${literal}`; 35 | const ast = parseTypeOnly({ source }); 36 | const namedType = ast.declarations?.[0] as AstNamedType; 37 | expect(namedType.type).toEqual({ 38 | whichType: "literal", 39 | literal: result, 40 | } as AstLiteralType); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-member-type.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstMemberType, AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for MemberType", () => { 6 | test("a member type with identifier", () => { 7 | const source = ` 8 | type T1 = Add[cv] 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toEqual({ 14 | whichType: "member", 15 | memberName: "cv", 16 | parentType: "Add", 17 | } as AstMemberType); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-named-import.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstClassicImport, AstNamespacedImport } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Named Import", () => { 6 | test("a classic import", () => { 7 | const source = `import { A as B, C as D } from "./abc.js"`; 8 | const ast = parseTypeOnly({ source }); 9 | const classicImport = ast.declarations?.[0] as AstClassicImport; 10 | expect(classicImport.whichDeclaration).toBe("import"); 11 | expect(classicImport.whichImport).toBe("classic"); 12 | expect(classicImport.from).toBe("./abc.js"); 13 | expect(classicImport.namedMembers).toEqual([ 14 | { 15 | name: "A", 16 | as: "B", 17 | }, 18 | { 19 | name: "C", 20 | as: "D", 21 | }, 22 | ]); 23 | }); 24 | 25 | test("a namespaced import", () => { 26 | const source = `import * as ns from "./abc.js"`; 27 | const ast = parseTypeOnly({ source }); 28 | const namespacedImport = ast.declarations?.[0] as AstNamespacedImport; 29 | expect(namespacedImport.whichDeclaration).toBe("import"); 30 | expect(namespacedImport.whichImport).toBe("namespaced"); 31 | expect(namespacedImport.from).toBe("./abc.js"); 32 | expect(namespacedImport.asNamespace).toBe("ns"); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-named-type.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstInterface, AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Named Types", () => { 6 | test("type as alias", () => { 7 | const source = ` 8 | type T1 = string 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toBe("string"); 14 | }); 15 | 16 | test("type assign with interface", () => { 17 | const source = ` 18 | type T1 = { a: number } 19 | `; 20 | const ast = parseTypeOnly({ source }); 21 | const namedType = ast.declarations?.[0] as AstNamedType; 22 | const type = namedType.type as AstInterface; 23 | expect(type.whichType).toBe("interface"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-nested-types.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstInterface, AstNamedInterface, AstProperty } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Nested Types", () => { 6 | test("an anonymous interface nested in a interface", () => { 7 | const source = ` 8 | interface I1 { 9 | a: { 10 | b: string 11 | } 12 | } 13 | `; 14 | const ast = parseTypeOnly({ source }); 15 | expect(ast.declarations?.length).toBe(1); 16 | const namedInterface = ast.declarations?.[0] as AstNamedInterface; 17 | expect(namedInterface.whichType).toBe("interface"); 18 | expect(namedInterface.entries?.length).toBe(1); 19 | const property = namedInterface.entries?.[0] as AstProperty; 20 | expect(property.whichEntry).toBe("property"); 21 | expect(property.name).toBe("a"); 22 | const subType = property.type as AstInterface; 23 | expect(subType.whichType).toBe("interface"); 24 | expect(subType.entries?.length).toBe(1); 25 | const subProperty = subType.entries?.[0] as AstProperty; 26 | expect(subProperty.whichEntry).toBe("property"); 27 | expect(subProperty.name).toBe("b"); 28 | expect(subProperty.type).toBe("string"); 29 | }); 30 | 31 | const deep = Math.round(Math.random() * 20) + 1; 32 | test(`a random number of nested interfaces (${deep})`, () => { 33 | const makeInterface = (deep: number): string => { 34 | if (deep <= 0) return "number"; 35 | return `{ a: ${makeInterface(deep - 1)} }`; 36 | }; 37 | const source = `interface I1 ${makeInterface(deep)}`; 38 | const ast = parseTypeOnly({ source }); 39 | let parent = ast.declarations?.[0] as AstInterface; 40 | for (let i = 0; i < deep - 1; ++i) { 41 | const child = parent.entries?.[0] as AstProperty; 42 | parent = child.type as AstInterface; 43 | expect(typeof parent).toBe("object"); 44 | expect(parent.whichType).toBe("interface"); 45 | } 46 | const child = parent.entries?.[0] as AstProperty; 47 | expect(child.type).toBe("number"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-precedence.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { 4 | AstArrayType, 5 | AstCompositeType, 6 | AstFunctionType, 7 | AstNamedType, 8 | } from "../../src/ast.d.ts"; 9 | 10 | describe("AST Specification for Precedence", () => { 11 | test("function with an array as return value", () => { 12 | const source = ` 13 | type T1 = () => A[] 14 | `; 15 | const ast = parseTypeOnly({ source }); 16 | const namedType = ast.declarations?.[0] as AstNamedType; 17 | const type = namedType.type as AstFunctionType; 18 | expect(type.whichType).toBe("function"); 19 | const returnType = type.returnType as AstArrayType; 20 | expect(returnType.whichType).toBe("array"); 21 | expect(returnType.itemType).toBe("A"); 22 | }); 23 | 24 | test("function with a composite type as return value", () => { 25 | const source = ` 26 | type T1 = () => A | B[] 27 | `; 28 | const ast = parseTypeOnly({ source }); 29 | const namedType = ast.declarations?.[0] as AstNamedType; 30 | const type = namedType.type as AstFunctionType; 31 | expect(type.whichType).toBe("function"); 32 | const returnType = type.returnType as AstCompositeType; 33 | expect(returnType.whichType).toBe("composite"); 34 | expect(returnType.types.length).toBe(2); 35 | expect(returnType.types[0]).toBe("A"); 36 | const arrayType = returnType.types[1] as AstArrayType; 37 | expect(arrayType.whichType).toBe("array"); 38 | expect(arrayType.itemType).toBe("B"); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-tuple.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | import type { AstNamedType } from "../../src/ast.d.ts"; 4 | 5 | describe("AST Specification for Tuples", () => { 6 | test("empty tuple", () => { 7 | const source = ` 8 | type T1 = [] 9 | `; 10 | const ast = parseTypeOnly({ source }); 11 | const namedType = ast.declarations?.[0] as AstNamedType; 12 | expect(namedType.name).toBe("T1"); 13 | expect(namedType.type).toEqual({ 14 | whichType: "tuple", 15 | }); 16 | }); 17 | 18 | test("a tuple with identifiers", () => { 19 | const source = ` 20 | type T1 = [string, number, boolean] 21 | `; 22 | const ast = parseTypeOnly({ source }); 23 | const namedType = ast.declarations?.[0] as AstNamedType; 24 | expect(namedType.name).toBe("T1"); 25 | expect(namedType.type).toEqual({ 26 | whichType: "tuple", 27 | itemTypes: ["string", "number", "boolean"], 28 | }); 29 | }); 30 | 31 | test("a tuple with identifiers and an interface", () => { 32 | const source = ` 33 | type T2 = [string, {a: number, b: string}, boolean] 34 | `; 35 | const ast = parseTypeOnly({ source }); 36 | const namedType = ast.declarations?.[0] as AstNamedType; 37 | expect(namedType.name).toBe("T2"); 38 | expect(namedType.type).toEqual({ 39 | whichType: "tuple", 40 | itemTypes: [ 41 | "string", 42 | { 43 | whichType: "interface", 44 | entries: [ 45 | { 46 | whichEntry: "property", 47 | name: "a", 48 | optional: false, 49 | readonly: false, 50 | type: "number", 51 | }, 52 | { 53 | whichEntry: "property", 54 | name: "b", 55 | optional: false, 56 | readonly: false, 57 | type: "string", 58 | }, 59 | ], 60 | }, 61 | "boolean", 62 | ], 63 | }); 64 | }); 65 | 66 | test("a tuple with a newline", () => { 67 | const source = ` 68 | type T3 = [string, number,boolean 69 | , 70 | dfdfd] 71 | `; 72 | const ast = parseTypeOnly({ source }); 73 | const namedType = ast.declarations?.[0] as AstNamedType; 74 | expect(namedType.name).toBe("T3"); 75 | expect(namedType.type).toEqual({ 76 | whichType: "tuple", 77 | itemTypes: ["string", "number", "boolean", "dfdfd"], 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/typeonly/tests/ast-tests/ast-whitespaces.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { parseTypeOnly } from "../../src/api.js"; 3 | 4 | describe("AST Specification about White Spaces", () => { 5 | test("weird spaces do not matter", () => { 6 | const source = ` 7 | 8 | interface I1 9 | 10 | { 11 | 12 | a : string 13 | 14 | b:string 15 | } 16 | 17 | `; 18 | const ast = parseTypeOnly({ source }); 19 | expect(ast.declarations?.length).toBe(1); 20 | }); 21 | 22 | test("new lines in a named type", () => { 23 | parseTypeOnly({ 24 | source: ` 25 | type T1 26 | = 27 | number 28 | [ 29 | 30 | ] 31 | `, 32 | }); 33 | }); 34 | 35 | test("new lines in a named interface", () => { 36 | parseTypeOnly({ 37 | source: ` 38 | export 39 | interface I1 { 40 | readonly 41 | a 42 | : 43 | string 44 | } 45 | `, 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/import-tests.spec.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import { describe, expect, test } from "vitest"; 4 | import { generateRtoModules } from "../../src/api.js"; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | describe("Imports", () => { 10 | test("imports external packages", async () => { 11 | const result = await generateRtoModules({ 12 | modulePaths: ["./proj01-types"], 13 | readFiles: { 14 | sourceDir: join(__dirname, "test-proj01", "types"), 15 | }, 16 | returnRtoModules: true, 17 | }); 18 | // console.log("result of imports", JSON.stringify(result, null, 2)) 19 | expect(result).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/@hello/world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": "types/main.d.ts" 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/@hello/world/types/main.d.ts: -------------------------------------------------------------------------------- 1 | export interface Main { 2 | main: string 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/@hello/world/types/other.d.ts: -------------------------------------------------------------------------------- 1 | export interface Other { 2 | other: string 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/simple-package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": "types/main.d.ts" 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/simple-package/types/main.d.ts: -------------------------------------------------------------------------------- 1 | export interface SimplePackage { 2 | simplePackage: string 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/node_modules/simple-package/types/simple-other.d.ts: -------------------------------------------------------------------------------- 1 | export interface SimpleOther { 2 | simpleOther: string 3 | } -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-proj01" 3 | } 4 | -------------------------------------------------------------------------------- /packages/typeonly/tests/import-tests/test-proj01/types/proj01-types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Main } from "@hello/world"; 2 | import type { Other } from "@hello/world/types/other"; 3 | import type { SimplePackage } from "simple-package"; 4 | import type { SimpleOther } from "simple-package/types/simple-other"; 5 | 6 | export interface Proj01 { 7 | main: Main; 8 | other: Other; 9 | simplePackage: SimplePackage; 10 | simpleOther: SimpleOther; 11 | } 12 | -------------------------------------------------------------------------------- /packages/typeonly/tests/rto-tests/rto-comment.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { createStandaloneRtoModule, parseTypeOnly } from "../../src/api.js"; 3 | import type { RtoBaseNamedType, RtoInterface, RtoTypeName } from "../../src/rto.d.ts"; 4 | 5 | describe("RTO Specification for Comment", () => { 6 | test("Comment with type", async () => { 7 | const source = ` 8 | /** 9 | * something 10 | */ 11 | export type T1 = number 12 | `; 13 | const rtoModule = createStandaloneRtoModule({ 14 | ast: parseTypeOnly({ source }), 15 | }); 16 | 17 | expect(rtoModule.namedTypes?.length).toBe(1); 18 | const rtoNamedType = rtoModule.namedTypes?.[0] as RtoTypeName & RtoBaseNamedType; 19 | expect(rtoNamedType).toEqual({ 20 | name: "T1", 21 | exported: true, 22 | docComment: "something", 23 | kind: "name", 24 | group: "primitive", 25 | refName: "number", 26 | }); 27 | }); 28 | 29 | test("Comment with interface", async () => { 30 | const source = ` 31 | /** 32 | * something 33 | */ 34 | export interface T1 { 35 | a: number 36 | } 37 | `; 38 | const rtoModule = createStandaloneRtoModule({ 39 | ast: parseTypeOnly({ source }), 40 | }); 41 | 42 | expect(rtoModule.namedTypes?.length).toBe(1); 43 | const rtoNamedType = rtoModule.namedTypes?.[0] as RtoInterface & RtoBaseNamedType; 44 | expect(rtoNamedType.docComment).toBe("something"); 45 | expect(rtoNamedType.exported).toBe(true); 46 | expect(rtoNamedType.generic).toBeUndefined(); 47 | expect(rtoNamedType.kind).toBe("interface"); 48 | expect(rtoNamedType.name).toBe("T1"); 49 | expect(rtoNamedType.properties).toEqual([ 50 | { 51 | name: "a", 52 | type: { kind: "name", group: "primitive", refName: "number" }, 53 | }, 54 | ]); 55 | }); 56 | 57 | test("Comment with interface property", async () => { 58 | const source = ` 59 | export interface T1 { 60 | a: number 61 | /** 62 | * something 63 | */ 64 | b: string 65 | } 66 | `; 67 | const rtoModule = createStandaloneRtoModule({ 68 | ast: parseTypeOnly({ source }), 69 | }); 70 | 71 | expect(rtoModule.namedTypes?.length).toBe(1); 72 | const rtoNamedType = rtoModule.namedTypes?.[0] as RtoInterface & RtoBaseNamedType; 73 | expect(rtoNamedType.docComment).toBeUndefined(); 74 | expect(rtoNamedType.exported).toBe(true); 75 | expect(rtoNamedType.generic).toBeUndefined(); 76 | expect(rtoNamedType.kind).toBe("interface"); 77 | expect(rtoNamedType.name).toBe("T1"); 78 | expect(rtoNamedType.properties).toEqual([ 79 | { 80 | name: "a", 81 | type: { kind: "name", group: "primitive", refName: "number" }, 82 | }, 83 | { 84 | name: "b", 85 | type: { kind: "name", group: "primitive", refName: "string" }, 86 | docComment: "something", 87 | }, 88 | ]); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/typeonly/tests/rto-tests/rto-import.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { generateRtoModules, parseTypeOnly } from "../../src/api.js"; 3 | import type { RtoBaseNamedType, RtoImportedTypeRef } from "../../src/rto.d.ts"; 4 | 5 | describe("RTO Specification for Import", () => { 6 | test("RtoImportedTypeRef", async () => { 7 | const source1 = ` 8 | import { T2 } from "./source2" 9 | export type T1 = T2 10 | `; 11 | const source2 = ` 12 | export type T2 = boolean 13 | `; 14 | const rtoModules = await generateRtoModules({ 15 | modulePaths: ["./source1"], 16 | astProvider: (modulePath) => { 17 | if (modulePath === "./source1") return parseTypeOnly({ source: source1 }); 18 | if (modulePath === "./source2") return parseTypeOnly({ source: source2 }); 19 | throw new Error(`Unknown module: ${modulePath}`); 20 | }, 21 | returnRtoModules: true, 22 | }); 23 | const rtoModule = rtoModules?.["./source1"]; 24 | 25 | expect(rtoModule?.namedTypes?.length).toBe(1); 26 | const rtoNamedType = rtoModule?.namedTypes?.[0] as RtoImportedTypeRef & RtoBaseNamedType; 27 | expect(rtoNamedType).toEqual({ 28 | name: "T1", 29 | exported: true, 30 | kind: "importedRef", 31 | refName: "T2", 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/typeonly/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "noEmit": true 10 | }, 11 | "include": ["."] 12 | } 13 | -------------------------------------------------------------------------------- /packages/typeonly/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "esModuleInterop": true, 10 | "outDir": "dist", 11 | "noEmitOnError": true, 12 | "declaration": true, 13 | "declarationDir": "scripts/declarations", 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "rootDir": "src" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/typeonly/typeonly-language.md: -------------------------------------------------------------------------------- 1 | # TypeOnly Language 2 | 3 | TypeOnly is a strict subset of TypeScript: any code that compiles with TypeOnly will also compile with TypeScript. 4 | 5 | An example of TypeOnly source files: 6 | 7 | ```ts 8 | // pencil-json-def.d.ts 9 | import { Color } from "./constants" 10 | 11 | export interface JsonDef { 12 | pencils: Pencil[] 13 | } 14 | 15 | export interface Pencil { 16 | color: Color 17 | size: "normal" | "small" 18 | } 19 | ``` 20 | 21 | ```ts 22 | // constants.d.ts 23 | 24 | export type Color = "red" | "green" | "blue" 25 | ``` 26 | 27 | # Allowed declarations 28 | 29 | A TypeOnly _module_ is a set of declarations. There are three sort of declarations: _imports_, _named types_ and _named interfaces_. 30 | 31 | Notice: Our naming slightly differs from that of TypeScript, especially on what we call an "interface". 32 | 33 | # Module, export, import 34 | 35 | A TypeOnly source file is called a _module_. Exported members can be imported the same way as ECMAScript modules, except that default imports or exports are not allowed. 36 | 37 | The `export` keyword on named types or named interfaces is optional. Only the module members with an `export` keyword can be imported from outside the module. 38 | 39 | # Named Type 40 | 41 | A named type starts with the optional keyword `export` followed by the `type` keyword, then a type name as an identifier, the `=` character, and a type definition. Example: 42 | 43 | ```ts 44 | type T1 = boolean 45 | ``` 46 | 47 | # Named Interface 48 | 49 | A named interface is a named type that starts with the optional keyword `export` followed by the `interface` keyword, then an interface name as an identifier, and the interface definition. Example: 50 | 51 | ```ts 52 | interface I1 { 53 | prop1: boolean 54 | } 55 | ``` 56 | 57 | # Types 58 | 59 | ## Interface 60 | 61 | An interface definition is surrounded by curly brackets `{` and `}`. It can contain three sort of entries: a list of properties, an index signature, and a mapped index signature. 62 | 63 | A **property** definition is composed with a name and a type. An example of interface with two properties: 64 | 65 | ```ts 66 | { 67 | prop1: boolean 68 | prop2: number 69 | } 70 | ``` 71 | 72 | An example of interface with an **index signature**: 73 | 74 | ```ts 75 | { 76 | [propName: string]: boolean 77 | } 78 | ``` 79 | 80 | An example of interface with a **mapped index signature**: 81 | 82 | ```ts 83 | { 84 | [K in "prop1" | "prop2"]: boolean 85 | } 86 | ``` 87 | 88 | Notice: An index signature can be mixed with properties in accordance with TypeScript rules. But a mapped index signature is incompatible with an index signature or properties. Additionally, a named interface cannot contains a mapped index signature. 89 | 90 | ## Type Name 91 | 92 | Here are the accepted type names: 93 | 94 | * **Primitive type names:** `string`, `number`, `bigint`, `boolean`, `symbol`, `null`, `undefined`; 95 | * **TypeScript type names:** `any`, `unknown`, `object`, `void`, `never`; 96 | * **JavaScript type names:** `String`, `Number`, `Bigint`, `Boolean`, `Symbol`, `Date`; 97 | * **Global type names:** A list of global types can be provided as an option; 98 | * **Local type names:** Names of named types in the same module; 99 | * **Imported type names:** Names of imported named types that are exported from other modules; 100 | * **Generic parameter names:** Names of generic parameters in declared parent scopes. 101 | 102 | ## Literal Type 103 | 104 | A literal type is a literal value in one of the following primitive types: `string`, `number`, `bigint`, `boolean`. An example: 105 | 106 | ```ts 107 | "a" 108 | ``` 109 | 110 | ## Composite Type 111 | 112 | A composite type is a union (`|`) or an intersection (`&`) of types. An example: 113 | 114 | ```ts 115 | "a" | "b" | undefined 116 | ``` 117 | 118 | ## Array Type 119 | 120 | An array type represents an array of items with the same type. Two syntaxes are accepted. 121 | 122 | An example with the short syntax: 123 | 124 | ```ts 125 | string[] 126 | ``` 127 | 128 | The same example with the generic syntax: 129 | 130 | ```ts 131 | Array 132 | ``` 133 | 134 | ## Tuple Type 135 | 136 | A tuple type is a fixed list of types. An example: 137 | 138 | ```ts 139 | [boolean, string, number] 140 | ``` 141 | 142 | ## Keyof Type 143 | 144 | A keyof type represents an union of property names. An example: 145 | 146 | ```ts 147 | keyof T1 148 | ``` 149 | 150 | ## Member Type 151 | 152 | A member type is the type of a property in a parent type. Example: 153 | 154 | ```ts 155 | T1.prop1 156 | ``` 157 | 158 | ## Generic Instance 159 | 160 | A generic instance is a type which is an instance of a generic type. An example: 161 | 162 | ```ts 163 | T1 164 | ``` 165 | 166 | 167 | 168 | -------------------------------------------------------------------------------- /packages/typeonly/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["**/src/**/*.spec.ts", "**/tests/**/*.spec.ts"], 6 | environment: "node", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/validator-cli/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | 4 | node_modules 5 | package-lock.json 6 | .npmrc 7 | 8 | /dist 9 | -------------------------------------------------------------------------------- /packages/validator-cli/.prettierignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /packages/validator-cli/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @typeonly/validator-cli 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 9759c6a: Documentation 8 | - Updated dependencies [9759c6a] 9 | - @typeonly/validator@1.0.1 10 | - typeonly@1.0.1 11 | 12 | ## 1.0.0 13 | 14 | ### Major Changes 15 | 16 | - ESM modules 17 | 18 | ## 0.6.0 19 | 20 | ### Minor Changes 21 | 22 | - upgrade dependencies & syntax 23 | 24 | ### Patch Changes 25 | 26 | - Updated dependencies 27 | - typeonly@1.0.0 28 | - @typeonly/validator@1.0.0 29 | - @typeonly/validator@0.6.0 30 | - typeonly@0.5.0 31 | -------------------------------------------------------------------------------- /packages/validator-cli/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | 117 | -------------------------------------------------------------------------------- /packages/validator-cli/README.md: -------------------------------------------------------------------------------- 1 | # @typeonly/validator-cli 2 | 3 | [![Build Status](https://travis-ci.com/paroi-tech/typeonly.svg?branch=master)](https://travis-ci.com/paroi-tech/typeonly) 4 | [![npm](https://img.shields.io/npm/dm/@typeonly/validator-cli)](https://www.npmjs.com/package/@typeonly/validator-cli) 5 | ![Type definitions](https://img.shields.io/npm/types/@typeonly/validator-cli) 6 | ![GitHub](https://img.shields.io/github/license/paroi-tech/typeonly) 7 | 8 | This package is part of **TypeOnly**, a lightweight validation library that uses TypeScript type definitions to validate JSON data. **[Learn more about TypeOnly here](https://www.npmjs.com/package/typeonly)**. 9 | 10 | ## Command Line Interface of the Validator 11 | 12 | Example: 13 | 14 | ```sh 15 | npx @typeonly/validator-cli -s src/file-name.d.ts -t RootTypeName data.json 16 | ``` 17 | 18 | Available options: 19 | 20 | ``` 21 | -h, --help Print this help message. 22 | -s, --source file.d.ts The typing file (one file allowed). 23 | --source-encoding string Encoding for typing files (default is utf8). 24 | --source-dir directory The source directory that contains typing files (optional). 25 | --rto-module file.rto.json The rto.json file to process (one file allowed). 26 | --rto-dir directory The source directory for rto.json file (optional). 27 | -t, --type string The type name of the root element in JSON. 28 | --non-strict Enable non-strict mode (accept extra properties). 29 | -e, --json-encoding string Encoding for JSON file to validate (default is utf8). 30 | --json file.json The JSON file to validate (by default at last position, one file allowed). 31 | ``` 32 | -------------------------------------------------------------------------------- /packages/validator-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeonly/validator-cli", 3 | "version": "1.0.1", 4 | "description": "A CLI to validate JSON files, using TypeScript typing definitions.", 5 | "author": "Paroi", 6 | "type": "module", 7 | "scripts": { 8 | "prepublishOnly": "npm run build", 9 | "clear": "rimraf dist/*", 10 | "tsc": "tsc", 11 | "tsc:watch": "tsc --watch", 12 | "build": "npm run clear && npm run tsc", 13 | "lint": "biome check . --json-formatter-enabled=false --organize-imports-enabled=false" 14 | }, 15 | "dependencies": { 16 | "@typeonly/validator": "^1.0.1", 17 | "command-line-args": "^6.0.1", 18 | "command-line-usage": "^7.0.3", 19 | "typeonly": "^1.0.1" 20 | }, 21 | "devDependencies": { 22 | "@types/command-line-args": "^5.2.3", 23 | "@types/command-line-usage": "^5.0.4", 24 | "@types/node": "22", 25 | "rimraf": "^6.0.1", 26 | "typescript": "^5.7.3" 27 | }, 28 | "bin": "dist/cli.js", 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/paroi-tech/typeonly.git" 32 | }, 33 | "homepage": "https://github.com/paroi-tech/typeonly/tree/master/packages/validator-cli", 34 | "license": "CC0-1.0", 35 | "keywords": [ 36 | "typescript", 37 | "json", 38 | "validation" 39 | ] 40 | } -------------------------------------------------------------------------------- /packages/validator-cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { readFileSync } from "node:fs"; 3 | import { basename, dirname } from "node:path"; 4 | import { createValidator } from "@typeonly/validator"; 5 | import { type RtoModules, generateRtoModules } from "typeonly"; 6 | 7 | import commandLineArgs from "command-line-args"; 8 | import commandLineUsage from "command-line-usage"; 9 | 10 | process.on("uncaughtException", (err) => { 11 | console.error("uncaughtException", err); 12 | process.exit(1); 13 | }); 14 | 15 | process.on("unhandledRejection", (err) => { 16 | console.trace("unhandledRejection", err); 17 | process.exit(1); 18 | }); 19 | 20 | class InvalidArgumentError extends Error { 21 | readonly causeCode = "invalidArgument"; 22 | } 23 | 24 | type OptionDefinition = commandLineUsage.OptionDefinition & commandLineArgs.OptionDefinition; 25 | 26 | const optionDefinitions: OptionDefinition[] = [ 27 | { 28 | name: "help", 29 | alias: "h", 30 | type: Boolean, 31 | description: "Print this help message.", 32 | }, 33 | { 34 | name: "source", 35 | alias: "s", 36 | description: "The typing file (one file allowed).", 37 | type: String, 38 | multiple: false, 39 | typeLabel: "{underline file.d.ts}", 40 | }, 41 | { 42 | name: "source-encoding", 43 | type: String, 44 | description: "Encoding for typing files (default is {underline utf8}).", 45 | }, 46 | { 47 | name: "source-dir", 48 | type: String, 49 | description: "The source directory that contains typing files (optional).", 50 | typeLabel: "{underline directory}", 51 | }, 52 | { 53 | name: "rto-module", 54 | description: "The rto.json file to process (one file allowed).", 55 | type: String, 56 | multiple: false, 57 | typeLabel: "{underline file.rto.json}", 58 | }, 59 | { 60 | name: "rto-dir", 61 | type: String, 62 | description: "The source directory for rto.json file (optional).", 63 | typeLabel: "{underline directory}", 64 | }, 65 | { 66 | name: "type", 67 | alias: "t", 68 | type: String, 69 | description: "The type name of the root element in JSON.", 70 | }, 71 | { 72 | name: "non-strict", 73 | type: Boolean, 74 | description: "Enable non-strict mode (accept extra properties).", 75 | }, 76 | { 77 | name: "json-encoding", 78 | alias: "e", 79 | type: String, 80 | description: "Encoding for JSON file to validate (default is {underline utf8}).", 81 | }, 82 | { 83 | name: "json", 84 | description: "The JSON file to validate (by default at last position, one file allowed).", 85 | type: String, 86 | multiple: false, 87 | defaultOption: true, 88 | typeLabel: "{underline file.json}", 89 | }, 90 | ]; 91 | 92 | cli().catch((error) => { 93 | console.error(`Error: ${error.message}`); 94 | }); 95 | 96 | async function cli() { 97 | const options = parseOptions(); 98 | if (!options) { 99 | printHelp(); 100 | return; 101 | } 102 | 103 | if (options.help) { 104 | printHelp(); 105 | return; 106 | } 107 | 108 | try { 109 | await processFile(options); 110 | } catch (error: any) { 111 | if (error.causeCode === "invalidArgument") { 112 | console.error(`Error: ${error.message}`); 113 | printHelp(); 114 | } else throw error; 115 | } 116 | } 117 | 118 | function printHelp() { 119 | const sections = [ 120 | { 121 | header: "TypeOnly Validator CLI", 122 | content: "A CLI to validate JSON files conformity with typing.", 123 | }, 124 | { 125 | header: "Synopsis", 126 | content: [ 127 | "$ npx @typeonly/validator-cli {bold -s} {underline src/file-name.d.ts} {bold -t} {underline RootTypeName} {underline dir/data.json}", 128 | "$ npx @typeonly/validator-cli {bold --help}", 129 | ], 130 | }, 131 | { 132 | header: "Options", 133 | optionList: optionDefinitions, 134 | }, 135 | { 136 | content: "Project home: {underline https://github.com/paroi-tech/typeonly}", 137 | }, 138 | ]; 139 | const usage = commandLineUsage(sections); 140 | console.log(usage); 141 | } 142 | 143 | interface OptionsObject { 144 | [name: string]: unknown; 145 | } 146 | 147 | function parseOptions(): OptionsObject | undefined { 148 | try { 149 | return commandLineArgs(optionDefinitions); 150 | } catch (error: any) { 151 | console.log(`Error: ${error.message}`); 152 | printHelp(); 153 | } 154 | } 155 | 156 | async function processFile(options: OptionsObject) { 157 | if (!options.source && !options["rto-module"]) 158 | throw new InvalidArgumentError("Missing typing file or rto.json file."); 159 | if (options.source && options["rto-module"]) 160 | throw new InvalidArgumentError("You must provide a typing file or a rto.json file not both."); 161 | if (!options.json) throw new InvalidArgumentError("Missing input JSON file to validate."); 162 | 163 | if (options.source) await validateFromTypingFile(options); 164 | else await validateFromRtoFile(options); 165 | } 166 | 167 | async function validateFromRtoFile(options: OptionsObject) { 168 | const moduleFile = options["rto-module"] as string; 169 | const bnad = baseNameAndDir(moduleFile); 170 | const baseDir = normalizeDir((options["rto-dir"] as string | undefined) ?? bnad.directory); 171 | const typeName = options.type as string; 172 | const data = readJsonFileSync(options); 173 | 174 | let modulePath = normalizeModulePath(moduleFile, baseDir); 175 | if (modulePath.endsWith(".rto.json")) modulePath = modulePath.slice(0, -9); 176 | 177 | const validator = await createValidator({ 178 | modulePaths: [modulePath], 179 | baseDir, 180 | acceptAdditionalProperties: !!options["non-strict"], 181 | }); 182 | 183 | const result = validator.validate(typeName, data, modulePath); 184 | 185 | if (!result.valid) { 186 | console.error(result.error); 187 | process.exit(1); 188 | } 189 | } 190 | 191 | async function validateFromTypingFile(options: OptionsObject) { 192 | let typingFile = options.source as string; 193 | const bnad = baseNameAndDir(typingFile); 194 | const sourceDir = normalizeDir((options["source-dir"] as string | undefined) ?? bnad.directory); 195 | 196 | if (typingFile.startsWith(sourceDir)) typingFile = typingFile.substring(sourceDir.length + 1); 197 | 198 | const typeName = options.type as string; 199 | 200 | const jsonData = readJsonFileSync(options); 201 | 202 | let sourceModulePath = normalizeModulePath(typingFile, sourceDir); 203 | if (!sourceModulePath.endsWith(".ts")) 204 | throw new InvalidArgumentError("Parameter 'source' must end with '.d.ts' or '.ts'"); 205 | sourceModulePath = sourceModulePath.substring( 206 | 0, 207 | sourceModulePath.length - (sourceModulePath.endsWith(".d.ts") ? 5 : 3), 208 | ); 209 | 210 | const encoding = (options["source-encoding"] ?? undefined) as string | undefined; 211 | validateBufferEncoding(encoding); 212 | 213 | const bundle = (await generateRtoModules({ 214 | modulePaths: [sourceModulePath], 215 | readFiles: { 216 | sourceDir, 217 | encoding, 218 | }, 219 | returnRtoModules: true, 220 | })) as RtoModules; 221 | 222 | const validator = createValidator({ 223 | bundle, 224 | acceptAdditionalProperties: !!options["non-strict"], 225 | }); 226 | 227 | const result = validator.validate(typeName, jsonData, sourceModulePath); 228 | 229 | if (!result.valid) { 230 | console.error(result.error); 231 | process.exit(1); 232 | } 233 | } 234 | 235 | function readJsonFileSync(options: OptionsObject): unknown { 236 | const fileToValidate = options.json as string; 237 | const encoding = (options["json-encoding"] as string | undefined) ?? "utf8"; 238 | validateBufferEncoding(encoding); 239 | try { 240 | const data = readFileSync(fileToValidate, encoding); 241 | return JSON.parse(data); 242 | } catch (err) { 243 | throw new InvalidArgumentError(`Cannot read file: ${fileToValidate}`); 244 | } 245 | } 246 | 247 | function normalizeModulePath(file: string, sourceDir: string): string { 248 | const prefix = `${sourceDir}/`.replace(/\\/g, "/"); 249 | let f = file.replace(/\\/g, "/"); 250 | if (f.startsWith(prefix)) f = `./${f.substr(prefix.length)}`; 251 | else if (!f.startsWith("./") && !f.startsWith("../")) f = `./${f}`; 252 | return f; 253 | } 254 | 255 | function normalizeDir(path: string): string { 256 | return path.replace(/\\/g, "/").replace(/\/+$/, ""); 257 | } 258 | 259 | interface BaseNameAndDir { 260 | directory: string; 261 | fileName: string; 262 | } 263 | 264 | function baseNameAndDir(file: string): BaseNameAndDir { 265 | return { 266 | directory: dirname(file), 267 | fileName: basename(file), 268 | }; 269 | } 270 | 271 | const bufferEncodingValues = new Set([ 272 | "ascii", 273 | "utf8", 274 | "utf-8", 275 | "utf16le", 276 | "ucs2", 277 | "ucs-2", 278 | "base64", 279 | "base64url", 280 | "latin1", 281 | "binary", 282 | "hex", 283 | ]); 284 | function validateBufferEncoding(s: string | undefined): asserts s is BufferEncoding | undefined { 285 | if (!s) return; 286 | if (!bufferEncodingValues.has(s)) throw new Error(`Invalid encoding value '${s}'`); 287 | } 288 | -------------------------------------------------------------------------------- /packages/validator-cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "esModuleInterop": true, 10 | "outDir": "dist", 11 | "noEmitOnError": true, 12 | "declaration": false 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/validator/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | 4 | node_modules 5 | package-lock.json 6 | .npmrc 7 | 8 | /dist 9 | /scripts/declarations 10 | -------------------------------------------------------------------------------- /packages/validator/.npmignore: -------------------------------------------------------------------------------- 1 | /scripts/declarations 2 | -------------------------------------------------------------------------------- /packages/validator/.prettierignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /packages/validator/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @typeonly/validator 2 | 3 | ## 1.0.1 4 | 5 | ### Patch Changes 6 | 7 | - 9759c6a: Documentation 8 | - Updated dependencies [9759c6a] 9 | - @typeonly/loader@1.0.1 10 | 11 | ## 1.0.0 12 | 13 | ### Major Changes 14 | 15 | - ESM modules 16 | 17 | ## 0.6.0 18 | 19 | ### Minor Changes 20 | 21 | - upgrade dependencies & syntax 22 | 23 | ### Patch Changes 24 | 25 | - Updated dependencies 26 | - @typeonly/loader@1.0.0 27 | 28 | ## 0.5.3 29 | 30 | ### Patch Changes 31 | 32 | - Improve validation error message (fix) 33 | - @typeonly/loader@0.6.0 34 | -------------------------------------------------------------------------------- /packages/validator/LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | 117 | -------------------------------------------------------------------------------- /packages/validator/README.md: -------------------------------------------------------------------------------- 1 | # @typeonly/validator 2 | 3 | [![Build Status](https://travis-ci.com/paroi-tech/typeonly.svg?branch=master)](https://travis-ci.com/paroi-tech/typeonly) 4 | [![npm](https://img.shields.io/npm/dm/@typeonly/validator)](https://www.npmjs.com/package/@typeonly/validator) 5 | ![Type definitions](https://img.shields.io/npm/types/@typeonly/validator) 6 | ![GitHub](https://img.shields.io/github/license/paroi-tech/typeonly) 7 | 8 | This package is part of **TypeOnly**, a lightweight validation library that uses TypeScript type definitions to validate JSON data. **[Learn more about TypeOnly here](https://www.npmjs.com/package/typeonly)**. 9 | -------------------------------------------------------------------------------- /packages/validator/bundle-tsd/bundle-tsd.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const bundleName = "validator" 10 | const compiledDir = join(__dirname, "declarations") 11 | const packageDir = join(__dirname, "..") 12 | 13 | try { 14 | writeFileSync(join(packageDir, "dist", `${bundleName}.d.ts`), makeDefinitionsCode()) 15 | } catch (err) { 16 | console.log(err.message, err.stack) 17 | } 18 | 19 | function makeDefinitionsCode() { 20 | const defs = [ 21 | "// -- API Definitions --", 22 | cleanGeneratedCode( 23 | removeLocalImportsExports((readFileSync(join(compiledDir, "api.d.ts"), "utf-8")).trim()), 24 | ), 25 | ] 26 | return defs.join("\n\n") 27 | } 28 | 29 | function removeLocalImportsExports(code) { 30 | const localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/ 31 | return code.split("\n").filter(line => { 32 | return !localImportExport.test(line) 33 | }).join("\n").trim() 34 | } 35 | 36 | function cleanGeneratedCode(code) { 37 | return code.replace(/;/g, "").replace(/ /g, " ") 38 | } 39 | -------------------------------------------------------------------------------- /packages/validator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeonly/validator", 3 | "version": "1.0.1", 4 | "description": "An API to validate JSON data or JavaScript objects, using TypeScript typing definitions.", 5 | "author": "Paroi Team", 6 | "scripts": { 7 | "prepublishOnly": "npm run build && npm run test", 8 | "clear": "rimraf dist/* scripts/declarations/*", 9 | "tsc": "tsc", 10 | "tsc:watch": "tsc --watch", 11 | "bundle-tsd": "node scripts/bundle-tsd", 12 | "build": "npm run clear && npm run tsc && npm run bundle-tsd", 13 | "lint": "biome check . --json-formatter-enabled=false --organize-imports-enabled=false", 14 | "test:watch": "vitest", 15 | "test": "vitest run" 16 | }, 17 | "dependencies": { 18 | "@typeonly/loader": "^1.0.1" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "22", 22 | "rimraf": "^6.0.1", 23 | "typeonly": "^1.0.1", 24 | "typescript": "^5.7.3", 25 | "vitest": "^3.0.5" 26 | }, 27 | "type": "module", 28 | "main": "dist/api.js", 29 | "types": "dist/validator.d.ts", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/paroi-tech/typeonly.git" 33 | }, 34 | "homepage": "https://github.com/paroi-tech/typeonly/tree/master/packages/validator", 35 | "license": "CC0-1.0", 36 | "keywords": [ 37 | "typescript", 38 | "json", 39 | "validation" 40 | ] 41 | } -------------------------------------------------------------------------------- /packages/validator/scripts/bundle-tsd.js: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "node:fs" 2 | import { join } from "node:path" 3 | import { fileURLToPath } from 'node:url'; 4 | import { dirname } from 'node:path'; 5 | 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | const bundleName = "validator" 10 | const compiledDir = join(__dirname, "declarations") 11 | const packageDir = join(__dirname, "..") 12 | 13 | try { 14 | writeFileSync(join(packageDir, "dist", `${bundleName}.d.ts`), makeDefinitionsCode()) 15 | } catch (err) { 16 | console.log(err.message, err.stack) 17 | } 18 | 19 | function makeDefinitionsCode() { 20 | const defs = [ 21 | "// -- API Definitions --", 22 | cleanGeneratedCode( 23 | removeLocalImportsExports((readFileSync(join(compiledDir, "api.d.ts"), "utf-8")).trim()), 24 | ), 25 | ] 26 | return defs.join("\n\n") 27 | } 28 | 29 | function removeLocalImportsExports(code) { 30 | const localImportExport = /^\s*(import|export) .* from "\.\/.*"\s*;?\s*$/ 31 | return code.split("\n").filter(line => { 32 | return !localImportExport.test(line) 33 | }).join("\n").trim() 34 | } 35 | 36 | function cleanGeneratedCode(code) { 37 | return code.replace(/;/g, "").replace(/ /g, " ") 38 | } 39 | -------------------------------------------------------------------------------- /packages/validator/src/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AsyncReadModulesOptions, 3 | type Modules, 4 | type SyncReadModulesOptions, 5 | isSyncReadModulesOptions, 6 | loadModules, 7 | } from "@typeonly/loader"; 8 | import Validator from "./Validator.js"; 9 | 10 | export interface ValidatorOptions { 11 | acceptAdditionalProperties?: boolean; 12 | } 13 | 14 | export interface SyncReadModulesValidatorOptions extends ValidatorOptions, SyncReadModulesOptions {} 15 | 16 | export interface AsyncReadModulesValidatorOptions 17 | extends ValidatorOptions, 18 | AsyncReadModulesOptions {} 19 | 20 | export type CreateValidatorOptions = 21 | | SyncReadModulesValidatorOptions 22 | | AsyncReadModulesValidatorOptions; 23 | 24 | export function createValidator(options: SyncReadModulesValidatorOptions): TypeOnlyValidator; 25 | export function createValidator( 26 | options: AsyncReadModulesValidatorOptions, 27 | ): Promise; 28 | export function createValidator(options: CreateValidatorOptions): any { 29 | if (isSyncReadModulesOptions(options)) return createValidatorSync(options); 30 | return createValidatorAsync(options); 31 | } 32 | 33 | function createValidatorSync(options: SyncReadModulesValidatorOptions): TypeOnlyValidator { 34 | return createValidatorFromModules(loadModules(options), options); 35 | } 36 | 37 | async function createValidatorAsync( 38 | options: AsyncReadModulesValidatorOptions, 39 | ): Promise { 40 | return createValidatorFromModules(await loadModules(options), options); 41 | } 42 | 43 | export function createValidatorFromModules( 44 | modules: Modules, 45 | options?: ValidatorOptions, 46 | ): TypeOnlyValidator { 47 | const validator = new Validator(modules, options); 48 | return { 49 | validate: (typeName: string, val: unknown, moduleName?: string) => { 50 | return validator.validate( 51 | moduleName ?? getDefaultModuleName(modules, typeName), 52 | typeName, 53 | val, 54 | ); 55 | }, 56 | }; 57 | } 58 | 59 | function getDefaultModuleName(modules: Modules, typeName: string): string { 60 | const candidates = []; 61 | for (const moduleName of Object.keys(modules)) { 62 | const module = modules[moduleName]; 63 | if (module.namedTypes[typeName]) candidates.push(moduleName); 64 | } 65 | if (candidates.length === 0) throw new Error(`Cannot find type '${typeName}' in modules.`); 66 | if (candidates.length > 1) 67 | throw new Error( 68 | `There are several module candidates for type '${typeName}': '${candidates.join("', '")}'.`, 69 | ); 70 | return candidates[0]; 71 | } 72 | 73 | export interface TypeOnlyValidator { 74 | validate(typeName: string, val: unknown, moduleName?: string): ValidateResult; 75 | } 76 | 77 | export interface ValidateResult { 78 | valid: boolean; 79 | error?: string; 80 | } 81 | -------------------------------------------------------------------------------- /packages/validator/src/error-message.ts: -------------------------------------------------------------------------------- 1 | import type { Type } from "@typeonly/loader"; 2 | import type { Unmatch } from "./Validator.js"; 3 | 4 | export function makeErrorMessage(unmatchs: Unmatch[]): string { 5 | const messages: string[] = []; 6 | for (const { val, type, cause, parentContextMsg } of unmatchs) { 7 | let message = `${valueAsString(val)} is not conform to ${typeAsString(type)}`; 8 | if (cause) message += `: ${cause}`; 9 | if (parentContextMsg) message = `In ${parentContextMsg}, value ${message}`; 10 | else message = `Value ${message}`; 11 | message += "."; 12 | messages.push(message); 13 | } 14 | messages.reverse(); 15 | return messages.join("\n"); 16 | } 17 | 18 | function valueAsString(val: unknown): string { 19 | if (Array.isArray(val)) return `[array of ${val.length}]`; 20 | switch (typeof val) { 21 | case "string": 22 | case "number": 23 | case "boolean": 24 | case "bigint": 25 | return `'${primitiveValueAsString(val)}'`; 26 | case "object": 27 | if (val === null) return "null"; 28 | return objectAsString(val as object); 29 | case "function": 30 | case "symbol": 31 | case "undefined": 32 | return typeof val; 33 | default: 34 | throw new Error(`Unexpected typeof val: ${typeof val}`); 35 | } 36 | } 37 | 38 | function primitiveValueAsString(val: string | number | bigint | boolean) { 39 | switch (typeof val) { 40 | case "string": 41 | return JSON.stringify(val.length <= 12 ? val : `${val.substring(0, 12)}…`); 42 | case "number": 43 | case "boolean": 44 | return String(val); 45 | case "bigint": 46 | return `${val}n`; 47 | default: 48 | throw new Error(`Unexpected primitive type: ${typeof val}`); 49 | } 50 | } 51 | 52 | function objectAsString(obj: object) { 53 | return `{${Object.entries(obj) 54 | .map(([key, val]) => { 55 | const t = typeof val; 56 | if (t === "string" || t === "number" || t === "bigint" || t === "boolean") 57 | return `${key}: ${primitiveValueAsString(val)}`; 58 | return key; 59 | }) 60 | .join(", ")}}`; 61 | } 62 | 63 | export function typeAsString(type: Type): string { 64 | if ((type as any).name) return (type as any).name; 65 | switch (type.kind) { 66 | case "name": 67 | return type.refName; 68 | case "localRef": 69 | return type.refName; 70 | case "importedRef": 71 | return type.refName; 72 | case "array": 73 | return `${typeAsString(type.itemType)}[]`; 74 | case "tuple": 75 | return `[${type.itemTypes.map(typeAsString).join(", ")}]`; 76 | case "composite": 77 | return type.op; 78 | case "function": 79 | case "genericInstance": 80 | case "interface": 81 | return type.kind; 82 | case "genericParameterName": 83 | return type.genericParameterName; 84 | case "keyof": 85 | return `keyof ${typeAsString(type.type)}`; 86 | case "literal": 87 | return JSON.stringify(type.literal); 88 | case "member": 89 | const propName = 90 | typeof type.memberName !== "string" 91 | ? JSON.stringify(type.memberName.literal) 92 | : type.memberName; 93 | return `${typeAsString(type.parentType)}[${propName}]`; 94 | default: 95 | throw new Error(`Unexpected type: ${(type as Type).kind}`); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/validator/src/helpers.ts: -------------------------------------------------------------------------------- 1 | const primitiveTypeNames = new Set([ 2 | "boolean", 3 | "number", 4 | "bigint", 5 | "string", 6 | "undefined", 7 | "symbol", 8 | ]); 9 | 10 | export function hasAncestor(val: unknown, ancestorName: string): boolean { 11 | let obj: object; 12 | if (primitiveTypeNames.has(typeof val) || val === null) obj = Object(val); 13 | else if (typeof val === "function") { 14 | const name: string = val.name; 15 | if (name === ancestorName) return true; 16 | obj = val.prototype; 17 | } else obj = val as object; 18 | 19 | do { 20 | if (obj.constructor.name === ancestorName) return true; 21 | obj = Object.getPrototypeOf(obj); 22 | } while (obj); 23 | 24 | return false; 25 | } 26 | -------------------------------------------------------------------------------- /packages/validator/tests/interface.spec.ts: -------------------------------------------------------------------------------- 1 | import { createStandaloneRtoModule, parseTypeOnly } from "typeonly"; 2 | import { describe, expect, test } from "vitest"; 3 | import { createValidator } from "../src/api.js"; 4 | 5 | describe("Validate Interface", () => { 6 | test("interface with primitive types", async () => { 7 | const source = ` 8 | export interface A { 9 | a: number, 10 | b: string 11 | } 12 | `; 13 | 14 | const validator = await createValidator({ 15 | modulePaths: ["./mod1"], 16 | rtoModuleProvider: async () => 17 | createStandaloneRtoModule({ 18 | ast: parseTypeOnly({ source }), 19 | }), 20 | }); 21 | 22 | const result = validator.validate( 23 | "A", 24 | { 25 | a: 12, 26 | b: 22, 27 | }, 28 | "./mod1", 29 | ); 30 | 31 | expect(result.valid).toBe(false); 32 | expect(result.error).toBeDefined(); 33 | }); 34 | 35 | // test("interface with array type", async () => { 36 | // const source = ` 37 | // export type A = { 38 | // a: number[], 39 | // b: string 40 | // } 41 | // ` 42 | // const validator = await createValidator({ 43 | // modulePaths: ["./mod1"], 44 | // rtoModuleProvider: async () => createStandaloneRtoModule({ 45 | // ast: parseTypeOnly({ source }) 46 | // }) 47 | // }) 48 | 49 | // const result = validator.validate( 50 | // "A", 51 | // { 52 | // a: [12, "23"], 53 | // b: "ab" 54 | // }, 55 | // "./mod1" 56 | // ) 57 | 58 | // expect(result.valid).toBe(false) 59 | // expect(result.error).toBeDefined() 60 | // }) 61 | 62 | // test("interface with literal type", async () => { 63 | // const source = ` 64 | // export type A = { 65 | // a: "test", 66 | // b: string 67 | // } 68 | // ` 69 | 70 | // const validator = await createValidator({ 71 | // modulePaths: ["./mod1"], 72 | // rtoModuleProvider: async () => createStandaloneRtoModule({ 73 | // ast: parseTypeOnly({ source }) 74 | // }) 75 | // }) 76 | 77 | // const result = validator.validate( 78 | // "A", 79 | // { 80 | // a: "test1", 81 | // b: "ab" 82 | // }, 83 | // "./mod1" 84 | // ) 85 | 86 | // expect(result.valid).toBe(false) 87 | // expect(result.error).toBeDefined() 88 | // }) 89 | 90 | // test("interface with tuple", async () => { 91 | // const source = ` 92 | // export interface A { 93 | // a: [string, number], 94 | // b: string 95 | // } 96 | // ` 97 | // const validator = await createValidator({ 98 | // modulePaths: ["./mod1"], 99 | // rtoModuleProvider: async () => createStandaloneRtoModule({ 100 | // ast: parseTypeOnly({ source }) 101 | // }) 102 | // }) 103 | 104 | // const result = validator.validate( 105 | // "A", 106 | // { 107 | // a: ["sd", "23"], 108 | // b: "ab" 109 | // }, 110 | // "./mod1" 111 | // ) 112 | 113 | // expect(result.valid).toBe(false) 114 | // expect(result.error).toBeDefined() 115 | // }) 116 | 117 | // test("validate interface with depth", async () => { 118 | // const source = ` 119 | // export type A = { 120 | // a: { 121 | // c: { d: boolean }[] 122 | // }, 123 | // b: string 124 | // } 125 | // ` 126 | // const validator = await createValidator({ 127 | // modulePaths: ["./mod1"], 128 | // rtoModuleProvider: async () => createStandaloneRtoModule({ 129 | // ast: parseTypeOnly({ source }) 130 | // }) 131 | // }) 132 | 133 | // const result = validator.validate( 134 | // "A", 135 | // { 136 | // a: { 137 | // c: [ 138 | // { 139 | // d: false 140 | // } 141 | // ] 142 | // }, 143 | // b: "ab" 144 | // }, 145 | // "./mod1" 146 | // ) 147 | 148 | // expect(result.valid).toBe(true) 149 | // expect(result.error).toBeUndefined() 150 | // }) 151 | }); 152 | -------------------------------------------------------------------------------- /packages/validator/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "noEmit": true 10 | }, 11 | "include": ["."] 12 | } 13 | -------------------------------------------------------------------------------- /packages/validator/tests/types.spec.ts: -------------------------------------------------------------------------------- 1 | import { loadModules } from "@typeonly/loader"; 2 | import { createStandaloneRtoModule, parseTypeOnly } from "typeonly"; 3 | import { describe, expect, test } from "vitest"; 4 | import { createValidator, createValidatorFromModules } from "../src/api.js"; 5 | 6 | describe("Validate Types", () => { 7 | test("TypeName", async () => { 8 | const source = ` 9 | export type A = B 10 | type B = number 11 | `; 12 | const modules = await loadModules({ 13 | modulePaths: ["./mod1"], 14 | rtoModuleProvider: async () => 15 | createStandaloneRtoModule({ 16 | ast: parseTypeOnly({ source }), 17 | }), 18 | }); 19 | const validator = createValidatorFromModules(modules); 20 | 21 | const response = validator.validate("A", "12", "./mod1"); 22 | 23 | expect(response.valid).toBe(false); 24 | expect(response.error).not.toBeUndefined(); 25 | }); 26 | 27 | test("ArrayType", async () => { 28 | const source = ` 29 | export type A = number[] 30 | `; 31 | const validator = await createValidator({ 32 | modulePaths: ["./mod1"], 33 | rtoModuleProvider: async () => 34 | createStandaloneRtoModule({ 35 | ast: parseTypeOnly({ source }), 36 | }), 37 | }); 38 | 39 | const result = validator.validate("A", [12, "90"], "./mod1"); 40 | 41 | expect(result.valid).toBe(false); 42 | expect(result.error).toBeDefined(); 43 | }); 44 | 45 | test("TupleType", async () => { 46 | const source = ` 47 | export type A = [number, string] 48 | `; 49 | const validator = await createValidator({ 50 | modulePaths: ["./mod1"], 51 | rtoModuleProvider: async () => 52 | createStandaloneRtoModule({ 53 | ast: parseTypeOnly({ source }), 54 | }), 55 | }); 56 | 57 | const result = validator.validate("A", [12, "90", 23], "./mod1"); 58 | 59 | expect(result.valid).toBe(false); 60 | expect(result.error).toBeDefined(); 61 | }); 62 | 63 | test("KeyofType", async () => { 64 | const source = ` 65 | export type A = keyof B 66 | interface B { 67 | [A: number]: boolean 68 | a: string 69 | } 70 | `; 71 | 72 | const validator = await createValidator({ 73 | modulePaths: ["./mod1"], 74 | rtoModuleProvider: async () => 75 | createStandaloneRtoModule({ 76 | ast: parseTypeOnly({ source }), 77 | }), 78 | }); 79 | 80 | const result = validator.validate("A", 12, "./mod1"); 81 | 82 | expect(result.valid).toBe(true); 83 | expect(result.error).toBeUndefined(); 84 | }); 85 | 86 | test("MemberType", async () => { 87 | const source = ` 88 | export type A = B["a"] 89 | interface B { 90 | a: string 91 | } 92 | `; 93 | 94 | const validator = await createValidator({ 95 | modulePaths: ["./mod1"], 96 | rtoModuleProvider: async () => 97 | createStandaloneRtoModule({ 98 | ast: parseTypeOnly({ source }), 99 | }), 100 | }); 101 | 102 | const result = validator.validate("A", "sdds", "./mod1"); 103 | 104 | expect(result.valid).toBe(true); 105 | expect(result.error).toBeUndefined(); 106 | }); 107 | 108 | test("FunctionType", async () => { 109 | const source = ` 110 | export type A = () => number 111 | `; 112 | 113 | const validator = await createValidator({ 114 | modulePaths: ["./mod1"], 115 | rtoModuleProvider: async () => 116 | createStandaloneRtoModule({ 117 | ast: parseTypeOnly({ source }), 118 | }), 119 | }); 120 | 121 | const result = validator.validate("A", () => "12", "./mod1"); 122 | 123 | expect(result.valid).toBe(true); 124 | expect(result.error).toBeUndefined(); 125 | }); 126 | 127 | test("CompositeType", async () => { 128 | const source = ` 129 | export type A = "12" | 12 130 | `; 131 | 132 | const validator = await createValidator({ 133 | modulePaths: ["./mod1"], 134 | rtoModuleProvider: async () => 135 | createStandaloneRtoModule({ 136 | ast: parseTypeOnly({ source }), 137 | }), 138 | }); 139 | 140 | const result = validator.validate("A", 12, "./mod1"); 141 | 142 | expect(result.valid).toBe(true); 143 | expect(result.error).toBeUndefined(); 144 | }); 145 | 146 | test("CompositeType with null type", async () => { 147 | const source = ` 148 | export type A = string | null 149 | `; 150 | 151 | const validator = await createValidator({ 152 | modulePaths: ["./mod1"], 153 | rtoModuleProvider: async () => 154 | createStandaloneRtoModule({ 155 | ast: parseTypeOnly({ source }), 156 | }), 157 | }); 158 | 159 | const result = validator.validate("A", null, "./mod1"); 160 | expect(result.valid).toBe(true); 161 | expect(result.error).toBeUndefined(); 162 | }); 163 | 164 | test("LiteralType", async () => { 165 | const source = ` 166 | export type A = "ff" 167 | `; 168 | 169 | const validator = await createValidator({ 170 | modulePaths: ["./mod1"], 171 | rtoModuleProvider: async () => 172 | createStandaloneRtoModule({ 173 | ast: parseTypeOnly({ source }), 174 | }), 175 | }); 176 | 177 | const result = validator.validate("A", "ff", "./mod1"); 178 | 179 | expect(result.valid).toBe(true); 180 | expect(result.error).toBeUndefined(); 181 | }); 182 | 183 | test("LocalTypeRef", async () => { 184 | const source = ` 185 | export type A = B 186 | type B = number 187 | `; 188 | 189 | const validator = await createValidator({ 190 | modulePaths: ["./mod1"], 191 | rtoModuleProvider: async () => 192 | createStandaloneRtoModule({ 193 | ast: parseTypeOnly({ source }), 194 | }), 195 | }); 196 | 197 | const result = validator.validate("A", 12, "./mod1"); 198 | 199 | expect(result.valid).toBe(true); 200 | expect(result.error).toBeUndefined(); 201 | }); 202 | 203 | test("Use default module name", async () => { 204 | const source = ` 205 | export type A = B 206 | type B = number 207 | `; 208 | const modules = await loadModules({ 209 | modulePaths: ["./mod1"], 210 | rtoModuleProvider: async () => 211 | createStandaloneRtoModule({ 212 | ast: parseTypeOnly({ source }), 213 | }), 214 | }); 215 | const validator = createValidatorFromModules(modules); 216 | 217 | const response = validator.validate("A", "12"); 218 | 219 | expect(response.valid).toBe(false); 220 | expect(response.error).not.toBeUndefined(); 221 | }); 222 | 223 | // test("GenericInstance", async () => { 224 | // const source = ` 225 | // export type A = B 226 | // type B = {a: number} 227 | // ` 228 | 229 | // // type A = B 230 | // // type B = G 231 | // // type G = Array 232 | 233 | // const validator = await createValidator({ 234 | // modulePaths: ["./mod1"], 235 | // rtoModuleProvider: async () => createStandaloneRtoModule({ 236 | // ast: parseTypeOnly({ source }) 237 | // }) 238 | // }) 239 | 240 | // const result = validator.validate("A", 12, "./mod1") 241 | 242 | // expect(result.valid).toBe(true) 243 | // expect(result.error).toBeUndefined() 244 | // }) 245 | }); 246 | -------------------------------------------------------------------------------- /packages/validator/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "sourceMap": false, 8 | "lib": ["esnext"], 9 | "outDir": "dist", 10 | "noEmitOnError": true, 11 | "declaration": true, 12 | "declarationDir": "scripts/declarations" 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/validator/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["**/src/**/*.spec.ts", "**/tests/**/*.spec.ts"], 6 | environment: "node", 7 | }, 8 | }); 9 | --------------------------------------------------------------------------------