为新 TypeScript 项目配置 tsconfig

1. 概览

文件 tsconfig.json 决定了 TypeScript 如何进行类型检查和编译代码。如果配置不当,你可能会在运行时遇到 import 错误,或者编译器会遗漏许多类型错误。

这篇文章将从零开始为新的 Node.js 项目构建一个 tsconfig.json。我们将启用严格检查标志、选择正确的模块系统,然后将 build 配置与 test 配置分离。读完之后,你将拥有一套开箱即用的配置,并理解其中的每一行。

2. 初始化项目

创建目录并将 TypeScript 作为开发依赖安装:

mkdir order-service && cd order-service
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init

命令 tsc --init 会生成一个包含大量注释的 tsconfig.json。这个文件便于查阅,但不易维护。我们将用一个简洁版本替换它,明确注明每个选项。

接下来,在 package.json 中声明项目使用 ES Module:

{
  "name": "order-service",
  "type": "module",
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/index.js"
  }
}

行 "type": "module" 告诉 Node.js 各 .js 文件是 ESM。这个选择直接影响 TypeScript 如何解析模块,如你将在第 3 节看到的。

3. Node.js 的最小配置

这是基础 tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "types": ["node"],

    "rootDir": ".",
    "outDir": "dist",
    "sourceMap": true,
    "declaration": false,

    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src", "tests"]
}

3.1. target、lib 和 types

  • target 是编译器输出的 JavaScript 版本。ES2022 适合 Node.js 18 及以上。使用这个 target,async/await、可选链和类字段会保持原样,不会被降级。
  • lib 声明运行时可用的 API。没有 "DOM",所以你不会在服务器代码中无意调用 document 或 window。
  • types: ["node"] 仅加载 Node.js 的类型。如果不声明,TypeScript 会自动加载 node_modules/@types 中的所有包。这有时会引入不希望的全局类型。

3.2. module 和 moduleResolution

对于直接在 Node.js 上运行的项目,两个选项都使用 NodeNext。在这种模式下,TypeScript 模拟 Node.js 解析 import 的方式。它读取 package.json 中的 "type" 和 "exports" 字段,并要求相对 import 必须有文件后缀。

假设你有两个文件:

// src/order.ts
export interface Order {
  id: string;
  items: string[];
  note?: string;
}

export function totalItems(order: Order): number {
  return order.items.length;
}
// src/index.ts
import { totalItems } from "./order";

console.log(totalItems({ id: "A1", items: ["pho", "tra-da"] }));

运行 npx tsc --noEmit 会报错:

src/index.ts:1:28 - error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './order.js'?

你需要改为 import { totalItems } from "./order.js";。后缀是 .js 而不是 .ts,因为运行时 Node.js 读取的是已编译的文件。这个规则初看很奇怪,但它帮助你避免部署时的 ERR_MODULE_NOT_FOUND 错误。

3.3. rootDir 和 outDir

outDir 是构建后 .js 文件所在的目录。rootDir 决定了 outDir 内部的目录结构。在基础文件中,rootDir 是 "." 因为 include 既有 src 又有 tests。在第 6 节,build 配置会将 rootDir 改为 src,使得 dist/index.js 在正确的位置,而不是 dist/src/index.js。

4. 严格模式及其他检查标志

标志 strict: true 启用一组检查,包括 strictNullChecks、noImplicitAny 和 strictFunctionTypes。对于新项目,你应该总是启用它。对于大型已有代码库,事后启用会花费更多时间。

除了 strict,还应添加以下标志:

