TypeScript 最佳实践
相关文档:TypeScript完全指南(/typescript-wan-quan-zhi-nan/) | JS模块系统(/js-mjs-ts-wen-jian-qu-bie-yu-zui-jia-shi-jian/) | JavaScript Promise 完全指南(/javascript-promise-wan-quan-zhi-nan/) 1. 概述(#%E6%A6%82%E8%BF%B0) 2. 编译配置:把检查开到最严(#%E4%B8%80%E3%80%81%E7%BC%96%E8%AF%91%E9%85%8D%E7%BD%AE%EF%BC%9
官方文档:https://www.typescriptlang.org/docs/handbook/declaration-files/do-s-and-don-ts.html
tsconfig 参考:https://www.typescriptlang.org/tsconfig
适用版本:TypeScript 5.4+(2026-05-23 核实)
相关文档:TypeScript完全指南 | JS模块系统 | JavaScript Promise 完全指南
目录
- 概述
- 编译配置:把检查开到最严
- 类型设计:让类型表达意图
- 函数与 API 设计
- 泛型:约束优先于自由
- 模块与类型导入
- 空值与防御性编程
- 错误处理
- 与第三方库协作
- 性能与构建
- 代码组织与命名
- 语法选择决策:多用什么、少用什么、何时用什么
- 类型层面测试
- 常见陷阱
- 参见
概述
What:本文不重复 TypeScript 语法手册(参见 TypeScript完全指南),而是回答"在真实项目里,怎样的 TS 代码才算高质量"——围绕类型设计、API 设计、错误处理、性能、与生态协作整理 35 条可执行实践。
Why:TypeScript 的类型系统极其强大,但默认配置宽松、any 兜底容易让团队退化成"加了类型注释的 JavaScript"。Flow 与 Sound TypeScript 的对比、TS 5.x 引入的 satisfies 与 verbatimModuleSyntax,使得 2024 年后写 TS 的方式已与 2020 年明显不同。
When:适用于 ≥ 3 人协作的中大型项目、需要长期维护的库、希望减少运行时崩溃的生产代码。对于一次性脚本或原型,部分严格规则可放宽。
一、编译配置:把检查开到最严
类型质量上限由 tsconfig.json 决定。下面是生产项目应启用的完整严格选项清单。
1.1 严格选项完整清单
| 选项 | 类型 | 推荐值 | 默认值 | 作用 |
|---|---|---|---|---|
strict |
boolean |
true |
false |
一次性开启全部 strict 子选项,新增子选项会自动启用 |
noImplicitAny |
boolean |
true |
跟随 strict |
禁止隐式 any,未注解的参数报错 |
strictNullChecks |
boolean |
true |
跟随 strict |
null 与 undefined 不再可赋给任意类型 |
strictFunctionTypes |
boolean |
true |
跟随 strict |
函数参数双变性检查(更严格) |
strictBindCallApply |
boolean |
true |
跟随 strict |
bind/call/apply 的参数类型检查 |
strictPropertyInitialization |
boolean |
true |
跟随 strict |
类属性必须在构造器中初始化或带 ! 断言 |
noImplicitThis |
boolean |
true |
跟随 strict |
禁止隐式 any 类型的 this |
useUnknownInCatchVariables |
boolean |
true |
跟随 strict |
catch (e) 中 e 类型为 unknown 而非 any |
alwaysStrict |
boolean |
true |
跟随 strict |
输出文件强制 "use strict" |
noUncheckedIndexedAccess |
boolean |
true |
false |
索引访问(arr[0]、obj[key])返回 T | undefined |
exactOptionalPropertyTypes |
boolean |
true |
false |
{ x?: string } 不允许显式赋 undefined |
noImplicitReturns |
boolean |
true |
false |
函数所有分支必须有 return |
noFallthroughCasesInSwitch |
boolean |
true |
false |
switch 的 case 必须 break/return |
noImplicitOverride |
boolean |
true |
false |
子类覆盖父类方法必须加 override |
noPropertyAccessFromIndexSignature |
boolean |
true |
false |
索引签名属性必须用 obj["key"] 而非 obj.key |
allowUnreachableCode |
boolean |
false |
undefined |
不允许死代码 |
allowUnusedLabels |
boolean |
false |
undefined |
不允许未使用的标签 |
1.2 推荐的 tsconfig.json 基线
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
1.3 实践要点
实践 1 — 严格模式必开,不留退路。
"strict": true 是底线。每次 TS 大版本都会向 strict 加入新子选项(如 5.0 的 useUnknownInCatchVariables),一次开启就能自动跟上。
// 正确
{ "strict": true }
// 错误:手动列出子选项,错过未来新增的检查
{ "noImplicitAny": true, "strictNullChecks": true }
实践 2 — 开启 noUncheckedIndexedAccess,承认索引访问可能为空。
数组与对象的索引访问默认返回 T,这是 TS 类型系统最大的不安全口子。开启后能强制你处理"键不存在"的情况。
const users: string[] = ["alice"];
// 关闭时:users[10] 推断为 string,运行时返回 undefined → 隐藏 bug
// 开启后:users[10] 推断为 string | undefined,必须先判空
const u = users[10];
if (u !== undefined) {
console.log(u.toUpperCase());
}
实践 3 — 开启 exactOptionalPropertyTypes,区分"缺失"与"显式 undefined"。
interface Config {
port?: number;
}
// exactOptionalPropertyTypes: false 时允许,但语义混乱
const c1: Config = { port: undefined };
// exactOptionalPropertyTypes: true 时报错;想"清除"必须用 delete 或不提供该 key
const c2: Config = {};
这能避免 JSON.stringify 序列化时 undefined 被丢失导致的客户端/服务端不一致。
二、类型设计:让类型表达意图
类型不是注释——它是编译期可执行的规约。好的类型让非法状态无法被构造。
2.1 优先类型推断,注解只在边界
TS 的局部推断非常强,过度注解反而让重构变难。
| 场景 | 是否注解 | 理由 |
|---|---|---|
局部变量 const x = 1 |
不注解 | 推断准确 |
| 函数返回值(公开 API) | 必须注解 | 防止内部修改意外改变签名 |
| 函数返回值(内部小函数) | 可不注解 | 减少噪音 |
| 函数参数 | 必须注解 | 参数无法推断 |
| 导出常量对象 | 用 satisfies |
既校验又保留窄类型 |
// 正确:边界注解,内部推断
export function fetchUser(id: string): Promise<User> {
const url = `/api/users/${id}`; // 不注解 string
return http.get<User>(url);
}
// 错误:过度注解
export function fetchUser(id: string): Promise<User> {
const url: string = `/api/users/${id}`; // 噪音
const path: string = "/api/users/"; // 噪音
return http.get<User>(url);
}
2.2 unknown 而非 any
any 关闭类型检查并向外传染;unknown 是"未知但安全的"——必须先收窄才能使用。
// 错误:any 让整个调用链失去类型
function parse(json: string): any {
return JSON.parse(json);
}
const data = parse('{"name":"a"}');
data.name.toUpperCase(); // 编译通过,运行时可能崩
// 正确:unknown 强制调用方先校验
function parse(json: string): unknown {
return JSON.parse(json);
}
const data = parse('{"name":"a"}');
if (isUser(data)) {
data.name.toUpperCase(); // 安全
}
唯一可以接受 any 的地方:与无类型 JS 互操作的临时桥,必须配 // TODO 与 issue 链接。
2.3 用判别联合(discriminated union)表达状态
把"互斥状态"建模为带共同字面量字段的联合,TS 能在分支内自动收窄。
// 错误:可选字段组合出非法状态
interface Resp {
loading: boolean;
data?: User;
error?: Error;
}
// 非法但能构造:{ loading: true, data: user, error: err }
// 正确:判别联合
type Resp =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
function render(r: Resp) {
switch (r.status) {
case "success":
return r.data.name; // 自动收窄,无需 r.data!
case "error":
return r.error.message;
case "loading":
case "idle":
return "...";
}
}
2.4 用 as const 与 satisfies 固化字面量
// 错误:类型被泛化为 string
const role = "admin"; // string
const config = { mode: "dev" }; // { mode: string }
// 正确 — as const:递归字面量化
const role = "admin" as const; // "admin"
const config = { mode: "dev" } as const; // { readonly mode: "dev" }
// 正确 — satisfies:校验形状但保留窄类型
type Theme = Record<string, { fg: string; bg: string }>;
const themes = {
light: { fg: "#000", bg: "#fff" },
dark: { fg: "#fff", bg: "#000" },
} satisfies Theme;
themes.light.fg; // string,类型安全
themes.unknown; // 报错:使用 satisfies 后保留 key 字面量
satisfies 是 TS 4.9 引入、5.x 推广的特性,优先于 : Theme 注解,因为后者会把 themes.light 拓宽为 Theme[string]。
2.5 品牌类型(Branded Types)区分语义相同的原始类型
UserId 与 OrderId 都是 string,但混用是 bug。用品牌防止误传。
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function asUserId(s: string): UserId {
return s as UserId;
}
function getUser(id: UserId) { /* ... */ }
const uid = asUserId("u_1");
const oid = "o_1" as OrderId;
getUser(uid); // OK
getUser(oid); // 报错:OrderId 不能赋给 UserId
getUser("u_1"); // 报错:string 不能赋给 UserId
2.6 用 Readonly 与 ReadonlyArray 表达不可变
// 函数声明它不会修改输入
function sum(nums: ReadonlyArray<number>): number {
// nums.push(1); // 报错
return nums.reduce((a, b) => a + b, 0);
}
// 对象参数同理
function render(user: Readonly<User>) { /* ... */ }
不要在函数内部用 as 强行去掉 readonly——这是设计意图被破坏的信号。
2.7 never 用于穷举检查
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; size: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.size ** 2;
default:
// 新增 kind 但忘了处理时,此处编译报错
const _exhaustive: never = s;
throw new Error(`unhandled: ${JSON.stringify(_exhaustive)}`);
}
}
封装成工具函数:
function assertNever(x: never): never {
throw new Error(`unexpected: ${JSON.stringify(x)}`);
}
三、函数与 API 设计
3.1 参数对象化(命名参数模式)
超过 2 个参数时,用对象传参代替位置参数,配合解构。
// 错误:调用方需要记住顺序与含义
function createUser(name: string, age: number, admin: boolean, email?: string) {}
createUser("alice", 30, true); // true 是什么?
// 正确
interface CreateUserInput {
name: string;
age: number;
admin: boolean;
email?: string;
}
function createUser(input: CreateUserInput) {}
createUser({ name: "alice", age: 30, admin: true });
布尔参数尤其要避免直接传——createUser("alice", 30, true, false, true) 完全不可读。
3.2 公开 API 显式声明返回类型
// 错误:返回类型靠推断,重构时悄悄变化
export async function getUser(id: string) {
return await db.query("SELECT * FROM users WHERE id=$1", [id]);
}
// 后续有人改了 query 返回类型,调用方全部受影响但不报错
// 正确
export async function getUser(id: string): Promise<User> {
return await db.query("SELECT * FROM users WHERE id=$1", [id]);
}
内部辅助函数可以省略,但任何被外部模块 import 的函数都要显式声明。
3.3 重载用于"输入类型决定输出类型"的真实多态
// 正确:输入与输出严格对应
function parse(input: string): object;
function parse(input: Buffer): Buffer;
function parse(input: string | Buffer): object | Buffer {
return typeof input === "string" ? JSON.parse(input) : input;
}
const a = parse("{}"); // object
const b = parse(Buffer.from("")); // Buffer
反面: 不要用重载模拟可选参数——直接用可选参数或联合类型。
3.4 优先返回 Promise<T>,避免回调
// 错误
function read(path: string, cb: (err: Error | null, data?: string) => void) {}
// 正确
async function read(path: string): Promise<string> {}
如果必须保留回调(如事件订阅),返回清理函数:
function subscribe(handler: (e: Event) => void): () => void {
emitter.on("x", handler);
return () => emitter.off("x", handler);
}
3.5 异步函数接受 AbortSignal 实现可取消
问题: 长时间运行的异步任务(HTTP、轮询、流读取、防抖的 fetch)若无法外部取消,会导致内存泄漏、组件卸载后仍触发副作用、用户切换路由时浪费网络请求。
约定: 任何可能耗时的异步函数都应在 options 对象中接受 signal?: AbortSignal,调用方负责创建与触发取消。
interface FetchOptions {
signal?: AbortSignal;
timeoutMs?: number;
}
async function fetchUser(id: string, opts: FetchOptions = {}): Promise<User> {
// 组合外部取消信号与超时信号
const signals: AbortSignal[] = [];
if (opts.signal) signals.push(opts.signal);
if (opts.timeoutMs) signals.push(AbortSignal.timeout(opts.timeoutMs));
const r = await fetch(`/api/users/${id}`, {
signal: signals.length ? AbortSignal.any(signals) : undefined,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
// 调用方:组件卸载或路由切换时取消
const ctrl = new AbortController();
fetchUser("u1", { signal: ctrl.signal, timeoutMs: 5000 })
.then(use)
.catch(e => {
if (e.name === "AbortError" || e.name === "TimeoutError") return;
throw e;
});
// React useEffect 清理函数
useEffect(() => {
const ctrl = new AbortController();
fetchUser(id, { signal: ctrl.signal }).then(setUser).catch(silenceAbort);
return () => ctrl.abort();
}, [id]);
AbortSignal 标准 API:
| API | 用途 | 平台支持 |
|---|---|---|
new AbortController() |
创建可手动触发的控制器 | 全支持 |
controller.abort(reason?) |
触发取消,可附带原因 | 全支持 |
controller.signal |
拿到对应的 signal | 全支持 |
AbortSignal.timeout(ms) |
创建到时自动取消的信号 | Node 17.3+、Chrome 103+ |
AbortSignal.any([s1, s2]) |
组合多个信号,任一触发即取消 | Node 20+、Chrome 116+ |
signal.aborted |
同步检查是否已取消(boolean) | 全支持 |
signal.reason |
取消原因(abort 传入的参数) | Node 17.2+、Chrome 100+ |
signal.throwIfAborted() |
已取消则抛出 signal.reason |
Node 17.3+、Chrome 100+ |
signal.addEventListener("abort", cb) |
监听取消事件 | 全支持 |
设计要点:
- options 对象包裹 signal,便于未来扩展字段(
timeoutMs、retries等)不破坏签名 - 取消错误的
name为"AbortError",超时的name为"TimeoutError",调用方按name分支 - 服务端框架(Express/Fastify/Hono)通过
req.signal暴露请求级 signal——透传到下游 fetch 可实现"客户端断开则取消上游" - Node
stream/db query/child_process.spawn等都已原生支持signal选项
反模式: 用 setTimeout + 手动标志位模拟取消——容易漏判,无法与 fetch、stream 等标准 API 组合。
四、泛型:约束优先于自由
4.1 总是用 extends 约束泛型
无约束的泛型几乎等于 any,无法对参数做任何操作。
// 错误:T 内部无法访问任何属性
function getId<T>(x: T) {
return x.id; // 报错
}
// 正确
function getId<T extends { id: string }>(x: T): string {
return x.id;
}
4.2 泛型默认值减少调用方负担
interface ApiResp<T = unknown, E = Error> {
data?: T;
error?: E;
}
const r: ApiResp = await fetch("..."); // T = unknown
const u: ApiResp<User> = await fetch("..."); // T = User
4.3 不要为了泛型而泛型
如果调用方只会用一种类型,泛型就是噪音。
// 错误:永远只传 string,泛型毫无意义
function logKey<T extends string>(k: T): void {
console.log(k);
}
// 正确
function logKey(k: string): void {
console.log(k);
}
经验法则:类型参数至少出现 2 次(一次在输入,一次在输出或另一个输入)才值得引入。
4.4 用 infer 提取嵌套类型,但优先用内置工具类型
// 内置 ReturnType 已能解决大部分场景
function fetchUser() { return { id: "1", name: "a" }; }
type User = ReturnType<typeof fetchUser>;
// 自定义提取也用 infer
type Awaited<T> = T extends Promise<infer U> ? U : T;
type FirstArg<T> = T extends (a: infer A, ...rest: any[]) => any ? A : never;
常用的内置工具类型应该背下来:Partial、Required、Readonly、Pick、Omit、Record、ReturnType、Parameters、Awaited、NonNullable、Extract、Exclude、InstanceType。
4.5 高级模式:模板字面量类型与递归条件类型
infer + 递归条件类型 + 模板字面量类型让 TS 类型系统具备图灵完备能力。能写但要克制——过度使用会让编译器爆炸、IDE 卡顿。
4.5.1 模板字面量类型实战
// 1) API 路径前缀强制
type ApiPath = `/api/${string}`;
declare function get(path: ApiPath): Promise<unknown>;
get("/api/users"); // OK
get("/users"); // 报错
// 2) CSS 单位约束
type CSSLength = `${number}px` | `${number}rem` | `${number}%` | "auto" | "0";
// 3) 事件名格式
type DOMEventName = `on${Capitalize<string>}`;
// 4) snake_case → camelCase 类型转换
type CamelCase<S extends string> =
S extends `${infer P}_${infer R}`
? `${P}${Capitalize<CamelCase<R>>}`
: S;
type T1 = CamelCase<"user_first_name">; // "userFirstName"
type T2 = CamelCase<"already_camel">; // "alreadyCamel"
// 5) 路由参数提取
type ExtractParams<S extends string> =
S extends `${string}:${infer P}/${infer R}`
? P | ExtractParams<`/${R}`>
: S extends `${string}:${infer P}`
? P
: never;
type P = ExtractParams<"/users/:userId/posts/:postId">; // "userId" | "postId"
4.5.2 嵌套对象路径类型
实现类似 lodash _.get(obj, "a.b.c") 的类型安全访问:
type Paths<T, K extends keyof T = keyof T> =
K extends string
? T[K] extends Record<string, unknown>
? `${K}` | `${K}.${Paths<T[K]>}`
: `${K}`
: never;
interface Config {
server: { port: number; host: string };
db: { user: string; pass: string };
}
type ConfigPath = Paths<Config>;
// "server" | "db" | "server.port" | "server.host" | "db.user" | "db.pass"
function get<T, P extends Paths<T>>(obj: T, path: P): unknown {
return path.split(".").reduce((o: any, k) => o?.[k], obj);
}
get(config, "server.port"); // 类型安全,IDE 自动补全
get(config, "server.unknown"); // 报错
4.5.3 DeepReadonly / DeepPartial
type DeepReadonly<T> =
T extends (...args: any[]) => any
? T
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type DeepPartial<T> =
T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type RoConfig = DeepReadonly<Config>; // 所有嵌套属性都 readonly
type PartialConfig = DeepPartial<Config>; // 所有嵌套属性都可选
4.5.4 边界与代价
| 模式 | 推荐深度上限 | 风险 |
|---|---|---|
| 模板字面量 split | ≤ 3 层嵌套 | 每层都是新的字符串实例化 |
| 递归条件类型 | ≤ 25 层 | 超过 50 层触发 Type instantiation is excessively deep |
| 联合 × 递归 | 联合 ≤ 8 项 | 联合大小 × 递归深度爆炸增长 |
Paths<T> |
对象字段总数 ≤ 30 | IDE 类型提示卡顿 |
CamelCase 等字符串变换 |
字符串长度 ≤ 50 | 短字符串无碍,长字符串拖慢 |
经验法则: 写复杂类型前先问"这个能在运行时校验吗"——能就别在类型层写。类型 ≠ 运行时,让两层各司其职。
五、模块与类型导入
5.1 启用 verbatimModuleSyntax,明确区分类型与值
TS 5.0 引入的强力选项:import/export 语句保持原样输出,因此必须显式标注哪些是类型。
// 配合 verbatimModuleSyntax: true
// 正确
import type { User } from "./user"; // 仅类型,编译后被擦除
import { fetchUser } from "./user"; // 运行时值
import { type Role, hasRole } from "./auth"; // 混合时用 inline type
// 错误:用 import 引入纯类型,可能导致 CJS/ESM 互操作问题
import { User } from "./user"; // verbatimModuleSyntax 下报错
类型导入被擦除还能解决循环依赖问题——import type 不会在运行时建立依赖关系。
5.2 避免 namespace
namespace 是 TS 早期产物,与 ES 模块功能重叠。除非维护历史代码或写 .d.ts 环境声明,永远用 ES 模块。
// 错误
namespace Utils {
export function fmt(d: Date) { /* ... */ }
}
// 正确
// utils.ts
export function fmt(d: Date) { /* ... */ }
5.3 谨慎使用桶文件(barrel files)
index.ts 重新导出整个目录看起来优雅,但有显著代价:
- 打包工具难以 tree-shake → 包体积膨胀
- 循环依赖更容易出现
- 大型仓库下编译变慢
经验: 库的公开入口可以用桶文件;项目内部业务模块直接相对路径导入即可。
5.4 路径别名替代 ../../..
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
}
}
}
注意:tsc 不会改写路径——构建器(Vite、esbuild、tsup)或 tsc-alias 必须同步配置。
六、空值与防御性编程
6.1 可选链 ?. vs 非空断言 !
| 用法 | 何时使用 | 风险 |
|---|---|---|
obj?.prop |
不确定 obj 是否存在 |
无 |
obj!.prop |
绝对确定 obj 存在(且能解释为什么) |
错了会运行时崩 |
// 正确:DOM 查询结果必然可能为 null
const btn = document.querySelector("#submit");
btn?.addEventListener("click", handler);
// 错误:滥用 ! 把责任推给运行时
const btn = document.querySelector("#submit")!;
btn.addEventListener("click", handler); // 元素不存在时崩溃
// 例外:构造器之后注入的依赖、ref 在 mounted 后必然有值
class Service {
private db!: DB; // 在 init() 中赋值
async init() { this.db = await connect(); }
}
6.2 ?? 而非 ||,避免 0/""/false 误判
// 错误:port = 0、name = "" 时会被覆盖
const port = config.port || 3000;
const name = user.name || "anonymous";
// 正确:仅 null/undefined 时取默认值
const port = config.port ?? 3000;
const name = user.name ?? "anonymous";
6.3 用类型守卫而不是断言
// 错误:断言关闭检查
function process(x: unknown) {
const u = x as User;
return u.name; // x 不是 User 时崩
}
// 正确:类型守卫
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null && "id" in x && "name" in x;
}
function process(x: unknown) {
if (isUser(x)) return x.name;
throw new TypeError("not a user");
}
外部数据(HTTP 响应、JSON.parse、localStorage)必须经过 Zod / Valibot / ArkType 等运行时校验库。
七、错误处理
7.1 catch 变量是 unknown
启用 useUnknownInCatchVariables 后:
try {
await op();
} catch (e) {
// e 是 unknown,不能直接 e.message
if (e instanceof Error) {
log(e.message);
} else {
log(String(e));
}
}
封装工具函数减少样板:
function toError(e: unknown): Error {
return e instanceof Error ? e : new Error(String(e));
}
7.2 自定义错误用 class 而非字符串/枚举
class NotFoundError extends Error {
constructor(public readonly resource: string, public readonly id: string) {
super(`${resource} ${id} not found`);
this.name = "NotFoundError";
}
}
try {
await getUser("u1");
} catch (e) {
if (e instanceof NotFoundError) {
// 类型收窄,e.resource / e.id 可用
}
}
继承 Error 后必须显式设置 this.name,否则 instanceof 在跨 realm(iframe/worker)场景可能失效。
7.3 显式返回 Result 类型处理可预期错误
对于业务可预期的失败(用户不存在、权限不足),不抛异常,返回判别联合:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function getUser(id: string): Promise<Result<User, NotFoundError>> {
const u = await db.find(id);
return u
? { ok: true, value: u }
: { ok: false, error: new NotFoundError("user", id) };
}
const r = await getUser("u1");
if (r.ok) {
use(r.value);
} else {
handle(r.error);
}
异常仅留给"程序无法继续"的情况(OOM、配置错误、网络断开)。
7.4 用 Zod / Valibot 统一运行时校验与类型
问题: 外部数据(HTTP 响应、表单输入、localStorage、配置文件、URL 参数)的类型只是"承诺"。承诺被打破时,as 注解的代码运行时崩溃且追踪困难。
解决: 用运行时校验库定义 schema,从 schema 自动推断 TS 类型——单点真相,schema 改了类型同步变化。
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(["admin", "user", "guest"]),
createdAt: z.coerce.date(), // 自动把 ISO 字符串转 Date
});
// 类型从 schema 推断,永不脱节
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const r = await fetch(`/api/users/${id}`);
const data: unknown = await r.json();
return UserSchema.parse(data); // 失败抛 ZodError,含完整错误路径
}
// 安全模式 + Result 模式
function parseUser(data: unknown): Result<User, z.ZodError> {
const r = UserSchema.safeParse(data);
return r.success
? { ok: true, value: r.data }
: { ok: false, error: r.error };
}
Zod 常用 API:
| API | 类型 | 说明 |
|---|---|---|
Schema.parse(data) |
(data: unknown) => T |
严格校验,失败抛 ZodError |
Schema.safeParse(data) |
(data: unknown) => { success, data | error } |
返回判别联合,不抛错 |
Schema.parseAsync(data) |
(data: unknown) => Promise<T> |
异步校验(含 async refine) |
z.infer<typeof S> |
type util | 推断输出类型 |
z.input<typeof S> |
type util | 推断输入类型(与 output 在 transform/coerce 后不同) |
Schema.partial() |
() => Schema |
所有字段变可选 |
Schema.required() |
() => Schema |
所有字段变必选 |
Schema.pick({ name: true }) |
(mask) => Schema |
挑选字段 |
Schema.omit({ pwd: true }) |
(mask) => Schema |
排除字段 |
Schema.extend({ x: z.string() }) |
(shape) => Schema |
扩展字段 |
Schema.refine(v => ok, "msg") |
(check, msg) => Schema |
自定义校验逻辑 |
Schema.transform(v => f(v)) |
(fn) => Schema |
校验后转换,可改输出类型 |
Schema.default(v) |
(value) => Schema |
缺失时填默认值 |
Schema.catch(v) |
(value) => Schema |
校验失败时填回退值 |
同一 schema 多场景复用:
const UserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
});
const CreateUserInput = UserSchema; // 注册表单
const UpdateUserInput = UserSchema.partial().omit({ password: true }); // 更新表单
const PublicUser = UserSchema.omit({ password: true }); // 对外响应
type CreateInput = z.infer<typeof CreateUserInput>;
type UpdateInput = z.infer<typeof UpdateUserInput>;
type PublicUser = z.infer<typeof PublicUser>;
库选择对比:
| 库 | bundle (gzip) | 类型推断 | 风格 | 适合场景 |
|---|---|---|---|---|
| Zod | ~14 KB | 优秀 | 链式 | 通用首选 |
| Valibot | ~1 KB | 优秀 | 函数式 + tree-shakeable | bundle 敏感(移动 / Edge / Worker) |
| ArkType | ~10 KB | 优秀 | 字符串语法(类 TS 字面) | 偏好简洁字面 |
| Yup | ~12 KB | 一般 | 链式 | 老项目 / Formik 生态 |
| io-ts | ~12 KB | 优秀 | 函数式 | fp-ts 生态 |
| Effect Schema | ~30 KB+ | 优秀 | 函数式 | 已用 Effect 框架 |
反模式:
- 手写守卫 + 重复定义 TS 类型——改一边忘了另一边
- 用
as User绕过校验后被业务依赖——任何上游变更都是定时炸弹 - 在多处 parse 同一份数据——只在边界(HTTP handler、表单提交)做一次
八、与第三方库协作
8.1 优先选有官方类型的库
包目录或 package.json 含 "types" 字段说明库自带类型,否则需要 @types/*。
| 来源 | 标识 | 注意 |
|---|---|---|
| 包内自带 | package.json 有 "types" 或 "typings" |
最新最准 |
@types/*(DefinitelyTyped) |
npm i -D @types/<pkg> |
社区维护,可能滞后 |
| 无类型 | 上述都没有 | 自己写 .d.ts 或换库 |
8.2 扩展第三方类型用 declare module
// types/express.d.ts —— 给 Express Request 添加自定义字段
import "express";
declare module "express" {
interface Request {
userId?: string;
}
}
确保该 .d.ts 文件被 tsconfig 的 include 覆盖。
8.3 用 import type 引入仅做类型用的依赖
避免运行时被打包:
import type { Plugin } from "vite";
export default function myPlugin(): Plugin { /* ... */ }
构建产物中不会包含对 vite 的 require/import。
8.4 编写 .d.ts 文件的三种模式
第三方库没类型、或需要给现有库扩展类型时,会用到三种不同的 .d.ts 写法。选错模式类型不会被识别——这是新手最常踩的坑。
模式 A:模块声明(给整个 npm 包加类型)
适用:some-lib 既没自带类型,也没有 @types/some-lib,需要自己写。
// src/types/some-lib.d.ts
declare module "some-lib" {
export interface Options {
timeout?: number;
retries?: number;
}
export function doSomething(opts?: Options): Promise<string>;
const _default: (input: string) => void;
export default _default;
}
关键点:
- 文件名任意,扩展名必须是
.d.ts - 确保
tsconfig.include覆盖到src/types/**/* - 顶层不要有
import/export——一旦成为模块,declare module的作用域会变
模式 B:模块扩充(给已有类型的包加字段/方法)
适用:Express Request 加 userId、Vite 配置加自定义字段、给 Vue 全局组件加类型。
// src/types/express.d.ts
import "express"; // 必须先 import,激活模块扩充语义
declare module "express" {
interface Request {
userId?: string;
requestId: string;
}
}
关键点:
- 必须有
import或export让该文件成为模块。否则declare module "express"会被当成重新声明整个模块,把原有类型全部覆盖(导致app.use都报错) - 只能扩充
interface,不能扩充type(前者支持声明合并) - 扩充的属性建议加
?可选,避免破坏未赋值场景
模式 C:环境声明(全局变量、特殊文件类型)
适用:Vite import.meta.env、*.svg 资源导入、注入到 window 的全局对象、Webpack 等定义的常量。
// src/types/global.d.ts
declare global {
interface Window {
__APP_VERSION__: string;
__INITIAL_STATE__: unknown;
}
const __DEV__: boolean;
const __BUILD_TIME__: string;
}
// 资源模块导入
declare module "*.svg" {
const content: string;
export default content;
}
declare module "*.svg?component" {
import type { Component } from "vue";
const c: Component;
export default c;
}
declare module "*.module.css" {
const classes: Record<string, string>;
export default classes;
}
export {}; // 让文件成为模块,激活 declare global
关键点:
declare global必须写在模块内——文件末加export {}把它变成模块- 全局变量用
declare const、全局类型用interface或type - Vite 项目通常用
<reference types="vite/client" />一键拉入import.meta.env类型,自己写时遵循 Vite 文档
三种模式速查表
| 想做什么 | 文件顶部需要 | 用什么语法 | 典型文件名 |
|---|---|---|---|
| 给无类型的包加类型 | 无需 import/export | declare module "包名" |
types/lib-name.d.ts |
| 给已有类型的包扩字段 | import "包名" 激活 |
declare module "包名" { interface ... } |
types/express.d.ts |
| 加全局变量/类型 | 末尾 export {} |
declare global { ... } |
types/global.d.ts |
| 给资源类型加 import 支持 | 无需 import/export | declare module "*.ext" |
types/assets.d.ts |
校验工作正常的最快方式: 在使用处写 import { x } from "lib",看 IDE 是否补全。不补全说明 .d.ts 未被识别(检查 include / 路径 / 文件名)。
九、性能与构建
9.1 启用 skipLibCheck
绝大多数项目都应该开。@types/* 之间的类型冲突不是你的责任,关掉能省 30%+ 的编译时间。
{ "skipLibCheck": true }
例外:你正在开发库,需要确保发布的类型与依赖兼容。
9.2 启用 isolatedModules
要求每个文件能被单文件转译器(esbuild、swc、Babel)独立处理。这是使用 Vite / Next.js / tsup 的前提。
{ "isolatedModules": true }
启用后会禁止:const enum、跨文件 type-only 重新导出(除非用 export type)等只有 tsc 全量编译才能正确处理的语法。
9.3 用 Project References 拆分巨型仓库
单仓库代码超过 ~50k LOC 时,把项目拆成多个 tsconfig.json 并用 references 串起来:
// tsconfig.json
{
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/cli" }
]
}
每个子项目独立增量编译,大幅提速 IDE 与 CI。
9.4 类型计算别失控
避免在热路径写出几百行的递归条件类型——TS 会触发 instantiation depth limit (50),且 IDE 卡顿。
// 危险:超长字符串模板字面量解析
type ParseURL<S extends string> = /* 30 行递归 */;
// 更好:保留类型为 string,把校验放到运行时
function parseURL(s: string): URL { /* ... */ }
9.5 类型层面的性能优化
大型项目 IDE 卡顿、tsc 慢的根因往往是类型计算过重而非文件数量。源码 1 万行的项目如果有几个失控的递归条件类型,能让编译时间从 5 秒涨到 5 分钟。
诊断手段
# 输出扩展诊断信息(耗时分布)
tsc --extendedDiagnostics --noEmit
# 生成类型计算追踪
tsc --generateTrace ./trace --noEmit
# 把生成的 trace/ 目录拖进 chrome://tracing 或 https://ui.perfetto.dev
关键指标:
| 指标 | 健康值 | 异常含义 |
|---|---|---|
Instantiations |
< 200k | 类型实例化过多,递归 / 联合爆炸 |
Types |
< 100k | 类型总量过大,考虑拆模块 |
Memory used |
< 1 GB | 内存压力,IDE 易卡顿 |
Total time |
< 30s(中型) | 超过则诊断 |
5 条优化原则
1) 优先 interface 而非长链 type & type
interface 有内部缓存,大型交叉类型每次实例化都重新计算。
// 慢
type Big = A & B & C & D & E;
// 快
interface Big extends A, B, C, D, E {}
2) 大型联合类型提到顶层
// 慢:每次使用 Lang 都重新计算
function tr(s: string, lang: "zh" | "en" | "fr" | "de" | "ja" | "ko" | "ru" | "es"): string {}
// 快
type Lang = "zh" | "en" | "fr" | "de" | "ja" | "ko" | "ru" | "es";
function tr(s: string, lang: Lang): string {}
3) 缓存复杂工具类型的结果
// 慢:每次使用 ExtractKeys<User> 都要计算
function f(): ExtractKeys<User> {}
function g(): ExtractKeys<User> {}
// 快:命名一次,复用
type UserKey = ExtractKeys<User>;
function f(): UserKey {}
function g(): UserKey {}
4) 限制递归条件类型深度
// 危险:无深度限制的递归
type Paths<T> = /* ... */;
// 改进:加深度计数器
type Paths<T, D extends number = 5, Acc extends number[] = []> =
Acc["length"] extends D ? never : /* 递归用 [...Acc, 0] */;
5) 必开 skipLibCheck: true
绕过 node_modules/**/*.d.ts 的类型检查,省 30%+ 编译时间。仅库作者发版时可关闭。
当一个文件特别慢
# 找出最慢的文件
tsc --diagnostics --noEmit | grep "instantiations"
# 或看 trace 输出中 instantiations 最高的 source file
常见原因:
- 大型
Record<string, ComplexType>字面量(路由表、i18n 词典) - 深嵌套泛型组合(
A<B<C<D<E>>>>) - 长字符串模板字面量类型解析(SQL/正则 AST)
修复策略: 把字面量类型断言为更宽的类型,把"精确推断"换成"运行时校验"。
十、代码组织与命名
10.1 类型命名规范
| 类型 | 命名 | 示例 |
|---|---|---|
| 接口/类型别名 | PascalCase |
User、ApiResponse |
| 泛型参数 | 单字母大写或 T 前缀 |
T、K、TKey、TResult |
| 枚举与枚举成员 | PascalCase |
LogLevel.Info |
| 字面量联合 | 小写字符串 | "idle" | "loading" |
| 布尔字段 | 形容词或 is/has/can 前缀 |
enabled、isAdmin、hasAccess |
不要给 interface 加 I 前缀(IUser)——这是 C# 习惯,TS 社区已抛弃。
10.2 interface vs type 的选择
选 interface 当 |
选 type 当 |
|---|---|
| 描述对象/类的形状 | 联合、交叉、元组、原始类型 |
需要被 implements |
需要工具类型如 Pick<...> |
| 期望第三方扩展(声明合并) | 需要条件类型 |
默认用 interface,遇到上述 type 场景才切换。两者性能差异在 TS 5.x 已基本消除。
10.3 避免巨型类型文件
把类型与使用它的实现放在同一文件,除非:
- 类型被 ≥ 3 处使用 → 抽到
types.ts - 公共领域模型 →
src/types/<domain>.ts
不要建一个全局 src/types/index.ts 把全项目类型都塞进去——会成为修改瓶颈。
10.4 ESLint + typescript-eslint 推荐规则
类型系统不能捕获所有问题:未 await 的 Promise、误用 Promise 作为 boolean、循环中的副作用、未使用的变量。typescript-eslint 是补充。
最小推荐配置(typescript-eslint v8+,Flat Config)
// eslint.config.js
import tseslint from "typescript-eslint";
export default tseslint.config(
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// 强烈推荐打开
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
}],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
// 按项目偏好调整
"@typescript-eslint/explicit-module-boundary-types": "off", // 库项目改为 error
"@typescript-eslint/no-empty-object-type": "warn",
},
},
);
必开规则的解释
| 规则 | 防止的 bug |
|---|---|
no-floating-promises |
漏 await → unhandled rejection → 进程崩溃 |
no-misused-promises |
if (asyncFn()) 永为 true(Promise 是 truthy) |
await-thenable |
对非 Promise 用 await → 静默 bug |
require-await |
async 函数体内无 await → 多余的 Promise 包装 |
no-unnecessary-condition |
x !== undefined 而 x 类型已确定非空 |
switch-exhaustiveness-check |
联合类型新增 case 时忘了处理 |
consistent-type-imports |
配合 verbatimModuleSyntax 自动加 type 修饰符 |
no-import-type-side-effects |
防止 type-only import 触发副作用 |
prefer-nullish-coalescing |
该用 ?? 时用 || |
prefer-optional-chain |
该用 ?. 时用 && 链 |
库项目额外开启
rules: {
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/explicit-function-return-type": ["error", {
allowExpressions: true,
allowTypedFunctionExpressions: true,
}],
"@typescript-eslint/no-explicit-any": "error",
}
配套:Prettier + Editorconfig
ESLint 管"语义问题",Prettier 管"格式"。两者分工:
// .prettierrc.json
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}
不在 ESLint 中开启格式规则(如 semi、indent)——交给 Prettier。
CI 集成
// package.json
{
"scripts": {
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm run lint && npm test"
}
}
--max-warnings 0 让 warning 也阻止 CI——逼团队真正处理而不是积累。
十一、语法选择决策:多用什么、少用什么、何时用什么
TypeScript 的语法表面相似但语义差距巨大。本节用四个角度回答"实战中怎么选":
- 场景 → 语法对照表:按你想做什么反查最佳语法
- 多用清单:推荐高频使用的语法及其解决的问题
- 少用/避免清单:解释为什么避开、用什么替代
- 易混淆语法对比:相似语法之间的精确边界
11.1 场景 → 语法对照表
按"我想做什么"反查。先看这张表,再去查细节。
| 你想做什么 | 推荐语法 | 避免语法 | 一句话理由 |
|---|---|---|---|
| 固化字面量类型 | as const |
: "literal" 注解 |
as const 递归冻结整个对象 |
| 校验类型又保留窄推断 | satisfies |
: T 注解 |
注解会拓宽类型 |
| 表达互斥状态(idle/loading/success/error) | 判别联合 + kind 字段 |
多个可选 boolean 标志 |
判别联合让非法状态无法构造 |
| 区分同型不同义(UserId vs OrderId) | 品牌类型 T & { __brand } |
类型别名 type UserId = string |
别名可互换,品牌不可 |
| 表达不可变数组 | ReadonlyArray<T> 或 readonly T[] |
T[] + 约定 |
类型层面阻止 push |
| 表达不可变对象 | Readonly<T> 或 as const |
注释"请勿修改" | 编译期保障 |
收窄 unknown/联合类型 |
类型守卫 x is T + typeof/instanceof/in |
as T 断言 |
守卫是检查,断言是 bypass |
| 公开 API 输入参数 ≥ 3 个 | 命名参数对象 fn({ a, b, c }) |
位置参数 fn(a, b, c) |
调用方不需要记顺序 |
| 表达"可能空但要先校验" | unknown + 守卫 |
any |
unknown 强制处理 |
| 表达"绝对不会发生" | never |
throw 而不声明 |
never 让穷举检查生效 |
| 表达"调用此函数永不返回" | : never 返回类型 |
: void |
void 可继续执行后续代码 |
| 表达"调用方不应使用返回值" | : void 返回类型 |
: undefined |
接口语义不同 |
| 跨模块仅引入类型 | import type { X } from "..." |
import { X } from "..." |
类型擦除避免循环依赖 |
| 在同一 import 混引类型和值 | import { type X, fn } from "..." |
拆成两条 import | 减少噪音 |
| 提取函数返回类型 | ReturnType<typeof fn> |
手写副本类型 | 单点真相 |
| 提取函数参数类型 | Parameters<typeof fn> |
手写副本类型 | 同上 |
| 提取 Promise 内部类型 | Awaited<T> |
T extends Promise<infer U> ? U : T 手写 |
已内置 |
| 从对象类型挑字段 | Pick<T, "a" | "b"> |
重新定义类型 | 跟随源类型演化 |
| 从对象类型去字段 | Omit<T, "secret"> |
重新定义类型 | 同上 |
| 创建键值对查找表 | Record<K, V> |
{ [k: string]: V } |
更易读 |
| 表达"可能为 null/undefined" | T | undefined 或 T | null |
T? 在类型位置 |
? 仅在属性位置合法 |
| 给可选字段去掉 undefined | NonNullable<T> |
T & {} 黑魔法 |
内置工具更明确 |
| 把可选字段变必选 | Required<T> |
重新定义 | 同上 |
| 把必选字段变可选 | Partial<T> |
重新定义 | 同上 |
| 链式访问可能空的属性 | a?.b?.c 可选链 |
a && a.b && a.b.c |
简洁且与 ?? 配合 |
| 提供"仅 null/undefined"默认值 | x ?? default |
x || default |
|| 会覆盖 0/""/false |
| 类的真正私有字段 | #field(ES 私有字段) |
private field(TS 关键字) |
# 在运行时也私有 |
| 表达"可能是任何形状的对象" | Record<string, unknown> |
object 或 {} |
后两者语义不明确 |
| 表达"任何非 nullish 值" | {} 或 NonNullable<unknown> |
Object |
Object 会接受 null 表现混乱 |
| 表达"任何函数" | (...args: never[]) => unknown |
Function |
Function 是黑洞 |
| 表达"任何数组" | readonly unknown[] |
any[] |
后者关闭检查 |
| 给第三方库扩展类型 | declare module "lib" { ... } |
复制声明文件 | 维护友好 |
| 给全局对象加属性 | declare global { interface Window { ... } } |
改 lib.d.ts | 项目内自治 |
| 工具类型变形 | type X = Pick<...> & Omit<...> |
写新 interface |
自动跟随源 |
| 写 React/Vue 组件 Props | interface Props {} |
type Props = {} |
期望被合并/扩展 |
| 写联合/交叉/元组 | type X = A | B |
interface |
interface 不支持联合 |
| 给参数加默认值 | 解构默认 function f({ x = 1 } = {}) |
function f(x?) { x = x ?? 1 } |
调用方与签名一致 |
| 表达字符串枚举 | as const 对象 + keyof |
enum E { A = "a" } |
无运行时副作用 |
| 表达数字枚举 | as const 对象 + keyof |
enum E { A, B } |
同上 |
| 调用方要 implements 实现 | interface |
type |
type 不推荐用于 implements |
| 反射式获取对象 keys | Object.keys(o) as Array<keyof typeof o> |
直接 Object.keys(o) |
TS 故意返回 string[] |
| 表达"接受任何对象但只读其结构" | Readonly<Record<string, unknown>> |
any |
不丢类型检查 |
11.2 应该多用的语法(按解决问题分组)
A. 类型推断与精确化
1) as const —— 解决"字面量被自动拓宽"
问题: const role = "admin" 推断为 string,丢失字面量信息。
何时用: 定义配置对象、字面量集合、希望被联合类型消费的字符串。
// 错误
const roles = ["admin", "user", "guest"]; // string[]
type Role = (typeof roles)[number]; // string
// 正确
const roles = ["admin", "user", "guest"] as const; // readonly ["admin", "user", "guest"]
type Role = (typeof roles)[number]; // "admin" | "user" | "guest"
2) satisfies —— 解决"既要校验形状,又要保留窄类型"
问题: : T 注解会把变量类型拓宽为 T,丢失原始字面量;as T 则会跳过检查。
何时用: 定义符合某接口的对象常量、路由表、配置树。
type RouteConfig = Record<string, { path: string; auth: boolean }>;
// 错误 — 注解:通过校验但类型被拓宽,丢失 key 自动补全
const routes: RouteConfig = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
};
routes.unknown; // 不报错,因为类型是 Record<string, ...>
// 错误 — 断言:跳过检查
const routes = { /* 形状错误也不报 */ } as RouteConfig;
// 正确 — satisfies:校验 + 保留窄类型
const routes = {
home: { path: "/", auth: false },
admin: { path: "/admin", auth: true },
} satisfies RouteConfig;
routes.unknown; // 报错:只有 "home" | "admin"
3) 判别联合(discriminated union)—— 解决"用可选字段建模状态"
问题: 多个 optional 字段组合可以构造出非法状态(如同时 loading 和 error)。
何时用: 异步状态、表单状态、有限状态机、消息协议。
type Msg =
| { type: "text"; content: string }
| { type: "image"; url: string; alt?: string }
| { type: "system"; code: number };
function render(m: Msg) {
switch (m.type) {
case "text": return m.content; // 自动收窄
case "image": return m.url;
case "system": return `[${m.code}]`;
}
}
判别字段命名约定: type / kind / tag / status 任选其一,团队内一致即可。
4) 类型守卫 x is T —— 解决"如何安全把 unknown 收窄"
问题: as T 关闭检查;typeof/instanceof 表达能力有限。
何时用: 校验外部数据、运行时多态分支、Array.prototype.filter 等高阶用法。
function isString(x: unknown): x is string {
return typeof x === "string";
}
const arr: (string | null)[] = ["a", null, "b"];
const clean: string[] = arr.filter(isString); // 类型正确
// 复合守卫
function isUser(x: unknown): x is User {
return typeof x === "object" && x !== null
&& "id" in x && typeof (x as any).id === "string"
&& "name" in x && typeof (x as any).name === "string";
}
生产建议: 复杂数据用 Zod / Valibot / ArkType 自动生成守卫,不要手写。
5) never 穷举检查 —— 解决"新增 case 忘了处理"
何时用: 任何 switch 处理判别联合的场景。
function assertNever(x: never): never {
throw new Error(`unhandled: ${JSON.stringify(x)}`);
}
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.size ** 2;
default: return assertNever(s); // 新增 kind 时此处编译报错
}
}
B. 不可变与防御
6) readonly / ReadonlyArray —— 解决"不希望被修改的参数被修改"
何时用: 任何函数参数(除非显式需要 mutate)、共享配置、Redux/Zustand state。
function sum(nums: readonly number[]): number {
// nums.push(1); // 报错
return nums.reduce((a, b) => a + b, 0);
}
7) ?. 可选链 —— 解决"链式访问中间环节可能为 null"
// 错误:层层 &&
const street = user && user.address && user.address.street;
// 正确
const street = user?.address?.street;
调用方法时也适用: obj.method?.()、arr?.[0]。
8) ?? 空值合并 —— 解决"|| 误把 0/false/'' 当无效值"
// 错误
const port = config.port || 3000; // port=0 时变 3000
const enabled = config.enabled || true; // enabled=false 时变 true
// 正确
const port = config.port ?? 3000;
const enabled = config.enabled ?? true;
配合赋值: x ??= defaultValue 仅在 x 为 null/undefined 时赋值。
C. 类型变形与提取
9) Pick / Omit / Partial / Required / Readonly —— 解决"派生类型重复定义"
何时用: 从领域模型派生 DTO、表单类型、更新输入。
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: Date;
}
type PublicUser = Omit<User, "password">; // 对外暴露
type UpdateUser = Partial<Pick<User, "name" | "email">>; // 更新接口
type NewUser = Omit<User, "id" | "createdAt">; // 创建接口
type UserView = Readonly<PublicUser>; // 视图层
10) Record<K, V> —— 解决"查找表/字典类型表达"
type StatusColor = Record<"idle" | "loading" | "error", string>;
const colors: StatusColor = {
idle: "gray",
loading: "blue",
error: "red",
};
// 漏一个 key 会报错
11) ReturnType / Parameters / Awaited —— 解决"从函数反推类型"
async function fetchUser(id: string) {
return { id, name: "alice" };
}
type FetchUserArgs = Parameters<typeof fetchUser>; // [id: string]
type FetchUserRet = ReturnType<typeof fetchUser>; // Promise<{id, name}>
type User = Awaited<ReturnType<typeof fetchUser>>; // {id, name}
12) NonNullable<T> —— 解决"去掉可空"
type Maybe = string | null | undefined;
type Sure = NonNullable<Maybe>; // string
D. 模块与依赖
13) import type 与 inline type —— 解决"仅类型用途的运行时副作用"
// 仅类型
import type { User } from "./models";
// 混合:把 type 修饰符放在导入项前
import { type Config, loadConfig } from "./config";
何时必须用: 启用了 verbatimModuleSyntax 时(推荐配置)。
14) 命名参数对象 —— 解决"参数顺序难记 / 布尔参数语义不明"
// 错误
function createWindow(width: number, height: number, modal: boolean, closable: boolean) {}
createWindow(800, 600, true, false); // 后两个 boolean 是什么?
// 正确
interface WindowOptions {
width: number;
height: number;
modal?: boolean;
closable?: boolean;
}
function createWindow(opts: WindowOptions) {}
createWindow({ width: 800, height: 600, modal: true });
阈值: ≥ 3 个参数、或含 ≥ 1 个布尔/枚举参数时强制使用。
E. 高级(按需使用)
15) 品牌类型 —— 解决"同型不同义的混用"
16) 模板字面量类型 —— 解决"字符串字面量需要约束格式"
type HexColor = `#${string}`;
type EventName = `on${Capitalize<string>}`;
const ok: HexColor = "#ff0000";
const bad: HexColor = "ff0000"; // 报错
何时用: API 路径校验、CSS 单位、事件名。不要为字符串解析写超过 30 行的递归模板类型。
17) infer 条件类型 —— 解决"提取嵌套结构中的某个类型"
type Unwrap<T> = T extends Array<infer U> ? U : T;
type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;
优先级: 先查内置工具类型 → 再用 infer 自己写。
11.3 应该少用或避免的语法(按危险等级排序)
🔴 高危:几乎永远不要用
1) any —— 类型黑洞
为什么避开: 关闭类型检查,且向外传染(any.foo.bar 不报错)。
替代: unknown(安全的未知)→ 用类型守卫收窄。
唯一例外: 与无类型 JS 库桥接,且必须配 // FIXME: 待加类型 + issue。
2) as unknown as T —— 双重断言强制转换
为什么避开: 完全绕过类型系统,等同 any 但更隐蔽。
替代: 用类型守卫;如果是数据校验,用 Zod。
// 错误
const user = JSON.parse(s) as unknown as User;
// 正确
const data: unknown = JSON.parse(s);
const user = UserSchema.parse(data);
3) ! 非空断言(无证据时)
为什么避开: 把"我以为这里非空"承诺给编译器,错了就运行时崩。
替代: ?. + ??、提前判空抛错、assertExists(x) 工具。
// 错误:DOM 查询不能保证元素存在
const el = document.getElementById("x")!;
// 正确
const el = document.getElementById("x");
if (!el) throw new Error("missing #x");
el.click();
允许例外: 类属性 db!: DB 配合 init() 注入;Vue/React ref 在 mounted 后的访问。这两种情况要写注释解释。
4) @ts-ignore —— 无差别屏蔽错误
为什么避开: 屏蔽所有错误,包括未来新出现的;不强制写理由。
替代: @ts-expect-error <理由>。后者在错误消失后会自己报错,提醒你删掉。
// 错误
// @ts-ignore
const x: number = "abc";
// 正确
// @ts-expect-error 第三方类型定义错误,已提 PR #123
const x: number = "abc";
🟡 中危:特定场景可以用,但默认避开
5) enum(特别是数字 enum)—— 运行时副作用 + 双向映射陷阱
为什么避开:
- 普通
enum编译出真实对象(增加包体积) - 数字 enum 是双向映射
E[0] === "A",反向查到字符串令人困惑 - 与
isolatedModules不能完全配合(const enum受限)
替代: as const 对象 + keyof typeof。
// 避免
enum Status { Idle, Loading, Success }
// 推荐
const Status = { Idle: "idle", Loading: "loading", Success: "success" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "idle" | "loading" | "success"
例外: 大量遗留代码已用 enum,新加成员保持一致;与 Protobuf/数据库列对应的整数枚举。
6) namespace / module 关键字 —— 与 ES 模块功能重叠
为什么避开: TS 早期产物,与 ES 模块功能重叠;现代打包工具无法 tree-shake namespace 内部。
替代: ES 模块文件 + import / export。
例外: 编写 .d.ts 环境声明、扩展全局对象(declare global { namespace ... })。
7) 函数重载(伪多态时)
为什么避开: 仅当输入类型决定输出类型时才需要重载;模拟可选参数或联合时用更简单的语法。
// 错误:用重载模拟"可选参数"
function greet(name: string): string;
function greet(): string;
function greet(name?: string): string {
return `hi ${name ?? "stranger"}`;
}
// 正确
function greet(name?: string): string {
return `hi ${name ?? "stranger"}`;
}
何时该用重载: 输入类型与输出类型严格绑定(parse(string): object; parse(Buffer): Buffer)。
8) 类的 private 关键字 —— 仅编译期私有
为什么避开: 运行时仍可被 obj["secret"] 或反序列化访问;JS 已有真正的私有字段 #field。
替代: #field(ES 私有字段)。
// 编译期私有,运行时可绕过
class A { private secret = 1; }
const a = new A();
(a as any).secret; // 1
// 运行时私有
class B { #secret = 1; }
const b = new B();
(b as any)["#secret"]; // undefined
例外: Vue 3 reactivity 与 #field 不兼容时(实例属性需被代理);某些 ORM 框架的元编程依赖反射。
9) 方法语法 name(): void 用于回调字段 —— 双变性陷阱
为什么避开: 方法语法在 strict 模式下仍是双变(bivariant),会接受不安全的子签名。
替代: 属性语法 name: () => void。
// 错误:方法语法
type Handler = { onClick(e: MouseEvent): void };
const h: Handler = { onClick: (e: Event) => {} }; // 通过,但 e.button 会丢失
// 正确:属性语法
type Handler = { onClick: (e: MouseEvent) => void };
const h: Handler = { onClick: (e: Event) => {} }; // 同样通过(逆变)
const h2: Handler = { onClick: (e: KeyboardEvent) => {} }; // 报错(按预期)
经验: 对象的"方法"用方法语法,对象的"回调字段"用属性语法。
10) Function / Object / {} 顶层类型
为什么避开:
Function没有调用签名,且接受任何对象Object接受null/undefined之外的几乎所有值(包括基本类型){}等价于"非 null/undefined 的任何值",常被误解为"空对象"
替代:
| 想表达 | 用 |
|---|---|
| 任意可调用 | (...args: never[]) => unknown 或 (...args: any[]) => any |
| 任意对象(字面意义) | Record<string, unknown> 或 object |
| 任意值 | unknown |
| 空对象 | Record<string, never> |
🟢 低危:风格选择,团队内一致即可
11) Array<T> vs T[] —— 选一种风格
简单元素用 T[],复合类型用 Array<T> 提升可读性:
const ids: string[] = [];
const handlers: Array<(e: Event) => void> = []; // 比 `((e: Event) => void)[]` 易读
12) interface vs type —— 见 §10.2 决策表
13) 三斜杠引用 /// <reference /> —— 现代项目避开
替代: import type 或 tsconfig.compilerOptions.types。
14) 旧式装饰器(experimentalDecorators)—— 等待标准
为什么避开: TS 5.0 已支持 Stage 3 装饰器(不带 experimentalDecorators);旧实现与新标准不兼容。
例外: Angular、NestJS、TypeORM 等框架强制要求旧装饰器;切换需等框架支持。
15) 桶文件 export * from "./*" —— 大型项目谨慎
为什么避开: 阻碍 tree-shaking、加剧循环依赖、拖慢编译。
何时用: 库的对外入口(一个 index.ts);项目内业务模块直接相对路径导入。
11.4 易混淆语法精确对比
as vs satisfies vs : T 注解
| 语法 | 做什么 | 何时用 |
|---|---|---|
const x: T = v |
校验 v 符合 T,并把 x 的类型设为 T(拓宽) |
函数参数、返回值显式声明 |
const x = v as T |
强制把 v 视为 T,不校验(仅在子类型/父类型关系内宽松允许) |
与第三方数据交互、收窄已知类型 |
const x = v satisfies T |
校验 v 符合 T,但 x 的类型保留 v 的窄推断 |
字面量常量、配置对象、路由表 |
type Theme = Record<string, string>;
const a: Theme = { dark: "#000" }; // a 类型 = Theme,丢字面量
const b = { dark: "#000" } as Theme; // 同上,且校验更弱
const c = { dark: "#000" } satisfies Theme; // c 类型 = { dark: string },保留 key
const d = { dark: "#000" as const } satisfies Theme; // d.dark 类型 = "#000"
unknown vs any vs never
| 类型 | 含义 | 可赋给它 | 它可赋给 |
|---|---|---|---|
unknown |
安全的"任意值" | 任何值 | 仅 unknown 和 any |
any |
关闭检查的"任意值" | 任何值 | 任何类型(危险) |
never |
不可能存在的值 | 仅 never |
任何类型 |
使用法则:
- 入参想"接受任何东西"→
unknown - 函数永不返回(抛错/无限循环)→ 返回
never - 穷举检查、过滤联合 →
never - 你以为需要
any→ 90% 应该是unknown
?? vs ||
| 表达式 | 取右值的条件 |
|---|---|
x || y |
x 为 falsy(0/""/false/null/undefined/NaN) |
x ?? y |
x 为 null 或 undefined |
0 || 10 // 10 ❌ 通常不希望
0 ?? 10 // 0 ✅
"" ?? "def" // "" ✅
null ?? "def" // "def"
?. vs && vs !
| 语法 | 行为 | 何时用 |
|---|---|---|
a?.b |
a 为 null/undefined 时短路返回 undefined |
不确定 a 是否存在 |
a && a.b |
a 为 falsy 时返回 a(不是 undefined) |
几乎已被 ?. 取代 |
a!.b |
告诉编译器 a 必非空,运行时不检查 |
有充分证据时(少用) |
readonly vs Readonly<T> vs as const
| 语法 | 深度 | 适用对象 |
|---|---|---|
readonly T[] / ReadonlyArray<T> |
顶层 | 数组类型 |
readonly 修饰符 |
单个属性 | 接口/类型字段 |
Readonly<T> |
顶层(一层) | 任意对象类型 |
as const |
递归冻结 | 字面量值 |
interface Point { x: number; y: number; }
type R1 = Readonly<Point>; // { readonly x: number; readonly y: number }
type R2 = readonly Point[]; // 数组只读,但元素可改
const p = { x: 1, y: 2 } as const; // { readonly x: 1; readonly y: 2 } — 字面量化
import type vs 内联 type 修饰符
// 整条仅类型
import type { User, Role } from "./auth";
// 同一条语句混合
import { type Config, loadConfig } from "./config";
// 等价于
import type { Config } from "./config";
import { loadConfig } from "./config";
何时整条 vs 内联: 整条更明确;混引时用内联减少重复。
interface vs type 决策表
| 需求 | 选 interface |
选 type |
|---|---|---|
| 描述对象/类形状 | ✅ | ✅ |
extends 继承 |
✅ | 用 & 交叉 |
implements |
✅ | ⚠️ |
联合 A | B |
❌ | ✅ |
交叉 A & B |
用 extends |
✅ |
元组 [a, b] |
❌ | ✅ |
映射类型 { [K in keyof T] } |
❌ | ✅ |
| 条件类型 | ❌ | ✅ |
| 声明合并(同名自动合并) | ✅ | ❌ |
| 第三方扩展(如扩展 Express Request) | ✅ | ❌ |
默认规则: 描述"对象/类"用 interface;其他全部场景用 type。
void vs undefined vs never(返回类型)
| 返回类型 | 含义 |
|---|---|
: void |
调用方不应使用返回值;实际可返回任意值(会被忽略) |
: undefined |
必须显式 return undefined 或 return |
: never |
函数永不正常返回(抛错或死循环) |
function log(): void { console.log("x"); } // OK
function noop(): undefined { return; } // OK
function fail(msg: string): never { throw new Error(msg); } // OK
// 回调中的 void 允许返回值被忽略:
[1, 2].forEach((x): void => x); // 即使返回了 x 也合法
Record<string, T> vs 索引签名 { [k: string]: T }
功能等价,但:
Record更短、更易读- 索引签名可与具体属性同存:
{ name: string; [k: string]: unknown } Record配合联合 key:Record<"a" | "b", T>是完全封闭的类型
11.5 经验法则汇总
- 想固化字面量 →
as const - 想校验形状又保留窄类型 →
satisfies - 建模状态 → 判别联合 + 穷举检查
- 收窄类型 → 类型守卫
x is T,不用as - 传 3+ 参数或带布尔 → 命名参数对象
- 代替 enum →
as const对象 - 代替 namespace → ES 模块
- 代替
||取默认 →?? - 代替手写嵌套判空 →
?. - 代替 any →
unknown+ 守卫 - 代替
private→#field - 代替
Function/Object→ 精确签名 /Record<string, unknown> - 代替手写派生类型 →
Pick/Omit/Partial/Required - 代替手写函数签名提取 →
Parameters/ReturnType/Awaited - 代替
@ts-ignore→@ts-expect-error <理由>
十二、类型层面测试
类型本身也是代码——工具类型、复杂泛型、对外 API 的类型签名都需要测试。否则一次"无害的重构"会悄悄破坏下游用户的类型推断,而 CI 不报错。
12.1 用 expectTypeOf(Vitest 内置)
Vitest 1.x+ 自带 expectTypeOf,无需额外依赖,与运行时断言混写。
import { expectTypeOf, test } from "vitest";
type FetchUser = (id: string) => Promise<{ id: string; name: string }>;
test("fetchUser 类型契约", () => {
expectTypeOf<FetchUser>().toBeFunction();
expectTypeOf<FetchUser>().parameters.toEqualTypeOf<[string]>();
expectTypeOf<FetchUser>().returns.resolves.toMatchTypeOf<{ id: string }>();
});
test("Pick 工具类型行为", () => {
type User = { id: string; name: string; secret: string };
type Public = Pick<User, "id" | "name">;
expectTypeOf<Public>().toEqualTypeOf<{ id: string; name: string }>();
expectTypeOf<Public>().not.toHaveProperty("secret");
});
test("可选字段处理", () => {
type T = { a: string; b?: number };
expectTypeOf<Required<T>>().toEqualTypeOf<{ a: string; b: number }>();
expectTypeOf<Partial<T>>().toEqualTypeOf<{ a?: string; b?: number }>();
});
核心 API:
| API | 检查 |
|---|---|
.toEqualTypeOf<T>() |
类型完全相等(双向赋值) |
.toMatchTypeOf<T>() |
可赋给 T(结构子集即可) |
.toBeString() / .toBeNumber() / .toBeBoolean() |
原始类型 |
.toBeNever() / .toBeUnknown() / .toBeAny() |
特殊类型 |
.toBeFunction() / .toBeObject() |
顶层种类 |
.toBeNullable() |
含 null 或 undefined |
.parameters |
函数参数(链式) |
.returns |
函数返回值(链式) |
.resolves |
解开 Promise(链式) |
.items |
数组元素类型(链式) |
.toHaveProperty("k") |
对象有该字段 |
.not |
取反 |
12.2 用 Equal / Expect 工具(无运行时依赖)
只想做类型相等检查、不依赖测试框架时,写两个工具类型:
// 来自 type-fest / 社区惯例
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
// 把类型断言写成 type alias,编译失败就报错
type _t1 = Expect<Equal<Pick<{ a: 1; b: 2 }, "a">, { a: 1 }>>;
type _t2 = Expect<Equal<Awaited<Promise<number>>, number>>;
type _t3 = Expect<Equal<NonNullable<string | null>, string>>;
// 错误用例必须失败
type _bad = Expect<Equal<string, number>>; // 报错:true 不能赋给 false
Equal vs extends 的区别: Equal 检查双向可赋值,能区分 any 和 unknown、string 和 string | undefined、{ a: 1 } 和 { a: 1; b?: undefined }。extends 只查单向。
12.3 用 tsd / dtslint(库发布前)
发布 npm 包时,要确保用户那一侧的类型推断符合预期。tsd 是社区标准做法。
// test-d/my-lib.test-d.ts
import { expectType, expectError, expectAssignable, expectNotAssignable } from "tsd";
import { fetchUser, type User } from "../src";
// 返回类型必须严格相等
expectType<Promise<User>>(fetchUser("u1"));
expectType<string>((await fetchUser("u1")).name);
// 错误用法必须报编译错误
expectError(fetchUser(123)); // 参数类型错
expectError(fetchUser()); // 缺参数
expectError(fetchUser("u1", "extra")); // 多参数
// 子类型可赋值
expectAssignable<{ id: string }>({ id: "1", name: "a" } as User);
expectNotAssignable<{ secret: string }>({ id: "1", name: "a" } as User);
package.json 配置:
{
"scripts": {
"test:types": "tsd"
},
"tsd": {
"directory": "test-d"
}
}
CI 跑 npm run test:types——任何用户感知的类型回归都会被捕获。
12.4 类型测试的写作原则
- 测公开 API 的类型契约,不测内部辅助类型——内部类型可以自由演化
- 测边界:可选字段、联合类型、
never、any、undefined、空对象 - 新增工具类型必须配类型测试——否则一次"小改"破坏所有用户
- 重构前先跑
tsc --noEmit,再跑类型测试,再跑运行时测试 - 类型测试与运行时测试同一文件,方便看到契约 + 行为的双重保障
12.5 何时不需要类型测试
| 场景 | 是否需要 | 理由 |
|---|---|---|
| 库 / SDK 对外发布 | 必要 | 用户类型推断是契约 |
| 内部工具类型库(团队复用) | 推荐 | 一次重构影响所有调用 |
| 应用项目内部类型 | 不需要 | tsc --noEmit 已足够 |
| 一次性脚本 | 不需要 | 投入产出不划算 |
| 类型只有一处使用 | 不需要 | 调用方即测试 |
结论: 库作者必做;应用作者按需做(核心工具类型值得测)。
十三、常见陷阱
陷阱 1:对象字面量的"多余属性检查"
现象: 把变量赋给类型时通过,直接传字面量时报错。
原因: 直接传字面量时 TS 会做额外的"多余属性检查"防拼写错误;中转变量后就放宽了。
解决: 修正拼写或显式接受额外字段。
interface Opts { url: string; method?: string; }
function req(o: Opts) {}
req({ url: "/a", methood: "GET" }); // 报错:methood 拼错
// 中转后不报错(TS 假设你知道自己在做什么),但仍是 bug
const o = { url: "/a", methood: "GET" };
req(o); // 不报错 → 隐藏问题
// 正确做法:修拼写。如果真要接受额外字段,扩展接口
interface Opts { url: string; method?: string; [k: string]: unknown; }
陷阱 2:as 类型断言不做转换
现象: const n = "abc" as unknown as number; n.toFixed(2) 编译通过,运行时崩。
原因: as 只在编译期改变 TS 对类型的理解,不会生成任何转换代码。
解决: 用真正的转换函数(Number()、String()、JSON.parse),仅当你能向编译器证明类型时才用 as。
// 错误
const n = "abc" as unknown as number;
// 正确
const n = Number("abc");
if (Number.isNaN(n)) throw new Error("invalid number");
陷阱 3:函数参数双变性导致回调签名误判
现象: 数组方法、事件回调中传入的函数参数比预期"宽"。
原因: 函数类型默认是双变(bivariant)的;启用 strictFunctionTypes 后变成逆变(contravariant),但方法语法仍是双变。
type Handler = { onEvent(e: MouseEvent): void }; // 方法语法 → 双变
const h: Handler = { onEvent(e: Event) {} }; // 通过,但运行时可能崩
type Handler2 = { onEvent: (e: MouseEvent) => void }; // 属性语法 → 严格
const h2: Handler2 = { onEvent: (e: Event) => {} }; // 通过
const h3: Handler2 = { onEvent: (e: KeyboardEvent) => {} }; // 报错
解决: 写回调类型时用属性语法 name: (...) => T 而非方法语法 name(...): T。
陷阱 4:enum 在生产代码中产生运行时副作用
现象: 期望 enum 像类型一样擦除,结果打包后多出一段 IIFE。
原因: 普通 enum 在编译后会输出真实对象;const enum 虽然会被内联但与 isolatedModules 冲突。
解决: 用 as const 对象或字面量联合替代:
// 错误(现代项目)
enum Status { Idle, Loading, Success }
// 正确
const Status = { Idle: 0, Loading: 1, Success: 2 } as const;
type Status = (typeof Status)[keyof typeof Status];
陷阱 5:Promise 链中遗漏 await 导致 unhandled rejection
现象: 函数返回成功但错误没传播,进程异常退出。
原因: 缺少 await,错误进入微任务队列后无人处理。
解决: 启用 ESLint 规则 @typescript-eslint/no-floating-promises,强制 await 或显式 .catch() / void。
// 错误
async function main() {
cleanup(); // 返回 Promise 但未 await
await mainLogic();
}
// 正确
async function main() {
await cleanup();
await mainLogic();
}
// 故意 fire-and-forget 时显式标注
void cleanup();
陷阱 6:可选字段在条件类型中的 undefined 渗漏
现象: T["x"] 推断出意料之外的 undefined。
原因: interface { x?: number } 等价于 x: number | undefined,索引访问会带上 undefined。
解决: 用 NonNullable<T["x"]> 或 Required<T>["x"]:
interface User { name?: string; }
type N1 = User["name"]; // string | undefined
type N2 = NonNullable<User["name"]>; // string
type N3 = Required<User>["name"]; // string
陷阱 7:JSON.parse 返回 any 污染类型
现象: const data = JSON.parse(s) 返回 any,后续所有访问都不被检查。
原因: TS 内置签名就是 any。
解决: 包装成返回 unknown 的工具,配合校验:
function parseJSON(s: string): unknown {
return JSON.parse(s);
}
const data = parseJSON(text);
const user = UserSchema.parse(data); // Zod / Valibot 等
陷阱 8:Object.keys 返回 string[] 而非 keyof T
现象: 对对象迭代时拿不到具体 key 字面量类型。
原因: TS 选择不返回 (keyof T)[],因为对象可能在运行时有额外的 key(结构子类型)。
解决: 已知对象封闭时显式断言:
const config = { host: "localhost", port: 5432 } as const;
// 错误:k 是 string,config[k] 报错
for (const k of Object.keys(config)) {
console.log(config[k]);
}
// 正确
for (const k of Object.keys(config) as Array<keyof typeof config>) {
console.log(config[k]);
}
// 或者用 entries
for (const [k, v] of Object.entries(config)) {
console.log(k, v);
}
参见
- TypeScript完全指南 — TS 类型系统、泛型、装饰器、tsconfig、Vue 项目最佳实践
- JS模块系统 — ESM/CJS 互操作与模块解析
- JavaScript Promise 完全指南 — Promise/async 与错误传播
- JavaScript入门 — JS 语言基础(TS 之前先掌握)
- Vue3入门 — Vue 3 + TS 集成
- React完全指南 — React + TS 模式
- Vite初级指南 — TS 项目的主流构建工具