用 DeepSeek 翻译一篇 2ality 的好文章,翻译不合理部分稍作修改
TypeScript 是 JavaScript 加上类型语法
尽管以下对 TypeScript 的描述并非 100% 准确(存在少数例外),但我发现它有助于理解其工作原理:TypeScript 就是 JavaScript 加上类型语法。
考虑以下 TypeScript 代码:
function add(x: number, y: number): number {
return x + y;
}
若要运行这段代码,我们必须移除类型语法,得到由 JavaScript 引擎执行的 JavaScript 代码:
function add(x, y) {
return x + y;
}
类型语法仅用于类型检查(在编辑和编译期间),为我们提供一致性校验和更好的自动补全功能。
运行 TypeScript 代码的方式
考虑以下 TypeScript 项目结构:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
tsconfig.json
是配置文件,用于指导 TypeScript 如何进行类型检查和代码编译- 其余文件均为 TypeScript 源代码
下面我们探索运行这些代码的不同方式。
直接运行 TypeScript
目前大多数服务端运行时已支持直接运行 TypeScript 代码——例如 Node.js、Deno 和 Bun。以 Node.js 23.6.0+ 为例:
cd ts-app/
node src/main.ts
打包 TypeScript
在开发 Web 应用时,打包是常见操作——即使是纯 JavaScript 项目也是如此:将所有 JavaScript 代码(应用代码和库代码)合并为单个 JavaScript 文件(有时会分割为少量文件),通常由 HTML 文件加载。这带来多个优势:
- 在 HTTP/2 之前,单个连接只能传输一个文件。但这个优势如今已不再重要
- 客户端需要请求和处理的每个文件仍会产生少量开销(即使无需新建连接)
- 提升 Web 服务器效率,避免处理大量(通常是小型)文件
- 单个大文件的压缩效率优于多个小文件
大多数打包工具直接或通过插件支持 TypeScript。这意味着我们可以通过打包工具生成的 bundle.js
文件来运行 TypeScript 代码:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
bundle.js
将 TypeScript 转译为 JavaScript
另一种方式是通过 TypeScript 编译器 tsc
将代码编译为 JavaScript 后运行。在服务端 JavaScript 运行时内置支持 TypeScript 之前,这是唯一可行的运行方式。
将源代码编译为另一种源代码的过程称为转译。tsconfig.json
决定了转译输出的位置。假设我们输出到 dist/
目录:
ts-app/
tsconfig.json
src/
main.ts
util.ts
util_test.ts
test/
integration_test.ts
dist/
src/
main.js
util.js
util_test.js
test/
integration_test.js
本地导入 TypeScript 模块的文件扩展名
默认情况下,TypeScript 不会修改导入模块的说明符。这意味着转译后的代码中的本地导入必须如下所示:
// main.ts
import {helperFunc} from './util.js';
但我们也可以配置 TypeScript 将文件扩展名 .ts
改写为 .js
(更多信息)。这样以下导入方式就能同时支持直接运行和转译后的代码:
// main.ts
import {helperFunc} from './util.ts';
向 npm registry 发布库包
npm registry 仍是最主流的包发布平台。虽然 Node.js 支持应用包使用 TypeScript 编写,但库包必须以 JavaScript 形式发布——这样才能同时被 JavaScript 和 TypeScript 项目使用。因此,单个库文件 lib.ts
通常会被发布为五个文件(其中四个由 TypeScript 编译产生):
- 必需文件:
lib.js
:lib.ts
的 JavaScript 部分lib.d.ts
:lib.ts
的类型部分
- 可选文件:源映射文件。它们将编译输出的代码位置映射回
lib.ts
lib.js.map
:lib.js
的源映射lib.d.ts.map
:lib.d.ts
的源映射lib.ts
:前两个源映射的目标文件
(下文将详细解释这些文件的含义。)
示例库包结构:
ts-lib/
package.json
tsconfig.json
src/
lib.ts
dist/
src/
lib.js
lib.js.map
lib.d.ts
lib.d.ts.map
package.json
是 npm 的包描述文件。其中部分数据(如 package exports)也会被 TypeScript 使用——例如在导入该包时查找类型信息dist/
下的所有文件均由 TypeScript 生成,通常不纳入版本控制系统(因其可随时重新生成)- 只有
tsconfig.json
不会被上传到 npm
必需文件:.js 和 .d.ts
有趣的是,.lib.ts
中结合的 JavaScript 和类型会被拆分为仅含 JavaScript 的 lib.js
和仅含类型的 lib.d.ts
。为何如此设计?这使得库包能同时被 JavaScript 和 TypeScript 代码使用:
- JavaScript 代码可忽略
.d.ts
文件 - TypeScript 用它们进行类型检查、自动补全和文档提示
实际上,许多编辑器(如 Visual Studio Code)在编辑 JavaScript 代码时会启用轻量级 TypeScript 模式,从而提供基础的类型检查和代码补全。
以下是 TypeScript 输入文件 lib.ts
:
/** 两数相加 */
export function add(x: number, y: number): number {
return x + y; // 数值相加
}
它被拆分为 lib.js
:
/** 两数相加 */
export function add(x, y) {
return x + y; // 数值相加
}
//# sourceMappingURL=lib.js.map
和 lib.d.ts
:
/** 两数相加 */
export declare function add(x: number, y: number): number;
//# sourceMappingURL=lib.d.ts.map
注意事项:
- 两个文件都指向各自的源映射
- 默认情况下两个文件都包含注释(但可通过配置移除):
lib.js
保留所有注释以提升可读性lib.d.ts
仅保留 JSDoc 注释(/** */
),因许多 IDE 用其显示内联文档
可选文件:源映射
若将文件 I
编译为文件 O
,则 O
的源映射会将 O
中的代码位置映射回 I
。这意味着我们可以通过 O
展示 I
的信息,例如:
lib.js.map
:将lib.js
的位置映射到lib.ts
,在运行前者时提供后者的调试和堆栈追踪lib.d.ts.map
:将lib.d.ts
的行号映射到lib.ts
,支持从导入跳转到原文件定义
除堆栈追踪外,所有源映射相关功能都需要访问原始 TypeScript 源代码。因此同时发布 lib.ts
才有意义。
lib.js.map
示例:
{
"version": 3,
"file": "lib.js",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
"mappings": "AAAA,uBAAuB;AACvB,MAAM,UAAU,···"
}
lib.d.ts.map
示例:
{
"version": 3,
"file": "lib.d.ts",
"sourceRoot": "",
"sources": [
"../../src/lib.ts"
],
"names": [],
"mappings": "AAAA,uBAAuB;AACvB,wBAAgB,GAAG,···"
}
实际输出中,"mappings"
的内容会被缩写,且 JSON 会被压缩为单行。
DefinitelyTyped:为无类型 npm 包提供类型的仓库
如今许多 npm 包自带 TypeScript 类型,但并非全部。此时 DefinitelyTyped 可以提供帮助:若其支持无类型包 pkg
,则可额外安装 @types/pkg
来获取类型定义。
Node.js 的一个重要 DefinitelyTyped 包是 @types/node
,包含其所有 API 的类型定义。若在 Node.js 上开发 TypeScript,通常需要将此包作为开发依赖。
使用 tsc 以外的工具编译 TypeScript
回顾 tsc
的主要功能(本节暂忽略源映射):
- 将 TypeScript 文件编译为 JavaScript 文件
- 将 TypeScript 文件编译为类型声明文件
- 对 TypeScript 文件进行类型检查
其中第 3 项非常复杂,只有 tsc
能完成。但对于前两项,存在只需语法处理的 TypeScript 子集,这意味着可以使用更快速的外部工具处理。
甚至可以通过 tsconfig.json
设置来警告是否超出这些子集范围(更多信息)。实践中这种限制影响不大。
类型剥离
类型剥离是将 TypeScript 编译为 JavaScript 的最简方式:
- 编译过程仅移除类型语法
- 不转译任何语言特性
第二点意味着不支持以下 TypeScript 特性:
- JSX(TypeScript 中的类 HTML 语法,如 React 所用)
- 枚举
- 类构造函数中的参数属性
- 命名空间
- 需要编译的未来 JavaScript 特性
类型剥离的显著优势是不需要任何配置(通过 tsconfig.json
或其他方式),这使得相关平台对 TypeScript 变更具有更好的稳定性。
类型剥离技术:用空格替换类型
ts-blank-space
工具(Bloomberg 的 Ashley Claymore 开发)开创了一种巧妙的技术:不直接删除类型语法,而是用空格替换。这使得输出代码的源位置保持不变,堆栈追踪中的位置信息仍适用于输入文件,从而减少对源映射的依赖:调试和跳转定义仍需源映射,但类型剥离生成的 JavaScript 与原始 TypeScript 非常接近,通常已足够使用。
示例输入(TypeScript):
function add(x: number, y: number): number {
return x + y;
}
输出(JavaScript):
function add(x , y ) {
return x + y;
}
可通过 ts-blank-space 在线演示进一步探索。
独立声明
"独立声明"是一种编写 TypeScript 的风格,使得声明文件(.d.ts
)的类型易于提取。这主要要求为导出的函数显式指定返回类型。虽然 TypeScript 可以自动推断,但简单的声明文件生成工具无法做到。未导出的函数则无需此约束。
原始 TypeScript 文件 strings.ts
:
// 不符合:导出的函数缺少返回类型
export function upperCase(str: string) {
return str.toUpperCase();
}
// 未导出,无需返回类型
function internalHelper() {}
采用独立声明风格的 strings.ts
:
// 符合:导出的函数有返回类型
export function upperCase(str: string): string {
return str.toUpperCase();
}
// 未导出,无需返回类型
function internalHelper() {}
生成的声明文件 strings.d.ts
(注意 internalHelper
未包含):
export declare function upperCase(str: string): string;
JSR——JavaScript registry
JavaScript registry JSR 是 npm registry 的替代方案,其工作方式如下:
- 对于 TypeScript 包,只需上传
.ts
文件 - 安装 TypeScript 包的方式取决于平台:
- 在支持 TypeScript 库包的 JavaScript 平台上,JSR 直接安装 TypeScript
- 其他平台会自动生成
.js
和.d.ts
文件并安装,同时附带.ts
文件。为实现自动生成,TypeScript 代码必须遵守"no slow types" 规则——类似于独立声明
相比之下,npm registry 中的 TypeScript 库包需上传 .js
和 .d.ts
文件才能在 Node.js 使用。
JSR 还提供 npm 不具备的功能,如自动文档生成。详见官方文档"Why JSR?"。
JSR 的归属
引用官方文档"Governance":
JSR 不属于任何个人或组织。它是一个面向整个 JavaScript 生态的社区驱动项目。
JSR 目前由 Deno 公司运营,正在筹建监督委员会,计划未来迁移至基金会。
编辑 TypeScript
两大主流 JavaScript IDE:
- Visual Studio Code(免费)
- WebStorm(付费)
本节以 Visual Studio Code 为例,但部分内容也适用于其他 IDE。
在 Visual Studio Code 中,我们有两种类型检查方式:
- 自动对打开的文件进行实时类型检查(内置 TypeScript 支持)
- 通过调用
tsc
进行全代码库类型检查(通过任务功能实现)
官方文档提供任务功能的详细信息。
对 JavaScript 文件进行类型检查
TypeScript 也可对 JavaScript 文件进行类型检查(效果有限)。但通过 JSDoc 注释可提供类型信息:
/**
* @param {number} x - 第一个操作数
* @param {number} y - 第二个操作数
* @returns {number} 两个操作数的和
*/
function add(x, y) {
return x + y;
}
这种方式本质上仍是使用 TypeScript,只是换用了不同语法。
优势:
- 无需构建步骤即可运行代码(即使在浏览器等不支持 TypeScript 的平台)
- 也可通过 JSDoc 注释从
.js
文件生成.d.ts
文件(需要额外构建步骤),详见TypeScript 手册
- 也可通过 JSDoc 注释从
- 支持在 JavaScript 代码库中逐步增强类型安全
劣势:
- 语法使用体验下降
对比接口定义方式:
TypeScript 方式:
interface Point {
x: number;
y: number;
/** 可选属性 */
z?: number;
}
JSDoc 方式:
/**
* @typedef Point
* @prop {number} x
* @prop {number} y
* @prop {number} [z] 可选属性
*/
更多信息参考 TypeScript 手册:
延伸阅读
- 免费在线书籍 "Tackling TypeScript"
- 2ality 文章 "tsconfig.json 指南"
- 2ality 演示 "直接在 TypeScript 中实现 Node.js CLI 应用"
- 2ality 教程 "发布基于 ESM 的 TypeScript npm 包"