2026-03-22 | 研究与探索 | UNLOCK

TypeScript 独有语法如何编译到 JavaScript

本文不讨论类型检查相关。

某些语法在新版 JavaScript 中已成为标准(如 ES2020 加入的 ?? 算符),本文不讨论这种从高版本到低版本的语法兼容转换,也不讨论装饰器语法。

纯类型项

TypeScript 自己声称 “TypeScript 就是 JavaScript 加上类型语法”(TypeScript is JavaScript with syntax for types)。这句话大体是对的,大多数 TypeScript 相比于 JavaScript 所增加的语法可以被简单逐字符替换为空格,因此有了 ts-blank-spaceamaro 这种项目,其将 TypeScript 代码中类型相关的部分去掉,剩下的代码可以直接作为 JavaScript 代码运行。

可直接去掉的项,包括但不限于:

  • 类型注释:let s: string = ".."
  • private, readonly 关键字
  • 范型参数:Uint8Array<ArrayBuffer>
  • 类型转换: <A>a, a as A
  • 类的 extends 关键字
  • import 语句中的 type 标记: import { type A } from "a";

但实际上,TypeScript 存在一些语法,不能通过直接去除的方式成为 JavaScript 代码,见下文。

名称空间(Namespace)

namespace 在 TypeScript 1.5 之前的版本写作 module,那时还没有 ES Module,网页中的脚本全在同一个全局作用域中执行,人们常用 IIFE 来建立独有的作用域来避免污染全局空间,名称空间就是 IIFE 写法的语法糖。

namesapce 的编译方式是替换为 IIFE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// TypeScript
namespace A {
var x = 114;
console.log('in A: x = ' + x);
export var y = 514;
console.log('in A: y = ' + y);
}

console.log('out of A: A.y = ' + A.y);

// JavaScript
var A;
(function (A) {
var x = 114;
console.log('in A: x = ' + x);
A.y = 514;
console.log('in A: y = ' + A.y);
})(A || (A = {}));
console.log('out of A: A.y = ' + A.y);

其中可以执行语句,声明局部变量,就像在普通函数体中一样。

TypeScript 支持在名称空间中声明变量时加上 export 描述符,这样会把这个变量变成名称空间的属性,称为导出变量,例如上面示例中的 y,在 A 的内部和外部都转换成了 A.y

类型也可以导出,写法如 export type T = number,这时简单删除即可,判断被导出的符号是值变量还是类型比较简单,和普通变量一样,看它的声明即可,typeinterface 这些一定是类型。

麻烦之处在于,同一个名称空间可以在同一个文件中多次声明,也可以在不同文件中声明(在脚本模式下所有文件都视作在全局作用域运行,同名的名称空间运行时都是同一个变量),只要有一个名称空间中声明了变量,其他同名名称空间中使用这个变量都会作为该名称空间的属性来用。例如在上一个例子的基础上再加一个文件:

1
2
3
namespace A {
console.log('ns2 in A: y = ' + y);
}

对应的编译结果是:

1
2
3
4
var A;
(function (A) {
console.log('ns2 in A: y = ' + A.y);
})(A || (A = {}));

自动将 y 转换成了 A.y。但如果局部作用域中有名叫 y 的变量,就不会转换了:

1
2
3
4
5
6
7
8
9
10
11
12
// TypeScript
namespace A {
var y = 514;
console.log('ns2 in A: y = ' + y);
}

// JavaScript
var A;
(function (A) {
var y = 514;
console.log('ns2 in A: y = ' + y);
})(A || (A = {}));

因此编译器在转换名称空间中的内容时,需要能记住符号是不是导出变量。在同一个文件内看来还是能解决的。而对于跨文件的情况,现在的第三方工具一般一次只转换单个文件,只能寄希望于随着 ES Module 的普及大家不要再使用 namespace 和脚本模式了。

import var = ...export = ...