{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

将这些行合并到基础文件的 compilerOptions 块中。前两个标志值得更详细的解释。

4.1. noUncheckedIndexedAccess

默认情况下,TypeScript 认为索引访问的元素总是存在的。实际上不是这样:

import type { Order } from "./order.js";

const ordersById: Record<string, Order> = {};

const order = ordersById["A1"];
console.log(order.id);

启用这个标志时,order 的类型是 Order | undefined,编译器会报错:

error TS18048: 'order' is possibly 'undefined'.

你必须先检查再使用:if (order) { console.log(order.id); }。如果关闭这个标志,上述代码仍能编译,但运行时会抛出 TypeError。

4.2. exactOptionalPropertyTypes

这个标志区分“没有属性”和“属性值为 undefined”:

import type { Order } from "./order.js";

const order: Order = { id: "A2", items: [], note: undefined };
error TS2375: Type '{ id: string; items: never[]; note: undefined; }' is not assignable
to type 'Order' with 'exactOptionalPropertyTypes: true'.

当你使用 Object.keys、in 操作符或序列化到 JSON 时,这种区分很重要。如果你确实想允许 undefined,请明确声明:note?: string | undefined。

注意: exactOptionalPropertyTypes 有时与第三方库的类型冲突,特别是表单处理和 ORM 库。如果这个标志引起过多无法控制的错误,可以关闭它。但 noUncheckedIndexedAccess 应该保留。

5. 模块之间的交互

添加以下两个标志到 compilerOptions:

{
  "compilerOptions": {
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

verbatimModuleSyntax 有一条简单的规则:没有 type 的 import 在输出中保持原样,带 type 的 import 则被删除。这样,编译结果容易预测。代价是你必须明确标记仅用于类型的 import:

// 错误: Order 仅是接口
import { Order } from "./order.js";
error TS1484: 'Order' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.
// 正确
import type { Order } from "./order.js";
import { totalItems } from "./order.js";

isolatedModules 确保每个文件都可以被独立编译。esbuild、swc 和 Vitest 等工具都是逐文件处理的,所以这个标志帮助你在问题出现之前发现它们。

你可能在许多旧指南中见过 esModuleInterop。使用 module: NodeNext 时,这个标志默认启用,所以不需要额外声明。

6. 分离 typecheck 和 build 配置

根 tsconfig.json 用于编辑器和 typecheck 命令。它包括 tests。构建时,你不想把测试文件放入 dist。因此,创建一个额外的 tsconfig.build.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "noEmit": false
  },
  "include": ["src"],
  "exclude": ["**/*.test.ts"]
}

extends 继承根文件的全部 compilerOptions。在子文件中,include 和 exclude 会完全替换父文件中的值而非合并。运行 build 命令:

npm run build
ls dist
index.js  index.js.map  order.js  order.js.map

dist 目录只包含应用代码,得益于 rootDir: "src",它们直接放在根部。

6.1. 用 Vitest 编写测试

安装 Vitest:

npm install --save-dev vitest

在 scripts 中添加 "test": "vitest run",然后为 totalItems 函数编写测试:

// tests/order.test.ts
import { describe, expect, it } from "vitest";
import type { Order } from "../src/order.js";
import { totalItems } from "../src/order.js";

describe("totalItems", () => {
  it("正确统计订单中的菜品数量", () => {
    const order: Order = { id: "A1", items: ["pho", "tra-da", "quay"] };
    expect(totalItems(order)).toBe(3);
  });

  it("对于空订单返回 0", () => {
    expect(totalItems({ id: "A2", items: [] })).toBe(0);
  });
});
npm run typecheck && npm test
 ✓ tests/order.test.ts (2 tests)
 Test Files  1 passed (1)
      Tests  2 passed (2)

Vitest 在运行时自动剥离类型而不检查它们。因此,务必在 CI 中运行 typecheck。否则,有类型错误的测试仍然可能“通过”。

7. 使用打包工具的项目变体

如果代码由 Vite、esbuild 或 webpack 打包而不是直接在 Node.js 上运行,改变模块部分如下:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "jsx": "react-jsx"
  }
}
  • moduleResolution: "Bundler" 允许 import 不需要文件后缀,因为打包工具自己解析。
  • noEmit: true 意味着 tsc 只检查类型,由打包工具负责输出文件。
  • 添加 "DOM" 因为代码运行在浏览器中。jsx 行仅在使用 React 时需要。

注意: 不要对直接由 node dist/index.js 运行的代码使用 Bundler。编译器会接受缺少 .js 后缀的 import,但 Node.js 会在运行时报错。

8. 常见错误

使用 paths 创建别名,然后用 tsc 构建。 假设你声明 "paths": { "@/*": ["src/*"] }。编辑器理解这个别名,但 tsc 不会在输出中重写路径,所以 node 找不到 @/order.js。对于 Node.js 项目,在 package.json 中使用子路径导入:

{
  "imports": {
    "#src/*": "./dist/*"
  }
}

然后用 import { totalItems } from "#src/order.js"; 导入。在 NodeNext 模式下,TypeScript 读取 imports 字段并从 dist 反向映射到 src,所以编辑器和运行时都能正确解析。

滥用 skipLibCheck。 这个标志只跳过 .d.ts 文件的检查,使构建更快,避免来自库类型的错误。它不会降低你代码的安全性。不过,如果你自己编写 .d.ts 文件,记住这些文件中的错误也会被忽略。

忘记 include。 没有 include 时,TypeScript 会扫描整个目录,包括 dist。结果通常是错误 TS5055: Cannot write file ... because it would overwrite input file。

混用 CommonJS 和 ESM。 如果 package.json 没有 "type": "module" 但你仍然使用 NodeNext,.ts 文件会被编译为 CommonJS。这样,import 仅支持 ESM 的包会失败。从一开始就选择一个方向。对于新项目,选择 ESM。

启用 strict 然后到处撒 any。 这样严格模式就失效了。当不知道数据的类型时,使用 unknown,然后通过检查语句缩小类型。

9. 总结

这篇文章为新的 Node.js 项目构建了一个 tsconfig.json,包括以下部分:

  • module 和 moduleResolution 设为 NodeNext,配合 "type": "module",使编译器正确模拟运行时。
  • strict 配合 noUncheckedIndexedAccess 和 exactOptionalPropertyTypes 来尽早捕获错误。
  • verbatimModuleSyntax 和 isolatedModules 使输出容易预测且与快速编译工具兼容。
  • 单独的 tsconfig.build.json 文件,确保 dist 只包含应用代码。

如果项目使用打包工具,只需切换到 moduleResolution: "Bundler" 和 noEmit: true。严格检查标志保持不变。最后,总是在 CI 中运行 tsc --noEmit,因为大多数测试工具和打包工具不会替你进行类型检查。

typescripttsconfignodejs配置vitest