TypeScript 知识点补充(第三部分:工程化与高级类型)
本部分是前两部分的延续。前面已覆盖「基础坑」「进阶坑」「实战模式」,本部分聚焦工程化配置、模块/声明文件、类型体操底层原理与框架实战。
一、模块与声明文件
1. 给无类型的第三方库补类型(.d.ts)
场景 :装了个老库 legacy-lib,没有任何类型提示。
ts
// types/legacy-lib.d.ts
declare module 'legacy-lib' {
export function init(opts: { debug?: boolean }): void;
const _default: { version: string };
export default _default;
}
要点:
declare module只声明"形状",不产生运行时代码。- 优先在
tsconfig的include或typeRoots里收录该声明目录。 - 能装
@types/xxx就别自己写。
2. 全局类型扩展(declare global)
场景 :给 window、Express Request 等加自定义字段。
ts
declare global {
interface Window {
__APP_BOOT__: { token: string } | undefined;
}
}
export {}; // 必须有 import/export,否则文件被视为全局脚本而非模块
要点:
- 末尾的
export {}是关键,缺了它declare global会报错。 - 利用
interface同名合并特性扩展,而不是重新声明。
3. ESM 与 CommonJS 互操作坑
现象 :import express from 'express' 报"没有默认导出",但运行时明明能用。
jsonc
// tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true, // 允许对 CJS 模块用默认导入语法
"allowSyntheticDefaultImports": true,
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
要点:
esModuleInterop解决的是"CJS 被当默认导入"的语法兼容。module与moduleResolution必须成对配置,版本不匹配会大量报"找不到模块"。
二、tsconfig 严格模式子开关拆解
"strict": true 其实是多个开关的集合,逐项了解才能精准放开:
| 开关 | 作用 | 常见取舍 |
|---|---|---|
strictNullChecks |
null/undefined 不再可赋给任意类型 |
核心,必开 |
noImplicitAny |
禁止隐式 any | 必开 |
strictFunctionTypes |
函数参数逆变检查(见下) | 建议开 |
strictBindCallApply |
bind/call/apply 参数检查 |
建议开 |
noImplicitThis |
禁止隐式 this 类型 | 建议开 |
alwaysStrict |
自动按 "use strict" 编译 |
开 |
额外推荐:
noUncheckedIndexedAccess:索引访问返回T | undefined,防越界。exactOptionalPropertyTypes:可选属性不能显式赋undefined,区分"没传"与"传了 undefined"。noImplicitReturns:函数不能有的分支返回值、有的不返回。
三、协变与逆变(函数兼容性的根)
核心结论 :函数参数是逆变(contravariant),返回值是协变(covariant)。
ts
type Animal = { name: string };
type Dog = { name: string; breed: string };
type Handler = (a: Animal) => void;
// 参数逆变:处理 Dog 的函数不能当"处理任意 Animal 的函数"用
const onlyDog: Handler = (d: Dog) => console.log(d.breed); // strictFunctionTypes 下报错
// 反过来:处理 Animal 的函数可以安全处理 Dog
const anyAnimal: Handler = (a: Animal) => console.log(a.name);
要点:
- 方法定义语法(
m(a: T): void)走双变 ,宽松;函数属性语法(m: (a: T) => void)走逆变,严格。 - 想拿到严格检查,就写函数属性形式 + 开
strictFunctionTypes。
四、类型体操底层原理
1. 映射类型键重映射(as)
场景:给对象所有 key 加前缀 / 转 camelCase。
ts
type AddPrefix<T, P extends string> = {
[K in keyof T as `${P}${K & string}`]: T[K];
};
type R = AddPrefix<{ id: number; name: string }, 'x_'>;
// { x_id: number; x_name: string }
要点 :in keyof T as ... 的 as 子句可重命名甚至用 never 删除某个 key。
2. 工具类型手写(理解优于记忆)
ts
type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyExclude<T, U> = T extends U ? never : T; // 分布式
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
要点 :Pick/Omit/Record/Partial/Required 全部基于「映射类型 + 条件类型 + infer」三件套。
3. 分布式条件类型陷阱
现象 :type T = string extends string ? 1 : 0; 得到 1,但写成泛型 Foo<string> 时会"分发"。
ts
type IsAny<T> = 0 extends (1 & T) ? true : false; // 判断 any 的经典技巧
要点 :裸类型参数(naked type parameter)进入条件类型才会分发;不想分发用 [T] extends [U] 包一层。
4. 递归深度限制与尾递归优化
ts
// 深层嵌套类型可能报 "Type instantiation is excessively deep"
// TS 4.8+ 对特定线性递归(如 MyAwaited、元组拼接)做尾递归优化,可撑到 ~1000 层
要点:
- 递归类型尽量写成"线性递推"以命中尾递归优化。
- 实在超限就拆分类型、或局部用宽类型兜底。
五、控制流分析的局限(容易误判的地方)
1. 闭包内收窄会丢失
ts
let n: string | number = getValue();
if (typeof n === "string") {
setTimeout(() => {
n.toUpperCase(); // 报错:闭包内 TS 无法保证 n 没被改
});
}
解决 :用 const 拷贝一份再在闭包用,或重新做一次 typeof 判断。
2. let 重新赋值打断收窄
ts
let x: string | null = null;
if (x) {
use(x); // 这里 x 是 string
x = null; // 重新赋值后
use2(x); // 又回到 string | null,之前收窄失效
}
六、框架实战类型
React
ts
type CardProps = {
title: string;
onSelect?: (id: string) => void;
};
// React 18 起 children 移出 FC 内置,不必显式指定
function Card({ title, onSelect }: CardProps) {
return <button onClick={() => onSelect?.("id")} aria-label={title} />;
}
// 受控事件类型:ChangeEvent 泛型来自元素
import type { ChangeEvent } from "react";
const onChange = (e: ChangeEvent<HTMLInputElement>) => e.target.value;
Vue 3
ts
// 泛型 defineProps + withDefaults 给默认值
const props = withDefaults(
defineProps<{ id: string; count?: number }>(),
{ count: 0 }
);
// emits 类型化
const emit = defineEmits<{ change: [value: string, id: number] }>();
emit("change", "a", 1);
要点 :现代框架推荐泛型宏 + 字面量 ,避免把 props 写成 any 或 Record<string, unknown>。
七、类型断言 / satisfies / 注解 三选一决策
| 写法 | 何时用 | 风险 |
|---|---|---|
const x: T = ...(注解) |
你知道确切类型,想约束并得到检查 | 可能把窄类型放宽 |
x satisfies T |
想验证符合 T,但保留更精确的推断类型 | 仅 TS 4.9+ |
x as T(断言) |
你比编译器更了解运行时(接口/DOM 取元素) | 不做检查,写错不报错 |
口诀 :能用注解就别断言;要保留字面量信息用 satisfies;只有"骗编译器"时才 as。
防坑总口诀(最终版)
- 值用
import、类型用import type,导入导出死磕带type。- 能不写
any就不写;非黑盒不可用时优先unknown+ 类型守卫收窄。- 字面量怕被放宽,用
as const或satisfies。- 接口数据 TS 类型不算数,必须 Zod / 运行时校验兜底。
- 泛型访问属性先
extends约束;从结构里抠类型用infer。switch联合类型用never兜底,漏分支立刻报错。strict系列 +noUncheckedIndexedAccess长期开启,越界与隐式 any 早发现。- 断言(
as)是"我负责",注解/satisfies 是"编译器帮我查",优先后者。