语法 import x = A.y; 只用于给 namespace 的导出变量起别名,后面跟的必须是 <namespace>.<item> 形式,可以是嵌套的 namespace<item> 可以是类型或者变量,如果是类型,擦除即可;如果是变量的话,转换方式是将 import 替换为 var

1
2
3
4
5
// TypeScript
import x = A.x;

// JavaScript
var x = A.x;

import x = require("...") 语法是为了直接对应 CommonJS 的 var x = require("...") 写法同时又便于静态分析,转换方式是将 import 替换为 const(目标版本老于 ES6 时则替换为 var):

1
2
3
4
5
6
7
// TypeScript
import x = require("./mod1");
console.log('x.x = ' + x.x);

// JavaScript
const x = require("./mod1");
console.log('x.x = ' + x.x);

export = ... 语法也是为了对应 CommonJS 的 module.exports = ... 写法,转换方式是将 export 替换为 module.exports并移到文件末尾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// TypeScript
var x = 123;
export = {
x: x,
};
console.log('(1) in mod1: x = ' + x);
x = x + 1;
console.log('(2) in mod1: x = ' + x);

// JavaScript
var x = 123;
console.log('(1) in mod1: x = ' + x);
x = x + 1;
console.log('(2) in mod1: x = ' + x);
module.exports = {
x: x,
};

参数属性(Parameter Properties)

TypeScript 的参数属性(Parameter Properties)语法支持在类构造器的参数前加 public/private/protected/readonly 修饰符来为类增加一个与该参数同名的属性。由参数属性增加的属性和普通属性一样,在 super() 调用后初始化,受 useDefineForClassFields 选项影响。注意,参数属性的参数可以在 super() 调用前被修改,结果是相应的属性使用修改后的参数值来初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// TypeScript
class A extends String {
constructor(public n: number) {
n = n + 1;
super();
}
}

// JavaScript (target = es2022)
class A extends String {
n;
constructor(n) {
n = n + 1;
super();
this.n = n;
}
}

枚举(Enum)

enum 也是转换为 IIFE。对于枚举 E 的每一项枚举成员声明 x = expr 或者 x

  • 如果 expr 是字符串常量,转换为 E["x"] = expr
  • 如果 expr 是数字常量或者其它,转换为 E[E["x"] = expr] = "x"
  • 如果没有 expr 且是第一个枚举成员,按 expr 是数字常量 0 来生成
  • 如果没有 expr 且上一个枚举成员是数字常量 k,按 expr 是数字常量 k + 1 来生成
  • 其它没有 expr 的情况转换为 E[E["x"] = void 0] = "x"

对于“常量”的定义可参见 TypeScript 文档。除了文档中描述的,字符串的简单计算也被支持,如 "a" + "b";使用 const 定义的编译期常量也被支持,例如 const a = 1;,注意声明中不能有类型注释,并且初始化表达式也必须是没有 as string 等的常量表达式,否则编译器会认为是非常量,报错。

例子(有编译错误):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// TypeScript
const num1 = 114 + 514;
const non_const = Math.random() > 0.5 ? 1 : 'str2';
enum E {
a,
b,
c = 0.5,
d,
e = 'e',
f = e + 'f',
g = num1,
h = non_const
}

// JavaScript
const num1 = 114 + 514;
const non_const = Math.random() > 0.5 ? 1 : 'str2';
var E;
(function (E) {
E[E["a"] = 0] = "a";
E[E["b"] = 1] = "b";
E[E["c"] = 0.5] = "c";
E[E["d"] = 1.5] = "d";
E["e"] = "e";
E["f"] = "ef";
E[E["g"] = 628] = "g";
E[E["h"] = non_const] = "h";
})(E || (E = {}));

这里的难点在于要编译期计算常量表达式和判断 const 变量是不是常量表达式。最大的问题是 const 变量可以跨文件和模块影响 enum 的生成结果。

const enum 的规则和 enum 一样,但是是纯类型的。可以以减少编译期优化为代价,总是按照 enum 规则生成,以支持单文件生成。

相关参考