工程化

2026-09-09 00:00    #typescript  

前面六组讲的是 TypeScript 这门语言。这一组讲的是怎么把它用在一个真实项目里:tsconfig.json 怎么配、代码怎么跑起来、声明文件怎么写、从 JS 迁移怎么做。

这一组的特殊性在于:它的内容高度依赖当前工具链状态。TypeScript 7 是一次编译器重写,改变了整个运行与构建生态的格局,很多教程上的做法已经失效。

先搞清楚当前版本格局

这是 2026 年写 TypeScript 必须知道的第一件事。

1$ npm view typescript version
27.0.2

TypeScript 7 是编译器的 Go 原生重写版,2026-07-08 起成为 npm 的 latest。它带来的变化不是语言层面的,而是工具链层面的:

版本性质状态
5.xJS 实现,稳定,生态最广大量生产项目在用
6.0JS 实现,最后一版,引入新默认值过渡版本
7.0Go 原生重写,性能大幅提升当前 latest

官方说法是 7.0 与 6.0 行为一致——同样的类型检查、同样的错误、同样的 JS 输出,主要收益是速度(宣称约 10 倍)。但有一个结构性变化:

TS 7.0 没有稳定的 programmatic API

TypeScript 7.0 不提供稳定的编译器 API(新的 API 计划在 7.1)。这直接打断了依赖旧 API 的工具:

  • ts-node@10 直接失效:它读取 ts.sys,而 7.0 不再暴露这个对象
  • ts-jest@29 不兼容:peer 依赖锁在 typescript <7
  • 自定义 AST transformer 插件会在编译中途失败

Microsoft 提供了 @typescript/typescript6 兼容包,让需要旧 API 的工具能和 7.0 并存。

实测:ts-node 确实坏了

1$ npx ts-node@10 run.ts
2TypeError: Cannot read properties of undefined (reading 'fileExists')
3    at readConfig (.../ts-node/dist/configuration.js:91:33)

ts.sys 是 undefined,所以读 ts.sys.fileExists 直接崩。这不是配置问题,无法绕过。

现在该用什么跑 TS

方式类型检查覆盖语法依赖推荐度
tsc --noEmit + node x.ts✓(分开)仅可擦除无★★★
tsx✗全部1 个★★★
ts-node✓全部—✗ 已失效
tsc 编译后 node✓全部无★★(构建场景)

详细对比见 运行 TypeScript。

tsconfig.json 详解

从哪里来

1npx tsc --init

TS 7 生成的默认配置(实测):

 1{
 2  "compilerOptions": {
 3    "module": "nodenext",
 4    "target": "esnext",
 5    "types": [],
 6    "sourceMap": true,
 7    "declaration": true,
 8    "declarationMap": true,
 9    "noUncheckedIndexedAccess": true,
10    "exactOptionalPropertyTypes": true,
11    "strict": true,
12    "jsx": "react-jsx",
13    "verbatimModuleSyntax": true,
14    "isolatedModules": true,
15    "noUncheckedSideEffectImports": true,
16    "moduleDetection": "force",
17    "skipLibCheck": true
18  }
19}

注意:TS 7 的默认值比 5.x 严格得多。noUncheckedIndexedAccess、exactOptionalPropertyTypes、verbatimModuleSyntax 这些在 5.x 时代需要手动开的选项,现在默认就开着。用旧教程的配置模板反而会失去这些保护。

文件范围

三个字段决定哪些文件参与编译:

字段作用是否受 exclude 影响
include包含的目录/文件,支持 glob是
exclude排除 include 里的文件—
files精确列出文件否
1{
2  "include": ["content/typescript/src/**/*.ts"],
3  "exclude": ["node_modules", "**/*.test.ts"]
4}

files 里的文件一定会被编译,即使它匹配了 exclude。

strict 家族

strict: true 是总闸,包含这些子选项:

选项作用
noImplicitAny禁止隐式 any(函数参数最常触发)
strictNullChecksnull/undefined 不自动属于所有类型
strictFunctionTypes函数参数逆变检查
strictBindCallApplybind/call/apply 参数检查
strictPropertyInitialization类字段必须在构造函数里初始化
noImplicitThis禁止隐式 any 的 this
alwaysStrict输出 "use strict"

