Skip to content

Commit 4eeb96b

Browse files
acutmorerobpalme
andauthored
feat: support type-only/uninstantiated namespaces (#32)
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]>
1 parent 14c8181 commit 4eeb96b

File tree

7 files changed

+224
-16
lines changed

7 files changed

+224
-16
lines changed

docs/unsupported_syntax.md

+69-6
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@
77

88
The following TypeScript features can not be erased by `ts-blank-space` because they have runtime semantics
99

10-
- `enum` (unless `declare enum`) [more details](#enum)
11-
- `namespace` (unless `declare namespace`)
12-
- `module` (unless `declare module`)
10+
- `enum` (unless `declare enum`) [more details](#enums)
11+
- `namespace` (unless only contains types) [more details](#namespace-declarations)
12+
- `module` (unless `declare module`) [more details](#module-namespace-declarations)
1313
- `import lib = ...`, `export = ...` (TypeScript style CommonJS)
1414
- `constructor(public x) {}` [more details](#constructor-parameter-properties)
1515

16-
### Enum
16+
For more details on use of `declare` see [the `declare` hazard](#the-declare--hazard).
17+
18+
### Enums
19+
20+
The following `enum` declaration will not be transformed by `ts-blank-space`.
1721

1822
```typescript
1923
enum Direction {
@@ -24,7 +28,7 @@ enum Direction {
2428
}
2529
```
2630

27-
Alternative approach to defining an enum like value and type, which is `ts-blank-space` compatible:
31+
An alternative approach to defining an enum like value and type, which is `ts-blank-space` compatible:
2832

2933
```typescript
3034
const Direction = {
@@ -39,13 +43,16 @@ type Direction = (typeof Direction)[keyof typeof Direction];
3943

4044
### Constructor Parameter Properties
4145

46+
The following usage of a constructor parameter property will not be transformed by `ts-blank-space`.
47+
4248
```typescript
4349
class Person {
4450
constructor(public name: string) {}
51+
// ^^^^^^
4552
}
4653
```
4754

48-
Alternative `ts-blank-space` compatible approach:
55+
The equivalent `ts-blank-space` compatible approach:
4956

5057
```typescript
5158
class Person {
@@ -56,6 +63,62 @@ class Person {
5663
}
5764
```
5865

66+
### `namespace` declarations
67+
68+
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:
69+
70+
- type aliases: `[export] type A = ...`
71+
- interfaces: `[export] interface I { ... }`
72+
- Importing types from other namespaces: `import A = OtherNamespace.X`
73+
- More non-instantiated namespaces (the rule is recursive)
74+
75+
`ts-blank-space` will always erase non-instantiated namespaces and namespaces marked with [`declare`](#the-declare--hazard).
76+
77+
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).
78+
79+
### `module` namespace declarations
80+
81+
`ts-blank-space` only erases TypeScript's `module` namespace declarations if they are marked with `declare` (see [`declare` hazard](#the-declare--hazard)).
82+
83+
All other TypeScript `module` declarations will trigger the `onError` callback and be left in the output text verbatim. Including an empty declaration:
84+
85+
```ts
86+
module M {} // `ts-blank-space` error
87+
```
88+
89+
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).
90+
91+
See https://github.com/microsoft/TypeScript/issues/51825 for more information.
92+
93+
### The `declare ...` hazard
94+
95+
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.
96+
`declare` in TypeScript is an _assertion_ by the author that a value will exist at runtime.
97+
98+
For example:
99+
100+
<!-- prettier-ignore -->
101+
```ts
102+
declare namespace N {
103+
export const x: number;
104+
}
105+
console.log(N.x);
106+
```
107+
108+
The above will not be a build time error and will be transformed to:
109+
110+
<!-- prettier-ignore -->
111+
```js
112+
113+
114+
115+
console.log(N.x);
116+
```
117+
118+
So it may throw at runtime if nothing created a runtime value for `N` as promised by the `declare`.
119+
120+
Tests are a great way to catch issues that may arise from an incorrect `declare`.
121+
59122
## Compile time only syntax
60123

61124
TypeScript type assertions have no runtime semantics, however `ts-blank-space` does not erase the legacy prefix-style type assertions.

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ts-blank-space",
33
"description": "A small, fast, pure JavaScript type-stripper that uses the official TypeScript parser.",
4-
"version": "0.5.0",
4+
"version": "0.5.1",
55
"license": "Apache-2.0",
66
"homepage": "https://bloomberg.github.io/ts-blank-space",
77
"contributors": [

src/index.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,10 @@ function visitExportAssignment(node: ts.ExportAssignment): VisitResult {
517517
}
518518

519519
function visitEnumOrModule(node: ts.EnumDeclaration | ts.ModuleDeclaration): VisitResult {
520-
if (node.modifiers && modifiersContainsDeclare(node.modifiers)) {
520+
if (
521+
(node.modifiers && modifiersContainsDeclare(node.modifiers)) ||
522+
(node.kind === SK.ModuleDeclaration && !valueNamespaceWorker(node as ts.ModuleDeclaration))
523+
) {
521524
blankStatement(node);
522525
return VISIT_BLANKED;
523526
} else {
@@ -526,6 +529,26 @@ function visitEnumOrModule(node: ts.EnumDeclaration | ts.ModuleDeclaration): Vis
526529
}
527530
}
528531

532+
function valueNamespaceWorker(node: ts.Node): boolean {
533+
switch (node.kind) {
534+
case SK.TypeAliasDeclaration:
535+
case SK.InterfaceDeclaration:
536+
return false;
537+
case SK.ImportEqualsDeclaration: {
538+
const { modifiers } = node as ts.ImportEqualsDeclaration;
539+
return modifiers?.some((m) => m.kind === SK.ExportKeyword) || false;
540+
}
541+
case SK.ModuleDeclaration: {
542+
if (!(node.flags & tslib.NodeFlags.Namespace)) return true;
543+
const { body } = node as ts.ModuleDeclaration;
544+
if (!body) return false;
545+
if (body.kind === SK.ModuleDeclaration) return valueNamespaceWorker(body);
546+
return body.forEachChild(valueNamespaceWorker) || false;
547+
}
548+
}
549+
return true;
550+
}
551+
529552
function modifiersContainsDeclare(modifiers: ArrayLike<ts.ModifierLike>): boolean {
530553
for (let i = 0; i < modifiers.length; i++) {
531554
const modifier = modifiers[i];

tests/errors.test.ts

+56-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { it, mock } from "node:test";
22
import assert from "node:assert";
33
import tsBlankSpace from "../src/index.ts";
4+
import ts from "typescript";
45

56
it("errors on enums", () => {
67
const onError = mock.fn();
@@ -42,21 +43,70 @@ it("errors on parameter properties", () => {
4243
);
4344
});
4445

45-
it("errors on namespace value", () => {
46+
function errorCallbackToModuleDeclarationNames(onError: import("node:test").Mock<(...args: any[]) => void>): string[] {
47+
return onError.mock.calls.map(({ arguments: [node] }) => {
48+
assert(ts.isModuleDeclaration(node));
49+
assert(ts.isIdentifier(node.name));
50+
return node.name.escapedText.toString();
51+
});
52+
}
53+
54+
it("errors on TypeScript `module` declarations due to overlap with github.com/tc39/proposal-module-declarations", () => {
4655
const onError = mock.fn();
4756
const out = tsBlankSpace(
4857
`
49-
namespace N {}
50-
module M {}
58+
module A {}
59+
module B { export type T = string; }
60+
module C { export const V = ""; }
61+
module D.E {}
5162
`,
5263
onError,
5364
);
54-
assert.equal(onError.mock.callCount(), 2);
65+
assert.equal(onError.mock.callCount(), 4);
66+
const errorNodeNames = errorCallbackToModuleDeclarationNames(onError);
67+
assert.deepEqual(errorNodeNames, ["A", "B", "C", "D"]);
68+
assert.equal(
69+
out,
70+
`
71+
module A {}
72+
module B { export type T = string; }
73+
module C { export const V = ""; }
74+
module D.E {}
75+
`,
76+
);
77+
});
78+
79+
it("errors on instantiated namespaces due to having runtime emit", () => {
80+
const onError = mock.fn();
81+
const out = tsBlankSpace(
82+
`
83+
namespace A { 1; }
84+
namespace B { globalThis; }
85+
namespace C { export let x; }
86+
namespace D { declare let x; }
87+
namespace E { export type T = any; 2; }
88+
namespace F { export namespace Inner { 3; } }
89+
namespace G.H { 4; }
90+
namespace I { export import X = E.T }
91+
namespace J { {} }
92+
`,
93+
onError,
94+
);
95+
assert.equal(onError.mock.callCount(), 9);
96+
const errorNodeNames = errorCallbackToModuleDeclarationNames(onError);
97+
assert.deepEqual(errorNodeNames, ["A", "B", "C", "D", "E", "F", "G", /* H (nested)*/ "I", "J"]);
5598
assert.equal(
5699
out,
57100
`
58-
namespace N {}
59-
module M {}
101+
namespace A { 1; }
102+
namespace B { globalThis; }
103+
namespace C { export let x; }
104+
namespace D { declare let x; }
105+
namespace E { export type T = any; 2; }
106+
namespace F { export namespace Inner { 3; } }
107+
namespace G.H { 4; }
108+
namespace I { export import X = E.T }
109+
namespace J { {} }
60110
`,
61111
);
62112
});

tests/fixture/cases/namespaces.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
namespace Empty {}
3+
// ^^^^^^^^^^^^^^^ empty namespace
4+
5+
namespace TypeOnly {
6+
type A = string;
7+
8+
export type B = A | number;
9+
10+
export interface I {}
11+
12+
export namespace Inner {
13+
export type C = B;
14+
}
15+
}
16+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ type-only namespace
17+
18+
namespace My.Internal.Types {
19+
export type Foo = number;
20+
}
21+
22+
namespace With.Imports {
23+
import Types = My.Internal.Types;
24+
export type Foo = Types.Foo;
25+
}
26+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ nested namespaces
27+
28+
// declaring the existence of a runtime namespace:
29+
declare namespace Declared {
30+
export function foo(): void
31+
}
32+
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `declare namespace`
33+
Declared.foo(); // May throw at runtime if declaration was false
34+
35+
export const x: With.Imports.Foo = 1;
36+
// ^^^^^^^^^^^^^^^^^^

tests/fixture/output/namespaces.js

+36
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)