From 4eeb96b1a25307ec23ff176e6b2e60623086a476 Mon Sep 17 00:00:00 2001 From: Ashley Claymore Date: Wed, 29 Jan 2025 10:30:29 +0000 Subject: [PATCH] feat: support type-only/uninstantiated namespaces (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for type-only/uninstantiated namespaces. i.e. namespaces that only use `type` and `interface`. ```ts export class C {} export namespace C { export type T = string; } ``` **Before:** Error. only `declare namespace ...` was allowed ❌ **Now:** Ok. namespace declaration erased ✅ --- The following cases remain unchanged: ```ts // Instantiated namespace errors namespace A { export let x = 1 } // error namespace B { ; } // error // Ambient namespace is erased declare namespace C { export let x = 1 } // ok erased declare module D { export let x = 1 } // ok erased // `module`-keyword errors module E { export let x = 1 } // error module F { export type x = number } // error ``` ## Testing Unit tests for both supported cases and unsupported cases (errors) have been added. ## Context This addition was motivated by [`--erasableSyntaxOnly`](https://github.com/microsoft/TypeScript/pull/61011) and the recognition that in today's TypeScript, the only way to augment a class with types after the initial declaration is via namespaces. Whilst that use case can be solved using `declare namespace` that comes with [a hazard](https://github.com/bloomberg/ts-blank-space/blob/main/docs/unsupported_syntax.md#the-declare--hazard). The combination of `--erasableSyntaxOnly` checks and transforming uninstantiated namespaces provides a safer option. Thanks to @jakebailey for brining this to our attention https://github.com/microsoft/TypeScript/pull/61011#issuecomment-2608557589 --------- Signed-off-by: Ashley Claymore Co-authored-by: Rob Palmer --- docs/unsupported_syntax.md | 75 +++++++++++++++++++++++++++--- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 25 +++++++++- tests/errors.test.ts | 62 +++++++++++++++++++++--- tests/fixture/cases/namespaces.ts | 36 ++++++++++++++ tests/fixture/output/namespaces.js | 36 ++++++++++++++ 7 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 tests/fixture/cases/namespaces.ts create mode 100644 tests/fixture/output/namespaces.js diff --git a/docs/unsupported_syntax.md b/docs/unsupported_syntax.md index 07b9251..d69e805 100644 --- a/docs/unsupported_syntax.md +++ b/docs/unsupported_syntax.md @@ -7,13 +7,17 @@ The following TypeScript features can not be erased by `ts-blank-space` because they have runtime semantics -- `enum` (unless `declare enum`) [more details](#enum) -- `namespace` (unless `declare namespace`) -- `module` (unless `declare module`) +- `enum` (unless `declare enum`) [more details](#enums) +- `namespace` (unless only contains types) [more details](#namespace-declarations) +- `module` (unless `declare module`) [more details](#module-namespace-declarations) - `import lib = ...`, `export = ...` (TypeScript style CommonJS) - `constructor(public x) {}` [more details](#constructor-parameter-properties) -### Enum +For more details on use of `declare` see [the `declare` hazard](#the-declare--hazard). + +### Enums + +The following `enum` declaration will not be transformed by `ts-blank-space`. ```typescript enum Direction { @@ -24,7 +28,7 @@ enum Direction { } ``` -Alternative approach to defining an enum like value and type, which is `ts-blank-space` compatible: +An alternative approach to defining an enum like value and type, which is `ts-blank-space` compatible: ```typescript const Direction = { @@ -39,13 +43,16 @@ type Direction = (typeof Direction)[keyof typeof Direction]; ### Constructor Parameter Properties +The following usage of a constructor parameter property will not be transformed by `ts-blank-space`. + ```typescript class Person { constructor(public name: string) {} + // ^^^^^^ } ``` -Alternative `ts-blank-space` compatible approach: +The equivalent `ts-blank-space` compatible approach: ```typescript class Person { @@ -56,6 +63,62 @@ class Person { } ``` +### `namespace` declarations + +While sharing the same syntax there are technically two categories of `namespace` within TypeScript. Instantiated and non-instantiated. Instantiated namespaces create objects that exist at runtime. Non-instantiated namespaces can be erased. A namespace is non-instantiated if it only contains types - more specifically it may only contain: + +- type aliases: `[export] type A = ...` +- interfaces: `[export] interface I { ... }` +- Importing types from other namespaces: `import A = OtherNamespace.X` +- More non-instantiated namespaces (the rule is recursive) + +`ts-blank-space` will always erase non-instantiated namespaces and namespaces marked with [`declare`](#the-declare--hazard). + +Examples of supported namespace syntax can be seen in the test fixture [tests/fixture/cases/namespaces.ts](../tests/fixture/cases/namespaces.ts). Error cases can be seen in [tests/errors](../tests/errors.test.ts). + +### `module` namespace declarations + +`ts-blank-space` only erases TypeScript's `module` namespace declarations if they are marked with `declare` (see [`declare` hazard](#the-declare--hazard)). + +All other TypeScript `module` declarations will trigger the `onError` callback and be left in the output text verbatim. Including an empty declaration: + +```ts +module M {} // `ts-blank-space` error +``` + +Note that, since TypeScript 5.6, use of `module` namespace declarations (not to be confused with _"ambient module declarations"_) will be shown with a strike-through (~~`module`~~) to hint that the syntax is deprecated in favour of [`namespace`](#namespace-declarations). + +See https://github.com/microsoft/TypeScript/issues/51825 for more information. + +### The `declare ...` hazard + +As with `declare const ...`, while `ts-blank-space` will erase syntax such as `declare enum ...` and `declare namespace ...` without error it should be used with understanding and mild caution. +`declare` in TypeScript is an _assertion_ by the author that a value will exist at runtime. + +For example: + + +```ts +declare namespace N { + export const x: number; +} +console.log(N.x); +``` + +The above will not be a build time error and will be transformed to: + + +```js + + + +console.log(N.x); +``` + +So it may throw at runtime if nothing created a runtime value for `N` as promised by the `declare`. + +Tests are a great way to catch issues that may arise from an incorrect `declare`. + ## Compile time only syntax TypeScript type assertions have no runtime semantics, however `ts-blank-space` does not erase the legacy prefix-style type assertions. diff --git a/package-lock.json b/package-lock.json index 3267f22..77b6558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ts-blank-space", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ts-blank-space", - "version": "0.5.0", + "version": "0.5.1", "license": "Apache-2.0", "dependencies": { "typescript": "5.1.6 - 5.7.x" diff --git a/package.json b/package.json index a354b03..12d9a77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ts-blank-space", "description": "A small, fast, pure JavaScript type-stripper that uses the official TypeScript parser.", - "version": "0.5.0", + "version": "0.5.1", "license": "Apache-2.0", "homepage": "https://bloomberg.github.io/ts-blank-space", "contributors": [ diff --git a/src/index.ts b/src/index.ts index 8f23606..f7ab2fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -517,7 +517,10 @@ function visitExportAssignment(node: ts.ExportAssignment): VisitResult { } function visitEnumOrModule(node: ts.EnumDeclaration | ts.ModuleDeclaration): VisitResult { - if (node.modifiers && modifiersContainsDeclare(node.modifiers)) { + if ( + (node.modifiers && modifiersContainsDeclare(node.modifiers)) || + (node.kind === SK.ModuleDeclaration && !valueNamespaceWorker(node as ts.ModuleDeclaration)) + ) { blankStatement(node); return VISIT_BLANKED; } else { @@ -526,6 +529,26 @@ function visitEnumOrModule(node: ts.EnumDeclaration | ts.ModuleDeclaration): Vis } } +function valueNamespaceWorker(node: ts.Node): boolean { + switch (node.kind) { + case SK.TypeAliasDeclaration: + case SK.InterfaceDeclaration: + return false; + case SK.ImportEqualsDeclaration: { + const { modifiers } = node as ts.ImportEqualsDeclaration; + return modifiers?.some((m) => m.kind === SK.ExportKeyword) || false; + } + case SK.ModuleDeclaration: { + if (!(node.flags & tslib.NodeFlags.Namespace)) return true; + const { body } = node as ts.ModuleDeclaration; + if (!body) return false; + if (body.kind === SK.ModuleDeclaration) return valueNamespaceWorker(body); + return body.forEachChild(valueNamespaceWorker) || false; + } + } + return true; +} + function modifiersContainsDeclare(modifiers: ArrayLike): boolean { for (let i = 0; i < modifiers.length; i++) { const modifier = modifiers[i]; diff --git a/tests/errors.test.ts b/tests/errors.test.ts index ee09f71..e5df8fb 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -1,6 +1,7 @@ import { it, mock } from "node:test"; import assert from "node:assert"; import tsBlankSpace from "../src/index.ts"; +import ts from "typescript"; it("errors on enums", () => { const onError = mock.fn(); @@ -42,21 +43,70 @@ it("errors on parameter properties", () => { ); }); -it("errors on namespace value", () => { +function errorCallbackToModuleDeclarationNames(onError: import("node:test").Mock<(...args: any[]) => void>): string[] { + return onError.mock.calls.map(({ arguments: [node] }) => { + assert(ts.isModuleDeclaration(node)); + assert(ts.isIdentifier(node.name)); + return node.name.escapedText.toString(); + }); +} + +it("errors on TypeScript `module` declarations due to overlap with github.com/tc39/proposal-module-declarations", () => { const onError = mock.fn(); const out = tsBlankSpace( ` - namespace N {} - module M {} + module A {} + module B { export type T = string; } + module C { export const V = ""; } + module D.E {} `, onError, ); - assert.equal(onError.mock.callCount(), 2); + assert.equal(onError.mock.callCount(), 4); + const errorNodeNames = errorCallbackToModuleDeclarationNames(onError); + assert.deepEqual(errorNodeNames, ["A", "B", "C", "D"]); + assert.equal( + out, + ` + module A {} + module B { export type T = string; } + module C { export const V = ""; } + module D.E {} + `, + ); +}); + +it("errors on instantiated namespaces due to having runtime emit", () => { + const onError = mock.fn(); + const out = tsBlankSpace( + ` + namespace A { 1; } + namespace B { globalThis; } + namespace C { export let x; } + namespace D { declare let x; } + namespace E { export type T = any; 2; } + namespace F { export namespace Inner { 3; } } + namespace G.H { 4; } + namespace I { export import X = E.T } + namespace J { {} } + `, + onError, + ); + assert.equal(onError.mock.callCount(), 9); + const errorNodeNames = errorCallbackToModuleDeclarationNames(onError); + assert.deepEqual(errorNodeNames, ["A", "B", "C", "D", "E", "F", "G", /* H (nested)*/ "I", "J"]); assert.equal( out, ` - namespace N {} - module M {} + namespace A { 1; } + namespace B { globalThis; } + namespace C { export let x; } + namespace D { declare let x; } + namespace E { export type T = any; 2; } + namespace F { export namespace Inner { 3; } } + namespace G.H { 4; } + namespace I { export import X = E.T } + namespace J { {} } `, ); }); diff --git a/tests/fixture/cases/namespaces.ts b/tests/fixture/cases/namespaces.ts new file mode 100644 index 0000000..a4a69b3 --- /dev/null +++ b/tests/fixture/cases/namespaces.ts @@ -0,0 +1,36 @@ + +namespace Empty {} +// ^^^^^^^^^^^^^^^ empty namespace + +namespace TypeOnly { + type A = string; + + export type B = A | number; + + export interface I {} + + export namespace Inner { + export type C = B; + } +} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type-only namespace + +namespace My.Internal.Types { + export type Foo = number; +} + +namespace With.Imports { + import Types = My.Internal.Types; + export type Foo = Types.Foo; +} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ nested namespaces + +// declaring the existence of a runtime namespace: +declare namespace Declared { + export function foo(): void +} +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `declare namespace` +Declared.foo(); // May throw at runtime if declaration was false + +export const x: With.Imports.Foo = 1; +// ^^^^^^^^^^^^^^^^^^ diff --git a/tests/fixture/output/namespaces.js b/tests/fixture/output/namespaces.js new file mode 100644 index 0000000..8a212ca --- /dev/null +++ b/tests/fixture/output/namespaces.js @@ -0,0 +1,36 @@ + + +// ^^^^^^^^^^^^^^^ empty namespace + + + + + + + + + + + + +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type-only namespace + + + + + + + + + +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ nested namespaces + +// declaring the existence of a runtime namespace: + + + +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `declare namespace` +Declared.foo(); // May throw at runtime if declaration was false + +export const x = 1; +// ^^^^^^^^^^^^^^^^^^