新项目一律开 strict,没有例外。

不属于 strict 但强烈建议开启

这些选项默认不开(TS 7 的 tsc --init 会开部分),但收益很大。实测对比:

1const unused = 1;                  // TS6133(需要 noUnusedLocals)
2function f(x) { return x; }        // TS7006(strict 已覆盖)
3const s: string = arr[0];          // TS2322(需要 noUncheckedIndexedAccess)
4switch (n) { case 1: console.log(); case 2: return; }  // TS7029(需要 noFallthroughCasesInSwitch)
选项抓什么实测错误
noUnusedLocals未使用的局部变量TS6133: 'unused' is declared but its value is never read.
noUnusedParameters未使用的参数同上
noImplicitReturns部分分支没返回值TS2366: Function lacks ending return statement...
noFallthroughCasesInSwitchswitch 穿透TS7029: Fallthrough case in switch.
noUncheckedIndexedAccess索引访问可能越界TS2322: Type 'string | undefined' is not assignable to type 'string'.
noImplicitOverride漏写 overrideTS4114: This member must have an 'override' modifier...
exactOptionalPropertyTypes? 属性不接受显式 undefinedTS2375: Type '{ debug: undefined; }' is not assignable to type 'Config' with 'exactOptionalPropertyTypes: true'.
noUncheckedIndexedAccess 的取舍

它把 arr[0] 的类型从 string 改成 string | undefined,这是事实正确的(越界访问真的返回 undefined)。代价是每次索引访问都要处理 undefined,代码会啰嗦不少。建议新项目开启,老项目按模块逐步开。

输出相关

选项作用
outDir输出目录
rootDir源码根目录,决定 outDir 里的目录结构
declaration生成 .d.ts
declarationMap生成 .d.ts.map,支持跳转到源码
sourceMap生成 .js.map,用于调试
noEmit只类型检查,不输出文件
incremental增量编译缓存

实测一个最小库项目的配置与产物:

1$ npx tsc -p .
2$ ls dist/
3index.d.ts  index.js  index.js.map
4$ cat dist/index.d.ts
5export declare function add(a: number, b: number): number;
TS 7 要求显式 rootDir

如果只指定 outDir 不指定 rootDir,当源码目录结构和预期不符时会报:

1error TS5011: The common source directory of 'tsconfig.json' is '...'. The 'rootDir'
2setting must be explicitly set to this or another path to adjust your output's file layout.

加上 rootDir 就能解决。

模块相关

选项作用建议
module输出模块格式nodenext(Node 项目)或 esnext
moduleResolution解析策略跟 module 一致
verbatimModuleSyntax强制 import type 区分类型导入开
isolatedModules保证每个文件可独立转译开
esModuleInterop兼容 CJS 默认导出开
allowImportingTsExtensions允许 import "./x.ts"按需

verbatimModuleSyntax 的实际效果(实测):

1import { SomeType } from "./other";
2// error TS1484: 'SomeType' is a type and must be imported using a type-only
3// import when 'verbatimModuleSyntax' is enabled.
4
5import type { SomeType } from "./other";   // 正确写法

这个选项很重要,因为 esbuild/swc 这类单文件转译工具无法知道 SomeType 是类型还是值。区分清楚能让这些工具正确处理。

编译与运行

开发时:类型检查与执行分离

这是 TS 7 之后最推荐的模式:

1# 一个终端持续类型检查
2npx tsc --noEmit --watch
3
4# 另一个终端直接跑(Node 原生,零依赖)
5node --watch src/index.ts

类型检查由 tsc 负责,执行由 Node 负责。好处是零额外依赖、启动快、职责清晰。

需要执行不可擦除语法时

Node 原生只支持可擦除语法(见 类型擦除)。实测这些会失败:

语法结果
enum E { A }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
const enum E { A = 1 }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
namespace N { export const a = 1 }ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
constructor(public x: number)ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
装饰器SyntaxError: Invalid or unexpected token
 1// 这些语法 Node 原生 type stripping 无法执行,
 2// 每个都会报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX。
 3//
 4// 用注释形式列出,避免这个文件本身跑不起来。
 5
 6// 1. enum(非 const)
 7//    enum E { A, B }
 8//    error: TypeScript enum is not supported in strip-only mode
 9
