《TypeScript编程入门》附录 D 常见问题与解决方案(FAQ)

本附录汇总初学者最常撞上的十三类 TypeScript 报错与困惑,覆盖模块找不到声明、属性不存在、可能为 null、类型不可赋值、never 参数、as 断言滥用、tsconfig 不生效、ESM 与 CJS 混用、isolatedModules、第三方库缺类型、CI 与本地结论不一致等,每问按症状、原因、解决三段给出。

本附录目标:把初学者最常遇到的十三类报错集中起来,每题按「症状 → 原因 → 解决」三段拆开。遇到报错时先在这里对号入座,多数问题能当场解决;若不在表内,再回对应章节看推导。

附录 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)是检索正文最快的关键词。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes