本附录目标:把初学者最常遇到的十三类报错集中起来,每题按「症状 → 原因 → 解决」三段拆开。遇到报错时先在这里对号入座,多数问题能当场解决;若不在表内,再回对应章节看推导。
附录 D 常见问题与解决方案(FAQ)
一个规律值得先说:绝大多数 TypeScript 报错不是「类型系统太严」,而是「推断结果与预期不同」。因此每道题的「原因」段比「解决」段更值得读——知道为什么,下次换个写法才不会又踩同一个坑。
1. Cannot find module ‘xxx’ or its corresponding type declarations
症状:导入第三方包或本地文件时报 TS2307,编辑器里该模块出现红色波浪线。
原因:三种可能——包真的没装;包没有类型声明也没有对应的 @types 包;模块解析策略与导入写法不匹配。
解决:按顺序排查。
npm ls xxx # 1. 确认包装了
ls node_modules/xxx/package.json # 2. 查 types / typings 字段
npm i -D @types/xxx # 3. 看有没有 @types 包
若两者都没有,自己写一份最小声明:
// src/types/xxx.d.ts
declare module "xxx" {
export function doSomething(input: string): number;
}
若是本地文件,检查 tsconfig.json 的 moduleResolution 与 baseUrl / paths 是否与导入写法匹配。详见 12.2 为无类型库编写声明
。
2. Property ‘xxx’ does not exist on type ‘yyy’
症状:访问 user.nickname 报 TS2339,但运行时确实有这个字段。
原因:类型里没有声明这个字段。要么接口定义漏了,要么字段是后端动态返回的,要么你访问的是联合类型中部分成员才有的属性。
解决:三条正路。
interface User { id: string; name: string; nickname?: string } // 正路一:补全接口
type Animal = { kind: "dog"; bark(): void } | { kind: "cat"; meow(): void };
function speak(a: Animal) {
if (a.kind === "dog") a.bark(); // 正路二:联合类型先收窄
}
type Loose = { id: string } & Record<string, unknown>; // 正路三:确实动态时用索引签名
不要用 (user as any).nickname 绕过——那只是把错误推迟到运行时。详见 5.3 结构化类型与两者取舍
。
3. Object is possibly ’null’ or ‘undefined’
症状:TS18047 / TS2532,常见于 document.getElementById(...) 之后直接取属性,或 arr.find(...) 之后直接使用。
原因:strictNullChecks 生效,null 与 undefined 不再隐式兼容其他类型。getElementById 返回的本来就是 HTMLElement | null,find 返回的本来就是 T | undefined——编译器说的是实话。
解决:
const el = document.getElementById("app");
if (!el) throw new Error("找不到 #app");
el.textContent = "ok"; // 此处已收窄为 HTMLElement
const name = user?.profile?.name ?? "匿名"; // 可选链 + 空值合并
el!.textContent = "ok"; // 非空断言:只在你能证明非空时用
! 是「让编译器闭嘴」而不是「保证非空」。详见 3.3 any·unknown·never·void 与类型断言
。
4. Type ‘X’ is not assignable to type ‘Y’
症状:最常见的 TS2322。两种相反的困惑:明明结构一样却不兼容,或者结构不一样却兼容了。
原因:TypeScript 用结构化类型——形状兼容就算兼容,与名字无关。反过来,多余属性检查只在直接赋字面量时生效。
interface Point { x: number; y: number }
interface Vector { x: number; y: number }
const p: Point = { x: 1, y: 2 };
const v: Vector = p; // 合法!结构相同
interface Opt { a: number }
const o1: Opt = { a: 1, b: 2 }; // 错误:多余属性 b
const tmp = { a: 1, b: 2 };
const o2: Opt = tmp; // 合法!绕过了多余属性检查
解决:结构相同却不兼容时,检查可选性、只读性是否有一处不同;多了属性报错时,删掉多余字段或抽出中间变量;少了属性报错时,说明该属性必填。详见 5.3 结构化类型与两者取舍 。
5. Argument of type ‘string’ is not assignable to parameter of type ’never’
症状:往数组 push 报 never,或调用函数时参数类型显示为 never。
原因:never 是「空联合」。这个错误几乎总是推断出了空联合类型,而不是真有 never 参数——典型场景是空数组。
const arr = []; // 推断为空联合类型
arr.push("a"); // 错误:参数类型为 never
const good: string[] = [];
good.push("a"); // 正确:显式标注
解决:给容器或参数一个显式类型标注。
const arr: string[] = [];
const items: Array<{ id: string }> = [];
items.push({ id: "1" });
若 never 出现在函数参数上,检查该函数的类型是不是被推成了「参数为空的联合」。详见 3.3 any·unknown·never·void 与类型断言
。
6. 用了 as 之后,错误不但没少反而更多了
症状:为了消掉一个报错随手写 as any 或 as SomeType,结果下游接连报出更多 TS2322、TS2339。
原因:as 不改变运行时,只改变编译器看到的类型。断言一旦与事实不符,错误不会消失,只会从断言点转移到使用点,而且更难定位。
const data = JSON.parse(raw) as User; // 断言成功,编译器不再报错
data.profile.name; // 若 profile 实际不存在,运行时崩溃
解决:把断言换成运行时校验。
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
profile: z.object({ name: z.string() }),
});
type User = z.infer<typeof UserSchema>;
const data = UserSchema.parse(JSON.parse(raw)); // 不合法就抛错,类型是真的
原则:as 只允许出现在「你比编译器知道得更多」的地方,例如库的声明有误或测试桩。详见 13.3 API 契约与边界数据校验
。
7. 改了 tsconfig.json 却没生效
症状:明明开了 strict,编辑器还是给宽松提示;或者命令行报错、编辑器不报错(反之亦然)。
原因:文件不在 include 内或落在 exclude 里;存在多层 tsconfig(如 tsconfig.app.json 继承自 tsconfig.json),你改的不是真正生效的那份;编辑器的 TS 版本与项目里的 typescript 不一致;编辑器缓存未刷新。
解决:
npx tsc --showConfig # 打印最终生效的配置(含继承与默认值)
npx tsc --version # 确认实际使用的 TS 版本
编辑器中切换到工作区版本(VS Code 命令面板搜索「TypeScript: Select TypeScript Version」),再重启 TS 服务。判断基准永远是 tsc --showConfig 的输出,不要凭印象。
8. ERR_REQUIRE_ESM / Cannot use import statement outside a module
症状:运行时报 ERR_REQUIRE_ESM,或 Node 直接报 Cannot use import statement outside a module。
原因:ESM 与 CJS 两套模块系统在同一进程里相撞。require 一个纯 ESM 包会直接失败;而 .ts 编译成哪种模块,由 tsconfig 的 module 与 package.json 的 "type" 共同决定。
解决:
// package.json —— 明确声明包类型
{ "type": "module" }
// tsconfig.json —— 让 module 与 moduleResolution 匹配运行时
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext" } }
再检查导入语句是否带了扩展名(Node 的 ESM 要求 ./a.js 而非 ./a)。详见 11.2 ESM/CJS 互操作与 moduleResolution
。
9. isolatedModules 下的两类报错
症状:开启 isolatedModules 后 const enum 报错,或重新导出类型时报 TS1205。
原因:isolatedModules 要求每个文件都能被单独转译,因为 esbuild / swc / Babel 都是一个文件一个文件处理的。const enum 需要跨文件信息,类型再导出则无法判断导出的是值还是类型。
// 报错:Re-exporting a type when 'isolatedModules' is enabled requires
// using 'export type'.
export { User } from "./types";
解决:
export type { User } from "./types"; // 明确区分类型
export { createUser } from "./factory"; // 值照常导出
const Dir = { Up: "up", Down: "down" } as const; // const enum 换成常量对象
type Dir = (typeof Dir)[keyof typeof Dir];
开启 verbatimModuleSyntax 后这一约束更严格,好处是导入导出意图一目了然。
10. 第三方库没有类型声明
症状:TS7016——Could not find a declaration file for module 'xxx'。
原因:包是纯 JavaScript 写的,没带 .d.ts,社区也没提供 @types/xxx。
解决:三条路,按投入递增。
// 路一:临时静默(只适合确实不重要的包)
// src/types/shims.d.ts
declare module "xxx";
// 路二:写最小可用声明(推荐)
declare module "xxx" {
export interface Options { debug?: boolean }
export function init(opts?: Options): void;
}
路三是写完整声明并贡献给社区。不要用 // @ts-ignore 逐行压制——声明写一次就能全项目受益。详见 12.2 为无类型库编写声明
。
11. 类型报错只在 CI 上出现,本地却是绿的
症状:本地 npm run build 一切正常,CI 上 tsc 报一堆错。
原因:本地用转译器(esbuild / swc)构建,根本没跑类型检查;node_modules 里的 @types 版本与 CI 不同;tsconfig 依赖了本地存在但被 .gitignore 忽略的 *.d.ts;大小写敏感差异(macOS 默认不区分文件名大小写,Linux CI 区分)。
解决:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && tsup"
}
}
再加上「本地用与 CI 相同的锁文件安装」。判断基准永远是 tsc --noEmit,不是构建是否成功。
12. 编译产物里为什么没有类型
症状:构建出的 dist/index.js 里看不到任何类型信息,运行时也拿不到类型。
原因:这是设计使然。TypeScript 的类型是编译期概念,产物是纯 JavaScript,类型注解被整体擦除(type erasure)。类型没有任何运行时表示,也不应该有。
// 错误期待:运行时能拿到类型
function isUser(x: unknown) {
return x instanceof User; // 错误:User 只是类型,不存在于运行时
}
// 正确做法一:运行时校验
function isUser2(x: unknown): x is User {
return typeof x === "object" && x !== null && "id" in x;
}
// 正确做法二:类型信息随数据一起传递
const schema = z.object({ id: z.string() });
需要跨包共享类型时,发布 .d.ts 即可(tsup / tsc --declaration 都能产出)。详见 13.1 类型擦除带来的运行时盲区
。
13. 回调函数的参数类型不兼容
症状:把一个函数当参数传进去时报错,提示参数类型「不可赋值」,但两个函数的签名看起来一样。
原因:开启 strictFunctionTypes 后,函数参数位置是逆变的——参数更宽的函数可以赋值给参数更窄的位置,反之不行。这保证的是类型安全:回调会被以「窄参数」调用。
type Handler = (e: MouseEvent) => void;
const h1: Handler = (e: Event) => {}; // 合法:参数更宽
const h2: Handler = (e: UIEvent) => {}; // 错误:参数更窄
解决:把回调参数放宽到「你能处理的最高层类型」,内部再收窄。
const onClick = (e: Event) => {
const me = e as MouseEvent;
console.log(me.clientX);
};
注意方法简写({ m(e: Event) {} })不受此规则约束,这是刻意保留的「双变」行为,用于兼容既有类库。
小结
- 十三道题里超过一半的根因是同一件事:推断结果与预期不同。读懂「原因」比记住「解决」更重要。
as与!不是修复手段,只是让编译器闭嘴;它们把错误从编译期推迟到运行期,而且更难定位。业务数据边界应当用运行时校验。- 模块类报错(第 1、8、9、10 题)几乎都出在「解析策略」而非「语法」上,优先检查
module/moduleResolution与package.json的type。 - 环境类报错(第 7、11 题)的判断基准永远是
tsc --showConfig与tsc --noEmit,不要凭编辑器显示下结论。 - 结构类报错(第 2、4、13 题)源于结构化类型与函数参数的逆变规则,理解规则后就不必再靠试错。
- 类型在运行时被擦除(第 12 题)是整本书的底层前提,也是所有运行时校验库存在的唯一理由。
- 若本附录没有覆盖你的问题,先回到对应章节看完整推导;报错编号(如
TS2322)是检索正文最快的关键词。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。