├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── mutation-testing.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── README.md ├── bin └── type-annotationify.js ├── convert-parameter-properties.gif ├── package-lock.json ├── package.json ├── src ├── cli.spec.ts ├── cli.ts ├── main.ts ├── transform-changes-report.spec.ts ├── transform-changes-report.ts ├── transform.spec.ts ├── transform.ts └── transformers │ ├── constructor-parameters.spec.ts │ ├── constructor-parameters.ts │ ├── enums.spec.ts │ ├── enums.ts │ ├── import-extensions.spec.ts │ ├── import-extensions.ts │ ├── namespaces.spec.ts │ ├── namespaces.ts │ └── transformer-result.ts ├── stryker.config.json ├── tsconfig.base.json ├── tsconfig.json ├── tsconfig.prod.json └── tsconfig.test.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.ts,*.js,*jsx,*tsx,*.json,,*.jsonc,*.code-workspace}] 2 | insert_final_newline = true 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: ~ 5 | push: 6 | 7 | jobs: 8 | build_and_test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 22 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - name: 📦 Install dependencies 17 | run: npm ci --engine-strict 18 | - name: 🤖 Build & lint & test 19 | run: npm run all 20 | - name: 📩 download incremental report 21 | run: npm run test:download-report 22 | - name: 👽 Mutation testing 23 | run: npm run test:mutation -- --incremental 24 | env: 25 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 26 | -------------------------------------------------------------------------------- /.github/workflows/mutation-testing.yml: -------------------------------------------------------------------------------- 1 | name: Mutation testing 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | mutation_testing: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 22 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - name: 📦 Install dependencies 17 | run: npm ci --engine-strict 18 | - name: 👽 Mutation testing 19 | run: npm run test:mutation 20 | env: 21 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | *.tsbuildinfo 4 | # stryker temp files 5 | .stryker-tmp 6 | /reports 7 | .parcel-cache -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": ["/**"], 12 | "type": "node" 13 | }, 14 | { 15 | "name": "🧪 Test", 16 | "runtimeArgs": [ 17 | "--test", 18 | "--experimental-test-isolation=none", 19 | "--experimental-strip-types", 20 | "--test-reporter=spec" 21 | ], 22 | "args": ["src/**/*.spec.ts"], 23 | "request": "launch", 24 | "skipFiles": ["/**"], 25 | "type": "node", 26 | "outputCapture": "std" 27 | }, 28 | 29 | { 30 | "type": "node", 31 | "request": "launch", 32 | "name": "Launch Program", 33 | "skipFiles": ["/**"], 34 | "runtimeArgs": ["--experimental-strip-types"], 35 | "program": "src/main.ts" 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "cSpell.language": "en", 4 | "cSpell.words": [ 5 | "annotationification", 6 | "annotationifies", 7 | "Annotationify", 8 | "clazz" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.1.1](https://github.com/nicojs/type-annotationify/compare/v0.1.0...v0.1.1) (2025-02-18) 2 | 3 | 4 | ### Features 5 | 6 | * **cli:** add `--explicit-property-types` option for explicit type annotations ([#52](https://github.com/nicojs/type-annotationify/issues/52)) ([fd46be6](https://github.com/nicojs/type-annotationify/commit/fd46be6ade60dafc80cc69a3cb5081bf27fc7224)) 7 | 8 | 9 | 10 | # 0.1.0 (2025-02-15) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * add bin ([97246ef](https://github.com/nicojs/type-annotationify/commit/97246ef298160e08440e7360209868cc9264399e)) 16 | * also support nested classes ([44041f3](https://github.com/nicojs/type-annotationify/commit/44041f38624f21ff008fc064a383f6d2b572f063)) 17 | * **enum:** add namespace declaration ([#14](https://github.com/nicojs/type-annotationify/issues/14)) ([cd25268](https://github.com/nicojs/type-annotationify/commit/cd252686393066b30137e81eb80d2706c496677b)) 18 | 19 | 20 | ### Features 21 | 22 | * **dry:** add `--dry` option ([#47](https://github.com/nicojs/type-annotationify/issues/47)) ([1e8607e](https://github.com/nicojs/type-annotationify/commit/1e8607e16e0d7692fb50e5263c89ce1940f98137)) 23 | * **enum:** add support for string enums ([#22](https://github.com/nicojs/type-annotationify/issues/22)) ([0d7b51f](https://github.com/nicojs/type-annotationify/commit/0d7b51fd4f11245488ba8ad1fd6b1ec4b7a35525)) 24 | * **enum:** simplify enum transformation ([#40](https://github.com/nicojs/type-annotationify/issues/40)) ([812f75b](https://github.com/nicojs/type-annotationify/commit/812f75b459bb4d4f80b22e96f1fd9a950bf41242)) 25 | * **enum:** support computed property names ([#16](https://github.com/nicojs/type-annotationify/issues/16)) ([98d326b](https://github.com/nicojs/type-annotationify/commit/98d326b6ea751a5768608b43869b37788bd05f64)) 26 | * **enum:** support const enum ([#24](https://github.com/nicojs/type-annotationify/issues/24)) ([2ebe014](https://github.com/nicojs/type-annotationify/commit/2ebe0145d0e549e3fb7dac5f997b3b552fd4dc5e)) 27 | * **enum:** support number initializers ([#19](https://github.com/nicojs/type-annotationify/issues/19)) ([ab245c8](https://github.com/nicojs/type-annotationify/commit/ab245c8d64385c5f866653c21caf1d006d6030a8)) 28 | * **help:** add help ([#45](https://github.com/nicojs/type-annotationify/issues/45)) ([c7b7a3d](https://github.com/nicojs/type-annotationify/commit/c7b7a3d2f49dae57ff9b2e0573003c0a2115dfd3)) 29 | * **import:** support rewriting relative import extensions ([#43](https://github.com/nicojs/type-annotationify/issues/43)) ([df4de4b](https://github.com/nicojs/type-annotationify/commit/df4de4b817dcd9a1638859588688bc16525d689e)) 30 | * **namespaces:** support namespaces ([#34](https://github.com/nicojs/type-annotationify/issues/34)) ([c77383c](https://github.com/nicojs/type-annotationify/commit/c77383cece852d010163283fb3d9beeb972569bc)) 31 | * **options:** add --no-enum-namespace-declaration option ([#42](https://github.com/nicojs/type-annotationify/issues/42)) ([533c28b](https://github.com/nicojs/type-annotationify/commit/533c28bce9be57d9b982fd385a5ea256a0b8b563)) 32 | * **super:** support `super()` calls with parameter properties ([#6](https://github.com/nicojs/type-annotationify/issues/6)) ([230d345](https://github.com/nicojs/type-annotationify/commit/230d3459dcde9a4bf0783c0200ac14a387814bc4)) 33 | * **transform:** introduce a report of what changed ([#46](https://github.com/nicojs/type-annotationify/issues/46)) ([8221ad6](https://github.com/nicojs/type-annotationify/commit/8221ad65dbf381ddaadc6eb444c30be22d3d7482)) 34 | * **type assertions:** support type assertions ([#25](https://github.com/nicojs/type-annotationify/issues/25)) ([192daa6](https://github.com/nicojs/type-annotationify/commit/192daa68bb23d6521149b992453ab1df8db974a4)) 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fnicojs%2Ftype-annotationify%2Fmain)](https://dashboard.stryker-mutator.io/reports/github.com/nicojs/type-annotationify/main) 2 | 3 | # Type Annotationify 4 | 5 | This is a simple tool to migrate full-fledged TypeScript code to type-annotated TypeScript code that is compatible with the [type annotation proposal](https://github.com/tc39/proposal-type-annotations) as well as NodeJS's[--experimental-strip-types](https://nodejs.org/en/blog/release/v22.6.0#experimental-typescript-support-via-strip-types) mode. 6 | 7 | Live demo: [nicojs.github.io/type-annotationify/](https://nicojs.github.io/type-annotationify/) 8 | 9 | ![Example of class parameter properties transformation](https://github.com/nicojs/type-annotationify/blob/main/convert-parameter-properties.gif) 10 | 11 | > [!NOTE] 12 | > See [running typescript natively on the NodeJS docs page](https://nodejs.org/en/learn/typescript/run-natively) for more info on `--experimental-strip-types`. 13 | 14 | ## Status 15 | 16 | | Syntax | Status | Notes | 17 | | ---------------------------------------- | ------ | ------------------------------------------ | 18 | | Parameter Properties | ✅ | | 19 | | Parameter Properties with `super()` call | ✅ | | 20 | | Plain Enum | ✅ | | 21 | | Number Enum | ✅ | | 22 | | String Enum | ✅ | | 23 | | Const Enum | ✅ | | 24 | | Type assertion expressions | ✅ | I.e. `value` --> `value as string` | 25 | | Namespaces | ✅ | With some limitations | 26 | | Rewrite relative import extensions | ✅ | with `--relative-import-extensions` | 27 | 28 | ## Installation 29 | 30 | ```bash 31 | npm install -g type-annotationify@latest 32 | # OR simply run directly with 33 | npx type-annotationify@latest 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```bash 39 | type-annotationify [options] 40 | ``` 41 | 42 | The default pattern is `**/!(*.d).?(m|c)ts?(x)`, excluding 'node_modules'. 43 | In other words, by default all TypeScript files are matched (also in subdirectories) except declaration files (d.ts). 44 | 45 | This will convert all the TypeScript files that match the pattern to type-annotated TypeScript files _in place_. So be sure to commit your code before running this tool. 46 | 47 | > [!TIP] 48 | > Running `type-annotationify` will rewrite your TypeScript files without taking your formatting into account. It is recommended to run `prettier` or another formatter after running `type-annotationify`. If you use manual formatting, it might be faster to do the work yourself 49 | 50 | ## Options 51 | 52 | ### `--dry` 53 | 54 | Don't write the changes to disk, but print changes that would have been made to the console. 55 | 56 | ### `--explicit-property-types` 57 | 58 | Add type annotations to properties. See [Parameter Properties](#parameter-properties) for more info. 59 | 60 | ### `--help` 61 | 62 | Print the help message and exit. 63 | 64 | ### `--no-enum-namespace-declaration` 65 | 66 | Disable the `declare namespace` output for enums. For example: 67 | 68 | ```ts 69 | // ❌ Disable this output for enums 70 | declare namespace Message { 71 | type Start = typeof Message.Start; 72 | type Stop = typeof Message.Stop; 73 | } 74 | ``` 75 | 76 | This makes it so you can't use enum values (i.e. `Message.Start`) as a type, but means a far cleaner output in general. This might result in compile errors, which are pretty easy to fix yourself: 77 | 78 | ```diff 79 | - let message: Message.Start; 80 | + let message: typeof Message.Start; 81 | ``` 82 | 83 | ### `--relative-import-extensions` 84 | 85 | Rewrite relative file extensions in import specifiers to `.ts`, `.cts` or `.mts`. See [Relative import extensions](#relative-import-extensions) for more info. 86 | 87 | ## Transformations 88 | 89 | ### Parameter Properties 90 | 91 | Input: 92 | 93 | ```ts 94 | class Foo { 95 | constructor( 96 | public bar: string, 97 | readonly baz: boolean, 98 | protected qux = 42, 99 | ) {} 100 | } 101 | ``` 102 | 103 | Type-annotationifies as: 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 145 | 146 | 147 |
Default--explicit-property-types
115 | 116 | ```ts 117 | class Foo { 118 | public bar; 119 | readonly baz; 120 | protected qux; 121 | constructor(bar: string, baz: boolean, qux = 42) { 122 | this.bar = bar; 123 | this.baz = baz; 124 | this.qux = qux; 125 | } 126 | } 127 | ``` 128 | 129 | 130 | 131 | ```ts 132 | class Foo { 133 | public bar: string; 134 | readonly baz: boolean; 135 | protected qux; 136 | constructor(bar: string, baz: boolean, qux = 42) { 137 | this.bar = bar; 138 | this.baz = baz; 139 | this.qux = qux; 140 | } 141 | } 142 | ``` 143 | 144 |
148 | 149 | When a `super()` call is present, the assignments in the constructor are moved to below the `super()` call (like in normal TypeScript transpilation). 150 | 151 | The property type annotations are left out by default, as the TypeScript compiler infers them from the constructor assignments. This is better for code maintainability (every type is listed once instead of twice), but does come with some limitations. However, if you want to be explicit, you can enable the `--explicit-property-types` option. 152 | 153 | #### Parameter property transformation limitations 154 | 155 | 1. It assumes `noImplicitAny` is enabled. Without it, the inference from the assignments in the constructor doesn't work. You can opt-out of this by enabling the `--explicit-property-types` option. 156 | 2. When you use the property as an assertion function you will get an error. For example: 157 | ```ts 158 | interface Options { 159 | Foo: string; 160 | } 161 | type OptionsValidator = (o: unknown) => asserts o is Options; 162 | class ConfigReader { 163 | private readonly validator; 164 | constructor(validator: OptionsValidator) { 165 | this.validator = validator; 166 | } 167 | public doValidate(options: unknown): Options { 168 | this.validator(options); 169 | // ^^^^^^^^^ 💥 Assertions require every name in the call target to be declared with an explicit type annotation.(2775) 170 | return options; 171 | } 172 | } 173 | ``` 174 | The solution is to add the type annotation to the property manually. 175 | ```diff 176 | - private readonly validator; 177 | + private readonly validator: OptionsValidator; 178 | ``` 179 | Or enable the `--explicit-property-types` option. 180 | 181 | ### Enum transformations 182 | 183 | An enum transforms to 3 components. The goal is to get as close to a drop-in replacement as possible, _without transforming the consuming side of enums_. 184 | 185 | Input: 186 | 187 | ```ts 188 | enum Message { 189 | Start, 190 | Stop, 191 | } 192 | ``` 193 | 194 | > [!NOTE] 195 | > String enums are also supported. 196 | 197 | Type-annotationifies as: 198 | 199 | ```ts 200 | const Message = { 201 | 0: 'Start', 202 | 1: 'Stop', 203 | Start: 0, 204 | Stop: 1, 205 | } as const; 206 | type Message = (typeof Message)[keyof typeof Message & string]; 207 | declare namespace Message { 208 | type Start = typeof Message.Start; 209 | type Stop = typeof Message.Stop; 210 | } 211 | ``` 212 | 213 | That's a mouthful. Let's break down each part. 214 | 215 | - The object literal 216 | ```ts 217 | const Message = { 218 | 0: 'Start', 219 | 1: 'Stop', 220 | Start: 0, 221 | Stop: 1, 222 | } as const; 223 | ``` 224 | This allows you to use `Message` as a value: `let message = Message.Start`. This is the actual JS footprint of the enum. The `as const` assertion, but makes sure we can use `typeof Message.Start`. 225 | - `type Message = (typeof Message)[keyof typeof Message & string];` \ 226 | This allows you to use `Message` as a type: `let message: Message`. Let's break it down further: 227 | - `typeof Message` means the object shape `{0: 'Start', 1: 'Stop', Start: 0, Stop: 1 }` 228 | - `keyof typeof Message` means the keys of that object: `0 | 1 | 'Start' | 'Stop'` 229 | - `& string` filters out the keys that are also strings: `'Start' | 'Stop'` 230 | - `(typeof Message)[keyof typeof Message & string]` means the type of the values of the object with the keys `'Start' | 'Stop'`, so only values `0 | 1`. This was the backing value of the original enum. 231 | - The `declare namespace` 232 | ```ts 233 | declare namespace Message { 234 | type Start = typeof Message.Start; 235 | type Stop = typeof Message.Stop; 236 | } 237 | ``` 238 | This allows you to use `Message.Start` as a type: `let message: Message.Start`. This can be disabled with the `--no-enum-namespace-declaration` option. 239 | 240 | #### Enum transformation limitations 241 | 242 | 1. Type inference of enum values are more narrow after the transformation. 243 | ```ts 244 | const bottle = { 245 | message: Message.Start, 246 | }; 247 | bottle.message = Message.Stop; 248 | // ^^^^^^^ 💥 Type '1' is not assignable to type '0'.(2322) 249 | ``` 250 | [Playground link](https://www.typescriptlang.org/play/?#code/C4TwDgpgBAshDO8CGBzaBeKAGKAfKAjANwCwAUKJLAsmgNIQjxSYDkAysEgE7Ct5QOwAPZhWpMgGNhAO3jBqiVBigBvclGwAuQZx58ANBsI6ho1kbKa9vHVkvWRYHQUsBfKMmABLeADNvBCgAJQhpbgATAB44JTQDRVoIBiYAPigAMhCw4UiYmmUU+ATYpNSJCLCAGx5oGSQAWwQwJEloUuU1Y0poGwVMHuE-ROUAOj6JTR6oTlEWKEHhjrRxpwk3cmk5BQAjYWBgKpV1KygmuIgdZYhV-XI3CT2Do9HzpPnr1dEiIA) \ 251 | In this example, the type of `bottle.message` is inferred as `0` instead of `Message`. This can be solved with a type annotation. 252 | ```diff 253 | - const bottle = { 254 | + const bottle: { message: Message } = { 255 | ``` 256 | 1. A const enum is transformed to a regular enum. This is because the caller-side of a `const enum` will assume that there is an actual value after type-stripping. 257 | 258 | ### Type assertion expressions 259 | 260 | Input: 261 | 262 | ```ts 263 | const value = JSON.parse('"test"'); 264 | ``` 265 | 266 | Type-annotationifies as: 267 | 268 | ```ts 269 | const value = JSON.parse('"test"') as string; 270 | ``` 271 | 272 | ### Namespaces 273 | 274 | Namespace transformation is a bit more complex. The goal is to keep the namespace as close to the original as possible, while still using erasable types only. It unfortunately _needs_ a couple of `@ts-ignore` comments to make it work. For more info and reasoning, see [#26](https://github.com/nicojs/type-annotationify/issues/26). 275 | 276 | Input: 277 | 278 | ```ts 279 | namespace Geometry { 280 | console.log('Foo is defined'); 281 | export const pi = 3.141527; 282 | export function areaOfCircle(radius: number) { 283 | return pi * radius ** 2; 284 | } 285 | } 286 | ``` 287 | 288 | Type-annotationifies as: 289 | 290 | ```ts 291 | // @ts-ignore Migrated namespace with type-annotationify 292 | declare namespace Geometry { 293 | const pi = 3.141527; 294 | function areaOfCircle(radius: number): number; 295 | } 296 | // @ts-ignore Migrated namespace with type-annotationify 297 | var Geometry: Geometry; 298 | { 299 | // @ts-ignore Migrated namespace with type-annotationify 300 | Geometry ??= {}; 301 | console.log('Foo is defined'); 302 | // @ts-ignore Migrated namespace with type-annotationify 303 | Geometry.pi = 3.141527; 304 | function areaOfCircle(radius: number) { 305 | return Geometry.pi * radius ** 2; 306 | } 307 | Geometry.areaOfCircle = areaOfCircle; 308 | } 309 | ``` 310 | 311 | #### Namespace transformation limitations 312 | 313 | 1. Nested namespaces are not supported yet. Please open an issue if you want support for this. 314 | 1. Referring to identifiers with their local name across namespaces declarations with the same name is not supported. For example: 315 | ```ts 316 | namespace Geometry { 317 | export const pi = 3.141527; 318 | } 319 | namespace Geometry { 320 | export function areaOfCircle(radius: number) { 321 | return pi * radius ** 2; 322 | } 323 | } 324 | ``` 325 | This will result in an error because `pi` is not defined in the second namespace. The solution is to refer to `pi` as `Geometry.pi`: 326 | ```diff 327 | - return pi * radius ** 2; 328 | + return Geometry.pi * radius ** 2; 329 | ``` 330 | 1. The `@ts-ignore` comments are necessary to make the namespace work. This is because there are a bunch of illegal TypeScript constructs needed, like declaring a namespace and a variable with the same name. This also means that _TypeScript_ is turned off entirely for these statements. 331 | 332 | ### Relative import extensions 333 | 334 | You can let type-annotationify rewrite relative import extensions from `.js`, `.cjs` or `.mjs` to `.ts`, `.cts` or `.mts` respectively. Since this isn't strictly 'type-annotationification', you'll need to enable this using the `--relative-import-extensions` flag. 335 | 336 | Input 337 | 338 | ```ts 339 | import { foo } from './foo.js'; 340 | ``` 341 | 342 | Type-annotationifies as: 343 | 344 | ```ts 345 | import { foo } from './foo.ts'; 346 | ``` 347 | 348 | This is useful when you want to use the `--experimental-strip-types` flag in NodeJS to run your TS code directly, where in the past you needed to transpile it first. 349 | 350 | > [!TIP] 351 | > After you've rewritten your imports, you should not forget to enable `allowImportingTsExtensions` in your tsconfig. If you still want to transpile your code to `.js` with `tsc`, you will also should enable `rewriteRelativeImportExtensions` in your tsconfig. 352 | 353 | ## FAQ 354 | 355 | ### Why would I want to use this tool? 356 | 357 | 1. You want to be aligned with the upcoming [type annotation proposal](https://github.com/tc39/proposal-type-annotations). 358 | 2. You want to use NodeJS's [--experimental-strip-types](https://nodejs.org/en/blog/release/v22.6.0#experimental-typescript-support-via-strip-types) mode. 359 | 3. You want to use TypeScript [`--erasableSyntaxOnly`](https://devblogs.microsoft.com/typescript/announcing-typescript-5-8-beta/#the---erasablesyntaxonly-option) option. 360 | 361 | ### How does this tool work? 362 | 363 | This tool uses the TypeScript compiler API to parse the TypeScript code and then rewrite it with type annotations. 364 | 365 | ### Why do I get `ExperimentalWarning` errors? 366 | 367 | This tool uses plain NodeJS as much as possible. It doesn't rely on [`glob`](https://www.npmjs.com/package/glob) or other libraries to reduce the download size and maintenance (the only dependency is TypeScript itself). That's also why the minimal version of node is set to 22. 368 | -------------------------------------------------------------------------------- /bin/type-annotationify.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { runTypeAnnotationifyCli } from '../dist/cli.js'; 3 | await runTypeAnnotationifyCli(process.argv.slice(2)); 4 | -------------------------------------------------------------------------------- /convert-parameter-properties.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicojs/type-annotationify/41692e8505700e7c135edfa3f21ec21c7c92df02/convert-parameter-properties.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "type-annotationify", 3 | "version": "0.1.1", 4 | "description": "Migrate full-fledged TypeScript code to type-annotated TypeScript code that is compatible with the type annotation proposal as well as NodeJS's --experimental-strip-types", 5 | "license": "Apache-2.0", 6 | "author": "nicojs", 7 | "type": "module", 8 | "main": "dist/main.js", 9 | "bin": { 10 | "type-annotationify": "bin/type-annotationify.js" 11 | }, 12 | "keywords": [ 13 | "typescript", 14 | "type-annotations", 15 | "strip-types", 16 | "transform", 17 | "experimental-strip-types" 18 | ], 19 | "files": [ 20 | "dist", 21 | "!dist/**/*.spec.js?(.map)" 22 | ], 23 | "engines": { 24 | "node": ">=22" 25 | }, 26 | "scripts": { 27 | "clean": "rm -rf dist *.tsbuildinfo", 28 | "all": "npm run format && npm run build && npm run test", 29 | "format:fix": "npm run format -- --write", 30 | "format": "prettier --check src/**/*.ts README.md", 31 | "build": "tsc -b", 32 | "test": "node --test --experimental-strip-types --test-reporter=spec \"src/**/*.spec.ts\"", 33 | "test:download-report": "curl -s --create-dirs -o reports/stryker-incremental.json https://dashboard.stryker-mutator.io/api/reports/github.com/nicojs/type-annotationify/main", 34 | "test:mutation": "stryker run", 35 | "version": "npm run release:generate-changelog && git add CHANGELOG.md", 36 | "postversion": "git push && git push --tags", 37 | "release:generate-changelog": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md", 38 | "release:patch": "npm version patch -m \"chore(release): %s\"", 39 | "release:minor": "npm version minor -m \"chore(release): %s\"", 40 | "release:major": "npm version major -m \"chore(release): %s\"" 41 | }, 42 | "dependencies": { 43 | "typescript": "^5.7.3" 44 | }, 45 | "devDependencies": { 46 | "@stryker-mutator/tap-runner": "^8.7.1", 47 | "@stryker-mutator/typescript-checker": "^8.7.1", 48 | "@types/node": "^22.10.5", 49 | "@types/sinon": "^17.0.3", 50 | "conventional-changelog-cli": "^5.0.0", 51 | "prettier": "^3.4.2", 52 | "sinon": "^19.0.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cli.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, it } from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | import { runTypeAnnotationifyCli } from './cli.ts'; 4 | import { DEFAULT_OPTIONS, parse, print, transform } from './transform.ts'; 5 | import sinon from 'sinon'; 6 | import type fs from 'fs/promises'; 7 | import type { GlobOptionsWithoutFileTypes } from 'node:fs'; 8 | import { TransformChangesReport } from './transform-changes-report.ts'; 9 | 10 | describe(runTypeAnnotationifyCli.name, async () => { 11 | class Context { 12 | parse = parse; 13 | print = print; 14 | transform = sinon.spy(transform); 15 | log = sinon.stub< 16 | Parameters, 17 | ReturnType 18 | >(); 19 | glob = sinon.stub< 20 | [string[], GlobOptionsWithoutFileTypes], 21 | NodeJS.AsyncIterator 22 | >(); 23 | readFile = sinon.stub<[string, 'utf-8'], Promise>(); 24 | writeFile = sinon.stub< 25 | Parameters, 26 | ReturnType 27 | >(); 28 | } 29 | 30 | let context: Context; 31 | beforeEach(() => { 32 | context = new Context(); 33 | }); 34 | afterEach(() => { 35 | sinon.restore(); 36 | }); 37 | 38 | await it('should display a smart help message when called with `--help` or `-h`', async () => { 39 | await act(['--help']); 40 | await act(['-h']); 41 | sinon.assert.calledTwice(context.log); 42 | const logMessages = context.log.args.map(([message]) => message); 43 | 44 | assert.equal(logMessages[0], logMessages[1]); 45 | sinon.assert.calledWithMatch(context.log, / --help/); 46 | sinon.assert.calledWithMatch(context.log, / -h/); 47 | Object.keys(DEFAULT_OPTIONS).forEach((option) => { 48 | sinon.assert.calledWithMatch( 49 | context.log, 50 | new RegExp(toKebabCase(option)), 51 | ); 52 | }); 53 | }); 54 | 55 | await it('should exclude node_modules', async () => { 56 | context.glob.returns(new AsyncFileIterable([])); 57 | await act(); 58 | sinon.assert.calledOnce(context.glob); 59 | sinon.assert.calledWith(context.glob, sinon.match.array, { 60 | exclude: sinon.match.func, 61 | }); 62 | const exclude = context.glob.args[0]![1].exclude!; 63 | assert.equal(exclude('node_modules'), true); 64 | assert.equal(exclude('something_else'), false); 65 | }); 66 | 67 | await it('should default use the default pattern', async () => { 68 | context.glob.returns(new AsyncFileIterable([])); 69 | await act(); 70 | sinon.assert.calledOnceWithExactly( 71 | context.glob, 72 | ['**/!(*.d).?(m|c)ts?(x)'], 73 | { 74 | exclude: sinon.match.func, 75 | }, 76 | ); 77 | }); 78 | 79 | await it('should use the provided patterns', async () => { 80 | context.glob.returns(new AsyncFileIterable([])); 81 | await act(['src/**/*.ts', 'test/**/*.ts']); 82 | sinon.assert.calledOnceWithExactly( 83 | context.glob, 84 | ['src/**/*.ts', 'test/**/*.ts'], 85 | { 86 | exclude: sinon.match.func, 87 | }, 88 | ); 89 | }); 90 | 91 | await it('should transform a single file and write it back in-place', async () => { 92 | context.glob.returns(new AsyncFileIterable(['src/cli.ts'])); 93 | context.readFile.resolves('const foo = JSON.parse(`"hello"`);'); 94 | await act(); 95 | sinon.assert.calledOnceWithExactly(context.readFile, 'src/cli.ts', 'utf-8'); 96 | sinon.assert.calledOnceWithExactly( 97 | context.writeFile, 98 | 'src/cli.ts', 99 | 'const foo = JSON.parse(`"hello"`) as string;\n', 100 | ); 101 | }); 102 | 103 | await it('should not write the file if it was not changed', async () => { 104 | context.glob.returns(new AsyncFileIterable(['src/cli.ts'])); 105 | context.readFile.resolves('const foo = JSON.parse(`"hello"`) as string;'); 106 | await act(); 107 | sinon.assert.notCalled(context.writeFile); 108 | }); 109 | 110 | await it('should report the number of changes', async () => { 111 | context.glob.returns(new AsyncFileIterable(['src/foo.ts', 'src/bar.ts'])); 112 | context.readFile 113 | .withArgs('src/foo.ts') 114 | .resolves('const foo = JSON.parse(`"hello"`);'); 115 | context.readFile.withArgs('src/bar.ts').resolves(''); 116 | await act(); 117 | const report = new TransformChangesReport(); 118 | report.typeAssertions++; 119 | sinon.assert.calledWith(context.log, `✅ src/foo.ts [${report.text}]`); 120 | sinon.assert.calledWith(context.log, `🎉 1 file transformed (1 untouched)`); 121 | }); 122 | 123 | await it('should report the number of changes as plural if there are more than one changed', async () => { 124 | context.glob.returns(new AsyncFileIterable(['src/foo.ts', 'src/bar.ts'])); 125 | context.readFile.resolves('const foo = JSON.parse(`"hello"`);'); 126 | await act(); 127 | const report = new TransformChangesReport(); 128 | report.typeAssertions++; 129 | sinon.assert.calledWith( 130 | context.log, 131 | `🎉 2 files transformed (0 untouched)`, 132 | ); 133 | }); 134 | 135 | await it('should use default options when no options are provided', async () => { 136 | context.glob.returns(new AsyncFileIterable(['src/foo.ts'])); 137 | context.readFile.resolves('const foo = JSON.parse(`"hello"`);'); 138 | await act(); 139 | sinon.assert.calledWithMatch( 140 | context.transform, 141 | sinon.match.any, 142 | DEFAULT_OPTIONS, 143 | ); 144 | }); 145 | await it('should set enumNamespaceDeclaration to false when `--no-enum-namespace-declaration` is set', async () => { 146 | context.glob.returns(new AsyncFileIterable(['src/foo.ts'])); 147 | context.readFile.resolves(''); 148 | await act(['--no-enum-namespace-declaration']); 149 | sinon.assert.calledWithMatch(context.transform, sinon.match.any, { 150 | ...DEFAULT_OPTIONS, 151 | enumNamespaceDeclaration: false, 152 | }); 153 | }); 154 | await it('should set relativeImportExtensions to true when `--relative-import-extensions` is set', async () => { 155 | context.glob.returns(new AsyncFileIterable(['src/foo.ts'])); 156 | context.readFile.resolves(''); 157 | await act(['--relative-import-extensions']); 158 | sinon.assert.calledWithMatch(context.transform, sinon.match.any, { 159 | ...DEFAULT_OPTIONS, 160 | relativeImportExtensions: true, 161 | }); 162 | }); 163 | 164 | await it('should only report files when `--dry` is set', async () => { 165 | context.glob.returns(new AsyncFileIterable(['src/foo.ts'])); 166 | context.readFile.resolves('const foo = JSON.parse(`"hello"`);'); 167 | await act(['--dry']); 168 | sinon.assert.notCalled(context.writeFile); 169 | const report = new TransformChangesReport(); 170 | report.typeAssertions++; 171 | sinon.assert.calledWith(context.log, `🚀 src/foo.ts [${report.text}]`); 172 | sinon.assert.calledWith( 173 | context.log, 174 | `🎉 1 file would have been transformed (0 untouched)`, 175 | ); 176 | }); 177 | 178 | async function act(args: string[] = []) { 179 | await runTypeAnnotationifyCli(args, context); 180 | } 181 | 182 | class AsyncFileIterable { 183 | private readonly values; 184 | constructor(values: string[]) { 185 | this.values = values; 186 | } 187 | [Symbol.asyncIterator]() { 188 | return this; 189 | } 190 | next() { 191 | const done = !this.values.length; 192 | return Promise.resolve({ 193 | value: this.values.shift(), 194 | done: done, 195 | } as IteratorResult); 196 | } 197 | } 198 | }); 199 | 200 | function toKebabCase(input: string): string { 201 | return input.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 202 | } 203 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs } from 'util'; 2 | import fs from 'fs/promises'; 3 | import { type TransformOptions, parse, print, transform } from './transform.ts'; 4 | import type { GlobOptionsWithoutFileTypes } from 'fs'; 5 | 6 | /** 7 | * Runs the type annotationify CLI. 8 | * @param args The command line arguments to use. 9 | * @param context The context with dependencies, used for testing. 10 | * @returns 11 | */ 12 | export async function runTypeAnnotationifyCli( 13 | args: string[], 14 | context = { 15 | parse, 16 | print, 17 | transform, 18 | log: console.log, 19 | glob: fs.glob as ( 20 | pattern: string[], 21 | opt: GlobOptionsWithoutFileTypes, 22 | ) => NodeJS.AsyncIterator, 23 | readFile: fs.readFile as ( 24 | fileName: string, 25 | encoding: 'utf-8', 26 | ) => Promise, 27 | writeFile: fs.writeFile, 28 | }, 29 | ): Promise { 30 | const { parse, print, transform, log, glob, writeFile, readFile } = context; 31 | const { positionals, values: options } = parseArgs({ 32 | args, 33 | options: { 34 | 'enum-namespace-declaration': { type: 'boolean', default: true }, 35 | 'explicit-property-types': { type: 'boolean', default: false }, 36 | 'relative-import-extensions': { type: 'boolean', default: false }, 37 | dry: { type: 'boolean', default: false }, 38 | help: { type: 'boolean', short: 'h' }, 39 | }, 40 | allowPositionals: true, 41 | allowNegative: true, 42 | }); 43 | 44 | if (options.help) { 45 | log(` 46 | Usage: type-annotationify [options] [patterns] 47 | 48 | Options: 49 | --dry Don't write the transformed files to disk, perform a test run only. 50 | --explicit-property-types Add an explicit type annotations to property declarations. 51 | --no-enum-namespace-declaration Don't emit "declare namespace..." when converting enum declarations. 52 | --relative-import-extensions Convert relative imports from .js to .ts 53 | -h, --help Display this help message 54 | 55 | Patterns: 56 | Glob patterns to match files to transform. Defaults to '**/!(*.d).?(m|c)ts?(x)' (excluding node_modules). 57 | In other words, by default all TypeScript files are matched (also in subdirectories) except declaration files (d.ts). 58 | `); 59 | return; 60 | } 61 | 62 | const patterns: string[] = [...positionals]; 63 | if (patterns.length === 0) { 64 | patterns.push('**/!(*.d).?(m|c)ts?(x)'); 65 | } 66 | const promises: Promise[] = []; 67 | let untouched = 0; 68 | const transformOptions: TransformOptions = { 69 | enumNamespaceDeclaration: options['enum-namespace-declaration'], 70 | relativeImportExtensions: options['relative-import-extensions'], 71 | explicitPropertyTypes: options['explicit-property-types'], 72 | }; 73 | for await (const file of glob(patterns, { 74 | exclude: (fileName) => fileName === 'node_modules', 75 | })) { 76 | promises.push( 77 | (async () => { 78 | const content = await readFile(file, 'utf-8'); 79 | const sourceFile = parse(file, content); 80 | const { node, report } = transform(sourceFile, transformOptions); 81 | if (report.changed) { 82 | if (options.dry) { 83 | log(`🚀 ${file} [${report.text}]`); 84 | } else { 85 | const transformedContent = print(node); 86 | await writeFile(file, transformedContent); 87 | log(`✅ ${file} [${report.text}]`); 88 | } 89 | } else { 90 | untouched++; 91 | } 92 | })(), 93 | ); 94 | } 95 | await Promise.allSettled(promises); 96 | const transformed = promises.length - untouched; 97 | log( 98 | `🎉 ${transformed} file${transformed === 1 ? '' : 's'}${options.dry ? ' would have been' : ''} transformed (${untouched} untouched)`, 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | export * from './cli.ts'; 2 | export * from './transform.ts'; 3 | -------------------------------------------------------------------------------- /src/transform-changes-report.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | import { TransformChangesReport } from './transform-changes-report.ts'; 4 | 5 | describe(TransformChangesReport.name, () => { 6 | let sut: TransformChangesReport; 7 | beforeEach(() => { 8 | sut = new TransformChangesReport(); 9 | }); 10 | 11 | describe('changed', () => { 12 | it('should be false when no changes are reported', async () => { 13 | assert.strictEqual(sut.changed, false); 14 | }); 15 | ( 16 | [ 17 | 'classConstructors', 18 | 'enumDeclarations', 19 | 'typeAssertions', 20 | 'namespaceDeclarations', 21 | 'relativeImportExtensions', 22 | ] as const 23 | ).forEach((property) => { 24 | it(`should be true when ${property} changes are reported`, async () => { 25 | sut[property]++; 26 | assert.strictEqual(sut.changed, true); 27 | }); 28 | }); 29 | }); 30 | 31 | describe('text', () => { 32 | it('should be empty when no changes are reported', async () => { 33 | assert.strictEqual(sut.text, ''); 34 | }); 35 | it('should be the singular form when one change is reported', async () => { 36 | sut.classConstructors++; 37 | assert.strictEqual(sut.text, '1 class constructor'); 38 | }); 39 | it('should be the plural form when multiple changes are reported', async () => { 40 | sut.classConstructors = 2; 41 | sut.enumDeclarations = 3; 42 | assert.strictEqual(sut.text, '2 class constructors, 3 enum declarations'); 43 | }); 44 | it('should be able to report all changes', async () => { 45 | sut.classConstructors = 1; 46 | sut.enumDeclarations = 2; 47 | sut.typeAssertions = 3; 48 | sut.namespaceDeclarations = 4; 49 | sut.relativeImportExtensions = 5; 50 | assert.strictEqual( 51 | sut.text, 52 | '1 class constructor, 2 enum declarations, 4 namespace declarations, 3 type assertions, 5 import extensions', 53 | ); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/transform-changes-report.ts: -------------------------------------------------------------------------------- 1 | const reportLabels = Object.freeze({ 2 | classConstructors: 'class constructor', 3 | enumDeclarations: 'enum declaration', 4 | namespaceDeclarations: 'namespace declaration', 5 | typeAssertions: 'type assertion', 6 | relativeImportExtensions: 'import extension', 7 | }); 8 | 9 | export class TransformChangesReport { 10 | classConstructors = 0; 11 | enumDeclarations = 0; 12 | namespaceDeclarations = 0; 13 | typeAssertions = 0; 14 | relativeImportExtensions = 0; 15 | 16 | #numberToText(name: keyof typeof reportLabels) { 17 | return this[name] > 0 18 | ? `${this[name]} ${reportLabels[name]}${this[name] > 1 ? 's' : ''}` 19 | : ''; 20 | } 21 | 22 | get changed(): boolean { 23 | return ( 24 | this.classConstructors > 0 || 25 | this.enumDeclarations > 0 || 26 | this.namespaceDeclarations > 0 || 27 | this.typeAssertions > 0 || 28 | this.relativeImportExtensions > 0 29 | ); 30 | } 31 | 32 | get text(): string { 33 | return [ 34 | this.#numberToText('classConstructors'), 35 | this.#numberToText('enumDeclarations'), 36 | this.#numberToText('namespaceDeclarations'), 37 | this.#numberToText('typeAssertions'), 38 | this.#numberToText('relativeImportExtensions'), 39 | ] 40 | .filter(Boolean) 41 | .join(', '); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/transform.spec.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import { parse, transform, type TransformOptions } from './transform.ts'; 3 | import assert from 'node:assert/strict'; 4 | import * as prettier from 'prettier'; 5 | import { describe, it } from 'node:test'; 6 | 7 | const IMAGINARY_FILE_NAME = 'ts.ts'; 8 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 9 | 10 | describe('transform', async () => { 11 | await describe('type assertions', async () => { 12 | await it('should transform a type assertion', async () => { 13 | await scenario(`const foo = 'foo';`, `const foo = 'foo' as a;`); 14 | }); 15 | }); 16 | 17 | await it('should provide an accurate report of changes', async () => { 18 | const source = parse( 19 | IMAGINARY_FILE_NAME, 20 | ` 21 | const foo = 'foo'; 22 | class Bar { 23 | constructor(public baz: string) {} 24 | } 25 | enum Baz { A } 26 | namespace Qux {} 27 | import('./foo.js'); 28 | `, 29 | ); 30 | const { report } = transform(source, { relativeImportExtensions: true }); 31 | assert.strictEqual(report.changed, true); 32 | assert.strictEqual(report.classConstructors, 1); 33 | assert.strictEqual(report.typeAssertions, 1); 34 | assert.strictEqual(report.enumDeclarations, 1); 35 | assert.strictEqual(report.namespaceDeclarations, 1); 36 | assert.strictEqual(report.relativeImportExtensions, 1); 37 | }); 38 | }); 39 | 40 | export async function scenario( 41 | input: string, 42 | expectedOutput = input, 43 | options?: Partial, 44 | ) { 45 | const expectedChanged = input !== expectedOutput; 46 | const source = parse(IMAGINARY_FILE_NAME, input); 47 | const expected = parse(IMAGINARY_FILE_NAME, expectedOutput); 48 | const actualTransformResult = transform(source, options); 49 | const actualCodeUnformatted = printer.printFile(actualTransformResult.node); 50 | const actualCode = await prettier.format(actualCodeUnformatted, { 51 | filepath: IMAGINARY_FILE_NAME, 52 | }); 53 | const expectedCode = await prettier.format(printer.printFile(expected), { 54 | filepath: IMAGINARY_FILE_NAME, 55 | }); 56 | assert.equal( 57 | actualTransformResult.report.changed, 58 | expectedChanged, 59 | expectedChanged 60 | ? `Expected input to be changed, but wasn't: \`${input}\`` 61 | : `Expected input to not be changed, but was: Expected:\`${input}\`\nActual:${actualCode}`, 62 | ); 63 | assert.deepEqual(actualCode, expectedCode); 64 | } 65 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import { transformConstructorParameters } from './transformers/constructor-parameters.ts'; 3 | import { transformEnum } from './transformers/enums.ts'; 4 | import { transformNamespace } from './transformers/namespaces.ts'; 5 | import { transformRelativeImportExtensions } from './transformers/import-extensions.ts'; 6 | import { TransformChangesReport } from './transform-changes-report.ts'; 7 | export function parse(fileName: string, content: string) { 8 | return ts.createSourceFile( 9 | fileName, 10 | content, 11 | ts.ScriptTarget.ESNext, 12 | /*setParentNodes */ true, 13 | ); 14 | } 15 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 16 | export function print(source: ts.SourceFile): string { 17 | return printer.printFile(source); 18 | } 19 | 20 | export interface TransformResult { 21 | node: ts.SourceFile; 22 | report: TransformChangesReport; 23 | } 24 | 25 | export interface TransformOptions { 26 | enumNamespaceDeclaration: boolean; 27 | relativeImportExtensions: boolean; 28 | explicitPropertyTypes: boolean; 29 | } 30 | 31 | export const DEFAULT_OPTIONS: Readonly = Object.freeze({ 32 | enumNamespaceDeclaration: true, 33 | explicitPropertyTypes: false, 34 | relativeImportExtensions: false, 35 | }); 36 | 37 | export function transform( 38 | source: ts.SourceFile, 39 | overrides?: Partial, 40 | ): TransformResult { 41 | const report = new TransformChangesReport(); 42 | const options = Object.freeze({ ...DEFAULT_OPTIONS, ...overrides }); 43 | return { 44 | node: ts.visitEachChild(source, transformNode, undefined), 45 | report, 46 | }; 47 | 48 | function transformNode(node: ts.Node): ts.Node | ts.Node[] { 49 | let resultingNode: ts.Node | ts.Node[] = node; 50 | if (ts.isClassDeclaration(node)) { 51 | const result = transformConstructorParameters(node, options); 52 | if (result.changed) { 53 | report.classConstructors++; 54 | } 55 | resultingNode = result.node; 56 | } 57 | if (ts.isEnumDeclaration(node)) { 58 | const result = transformEnum(node, options); 59 | if (result.changed) { 60 | report.enumDeclarations++; 61 | } 62 | resultingNode = result.node; 63 | } 64 | if (ts.isTypeAssertionExpression(node)) { 65 | resultingNode = ts.factory.createAsExpression(node.expression, node.type); 66 | report.typeAssertions++; 67 | } 68 | if (ts.isModuleDeclaration(node)) { 69 | const result = transformNamespace(node); 70 | if (result.changed) { 71 | report.namespaceDeclarations++; 72 | } 73 | resultingNode = result.node; 74 | } 75 | if (ts.isImportDeclaration(node) || ts.isCallExpression(node)) { 76 | const result = transformRelativeImportExtensions(node, options); 77 | if (result.changed) { 78 | report.relativeImportExtensions++; 79 | } 80 | resultingNode = result.node; 81 | } 82 | if (Array.isArray(resultingNode)) { 83 | return resultingNode.map((node) => 84 | ts.visitEachChild(node, transformNode, undefined), 85 | ); 86 | } else { 87 | return ts.visitEachChild(resultingNode, transformNode, undefined); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/transformers/constructor-parameters.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { scenario } from '../transform.spec.ts'; 3 | import { transformConstructorParameters } from './constructor-parameters.ts'; 4 | 5 | describe(transformConstructorParameters.name, async () => { 6 | await it('should transform a parameter property', async () => { 7 | await scenario( 8 | `class Iban { 9 | constructor(public bankCode: string) {} 10 | }`, 11 | `class Iban { 12 | public bankCode; 13 | constructor(bankCode: string) { 14 | this.bankCode = bankCode; 15 | } 16 | }`, 17 | ); 18 | }); 19 | 20 | await it('should add the property type when `explicitPropertyTypes` is set', async () => { 21 | await scenario( 22 | `class Iban { 23 | constructor(public bankCode: string) {} 24 | }`, 25 | `class Iban { 26 | public bankCode: string; 27 | constructor(bankCode: string) { 28 | this.bankCode = bankCode; 29 | } 30 | }`, 31 | { explicitPropertyTypes: true }, 32 | ); 33 | }); 34 | 35 | await it('should transform a parameter property deeper in the AST', async () => { 36 | await scenario( 37 | ` 38 | function foo() { 39 | class Bar { 40 | doSomething() { 41 | class Iban { 42 | constructor(public bankCode: string) {} 43 | } 44 | } 45 | } 46 | }`, 47 | ` 48 | function foo() { 49 | class Bar { 50 | doSomething() { 51 | class Iban { 52 | public bankCode; 53 | constructor(bankCode: string) { 54 | this.bankCode = bankCode; 55 | } 56 | } 57 | } 58 | } 59 | }`, 60 | ); 61 | }); 62 | 63 | await it('should move any initializer to the parameter', async () => { 64 | await scenario( 65 | ` 66 | class Foo { 67 | constructor( 68 | public bar: string, 69 | readonly baz: boolean, 70 | protected qux = 42, 71 | ) {} 72 | }`, 73 | ` 74 | class Foo { 75 | public bar; 76 | readonly baz; 77 | protected qux; 78 | constructor( 79 | bar: string, 80 | baz: boolean, 81 | qux = 42, 82 | ) { 83 | this.bar = bar; 84 | this.baz = baz; 85 | this.qux = qux; 86 | } 87 | } 88 | `, 89 | ); 90 | }); 91 | 92 | await it('should support a class inside a class', async () => { 93 | await scenario( 94 | `class Iban { 95 | constructor(public bankCode: string) {} 96 | 97 | doWork() { 98 | class Bic { 99 | constructor(public bic: string) {} 100 | 101 | } 102 | } 103 | }`, 104 | `class Iban { 105 | public bankCode; 106 | constructor(bankCode: string) { 107 | this.bankCode = bankCode; 108 | } 109 | 110 | doWork() { 111 | class Bic { 112 | public bic; 113 | constructor(bic: string) { 114 | this.bic = bic; 115 | } 116 | } 117 | } 118 | }`, 119 | ); 120 | }); 121 | 122 | await it('should transform multiple parameter properties', async () => { 123 | await scenario( 124 | `class Iban { 125 | constructor(public bankCode: string, public bic: string) {} 126 | }`, 127 | `class Iban { 128 | public bankCode; 129 | public bic; 130 | constructor(bankCode: string, bic: string) { 131 | this.bankCode = bankCode; 132 | this.bic = bic; 133 | } 134 | }`, 135 | ); 136 | }); 137 | 138 | await it('should support a constructor with a super() call', async () => { 139 | await scenario( 140 | `class Iban extends Base { 141 | constructor(public bankCode: string) { 142 | super(); 143 | } 144 | }`, 145 | `class Iban extends Base { 146 | public bankCode; 147 | constructor(bankCode: string) { 148 | super(); 149 | this.bankCode = bankCode; 150 | } 151 | }`, 152 | ); 153 | }); 154 | await it('should support a constructor with a super() call with parameters', async () => { 155 | await scenario( 156 | `class Iban extends Base { 157 | constructor(public bankCode: string, bic: string) { 158 | super(bic); 159 | } 160 | }`, 161 | `class Iban extends Base { 162 | public bankCode; 163 | constructor(bankCode: string, bic: string) { 164 | super(bic); 165 | this.bankCode = bankCode; 166 | } 167 | }`, 168 | ); 169 | }); 170 | await it('should support a constructor with statements before the super() call', async () => { 171 | await scenario( 172 | `class Iban extends Base { 173 | constructor(public bankCode: string) { 174 | console.log('foo'); 175 | console.log('bar'); 176 | super(); 177 | console.log('baz'); 178 | } 179 | }`, 180 | `class Iban extends Base { 181 | public bankCode; 182 | constructor(bankCode: string) { 183 | console.log('foo'); 184 | console.log('bar'); 185 | super(); 186 | this.bankCode = bankCode; 187 | console.log('baz'); 188 | } 189 | }`, 190 | ); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /src/transformers/constructor-parameters.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { TransformerResult } from './transformer-result.ts'; 3 | import type { TransformOptions } from '../transform.ts'; 4 | 5 | export function transformConstructorParameters( 6 | clazz: ts.ClassDeclaration, 7 | options: Pick, 8 | ): TransformerResult { 9 | const constructor = clazz.members.find((member) => 10 | ts.isConstructorDeclaration(member), 11 | ); 12 | const constructorParameterProperties: ts.ParameterDeclaration[] = []; 13 | if (constructor) { 14 | const constructorParams = constructor.parameters; 15 | constructorParams.forEach((param) => { 16 | if (ts.isParameterPropertyDeclaration(param, param.parent)) { 17 | constructorParameterProperties.push(param); 18 | } 19 | }); 20 | } 21 | if (constructorParameterProperties.length === 0) { 22 | return { changed: false, node: clazz }; 23 | } 24 | return { 25 | changed: true, 26 | node: ts.factory.updateClassDeclaration( 27 | clazz, 28 | clazz.modifiers, 29 | clazz.name, 30 | clazz.typeParameters, 31 | clazz.heritageClauses, 32 | [ 33 | ...toClassProperties(constructorParameterProperties, options), 34 | ...clazz.members.map((member) => { 35 | if (!ts.isConstructorDeclaration(member)) { 36 | return member; 37 | } 38 | 39 | const statements: readonly ts.Statement[] = 40 | member.body?.statements || []; 41 | 42 | const indexOfSuperCall = statements.findIndex( 43 | (statement) => 44 | ts.isExpressionStatement(statement) && 45 | ts.isCallExpression(statement.expression) && 46 | statement.expression.expression.kind === 47 | ts.SyntaxKind.SuperKeyword, 48 | ); 49 | 50 | return ts.factory.updateConstructorDeclaration( 51 | member, 52 | member.modifiers, 53 | removeModifiersFromProperties(member.parameters), 54 | ts.factory.createBlock([ 55 | ...statements.slice(0, indexOfSuperCall + 1), 56 | ...toPropertyInitializers(constructorParameterProperties), 57 | ...statements.slice(indexOfSuperCall + 1), 58 | ]), 59 | ); 60 | }), 61 | ], 62 | ), 63 | }; 64 | } 65 | 66 | function toPropertyInitializers( 67 | constructorParameterProperties: ts.ParameterDeclaration[], 68 | ) { 69 | return constructorParameterProperties.map((param) => { 70 | return ts.factory.createExpressionStatement( 71 | ts.factory.createBinaryExpression( 72 | ts.factory.createPropertyAccessExpression( 73 | ts.factory.createThis(), 74 | (param.name as ts.Identifier).text, 75 | ), 76 | ts.SyntaxKind.EqualsToken, 77 | ts.factory.createIdentifier((param.name as ts.Identifier).text), 78 | ), 79 | ); 80 | }); 81 | } 82 | function toClassProperties( 83 | parameterProperties: ts.ParameterDeclaration[], 84 | options: Pick, 85 | ) { 86 | return parameterProperties.map((param) => { 87 | return ts.factory.createPropertyDeclaration( 88 | param.modifiers, 89 | (param.name as ts.Identifier).text, 90 | param.questionToken, 91 | /* type */ options.explicitPropertyTypes ? param.type : undefined, 92 | /* initializer */ undefined, 93 | ); 94 | }); 95 | } 96 | function removeModifiersFromProperties( 97 | params: ts.NodeArray, 98 | ): readonly ts.ParameterDeclaration[] { 99 | return params.map((param) => { 100 | if (ts.isParameterPropertyDeclaration(param, param.parent)) { 101 | return ts.factory.updateParameterDeclaration( 102 | param, 103 | /* modifiers */ undefined, 104 | param.dotDotDotToken, 105 | param.name, 106 | param.questionToken, 107 | param.type, 108 | param.initializer, 109 | ); 110 | } 111 | return param; 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /src/transformers/enums.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { scenario } from '../transform.spec.ts'; 3 | import { transformEnum } from './enums.ts'; 4 | 5 | describe(transformEnum.name, async () => { 6 | await it('should transform a plain enum', async () => { 7 | await scenario( 8 | 'enum MessageKind { Start, Work, Stop }', 9 | `const MessageKind = { 10 | 0: 'Start', 11 | 1: 'Work', 12 | 2: 'Stop', 13 | Start: 0, 14 | Work: 1, 15 | Stop: 2 16 | } as const; 17 | type MessageKind = typeof MessageKind[keyof typeof MessageKind & string]; 18 | declare namespace MessageKind { 19 | type Start = typeof MessageKind.Start; 20 | type Work = typeof MessageKind.Work; 21 | type Stop = typeof MessageKind.Stop; 22 | }`, 23 | ); 24 | }); 25 | await it('should transform an exported enum', async () => { 26 | await scenario( 27 | 'export enum MessageKind { Start }', 28 | ` 29 | export const MessageKind = { 30 | 0: 'Start', 31 | Start: 0, 32 | } as const; 33 | export type MessageKind = typeof MessageKind[keyof typeof MessageKind & string]; 34 | export declare namespace MessageKind { 35 | type Start = typeof MessageKind.Start; 36 | }`, 37 | ); 38 | }); 39 | 40 | await it('should not emit a namespace declaration when enumNamespaceDeclaration is disabled', async () => { 41 | await scenario( 42 | 'enum MessageKind { Start }', 43 | ` 44 | const MessageKind = { 45 | 0: 'Start', 46 | Start: 0, 47 | } as const; 48 | type MessageKind = typeof MessageKind[keyof typeof MessageKind & string]; 49 | `, 50 | { enumNamespaceDeclaration: false }, 51 | ); 52 | }); 53 | 54 | await it('should transform an enum with number initializers', async () => { 55 | await scenario( 56 | 'enum Rank { Two = 2, Three = 3 }', 57 | ` 58 | const Rank = { 59 | 2: 'Two', 60 | 3: 'Three', 61 | Two: 2, 62 | Three: 3, 63 | } as const; 64 | type Rank = typeof Rank[keyof typeof Rank & string]; 65 | declare namespace Rank { 66 | type Two = typeof Rank.Two; 67 | type Three = typeof Rank.Three; 68 | } 69 | `, 70 | ); 71 | }); 72 | await it('should transform an enum with and without number initializers', async () => { 73 | await scenario( 74 | 'enum Numbers { One = 1, Two, FortyTwo = 42, FortyThree }', 75 | ` 76 | const Numbers = { 77 | 1: 'One', 78 | 2: 'Two', 79 | 42: 'FortyTwo', 80 | 43: 'FortyThree', 81 | One: 1, 82 | Two: 2, 83 | FortyTwo: 42, 84 | FortyThree: 43 85 | } as const; 86 | type Numbers = typeof Numbers[keyof typeof Numbers & string]; 87 | declare namespace Numbers { 88 | type One = typeof Numbers.One; 89 | type Two = typeof Numbers.Two; 90 | type FortyTwo = typeof Numbers.FortyTwo; 91 | type FortyThree = typeof Numbers.FortyThree; 92 | } 93 | `, 94 | ); 95 | }); 96 | await it('should transform an enum duplicate number values', async () => { 97 | await scenario( 98 | 'enum NumbersI18n { Two = 2, Three, Deux = 2, Trois }', 99 | ` 100 | const NumbersI18n = { 101 | 2: 'Deux', 102 | 3: 'Trois', 103 | Two: 2, 104 | Three: 3, 105 | Deux: 2, 106 | Trois: 3 107 | } as const; 108 | type NumbersI18n = typeof NumbersI18n[keyof typeof NumbersI18n & string]; 109 | declare namespace NumbersI18n { 110 | type Two = typeof NumbersI18n.Two; 111 | type Three = typeof NumbersI18n.Three; 112 | type Deux = typeof NumbersI18n.Deux; 113 | type Trois = typeof NumbersI18n.Trois; 114 | } 115 | `, 116 | ); 117 | }); 118 | 119 | await it('should transform a string enum', async () => { 120 | await scenario( 121 | 'enum Foo { Bar = "bar", Baz = "baz" }', 122 | ` 123 | const Foo = { 124 | Bar: 'bar', 125 | Baz: 'baz', 126 | } as const; 127 | type Foo = typeof Foo[keyof typeof Foo]; 128 | declare namespace Foo { 129 | type Bar = typeof Foo.Bar; 130 | type Baz = typeof Foo.Baz; 131 | } 132 | `, 133 | ); 134 | }); 135 | await it('should transform a mixed enum (strings and numbers)', async () => { 136 | await scenario( 137 | 'enum Foo { A = "a", One = 1, Two, B = "b"}', 138 | ` 139 | const Foo = { 140 | 1: 'One', 141 | 2: 'Two', 142 | A: 'a', 143 | One: 1, 144 | Two: 2, 145 | B: 'b', 146 | } as const; 147 | type Foo = typeof Foo[keyof typeof Foo & string]; 148 | declare namespace Foo { 149 | type A = typeof Foo.A; 150 | type One = typeof Foo.One; 151 | type Two = typeof Foo.Two; 152 | type B = typeof Foo.B; 153 | } 154 | `, 155 | ); 156 | }); 157 | await it('should transform a computed property name enum', async () => { 158 | await scenario( 159 | 'enum PathSeparator { ["/"], ["\\\\"] }', 160 | ` 161 | const PathSeparator = { 162 | 0: '/', 163 | 1: '\\\\', 164 | '/': 0, 165 | '\\\\': 1 166 | } as const; 167 | type PathSeparator = typeof PathSeparator[keyof typeof PathSeparator & string]; 168 | `, 169 | ); 170 | }); 171 | 172 | await it('should support const enums', async () => { 173 | await scenario( 174 | 'const enum Foo { Bar, Baz }', 175 | ` 176 | const Foo = { 177 | 0: 'Bar', 178 | 1: 'Baz', 179 | Bar: 0, 180 | Baz: 1, 181 | } as const; 182 | type Foo = typeof Foo[keyof typeof Foo & string]; 183 | declare namespace Foo { 184 | type Bar = typeof Foo.Bar; 185 | type Baz = typeof Foo.Baz; 186 | } 187 | `, 188 | ); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/transformers/enums.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { TransformOptions } from '../transform.ts'; 3 | import type { TransformerResult } from './transformer-result.ts'; 4 | 5 | export function transformEnum( 6 | enumDeclaration: ts.EnumDeclaration, 7 | { enumNamespaceDeclaration }: Readonly, 8 | ): TransformerResult { 9 | let initValue: number | string = 0; 10 | const enumValueMap = new Map( 11 | enumDeclaration.members.map((member) => { 12 | if (member.initializer) { 13 | if (ts.isNumericLiteral(member.initializer)) { 14 | initValue = parseInt(member.initializer.text); 15 | } else if (ts.isStringLiteral(member.initializer)) { 16 | initValue = member.initializer.text; 17 | } 18 | } 19 | const keyValue = [member, initValue] as const; 20 | initValue = typeof initValue === 'number' ? initValue + 1 : initValue; 21 | return keyValue; 22 | }), 23 | ); 24 | const enumNameMap = new Map( 25 | enumDeclaration.members.map( 26 | (member) => 27 | [ 28 | member, 29 | ts.factory.createStringLiteral( 30 | ts.isComputedPropertyName(member.name) 31 | ? (member.name.expression as ts.StringLiteral).text // Computed property names other than string literals are not allowed in enum declarations 32 | : member.name.text, 33 | ), 34 | ] as const, 35 | ), 36 | ); 37 | 38 | const nodes = [ 39 | // Object literal: const MessageKind = { 0: 'Start', 1: 'Work', 2: 'Stop', Start: 0, Work: 1, Stop: 2 } as const; 40 | createObjectLiteral(enumDeclaration, enumValueMap, enumNameMap), 41 | // Type alias for values: type MessageKind = typeof MessageKind[keyof typeof MessageKind & string]; 42 | createTypeAlias(enumDeclaration), 43 | ]; 44 | if (enumNamespaceDeclaration) { 45 | // Finish with the namespace declaration: declare namespace MessageKind { type Start = typeof MessageKind.Start; ... } 46 | const moduleDeclaration = createModuleDeclarationIfNeeded(enumDeclaration); 47 | if (moduleDeclaration) { 48 | nodes.push(moduleDeclaration); 49 | } 50 | } 51 | 52 | return { 53 | changed: true, 54 | node: nodes, 55 | }; 56 | } 57 | 58 | function createModuleDeclarationIfNeeded( 59 | enumDeclaration: ts.EnumDeclaration, 60 | ): ts.Node | undefined { 61 | // Only identifiers need to be types. I.e. computed enum values cannot be used as types 62 | // ex: `enum Foo { ['😀'] }; type A = Foo['😀']` is invalid 63 | const enumTypeAliasDeclarations = enumDeclaration.members 64 | .map(({ name }) => name) 65 | .filter((name) => ts.isIdentifier(name)) 66 | .map((name) => 67 | ts.factory.createTypeAliasDeclaration( 68 | undefined, 69 | name, 70 | undefined, 71 | ts.factory.createTypeQueryNode( 72 | ts.factory.createQualifiedName( 73 | ts.factory.createIdentifier(enumDeclaration.name.text), 74 | name, 75 | ), 76 | ), 77 | ), 78 | ); 79 | 80 | if (enumTypeAliasDeclarations.length === 0) { 81 | return; 82 | } 83 | 84 | return ts.factory.createModuleDeclaration( 85 | [ 86 | ...(createModifiers(enumDeclaration.modifiers) ?? []), 87 | ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword), 88 | ], 89 | enumDeclaration.name, 90 | ts.factory.createModuleBlock(enumTypeAliasDeclarations), 91 | ts.NodeFlags.Namespace, 92 | ); 93 | } 94 | 95 | function createObjectLiteral( 96 | enumDeclaration: ts.EnumDeclaration, 97 | enumValueMap: Map, 98 | enumNameMap: Map, 99 | ): ts.Node { 100 | // An enum may have duplicate values, but an object literal doesn't allow duplicate keys 101 | // Ex. enum NumbersI18n { Two = 2, Three, Deux = 2, Trois }, should be transformed to: const NumbersI18n = { 2: 'Deux', 3: 'Trois', ... } 102 | const memberMap = new Map( 103 | enumDeclaration.members.map((member) => { 104 | const value = enumValueMap.get(member)!; 105 | return [value, member] as const; 106 | }), 107 | ); 108 | 109 | return ts.factory.createVariableStatement( 110 | createModifiers(enumDeclaration.modifiers), 111 | ts.factory.createVariableDeclarationList( 112 | [ 113 | ts.factory.createVariableDeclaration( 114 | enumDeclaration.name, 115 | undefined, 116 | undefined, 117 | ts.factory.createAsExpression( 118 | ts.factory.createObjectLiteralExpression( 119 | [ 120 | ...[...memberMap.entries()] 121 | .filter(([key]) => typeof key === 'number') 122 | .map(([key, member]) => 123 | ts.factory.createPropertyAssignment( 124 | ts.factory.createNumericLiteral(key), 125 | enumNameMap.get(member)!, 126 | ), 127 | ), 128 | ...enumDeclaration.members.map((member) => 129 | ts.factory.createPropertyAssignment( 130 | enumNameMap.get(member)!, 131 | createLiteral(enumValueMap.get(member)!), 132 | ), 133 | ), 134 | ], 135 | true, 136 | ), 137 | ts.factory.createTypeReferenceNode('const'), 138 | ), 139 | ), 140 | ], 141 | ts.NodeFlags.Const, 142 | ), 143 | ); 144 | } 145 | 146 | function createModifiers(enumModifiers?: ts.NodeArray) { 147 | if (!enumModifiers) { 148 | return; 149 | } 150 | return enumModifiers.filter((mod) => mod.kind !== ts.SyntaxKind.ConstKeyword); 151 | } 152 | 153 | function createTypeAlias(enumDeclaration: ts.EnumDeclaration): ts.Node { 154 | const isStringEnum = enumDeclaration.members.every( 155 | (member) => member.initializer && ts.isStringLiteral(member.initializer), 156 | ); 157 | const keyOfTypeOperator = ts.factory.createTypeOperatorNode( 158 | ts.SyntaxKind.KeyOfKeyword, 159 | ts.factory.createTypeQueryNode(enumDeclaration.name), 160 | ); 161 | return ts.factory.createTypeAliasDeclaration( 162 | createModifiers(enumDeclaration.modifiers), 163 | enumDeclaration.name, 164 | undefined, 165 | ts.factory.createIndexedAccessTypeNode( 166 | ts.factory.createTypeQueryNode(enumDeclaration.name), 167 | isStringEnum 168 | ? keyOfTypeOperator 169 | : ts.factory.createIntersectionTypeNode([ 170 | keyOfTypeOperator, 171 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 172 | ]), 173 | ), 174 | ); 175 | } 176 | 177 | function createLiteral(value: string | number) { 178 | return typeof value === 'number' 179 | ? ts.factory.createNumericLiteral(value) 180 | : ts.factory.createStringLiteral(value); 181 | } 182 | -------------------------------------------------------------------------------- /src/transformers/import-extensions.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { transformRelativeImportExtensions } from './import-extensions.ts'; 3 | import { scenario } from '../transform.spec.ts'; 4 | 5 | describe(transformRelativeImportExtensions.name, async () => { 6 | await it('should not change a bare imports', async () => { 7 | const input = ` 8 | import 'module/path.js'; 9 | import 'module/path.cjs'; 10 | import 'module/path.mjs'; 11 | import('module/path.js'); 12 | import('module/path.cjs'); 13 | import('module/path.mjs'); 14 | `; 15 | await scenario(input, input, { relativeImportExtensions: true }); 16 | }); 17 | await it('should not change a relative import when relativeImportExtensions is false', async () => { 18 | const input = ` 19 | import './module/path.js'; 20 | `; 21 | await scenario(input, input, { relativeImportExtensions: false }); 22 | }); 23 | await it('should not rewrite a relative import when it already has ".ts"', async () => { 24 | const input = ` 25 | import './module/path.ts'; 26 | `; 27 | await scenario(input, input, { relativeImportExtensions: false }); 28 | }); 29 | await it('should rewrite a relative import when relativeImportExtensions is true', async () => { 30 | await scenario("import './module/path.js';", "import './module/path.ts';", { 31 | relativeImportExtensions: true, 32 | }); 33 | }); 34 | await it('should also rewrite .cjs and .mjs extensions', async () => { 35 | await scenario( 36 | `import './module/path.cjs'; 37 | import './module/path.mjs';`, 38 | `import './module/path.cts'; 39 | import './module/path.mts';`, 40 | { 41 | relativeImportExtensions: true, 42 | }, 43 | ); 44 | }); 45 | await it('should also rewrite type-only imports', async () => { 46 | await scenario( 47 | `import type { A } from './module/path.js'; 48 | import type { B } from './module/path.cjs'; 49 | import type { C } from './module/path.mjs';`, 50 | `import type { A } from './module/path.ts'; 51 | import type { B } from './module/path.cts'; 52 | import type { C } from './module/path.mts';`, 53 | { 54 | relativeImportExtensions: true, 55 | }, 56 | ); 57 | }); 58 | await it('should rewrite conditional imports', async () => { 59 | await scenario( 60 | `import('./module/path.js'); 61 | import('./module/path.cjs'); 62 | import('./module/path.mjs');`, 63 | `import('./module/path.ts'); 64 | import('./module/path.cts'); 65 | import('./module/path.mts');`, 66 | { 67 | relativeImportExtensions: true, 68 | }, 69 | ); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/transformers/import-extensions.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { TransformOptions } from '../transform.ts'; 3 | import type { TransformerResult } from './transformer-result.ts'; 4 | 5 | export function transformRelativeImportExtensions( 6 | node: ts.ImportDeclaration | ts.CallExpression, 7 | options: Pick, 8 | ): TransformerResult { 9 | if (!options.relativeImportExtensions) { 10 | return { changed: false, node }; 11 | } 12 | if (ts.isCallExpression(node)) { 13 | return transformRelativeImportCallExpression(node); 14 | } 15 | return transformRelativeImportDeclaration(node); 16 | } 17 | 18 | function transformRelativeImportCallExpression( 19 | node: ts.CallExpression, 20 | ): TransformerResult { 21 | if ( 22 | node.expression.kind !== ts.SyntaxKind.ImportKeyword || 23 | !node.arguments[0] || 24 | !ts.isStringLiteral(node.arguments[0]) 25 | ) { 26 | return { 27 | changed: false, 28 | node: node, 29 | }; 30 | } 31 | const moduleSpecifier = rewriteRelativeExtension(node.arguments[0].text); 32 | const changed = moduleSpecifier !== node.arguments[0].text; 33 | return { 34 | changed, 35 | node: changed 36 | ? ts.factory.createCallExpression(node.expression, node.typeArguments, [ 37 | ts.factory.createStringLiteral(moduleSpecifier), 38 | ]) 39 | : node, 40 | }; 41 | } 42 | 43 | function transformRelativeImportDeclaration( 44 | node: ts.ImportDeclaration, 45 | ): TransformerResult { 46 | if (!ts.isStringLiteral(node.moduleSpecifier)) { 47 | return { changed: false, node: node }; 48 | } 49 | 50 | const moduleSpecifier = rewriteRelativeExtension(node.moduleSpecifier.text); 51 | const changed = moduleSpecifier !== node.moduleSpecifier.text; 52 | return { 53 | changed, 54 | node: changed 55 | ? ts.factory.createImportDeclaration( 56 | node.modifiers, 57 | node.importClause, 58 | ts.factory.createStringLiteral(moduleSpecifier), 59 | node.attributes, 60 | ) 61 | : node, 62 | }; 63 | } 64 | 65 | function rewriteRelativeExtension(moduleSpecifier: string) { 66 | if (!moduleSpecifier.startsWith('.')) { 67 | return moduleSpecifier; 68 | } 69 | 70 | if (moduleSpecifier.endsWith('.js')) { 71 | return moduleSpecifier.replace(/\.js$/, '.ts'); 72 | } 73 | if (moduleSpecifier.endsWith('.mjs')) { 74 | return moduleSpecifier.replace(/\.mjs$/, '.mts'); 75 | } 76 | if (moduleSpecifier.endsWith('.cjs')) { 77 | return moduleSpecifier.replace(/\.cjs$/, '.cts'); 78 | } 79 | return moduleSpecifier; 80 | } 81 | -------------------------------------------------------------------------------- /src/transformers/namespaces.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import { transformNamespace } from './namespaces.ts'; 3 | import { scenario } from '../transform.spec.ts'; 4 | 5 | describe(transformNamespace.name, async () => { 6 | await it('should remove empty namespaces', async () => { 7 | await scenario(`namespace Foo {}`, ``); 8 | await scenario(`module Foo {}`, ``); 9 | }); 10 | 11 | await it('should not transform namespace declarations', async () => { 12 | await scenario(` 13 | declare namespace Foo { 14 | export const pi: 3.14; 15 | }`); 16 | }); 17 | 18 | await it('should transform namespaces without exports', async () => { 19 | await scenario( 20 | ` 21 | namespace Foo { 22 | console.log('Foo created'); 23 | }`, 24 | ` 25 | // @ts-ignore Migrated namespace with type-annotationify 26 | declare namespace Foo {} 27 | // @ts-ignore Migrated namespace with type-annotationify 28 | var Foo: Foo; 29 | { 30 | // @ts-ignore Migrated namespace with type-annotationify 31 | Foo ??= {}; 32 | console.log('Foo created'); 33 | } 34 | `, 35 | ); 36 | }); 37 | 38 | await it('should transform namespaces with a type exports', async () => { 39 | await scenario( 40 | ` 41 | namespace Foo { 42 | export type Bar = number; 43 | export interface Baz { 44 | name: string; 45 | } 46 | }`, 47 | ` 48 | // @ts-ignore Migrated namespace with type-annotationify 49 | declare namespace Foo { 50 | type Bar = number; 51 | interface Baz { 52 | name: string; 53 | } 54 | } 55 | // @ts-ignore Migrated namespace with type-annotationify 56 | var Foo: Foo; 57 | { 58 | // @ts-ignore Migrated namespace with type-annotationify 59 | Foo ??= {}; 60 | } 61 | `, 62 | ); 63 | }); 64 | 65 | await it('should transform namespaces with variable statement exports', async () => { 66 | await scenario( 67 | ` 68 | namespace Foo { 69 | export const pi: 3.14 = 3.14; 70 | }`, 71 | ` 72 | // @ts-ignore Migrated namespace with type-annotationify 73 | declare namespace Foo { 74 | const pi: 3.14; 75 | } 76 | // @ts-ignore Migrated namespace with type-annotationify 77 | var Foo: Foo; 78 | { 79 | // @ts-ignore Migrated namespace with type-annotationify 80 | Foo ??= {}; 81 | // @ts-ignore Migrated namespace with type-annotationify 82 | Foo.pi = 3.14; 83 | }`, 84 | ); 85 | }); 86 | 87 | await it('should transform namespace exports with "const" keyword using "@ts-ignore"', async () => { 88 | await scenario( 89 | ` 90 | namespace Foo { 91 | export const pi: 3.14 = 3.14; 92 | export let e: 2.71 = 2.71; 93 | export var tau: 6.28 = 6.28; 94 | } 95 | `, 96 | `// @ts-ignore Migrated namespace with type-annotationify 97 | declare namespace Foo { 98 | const pi: 3.14; 99 | let e: 2.71; 100 | var tau: 6.28; 101 | } 102 | // @ts-ignore Migrated namespace with type-annotationify 103 | var Foo: Foo; 104 | { 105 | // @ts-ignore Migrated namespace with type-annotationify 106 | Foo ??= {}; 107 | // @ts-ignore Migrated namespace with type-annotationify 108 | Foo.pi = 3.14; 109 | Foo.e = 2.71; 110 | Foo.tau = 6.28; 111 | }`, 112 | ); 113 | }); 114 | 115 | await it('should support exporting a namespace', async () => { 116 | await scenario( 117 | ` 118 | export namespace Foo { 119 | export const pi: 3.14 = 3.14; 120 | }`, 121 | ` 122 | // @ts-ignore Migrated namespace with type-annotationify 123 | export declare namespace Foo { 124 | const pi: 3.14; 125 | } 126 | 127 | // @ts-ignore Migrated namespace with type-annotationify 128 | export var Foo: Foo; 129 | { 130 | // @ts-ignore Migrated namespace with type-annotationify 131 | Foo ??= {}; 132 | // @ts-ignore Migrated namespace with type-annotationify 133 | Foo.pi = 3.14; 134 | }`, 135 | ); 136 | }); 137 | 138 | await it('should transform a namespace with a variable statement without initializer', async () => { 139 | await scenario( 140 | ` 141 | namespace Math2 { 142 | export let answer: number; 143 | }`, 144 | ` 145 | // @ts-ignore Migrated namespace with type-annotationify 146 | declare namespace Math2 { 147 | let answer: number; 148 | } 149 | // @ts-ignore Migrated namespace with type-annotationify 150 | var Math2: Math2; 151 | { 152 | // @ts-ignore Migrated namespace with type-annotationify 153 | Math2 ??= {}; 154 | }`, 155 | ); 156 | }); 157 | 158 | await it('should transform namespaces with a function export', async () => { 159 | await scenario( 160 | ` 161 | namespace Math2 { 162 | export function add (a: number, b: number): number { 163 | return a + b; 164 | } 165 | }`, 166 | ` 167 | // @ts-ignore Migrated namespace with type-annotationify 168 | declare namespace Math2 { 169 | function add(a: number, b: number): number; 170 | } 171 | // @ts-ignore Migrated namespace with type-annotationify 172 | var Math2: Math2; 173 | { 174 | // @ts-ignore Migrated namespace with type-annotationify 175 | Math2 ??= {}; 176 | function add(a: number, b: number): number { 177 | return a + b; 178 | } 179 | Math2.add = add; 180 | }`, 181 | ); 182 | }); 183 | 184 | await it('should transform namespaces with a class export', async () => { 185 | await scenario( 186 | ` namespace Zoo { 187 | export abstract class Animal { 188 | public legs; 189 | constructor(legs: number) { 190 | this.legs = legs; 191 | } 192 | abstract makeSound(): string; 193 | } 194 | export class Dog extends Animal { 195 | protected breed; 196 | constructor(legs: number, breed: string) { 197 | super(legs); 198 | this.breed = breed; 199 | } 200 | makeSound() { 201 | return 'Woof'; 202 | }; 203 | } 204 | export const dog = new Dog(4, 'Poodle'); 205 | } 206 | }`, 207 | ` // @ts-ignore Migrated namespace with type-annotationify 208 | declare namespace Zoo { 209 | abstract class Animal { 210 | legs: number; 211 | constructor(legs: number); 212 | abstract makeSound(): string; 213 | } 214 | class Dog extends Animal { 215 | protected breed: string; 216 | constructor(legs: number, breed: string); 217 | makeSound(): string; 218 | } 219 | const dog: Dog; 220 | } 221 | 222 | // @ts-ignore Migrated namespace with type-annotationify 223 | var Zoo: Zoo; 224 | { 225 | // @ts-ignore Migrated namespace with type-annotationify 226 | Zoo ??= {}; 227 | abstract class Animal { 228 | public legs; 229 | constructor(legs: number) { 230 | this.legs = legs; 231 | } 232 | abstract makeSound(): string; 233 | } 234 | // @ts-ignore Migrated namespace with type-annotationify 235 | Zoo.Animal = Animal; 236 | class Dog extends Animal { 237 | protected breed; 238 | constructor(legs: number, breed: string) { 239 | super(legs); 240 | this.breed = breed; 241 | } 242 | makeSound() { 243 | return 'Woof'; 244 | } 245 | } 246 | // @ts-ignore Migrated namespace with type-annotationify 247 | Zoo.Dog = Dog; 248 | // @ts-ignore Migrated namespace with type-annotationify 249 | Zoo.dog = new Dog(4, 'Poodle'); 250 | } 251 | `, 252 | ); 253 | }); 254 | 255 | await it('should add a @ts-ignore comment to class instance exports', async () => { 256 | await scenario( 257 | `namespace Farm { 258 | export class Animal {} 259 | export let dog = new Animal(); 260 | }`, 261 | `// @ts-ignore Migrated namespace with type-annotationify 262 | declare namespace Farm { 263 | class Animal {} 264 | let dog: Animal; 265 | } 266 | // @ts-ignore Migrated namespace with type-annotationify 267 | var Farm: Farm; 268 | { 269 | // @ts-ignore Migrated namespace with type-annotationify 270 | Farm ??= {}; 271 | class Animal {} 272 | // @ts-ignore Migrated namespace with type-annotationify 273 | Farm.Animal = Animal; 274 | // @ts-ignore Migrated namespace with type-annotationify 275 | Farm.dog = new Animal(); 276 | }`, 277 | ); 278 | }); 279 | 280 | describe('exported identifier transformations', async () => { 281 | await it('should transform exported variables to namespace properties', async () => { 282 | await scenario( 283 | ` 284 | namespace Foo { 285 | export const bar = 42; 286 | console.log(bar); 287 | export function qux() { 288 | return bar; 289 | } 290 | } 291 | `, 292 | ` 293 | // @ts-ignore Migrated namespace with type-annotationify 294 | declare namespace Foo { 295 | const bar = 42; 296 | function qux(): number; 297 | } 298 | 299 | // @ts-ignore Migrated namespace with type-annotationify 300 | var Foo: Foo; 301 | { 302 | // @ts-ignore Migrated namespace with type-annotationify 303 | Foo ??= {}; 304 | // @ts-ignore Migrated namespace with type-annotationify 305 | Foo.bar = 42; 306 | console.log(Foo.bar); 307 | function qux() { 308 | return Foo.bar; 309 | } 310 | Foo.qux = qux; 311 | } 312 | `, 313 | ); 314 | }); 315 | 316 | await it('should not transform shadowed parameters', async () => { 317 | await scenario( 318 | ` 319 | namespace Counter { 320 | export let count = 0; 321 | 322 | function doIncrement(count: number) { 323 | return count + 1; 324 | } 325 | export function increment() { 326 | count = doIncrement(count); 327 | } 328 | } 329 | `, 330 | ` 331 | // @ts-ignore Migrated namespace with type-annotationify 332 | declare namespace Counter { 333 | let count: number; 334 | function increment(): void; 335 | } 336 | // @ts-ignore Migrated namespace with type-annotationify 337 | var Counter: Counter; 338 | { 339 | // @ts-ignore Migrated namespace with type-annotationify 340 | Counter ??= {}; 341 | Counter.count = 0; 342 | function doIncrement(count: number) { 343 | return count + 1; 344 | } 345 | function increment() { 346 | Counter.count = doIncrement(Counter.count); 347 | } 348 | Counter.increment = increment; 349 | } 350 | `, 351 | ); 352 | }); 353 | 354 | await it('should not transform shadowed variables', async () => { 355 | await scenario( 356 | ` 357 | namespace Counter { 358 | export let count = 0; 359 | 360 | function logZero() { 361 | let count = 0; 362 | console.log(count); 363 | } 364 | 365 | export function increment10() { 366 | let result = count; 367 | for (let count = 0; count < 10; count++) { 368 | result++; 369 | } 370 | count = result 371 | } 372 | } 373 | `, 374 | ` 375 | // @ts-ignore Migrated namespace with type-annotationify 376 | declare namespace Counter { 377 | let count: number; 378 | function increment10(): void; 379 | } 380 | // @ts-ignore Migrated namespace with type-annotationify 381 | var Counter: Counter; 382 | { 383 | // @ts-ignore Migrated namespace with type-annotationify 384 | Counter ??= {}; 385 | Counter.count = 0; 386 | function logZero() { 387 | let count = 0; 388 | console.log(count); 389 | } 390 | function increment10() { 391 | let result = Counter.count; 392 | for (let count = 0; count < 10; count++) { 393 | result++; 394 | } 395 | Counter.count = result; 396 | } 397 | Counter.increment10 = increment10; } 398 | `, 399 | ); 400 | }); 401 | 402 | await it('should transform exported identifiers inside variable initializers', async () => { 403 | await scenario( 404 | ` 405 | namespace Counter { 406 | export let count = 0; 407 | export let count2: typeof count = count; 408 | } 409 | `, 410 | ` 411 | // @ts-ignore Migrated namespace with type-annotationify 412 | declare namespace Counter { 413 | let count: number; 414 | let count2: typeof count; 415 | } 416 | // @ts-ignore Migrated namespace with type-annotationify 417 | var Counter: Counter; 418 | { 419 | // @ts-ignore Migrated namespace with type-annotationify 420 | Counter ??= {}; 421 | Counter.count = 0; 422 | Counter.count2 = Counter.count; 423 | } 424 | `, 425 | ); 426 | }); 427 | 428 | await it('should not transform type annotations', async () => { 429 | await scenario( 430 | ` 431 | namespace Counter { 432 | export let count: number = 0; 433 | function n(): count { 434 | } 435 | } 436 | `, 437 | ` 438 | // @ts-ignore Migrated namespace with type-annotationify 439 | declare namespace Counter { 440 | let count: number; 441 | } 442 | // @ts-ignore Migrated namespace with type-annotationify 443 | var Counter: Counter; 444 | { 445 | // @ts-ignore Migrated namespace with type-annotationify 446 | Counter ??= {}; 447 | Counter.count = 0; 448 | function n(): count { 449 | } 450 | } 451 | `, 452 | ); 453 | }); 454 | 455 | await it('should transform variable names inside type queries', async () => { 456 | await scenario( 457 | ` 458 | namespace Counter { 459 | export let count: number = 0; 460 | function n(): typeof count { 461 | } 462 | } 463 | `, 464 | ` 465 | // @ts-ignore Migrated namespace with type-annotationify 466 | declare namespace Counter { 467 | let count: number; 468 | } 469 | // @ts-ignore Migrated namespace with type-annotationify 470 | var Counter: Counter; 471 | { 472 | // @ts-ignore Migrated namespace with type-annotationify 473 | Counter ??= {}; 474 | Counter.count = 0; 475 | function n(): typeof Counter.count { 476 | } 477 | } 478 | `, 479 | ); 480 | }); 481 | }); 482 | 483 | await it('should transform namespaces with a mix of exports', async () => { 484 | await scenario( 485 | ` 486 | namespace Foo { 487 | export const pi: 3.14 = 3.14, e: 2.71 = 2.71, tau: 6.28 = 6.28; 488 | export type Bar = number; 489 | export interface Baz { 490 | name: string; 491 | } 492 | }`, 493 | `// @ts-ignore Migrated namespace with type-annotationify 494 | declare namespace Foo { 495 | const pi: 3.14, e: 2.71, tau: 6.28; 496 | type Bar = number; 497 | interface Baz { 498 | name: string; 499 | } 500 | } 501 | // @ts-ignore Migrated namespace with type-annotationify 502 | var Foo: Foo; 503 | { 504 | // @ts-ignore Migrated namespace with type-annotationify 505 | Foo ??= {}; 506 | // @ts-ignore Migrated namespace with type-annotationify 507 | Foo.pi = 3.14, Foo.e = 2.71, Foo.tau = 6.28; 508 | }`, 509 | ); 510 | }); 511 | }); 512 | -------------------------------------------------------------------------------- /src/transformers/namespaces.ts: -------------------------------------------------------------------------------- 1 | import ts, { isTypeReferenceNode, type Identifier } from 'typescript'; 2 | import type { TransformerResult } from './transformer-result.ts'; 3 | const IGNORE_COMMENT = ' @ts-ignore Migrated namespace with type-annotationify'; 4 | 5 | export function transformNamespace( 6 | namespace: ts.ModuleDeclaration, 7 | ): TransformerResult { 8 | // Don't transpile empty namespaces 9 | if ( 10 | namespace.modifiers?.some( 11 | (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword, 12 | ) || 13 | namespace.body?.kind !== ts.SyntaxKind.ModuleBlock 14 | ) { 15 | return { changed: false, node: namespace }; 16 | } 17 | 18 | if (!namespace.body || namespace.body.statements.length === 0) { 19 | return { changed: true, node: [] }; // Remove empty namespaces, this is compatible with typescript transpilation 20 | } 21 | 22 | return { 23 | changed: true, 24 | node: [ 25 | createNamespaceDeclaration(namespace), 26 | ...createBlock(namespace, namespace.body), 27 | ], 28 | }; 29 | } 30 | 31 | function createNamespaceDeclaration(namespace: ts.ModuleDeclaration): ts.Node { 32 | const declarationText = ts.transpileDeclaration(namespace.getText(), { 33 | reportDiagnostics: false, 34 | compilerOptions: { strict: true }, 35 | }).outputText; 36 | const foreignDeclaration = ts.createSourceFile( 37 | 'ts2.ts', 38 | declarationText, 39 | ts.ScriptTarget.ESNext, 40 | /*setParentNodes:*/ false, 41 | ).statements[0]!; 42 | const declaration = ts.setTextRange(foreignDeclaration, namespace); 43 | 44 | // @ts-expect-error the parent is needed here 45 | declaration.parent = namespace.parent; 46 | 47 | return addIgnoreComment(declaration); 48 | } 49 | 50 | function addIgnoreComment(node: T): T { 51 | return ts.addSyntheticLeadingComment( 52 | node, 53 | ts.SyntaxKind.SingleLineCommentTrivia, 54 | IGNORE_COMMENT, 55 | /* hasTrailingNewLine: */ false, 56 | ); 57 | } 58 | 59 | function createBlock( 60 | namespace: ts.ModuleDeclaration, 61 | block: ts.ModuleBlock, 62 | ): ts.Node[] { 63 | const namespaceName = namespace.name as Identifier; 64 | const initialization = addIgnoreComment( 65 | ts.factory.createExpressionStatement( 66 | ts.factory.createBinaryExpression( 67 | namespaceName, 68 | ts.SyntaxKind.QuestionQuestionEqualsToken, 69 | ts.factory.createObjectLiteralExpression(), 70 | ), 71 | ), 72 | ); 73 | const exportedIdentifierNames: string[] = []; 74 | 75 | return [ 76 | addIgnoreComment( 77 | ts.factory.createVariableStatement( 78 | namespace.modifiers, 79 | ts.factory.createVariableDeclarationList([ 80 | ts.factory.createVariableDeclaration( 81 | namespaceName, 82 | undefined, 83 | ts.factory.createTypeReferenceNode(namespace.name as Identifier), 84 | ), 85 | ]), 86 | ), 87 | ), 88 | ts.factory.createBlock( 89 | [ 90 | initialization, 91 | ...block.statements 92 | .filter(isNotInterfaceOrTypeDeclaration) 93 | .flatMap((statement) => { 94 | if (isNamespaceExportableValue(statement) && isExported(statement)) 95 | switch (statement.kind) { 96 | case ts.SyntaxKind.VariableStatement: 97 | exportedIdentifierNames.push( 98 | ...statement.declarationList.declarations 99 | .map(({ name }) => name) 100 | .filter(ts.isIdentifier) 101 | .map(({ text }) => text), 102 | ); 103 | 104 | return toSyntheticExportedVariableStatement( 105 | statement, 106 | namespaceName, 107 | exportedIdentifierNames, 108 | ); 109 | case ts.SyntaxKind.FunctionDeclaration: 110 | return toSyntheticExportedFunctionDeclaration( 111 | transformExportedIdentifiersToNamespaceProperties( 112 | statement, 113 | namespaceName, 114 | exportedIdentifierNames, 115 | ), 116 | namespaceName, 117 | ); 118 | case ts.SyntaxKind.ClassDeclaration: 119 | return toSyntheticExportedClassDeclaration( 120 | transformExportedIdentifiersToNamespaceProperties( 121 | statement, 122 | namespaceName, 123 | exportedIdentifierNames, 124 | ), 125 | namespaceName, 126 | ); 127 | default: 128 | throw new Error( 129 | `Exported ${ts.SyntaxKind[(statement satisfies never as ts.Statement).kind]} not supported`, 130 | ); 131 | } 132 | return transformExportedIdentifiersToNamespaceProperties( 133 | statement, 134 | namespaceName, 135 | exportedIdentifierNames, 136 | ); 137 | }), 138 | ], 139 | true, 140 | ), 141 | ]; 142 | } 143 | 144 | /** 145 | * Transforms all exported identifiers to namespace properties 146 | * @example 147 | * // Before 148 | * export const foo = 'bar'; 149 | * export function qux() { 150 | * return foo; 151 | * } 152 | * // After 153 | * Foo.foo = 'bar'; 154 | * function qux() { 155 | * return Foo.foo; 156 | * } 157 | */ 158 | function transformExportedIdentifiersToNamespaceProperties( 159 | node: T, 160 | namespaceName: Identifier, 161 | exportedIdentifiers: readonly string[], 162 | ): T { 163 | /** 164 | * Rebinds identifiers that are shadowed by a variable declaration 165 | */ 166 | function removeShadowedNames( 167 | nodes: readonly Pick[], 168 | ) { 169 | const reboundNames = nodes 170 | .map(({ name }) => name) 171 | .filter(ts.isIdentifier) 172 | .map(({ text }) => text); 173 | exportedIdentifiers = exportedIdentifiers.filter( 174 | (id) => !reboundNames.includes(id), 175 | ); 176 | } 177 | 178 | if (ts.isFunctionDeclaration(node)) { 179 | // Remove shadowed parameters 180 | removeShadowedNames(node.parameters); 181 | } 182 | if ( 183 | ts.isForStatement(node) && 184 | node.initializer && 185 | ts.isVariableDeclarationList(node.initializer) 186 | ) { 187 | // Remove shadowed variables in for initializer 188 | removeShadowedNames(node.initializer.declarations); 189 | } 190 | 191 | if (ts.isBlock(node)) { 192 | node.statements 193 | .filter(ts.isVariableStatement) 194 | .forEach((variableStatement) => 195 | // Remove shadowed variables 196 | removeShadowedNames(variableStatement.declarationList.declarations), 197 | ); 198 | } 199 | 200 | if (ts.isParameter(node)) { 201 | // Skip parameters 202 | return node; 203 | } 204 | if (ts.isVariableDeclaration(node)) { 205 | // Skip variable name declarations 206 | return ts.factory.updateVariableDeclaration( 207 | node, 208 | node.name, 209 | node.exclamationToken, 210 | node.type, 211 | node.initializer 212 | ? (transformExportedIdentifiersToNamespaceProperties( 213 | node.initializer, 214 | namespaceName, 215 | exportedIdentifiers, 216 | ) as ts.Expression) 217 | : undefined, 218 | ) as unknown as T; 219 | } 220 | 221 | if (isTypeReferenceNode(node)) { 222 | // Skip type references 223 | return node; 224 | } 225 | if (ts.isIdentifier(node) && exportedIdentifiers.includes(node.text)) { 226 | // Replace identifier with namespace property. I.e. `foo` -> `Foo.foo` 227 | return ts.factory.createPropertyAccessExpression( 228 | namespaceName, 229 | node, 230 | ) as unknown as T; 231 | } 232 | 233 | // Recursive, do the same for all children 234 | return ts.visitEachChild( 235 | node, 236 | (child) => { 237 | return transformExportedIdentifiersToNamespaceProperties( 238 | child, 239 | namespaceName, 240 | exportedIdentifiers, 241 | ); 242 | }, 243 | undefined, 244 | ); 245 | } 246 | 247 | function toSyntheticExportedFunctionDeclaration( 248 | statement: ts.FunctionDeclaration, 249 | namespaceName: ts.Identifier, 250 | ): ts.Statement[] { 251 | return [ 252 | ts.factory.updateFunctionDeclaration( 253 | statement, 254 | modifiersExceptExport(statement.modifiers), 255 | statement.asteriskToken, 256 | statement.name, 257 | statement.typeParameters, 258 | statement.parameters, 259 | statement.type, 260 | statement.body, 261 | ), 262 | ts.factory.createExpressionStatement( 263 | ts.factory.createBinaryExpression( 264 | ts.factory.createPropertyAccessExpression( 265 | namespaceName, 266 | statement.name!, 267 | ), 268 | ts.SyntaxKind.EqualsToken, 269 | statement.name!, 270 | ), 271 | ), 272 | ]; 273 | } 274 | 275 | function toSyntheticExportedClassDeclaration( 276 | statement: ts.ClassDeclaration, 277 | namespaceName: ts.Identifier, 278 | ): ts.Statement[] { 279 | return [ 280 | ts.factory.createClassDeclaration( 281 | modifiersExceptExport(statement.modifiers), 282 | statement.name, 283 | statement.typeParameters, 284 | statement.heritageClauses, 285 | statement.members, 286 | ), 287 | addIgnoreComment( 288 | ts.factory.createExpressionStatement( 289 | ts.factory.createBinaryExpression( 290 | ts.factory.createPropertyAccessExpression( 291 | namespaceName, 292 | statement.name!, 293 | ), 294 | ts.SyntaxKind.EqualsToken, 295 | statement.name!, 296 | ), 297 | ), 298 | ), 299 | ]; 300 | } 301 | 302 | /** 303 | * Converts `export const foo = 'bar';` to `Namespace.foo = 'bar'` 304 | */ 305 | function toSyntheticExportedVariableStatement( 306 | variableExport: ts.VariableStatement, 307 | namespaceName: ts.Identifier, 308 | exportedIdentifierNames: string[], 309 | ) { 310 | const declarations = variableExport.declarationList.declarations.filter( 311 | (declaration) => declaration.initializer, 312 | ); 313 | if (!declarations.length) { 314 | return ts.factory.createEmptyStatement(); 315 | } 316 | 317 | let expression = exportValueOfVariableDeclaration( 318 | namespaceName, 319 | declarations[0]!, 320 | exportedIdentifierNames, 321 | ); 322 | let hasClassDeclaration = 323 | declarations[0]!.initializer?.kind === ts.SyntaxKind.NewExpression; 324 | for (let i = 1; i < declarations.length; i++) { 325 | expression = ts.factory.createBinaryExpression( 326 | expression, 327 | ts.SyntaxKind.CommaToken, 328 | exportValueOfVariableDeclaration( 329 | namespaceName, 330 | declarations[i]!, 331 | exportedIdentifierNames, 332 | ), 333 | ); 334 | hasClassDeclaration = 335 | declarations[i]!.initializer?.kind === ts.SyntaxKind.NewExpression; 336 | } 337 | const expressionStatement = ts.factory.createExpressionStatement(expression); 338 | 339 | if ( 340 | variableExport.declarationList.flags & ts.NodeFlags.Const || 341 | hasClassDeclaration 342 | ) { 343 | return addIgnoreComment(expressionStatement); 344 | } 345 | 346 | return expressionStatement; 347 | } 348 | 349 | function exportValueOfVariableDeclaration( 350 | namespaceName: ts.Identifier, 351 | declaration: ts.VariableDeclaration, 352 | exportedIdentifierNames: string[], 353 | ): ts.Expression { 354 | return ts.factory.createBinaryExpression( 355 | ts.factory.createPropertyAccessExpression( 356 | namespaceName, 357 | declaration.name as ts.Identifier, 358 | ), 359 | ts.SyntaxKind.EqualsToken, 360 | transformExportedIdentifiersToNamespaceProperties( 361 | declaration.initializer!, 362 | namespaceName, 363 | exportedIdentifierNames, 364 | ), 365 | ); 366 | } 367 | 368 | function modifiersExceptExport( 369 | modifiers: ts.NodeArray | undefined, 370 | ) { 371 | return modifiers?.filter((mod) => mod.kind !== ts.SyntaxKind.ExportKeyword); 372 | } 373 | 374 | function isNotInterfaceOrTypeDeclaration(statement: ts.Statement): boolean { 375 | return !isInterfaceOrTypeDeclaration(statement); 376 | } 377 | 378 | function isNamespaceExportableValue( 379 | statement: ts.Statement, 380 | ): statement is NamespaceExportableValue { 381 | return ( 382 | ts.isVariableStatement(statement) || 383 | ts.isFunctionDeclaration(statement) || 384 | ts.isClassDeclaration(statement) 385 | ); 386 | } 387 | 388 | function isInterfaceOrTypeDeclaration( 389 | statement: ts.Statement, 390 | ): statement is ts.InterfaceDeclaration | ts.TypeAliasDeclaration { 391 | return ( 392 | ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) 393 | ); 394 | } 395 | 396 | function isExported(statement: NamespaceExportableValue): boolean { 397 | return ( 398 | statement.modifiers?.some( 399 | (mod) => mod.kind === ts.SyntaxKind.ExportKeyword, 400 | ) ?? false 401 | ); 402 | } 403 | 404 | export type NamespaceExportableValue = 405 | | ts.VariableStatement 406 | | ts.FunctionDeclaration 407 | | ts.ClassDeclaration; 408 | -------------------------------------------------------------------------------- /src/transformers/transformer-result.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | export interface TransformerResult { 3 | changed: boolean; 4 | node: TNode; 5 | } 6 | -------------------------------------------------------------------------------- /stryker.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", 3 | "_comment": "This config was generated using 'stryker init'. Please take a look at: https://stryker-mutator.io/docs/stryker-js/configuration/ for more information.", 4 | "packageManager": "npm", 5 | "reporters": ["html", "clear-text", "progress", "dashboard"], 6 | "testRunner": "tap", 7 | "testRunner_comment": "Take a look at https://stryker-mutator.io/docs/stryker-js/tap-runner for information about the tap plugin.", 8 | "coverageAnalysis": "perTest", 9 | "checkers": ["typescript"], 10 | "tap": { 11 | "nodeArgs": ["--experimental-strip-types"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "composite": true, 5 | "esModuleInterop": true, 6 | "skipLibCheck": true, 7 | "target": "es2023", 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "moduleDetection": "force", 11 | "isolatedModules": true, 12 | "verbatimModuleSyntax": true, 13 | "strict": true, 14 | "noUncheckedIndexedAccess": true, 15 | "noImplicitOverride": true, 16 | "module": "NodeNext", 17 | "outDir": "dist", 18 | "sourceMap": true, 19 | "declaration": true, 20 | "declarationMap": true, 21 | "lib": ["es2023", "ESNext.Iterator"], 22 | "allowImportingTsExtensions": true, 23 | "rewriteRelativeImportExtensions": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.prod.json" }, 5 | { "path": "./tsconfig.test.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["src/**/*.ts"], 4 | "exclude": ["src/**/*.spec.ts"], 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["src/**/*.spec.ts"], 4 | "compilerOptions": { 5 | "noEmit": true 6 | }, 7 | "references": [ 8 | { 9 | "path": "./tsconfig.prod.json" 10 | } 11 | ] 12 | } 13 | --------------------------------------------------------------------------------