Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support type-only/uninstantiated namespaces #32

Merged
merged 4 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions docs/unsupported_syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-declarations)
acutmore marked this conversation as resolved.
Show resolved Hide resolved
- `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 {
Expand All @@ -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 = {
Expand All @@ -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 {
Expand All @@ -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:
acutmore marked this conversation as resolved.
Show resolved Hide resolved

- 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` declarations
acutmore marked this conversation as resolved.
Show resolved Hide resolved

`ts-blank-space` only erases TypeScript's `module` declarations if they are marked with `declare` (see [`declare` hazard](#the-declare--hazard)).
acutmore marked this conversation as resolved.
Show resolved Hide resolved

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 the knowledge that of what it symbolizes.
acutmore marked this conversation as resolved.
Show resolved Hide resolved
When using `declare` in TypeScript it is an _assertion_ by the author than the value will exist at runtime.
acutmore marked this conversation as resolved.
Show resolved Hide resolved

For example:

<!-- prettier-ignore -->
```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:

<!-- prettier-ignore -->
```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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
25 changes: 24 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ts.ModifierLike>): boolean {
for (let i = 0; i < modifiers.length; i++) {
const modifier = modifiers[i];
Expand Down
62 changes: 56 additions & 6 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 { {} }
`,
);
});
Expand Down
36 changes: 36 additions & 0 deletions tests/fixture/cases/namespaces.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link

@magic-akari magic-akari Feb 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our one-pass design (SWC), while optimal for speed, inherently defers complex symbol resolution — a trade-off that aligns with tsc's isolatedModules for explicit import type syntax in ESM interactions.

However, this architecture faces new challenges as we now support namespaces. Unlike ESM's enforced type/value distinction, namespace imports/exports (e.g., My.Internal.Types) require non-trivial static analysis to resolve ambiguities:

  • Prior Approach: Namespaces were ignored, sidestepping the need for deep resolution.
  • Current Need: Determining if Types is a value or type within namespaces demands preliminary metadata collection, contradicting our single-scan constraint.

Adopting TypeScript syntax that mirrors ESM's explicitness — such as:

import type Types = My.Internal.Types;  // namespace type binding

would extend the same design principle to namespaces. By declaring intent upfront, the compiler could resolve symbols in a single pass without heuristic fallbacks, bridging the gap between ESM rigor and namespace flexibility.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #38 to add more test cases.

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;
// ^^^^^^^^^^^^^^^^^^
36 changes: 36 additions & 0 deletions tests/fixture/output/namespaces.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.