10// 2. const enum
11//    const enum E { A = 1 }
12
13// 3. namespace(带成员)
14//    namespace N { export const a = 1 }
15
16// 4. 构造函数参数属性
17//    class C { constructor(public x: number) {} }
18
19// 5. 装饰器(连解析都失败)
20//    class C { @d m() {} }
21//    error: SyntaxError: Invalid or unexpected token
22
23// 6. import x = require("...")
24//    import fs = require("fs")
25
26// 可擦除的(Node 原生能跑):
27// - 类型注解、interface、type
28// - 泛型参数与泛型调用
29// - 类型断言 as
30// - implements / declare
31// - import type / export type
32// - 可选参数、readonly、可见性修饰符(不带参数属性)
33
34// 这个文件本身是可擦除的,能跑
35const note = "见文件注释里的不可擦除语法列表";
36console.log(note);
1见文件注释里的不可擦除语法列表

而这些都是可擦除的,Node 原生能跑:

 1// 纯可擦除语法:Node 原生可以直接运行
 2//   node content/typescript/src/engineering/erasable.ts
 3
 4interface User {
 5  name: string;
 6  age: number;
 7}
 8
 9type ID = string | number;
10
11type Mapper<T> = (item: T) => string;
12
13class Greeter {
14  // 注意:这里不能用参数属性(constructor(public name: string)),那是不可擦除的
15  name: string;
16  constructor(name: string) {
17    this.name = name;
18  }
19  greet(): string {
20    return `hi ${this.name}`;
21  }
22}
23
24const u: User = { name: "rainboy", age: 18 };
25const id: ID = 1;
26const mapper: Mapper<User> = (x) => x.name;
27
28// 类型断言、泛型调用都是可擦除的
29const asUser = u as User;
30const list: Array<number> = [1, 2, 3];
31
32console.log(new Greeter(u.name).greet(), mapper(asUser), id, list.length);
1hi rainboy rainboy 1 3

需要执行 enum、namespace、装饰器时,用 tsx:

1$ npx tsx src/index.ts

生产构建

用 tsc 编译出 JS,再用 Node 运行:

1npx tsc          # 按 tsconfig 输出到 outDir
2node dist/index.js

或者用打包工具(esbuild / swc / rollup)转译,tsc --noEmit 单独做类型检查:

1npx tsc --noEmit && npx esbuild src/index.ts --bundle --outfile=dist/index.js
为什么要分开

esbuild/swc 的速度来自"单文件转译"——它们不做类型检查,只是把类型语法删掉。所以构建快,但类型错误不会拦你。标准做法是让它们负责产出,tsc --noEmit 负责把关。

调试配置

VSCode 的 .vscode/launch.json:

 1{
 2  "version": "0.2.0",
 3  "configurations": [
 4    {
 5      "type": "node",
 6      "request": "launch",
 7      "name": "Debug TS (Node native)",
 8      "program": "${file}",
 9      "runtimeArgs": ["--experimental-strip-types"],
10      "console": "integratedTerminal",
11      "skipFiles": ["<node_internals>/**"]
12    }
13  ]
14}

如果需要 sourceMap 断点映射(编译后调试源码),配置:

1{
2  "compilerOptions": {
3    "sourceMap": true,
4    "inlineSources": true
5  }
6}

然后 launch.json 里指向编译产物,Node 会通过 .js.map 映射回 .ts。

.d.ts 与第三方类型

.d.ts 是什么

.d.ts 文件只包含类型声明,没有实现。它描述"某个东西的类型是什么",但不产生任何运行时代码。

 1// 环境声明文件:只有类型,没有实现
 2// 这类文件不需要 import/export,是"全局脚本"文件
 3
 4// 1. 声明一个全局常量(运行时由构建工具注入)
 5declare const __APP_VERSION__: string;
 6
 7// 2. 声明一个全局函数
 8declare function parseAmount(text: string): number;
 9
