[翻译] What is TypeScript? An overview for JavaScript programmers

15 阅读9分钟

用 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.jslib.ts 的 JavaScript 部分
    • lib.d.tslib.ts 的类型部分
  • 可选文件:源映射文件。它们将编译输出的代码位置映射回 lib.ts
    • lib.js.maplib.js 的源映射
    • lib.d.ts.maplib.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 的主要功能(本节暂忽略源映射):

  1. 将 TypeScript 文件编译为 JavaScript 文件
  2. 将 TypeScript 文件编译为类型声明文件
  3. 对 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 为例,但部分内容也适用于其他 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 手册
  • 支持在 JavaScript 代码库中逐步增强类型安全

劣势:

  • 语法使用体验下降

对比接口定义方式:

TypeScript 方式:

interface Point {
  x: number;
  y: number;
  /** 可选属性 */
  z?: number;
}

JSDoc 方式:

/**
 * @typedef Point
 * @prop {number} x
 * @prop {number} y
 * @prop {number} [z] 可选属性
 */

更多信息参考 TypeScript 手册:

延伸阅读