前面六组讲的是 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.x | JS 实现,稳定,生态最广 | 大量生产项目在用 |
| 6.0 | JS 实现,最后一版,引入新默认值 | 过渡版本 |
| 7.0 | Go 原生重写,性能大幅提升 | 当前 latest |
官方说法是 7.0 与 6.0 行为一致——同样的类型检查、同样的错误、同样的 JS 输出,主要收益是速度(宣称约 10 倍)。但有一个结构性变化:
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(函数参数最常触发) |
strictNullChecks | null/undefined 不自动属于所有类型 |
strictFunctionTypes | 函数参数逆变检查 |
strictBindCallApply | bind/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... |
noFallthroughCasesInSwitch | switch 穿透 | TS7029: Fallthrough case in switch. |
noUncheckedIndexedAccess | 索引访问可能越界 | TS2322: Type 'string | undefined' is not assignable to type 'string'. |
noImplicitOverride | 漏写 override | TS4114: This member must have an 'override' modifier... |
exactOptionalPropertyTypes | ? 属性不接受显式 undefined | TS2375: 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;
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 文件的类型检查。好处是:
- 编译明显变快
- 避免第三方类型定义之间的冲突(不同库依赖不同版本的
@types/node时很常见)
代价是你自己的 .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 治理的实用顺序
- 先加
unknown:把any改成unknown,强制调用方收窄(见 类型收窄)。这一步就能暴露大量假设。 - 边界处做运行时校验:
JSON.parse、process.env、HTTP 响应这些地方,any改成unknown加校验函数。 - 内部代码补类型:真正的业务逻辑,类型可以从数据结构推导出来。
常见坑
坑一: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}
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 版本现状。