10// 3. 给不存在的模块补类型
11declare module "virtual-lib" {
12  export function helper(): string;
13}
14
15// 4. 声明一个通配模块(用于 import "./style.css" 这类)
16declare module "*.css" {
17  const classes: Record<string, string>;
18  export default classes;
19}
 1// .d.ts 与 declare:只提供类型,不提供运行时实现
 2//
 3// 这个文件演示的是"类型层面成立、运行时不存在"的情况,
 4// 所以它不能直接运行(会报 Cannot find module 'virtual-lib')。
 5// 它的作用是让 tsc --noEmit 验证 ambient.d.ts 里的声明是有效的。
 6
 7// 1. 全局常量(运行时由构建工具注入,如 vite 的 define)
 8const v: string = __APP_VERSION__;
 9
10// 2. 全局函数(运行时由某个 script 标签提供)
11const n: number = parseAmount("1,234");
12
13// 3. 声明过的模块(运行时需要真实存在这个包)
14import { helper } from "virtual-lib";
15
16// 4. 通配模块:import "./style.css" 能通过类型检查
17import classes from "./style.css";
18
19// 5. 全局接口扩展:给 Array 加方法
20const arr: number[] = [1, 2, 3];
21const last: number | undefined = arr.last();
22
23// 关键:以上全部只保证类型正确。
24// 如果运行时的对应实现不存在,代码会在运行时报错。
25// 这正是"类型擦除"的直接后果 —— 见 type-erasure.md
26
27export const demo = { v, n, helper, classes, last };
declare 不提供运行时实现

dts-demo.ts 能通过 tsc --noEmit,但不能运行:

1Error: Cannot find module 'virtual-lib'

因为 declare module "virtual-lib" 只是告诉类型检查器"有这么个模块",运行时它并不存在。这是 类型擦除 的直接体现。

declare const __APP_VERSION__ 同理——它假设运行时由构建工具(如 Vite 的 define)注入。

五种常见声明

 1// 1. 全局常量
 2declare const __APP_VERSION__: string;
 3
 4// 2. 全局函数
 5declare function parseAmount(text: string): number;
 6
 7// 3. 模块声明
 8declare module "virtual-lib" {
 9  export function helper(): string;
10}
11
12// 4. 通配模块
13declare module "*.css" {
14  const classes: Record<string, string>;
15  export default classes;
16}
17
18// 5. 扩展全局接口(必须写在模块文件里)
19export {};
20declare global {
21  interface Array<T> { last(): T | undefined }
22}

第 5 条的规则容易踩:declare global 必须在模块文件(有 import/export)里,否则报 TS2664: Invalid module name in augmentation。所以它通常单独放一个文件,配 export {} 显式声明为模块。

