为新 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,因为大多数测试工具和打包工具不会替你进行类型检查。