Skip to content

Commit

Permalink
feat: support type-only/uninstantiated namespaces (#32)
Browse files Browse the repository at this point in the history
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`](microsoft/TypeScript#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
microsoft/TypeScript#61011 (comment)

---------

Signed-off-by: Ashley Claymore <[email protected]>
Co-authored-by: Rob Palmer <[email protected]>
  • Loading branch information
acutmore and robpalme authored Jan 29, 2025
1 parent 14c8181 commit 4eeb96b
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 16 deletions.
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-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 {
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:

- 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:

<!-- 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;
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.

0 comments on commit 4eeb96b

Please sign in to comment.