@types/* 包

第三方库的类型有三种情况:

情况做法
库自带类型直接可用(看 package.json 的 types 字段)
有 @types/xxx 包npm i -D @types/xxx
都没有自己写 declare module

查有没有类型包:

1npm view @types/jquery version

装类型包:

1npm install --save-dev @types/jquery

自己写声明(给没有类型的库):

1// types/my-lib.d.ts
2declare module "my-lib" {
3  export function run(config: { debug?: boolean }): void;
4}

然后确保 tsconfig.json 的 include 覆盖了 types/ 目录。

临时绕过:declare var

迁移老代码时,最省事的临时方案是在文件顶部声明:

1declare var $: any;   // 让 $ 能用,但没有任何类型检查

这能让你先跑起来,但记得后面替换成 @types/jquery。

skipLibCheck 的取舍

1{ "skipLibCheck": true }

它会跳过所有 .d.ts 文件的类型检查。好处是:

代价是你自己的 .d.ts 里的错误也不会被发现。所以写自己的声明文件时,临时关掉它验证一遍。

从 JS 迁移

分阶段策略

阶段 0:先让项目跑起来

1{
2  "compilerOptions": {
3    "allowJs": true,
4    "checkJs": false,
5    "noEmit": true,
6    "strict": false
7  }
8}

allowJs 让 .js 也参与编译(不检查),checkJs: false 表示不检查 JS 文件的类型。这一步只是让 tsc --noEmit 能跑通。

阶段 1:逐文件改后缀

把 .js 改成 .ts,一次一个文件。改一个就修一个的类型错误。不要一次全改。

阶段 2:收紧配置

1{
2  "compilerOptions": {
3    "strict": true,
4    "noImplicitAny": true,
5    "strictNullChecks": true
6  }
7}

按模块逐步开,不要全局一次性开 strict——老项目上会瞬间出现上千个错误。

阶段 3:清理 any

用 noImplicitAny 找出隐式 any,显式标注。用 lint 规则限制显式 any:

1// eslint
2{ "rules": { "@typescript-eslint/no-explicit-any": "warn" } }

any 治理的实用顺序

  1. 先加 unknown:把 any 改成 unknown,强制调用方收窄(见 类型收窄)。这一步就能暴露大量假设。
  2. 边界处做运行时校验:JSON.parse、process.env、HTTP 响应这些地方,any 改成 unknown 加校验函数。
  3. 内部代码补类型:真正的业务逻辑,类型可以从数据结构推导出来。

常见坑

坑一:strictNullChecks 打开后错误爆炸

老代码里 obj.foo.bar 假设 foo 一定存在。打开 strictNullChecks 后全部报错。

对策:先只在新文件上开,用 // @ts-strict-ignore(或分目录配置)逐步推进。

坑二:this 类型推断差异

1// JS 里这样写没问题
2const obj = {
3  count: 0,
4  inc() { this.count++; }
5};

TS 的 noImplicitThis 会要求注解 this。用对象方法简写时通常没问题,但把函数拆出去就会报错。

坑三:CommonJS 与 ESM 混用

1// 老代码
2const fs = require("fs");
3module.exports = { run };
4
5// TS 里
6import fs from "fs";           // 需要 esModuleInterop
7export { run };

esModuleInterop: true + module: "nodenext" 能处理大部分情况,但 require 和 import 混用的文件需要逐个改。

坑四:class 字段初始化语义变化

1class C {
2  field = 1;
3}

useDefineForClassFields 默认为 true(target 为 es2022+)时,字段用 Object.defineProperty 语义定义,而不是赋值。在继承场景下,这会导致子类字段覆盖父类访问器:

1class Base {
2  set value(v: number) { void v; }
3}
4class Derived extends Base {
5  value = 1;    // 用 defineProperty 定义,不走父类的 setter
6}

实测这个写法直接报错:

1error TS2610: 'value' is defined as an accessor in class 'Base', but is overridden
2here in 'Derived' as an instance property.

如果依赖老行为(走 setter),显式设 useDefineForClassFields: false。

一个可用的最小配置

综合以上,本仓库的配置是一个实际可用的例子:

 1{
 2  "compilerOptions": {
 3    "target": "esnext",
 4    "module": "nodenext",
 5    "moduleResolution": "nodenext",
 6    "lib": ["esnext"],
 7    "strict": true,
 8    "types": ["node"],
 9    "noEmit": true,
10    "skipLibCheck": true,
11    "forceConsistentCasingInFileNames": true
12  },
13  "include": ["content/typescript/src/**/*.ts"]
14}

配套脚本:

 1{
 2  "scripts": {
 3    "check": "tsc --noEmit"
 4  },
 5  "devDependencies": {
 6    "typescript": "^7.0.2",
 7    "tsx": "^4.23.15",
 8    "@types/node": "^26.6.2"
 9  }
10}
TS 7 的 lib 与平台包

TS 7 把 lib 文件移到了平台特定的包里(如 @typescript/typescript-darwin-arm64)。实测影响:lib: ["esnext"] 不再自动包含 console,会报 TS2584: Cannot find name 'console'。

解决办法是显式声明 types:

1{ "compilerOptions": { "types": ["node"] } }

这是 TS 7 迁移中一个容易困惑的报错。

小结

主题核心结论
版本格局TS 7 是 Go 重写版,无稳定 API,ts-node/ts-jest 已失效
默认配置TS 7 的 tsc --init 比 5.x 严格得多,别套用旧模板
严格选项strict 必开;noUncheckedIndexedAccess 等强烈建议开
运行方式tsc --noEmit + Node 原生(推荐);不可擦除语法用 tsx
构建打包工具转译 + tsc --noEmit 把关
.d.ts只有类型没有实现,declare 不产生运行时
迁移分阶段:allowJs → 逐文件改 → 收紧配置 → 治理 any

下一篇:TypeScript 版本现状。