TypeScript 第二周 BOSS 战 — 手写 typed-utils 工具类型库 + 泛型 dataHub 重构
六天积攒的弹药今天全部上膛。战役 A:手写
typed-utils工具类型库——13+ 个自定义工具类型,每个都有验证用例;战役 B:用泛型重构第 1 周的 dataHub——Repository 模式接管增删改查、Result 模式接管错误处理、泛型事件总线接管通信。打完这一仗,你对"泛型"的理解将从"看懂"升级为"能造"。
目录
一、战斗目标与战前检查
1.1 两大战役
战役 A:typed-utils.ts —— 手写工具类型库(15+ 个)
├── 结构变换:MyPartial / MyRequired / MyReadonly / Mutable
├── 字段选择:MyPick / MyOmit / StrictOmit
├── 联合运算:MyExclude / MyExtract / NonNull
├── 深层操作:DeepReadonly / DeepPartial
├── 按值过滤:PickByType / OmitByType / MethodsOf
├── 函数反解:MyReturnType / MyParameters / MyAwaited
└── 业务通用:TreeNode<T> / Paged<T> / Result<T, E>
战役 B:泛型 dataHub —— 重构第 1 周项目
├── 改造 1:Repository<T extends HasId> 替换手写增删改查
├── 改造 2:Result<T, E> 替换 null 返回值
├── 改造 3:EventBus<M> 泛型事件总线
└── 改造 4:查询接口工具类型化(Pick 瘦身)
1.2 战前检查(昨天的知识是否就位)
在开始前,确认你能不看笔记回答:
// 检查 1:这两个的输出分别是什么?
type A = MyExclude<"a" | "b" | "c", "b">; // ?
type B = MyPick<{ x: number; y: string }, "x">; // ?
// 检查 2:as never 在键重映射里干什么?
type C = {
[K in "a" | "b" as K extends "a" ? never : K]: number;
}; // ?
// 检查 3:infer 在这里捕获什么?
type D = [string, number] extends [...any[], infer L] ? L : never; // ?
点击核对答案
A = "a" | "c"(分配律逐成员过滤,"b" 变 never 被吸收)
B = { x: number }(映射遍历的是 K = "x")
C = { b: number }("a" 被映射成 never 键,直接丢弃)
D = number(捕获元组最后一项)
三题全对 → 开战。有迟疑 → 回看 Day 12 第八、九节,Day 13 第五、六节。
二、战役 A:typed-utils 工具类型库
建议新建目录
typed-utils/,分两个文件:structs.ts(结构变换类)与utils.ts(函数与业务类)。下面按模块给出完整代码 + 逐个讲解。
2.1 模块一:结构变换(映射类型)
// ===== typed-utils/structs.ts =====
/** 全可选(官方 Partial 同款:同态映射,保留 readonly) */
export type MyPartial<T> = {
[K in keyof T]?: T[K];
};
/** 全必填(-? 去掉可选标记) */
export type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
/** 全只读(readonly 修饰符加到每个字段) */
export type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
/** 去只读(官方没有的常用工具:解冻) */
export type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
讲解要点:
四个类型全部基于同态映射
[K in keyof T]——原字段的readonly/?修饰符被自动保留,?的增减通过?/-?显式控制Mutable是官方缺失的补位:Readonly<T>的反向操作
2.2 模块二:字段选择(映射 + 条件组合)
/** 挑字段:K 是键的子集,遍历 K(不是 keyof T!)*/
export type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
/** 删字段 = Pick + Exclude 两步组合 */
export type MyOmit<T, K extends keyof any> = MyPick<T, Exclude<keyof T, K>>;
/**
* 严格版 Omit:K 必须是 T 的键(官方 Omit 拼错键静默忽略的修正版)
*/
export type StrictOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;
// Omit<Device, "nonexistent"> ✅ 不报错(隐患!)
// StrictOmit<Device, "nonexistent"> ❌ 编译错误(安全!)
讲解要点:
MyOmit的两步:Exclude<keyof T, K>先从全部键里删掉 K,再MyPick挑剩下的keyof any=string | number | symbol——官方为了兼容宽泛用法牺牲了安全性,StrictOmit是工程上的修正
2.3 模块三:联合运算(条件类型 + never 吸收)
/** 从联合中排除(分配律逐成员判断,不匹配的变 never 被吸收) */
export type MyExclude<T, U> = T extends U ? never : T;
/** 从联合中提取(交集) */
export type MyExtract<T, U> = T extends U ? T : never;
/** 排除 null 和 undefined */
export type NonNull<T> = T extends null | undefined ? never : T;
讲解要点:三个类型都是 Day 12 分配律 + never 吸收的直接应用——裸类型参数 T 传入联合自动拆开逐个判断,被"干掉"的成员变 never,合并时自动消失。
2.4 模块四:深层操作(递归映射 + 递归条件)
/**
* 深度只读:递归版 Readonly
* 原理:T 是对象 → 映射每个字段并递归处理字段值;否则(原始类型)原样返回
*/
export type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
/**
* 深度可选:递归版 Partial(嵌套对象的每层都变可选)
* 场景:深度合并配置的 patch 参数
*/
export type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
讲解要点:
递归的终止条件:
T extends object为 false(即 T 是 string/number 等原始类型)时返回 T 本身每轮递归深入一层,问题规模缩小——不会无限递归
验证场景:嵌套主题配置(Day 13 第九节)、多层菜单配置
2.5 模块五:按值过滤(键重映射 as + 条件)
/**
* 只保留值类型为 U 的字段
* 原理:as 后的条件类型把"不想要的键"映射成 never → 该键被丢弃
*/
export type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
/** 剔除值类型为 U 的字段(反向版) */
export type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
/** 只保留函数字段:提取对象的方法集合 */
export type MethodsOf<T> = PickByType<T, (...args: any[]) => any>;
讲解要点:as 重映射的"过滤"用法——键名位置的 T[K] extends U ? K : never 相当于给每个键做体检,不合格(never)的直接从结果里消失。
2.6 模块六:函数反解(infer)
/** 函数返回值类型(官方 ReturnType 原理) */
export type MyReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
/** 函数参数元组(官方 Parameters 原理) */
export type MyParameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
/** 解 Promise:支持任意嵌套层级(递归版) */
export type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
讲解要点:infer 在模式中占位,匹配成功绑定实际类型;MyAwaited 的递归让它能解 Promise<Promise<X>>(官方 Awaited 同样支持递归)。
2.7 模块七:业务通用结构(泛型接口与类)
// ===== typed-utils/utils.ts =====
/**
* 树形节点:泛型递归接口
* 场景:菜单树、组织架构树、设备拓扑树——大屏项目遍地都是树
*/
export interface TreeNode<T> {
data: T;
children?: TreeNode<T>[];
}
/** 分页结构:任何列表接口的通用形状 */
export interface Paged<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
/**
* Rust 风格 Result:类型安全的成功/失败表达
* 替代 null 返回值——错误信息不再丢失、不再需要类型断言
*/
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
/** 成功构造器 */
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
/** 失败构造器 */
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
讲解要点:
TreeNode<T>的children?: TreeNode<T>[]是泛型自引用——类型层面的递归结构Result<T, E>是 D3 判别联合 + D8 泛型的联手之作:ok字段是判别器,收窄后 value/error 各自精确err返回Result<never, E>:never 让它能赋给任何Result<T, E>(never 是所有类型的子类型)
2.8 进阶模块:模板字面量应用(选做加分)
/**
* 蛇形转驼峰:递归模板字面量 + infer
* "work_temp_max" → "workTempMax"
*/
export type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
/** 对象键批量转驼峰 */
export type Camelize<T> = {
[K in keyof T as SnakeToCamel<string & K>]: T[K];
};
/** 事件名生成:域 × 动作的笛卡尔积 */
export type EventName<D extends string, A extends string> = `${D}:${A}`;
三、战役 A 验证:对照官方逐个测试
3.1 验证文件
// ===== typed-utils/__tests__/verify.ts =====
// (无测试框架,纯类型层的"人肉验收":悬停对比 + 故意写错看是否爆红)
interface Device {
id: string;
name: string;
temp: number;
battery: number;
status: "running" | "fault";
readonly createdAt: number;
}
// ===== 1. MyPartial vs Partial =====
type P1 = MyPartial<Device>; // 悬停:全可选
type P2 = Partial<Device>; // 悬停:应完全一致 ✅
// readonly createdAt? 保留了吗?(同态映射应保留)✅
// ===== 2. MyRequired vs Required =====
type R1 = MyRequired<Partial<Device>>; // 应还原为全必填
type R2 = Required<Partial<Device>>; // 对比一致 ✅
// ===== 3. MyReadonly / Mutable 互逆 =====
type RO = MyReadonly<Device>;
type Back = Mutable<RO>; // 应回到 Device 的形状(readonly 没了)
// ===== 4. MyPick =====
type D1 = MyPick<Device, "id" | "temp">; // { id: string; temp: number }
// ===== 5. MyOmit / StrictOmit =====
type D2 = MyOmit<Device, "id">; // { name; temp; battery; status; createdAt }
type D3 = StrictOmit<Device, "id">; // 同上
// type D4 = StrictOmit<Device, "typo">; // ❌ 取消注释应爆红 ✅
// ===== 6. 联合运算 =====
type U1 = MyExclude<"running" | "fault" | "offline", "fault">; // "running" | "offline"
type U2 = MyExtract<1 | 2 | 3 | string, number>; // 1 | 2 | 3
type U3 = NonNull<number | null | undefined>; // number
// ===== 7. 深层操作 =====
const theme = {
colors: { primary: "#0f0" },
chart: { line: { width: 2 } }
};
type DeepTheme = DeepReadonly<typeof theme>;
const t: DeepTheme = theme;
// t.colors.primary = "#000"; // ❌ 爆红 ✅(任意深度只读)
// t.chart.line.width = 3; // ❌ 爆红 ✅
interface Nested { a: { b: { c: number } } }
type DP = DeepPartial<Nested>;
const dp: DP = {}; // ✅ 每层都可省略(普通 Partial 做不到!)
// ===== 8. 按值过滤 =====
type Numbers = PickByType<Device, number>;
// { temp: number; battery: number; createdAt: number }(readonly 保留)
type NoNumbers = OmitByType<Device, number>;
// { id: string; name: string; status: "running" | "fault" }
interface Svc { getName(): string; refresh(): void; id: string }
type SvcMethods = MethodsOf<Svc>; // { getName: () => string; refresh: () => void }
// ===== 9. 函数反解 =====
async function fetchDevices(url: string, page: number): Promise<Device[]> {
return [];
}
type Args = MyParameters<typeof fetchDevices>; // [url: string, page: number]
type Ret = MyReturnType<typeof fetchDevices>; // Promise<Device[]>
type Data = MyAwaited<Ret>; // Device[]
type DeepData = MyAwaited<Promise<Promise<Device[]>>>; // Device[](递归解包)
// ===== 10. Result =====
const r1 = ok<Device>({ id: "1", name: "n", temp: 1, battery: 1, status: "running", createdAt: 1 });
const r2 = err({ code: "PARSE_FAIL", reason: "格式错误" });
if (r1.ok) {
r1.value.temp.toFixed(1); // ✅ value: Device
} else {
r1.error; // ✅ Error(默认泛型)
}
// r1.ok 为 true 时访问 r1.error → ❌ 爆红(判别联合收窄)✅
// ===== 11. 业务结构 =====
const menu: TreeNode<Device>[] = [
{ data: { /* Device */ } as Device, children: [{ data: {} as Device }] }
];
const page1: Paged<Device> = { list: [], total: 0, page: 1, pageSize: 20 };
3.2 验收标准
每一条验证都要做两个动作:
正向:悬停看推导结果,与注释里的预期一致
反向:把注释掉的错误示例打开,确认编译器爆红
MyPartial<Device> 与 Partial<Device> 悬停显示完全一致 = 战役 A 通关。
四、战役 B:泛型 dataHub 重构
4.1 重构目标(第 1 周项目的四大痛点)
// ===== 第 1 周 dataHub 的痛点回顾 =====
// 痛点 1:每种实体手写一套增删改查(deviceStore / alertStore 结构几乎一样)
class DeviceStore {
private map = new Map<string, Device>();
add(d: Device) { /* ... */ }
get(id: string): Device | undefined { /* ... */ }
// …… alertStore 再抄一遍
}
// 痛点 2:ingest 返回 null 表示失败——失败原因全靠猜
ingest(payload: unknown): { count: number } | null {
// 调用方拿到 null:格式错了?字段缺了?级别非法?不知道。
}
// 痛点 3:事件总线绑定 HubEvents,换个项目要复制整个类
class HubEventBus { /* on/off/emit 全是 HubEvents 写死 */ }
// 痛点 4:getBriefs 手动挑字段,返回类型手写,实体加字段不同步
getBriefs(): { id: string; name: string; status: string }[] { /* ... */ }
4.2 改造 1:Repository 接管增删改查
/**
* 通用仓库:任何"有 id 的实体"的增删改查
* D10 的 Repository 直接搬来——这就是"写一次,全项目复用"
*/
import { HasId } from "./types";
export class Repository<T extends HasId> {
private store = new Map<string, T>();
/** 新增/覆盖 */
add(item: T): void {
this.store.set(item.id, item);
}
/** 按 id 查 */
get(id: string): T | undefined {
return this.store.get(id);
}
/** 部分更新(Partial 的第一次实战)*/
update(id: string, patch: Partial<T>): T | undefined {
const item = this.store.get(id);
if (!item) return undefined;
const updated = { ...item, ...patch };
this.store.set(id, updated);
return updated;
}
/** 按任意字段查(D10 练习 3 的答案)*/
findBy<K extends keyof T>(key: K, value: T[K]): T[] {
return this.list().filter(item => item[key] === value);
}
/** 删除 */
remove(id: string): boolean {
return this.store.delete(id);
}
/** 全量列表 */
list(): T[] {
return [...this.store.values()];
}
}
效果:原来 DeviceStore + AlertStore 两个类约 80 行,现在一个泛型类 40 行服务所有实体。
4.3 改造 2:Result 接管错误处理
import { Result, ok, err } from "./typed-utils/utils";
/** 解析失败的结构化错误(E 泛型的实例化) */
interface ParseError {
code: "PARSE_FAIL" | "EMPTY_PAYLOAD" | "INVALID_LEVEL";
reason: string;
}
/**
* 改造后的 ingest:错误信息类型化
* 调用方对失败原因一目了然,且有编译器保证不漏处理
*/
ingest(payload: unknown): Result<{ count: number }, ParseError> {
// 空载荷检查
if (payload === null || payload === undefined) {
return err({ code: "EMPTY_PAYLOAD", reason: "载荷为空" });
}
// 格式检查(D4 类型守卫复习)
if (!isDevicePayload(payload)) {
return err({ code: "PARSE_FAIL", reason: "字段缺失或类型错误" });
}
const devices = normalize(payload);
if (devices.length === 0) {
return err({ code: "EMPTY_PAYLOAD", reason: "无有效设备数据" });
}
devices.forEach(d => this.deviceRepo.add(d));
this.emit("hub:stats", this.getStats());
return ok({ count: devices.length });
}
// ===== 调用方:判别联合分发,错误信息类型安全 =====
const r = hub.ingest(rawPayload);
if (!r.ok) {
// r.error 被 D3 判别联合收窄为 ParseError
console.error(`[${r.error.code}] ${r.error.reason}`); // ✅ 精确
// r.error.code 的自动补全会列出全部 3 种错误码 ✅
} else {
console.log(`已接入 ${r.value.count} 台设备`);
}
4.4 改造 3:泛型事件总线
/**
* D9 的泛型 EventBus:映射表作为类型参数
* 第 1 周的专用总线类直接删除
*/
export class EventBus<M extends Record<string, unknown>> {
private handlers: { [K in keyof M]?: ((payload: M[K]) => void)[] } = {};
on<K extends keyof M>(event: K, fn: (payload: M[K]) => void): void {
(this.handlers[event] ??= []).push(fn);
}
off<K extends keyof M>(event: K, fn: (payload: M[K]) => void): void {
const list = this.handlers[event];
if (!list) return;
const i = list.indexOf(fn);
if (i >= 0) list.splice(i, 1);
}
emit<K extends keyof M>(event: K, payload: M[K]): void {
this.handlers[event]?.forEach(fn => fn(payload));
}
}
// dataHub 用 HubEvents 实例化:
type HubEvents = {
"hub:alert": AlertRecord;
"hub:stats": DeviceStats;
"hub:clear": { deviceId: string };
};
// 想给 UI 层再建一个总线?两行:
type UiEvents = { "modal:open": string; "modal:close": undefined };
const uiBus = new EventBus<UiEvents>();
4.5 改造 4:查询接口工具类型化
/**
* getBriefs 的瘦身返回:Pick 一行派生
* 实体加字段时,Brief 自动保持只含三字段的形状
*/
getBriefs(): Pick<Device, "id" | "name" | "status">[] {
return this.deviceRepo.list().map(d => ({
id: d.id,
name: d.name,
status: d.status
}));
}
/**
* 查询参数:Partial + Pick 组合
*/
type HubQuery = Partial<Pick<Device, "status" | "name">> & {
page?: number;
pageSize?: number;
};
query(q: HubQuery): Paged<Pick<Device, "id" | "name" | "status">> {
// 过滤 + 分页逻辑……
}
五、战役 B 完整代码
// ===== datahub/types.ts =====
/** 实体类型定义 */
export interface HasId {
id: string;
}
export interface Device extends HasId {
name: string;
temp: number;
battery: number;
status: "running" | "standby" | "fault";
createdAt: number;
}
export interface AlertRecord extends HasId {
level: 1 | 2 | 3;
reason: string;
ts: number;
}
export interface DeviceStats {
total: number;
running: number;
fault: number;
}
export type HubEvents = {
"hub:alert": AlertRecord;
"hub:stats": DeviceStats;
"hub:clear": { deviceId: string };
};
// ===== datahub/datahub.ts =====
import { Repository } from "./repository";
import { EventBus } from "./event-bus";
import { Result, ok, err, Paged } from "./typed-utils/utils";
/** 解析错误结构 */
interface ParseError {
code: "PARSE_FAIL" | "EMPTY_PAYLOAD";
reason: string;
}
/**
* 泛型版 DataHub:仓库 + 事件总线 + Result 三合一
*/
export class DataHub extends EventBus<HubEvents> {
private deviceRepo = new Repository<Device>();
private alertRepo = new Repository<AlertRecord>();
/** 接收原始载荷:Result 化的错误处理 */
ingest(payload: unknown): Result<{ count: number }, ParseError> {
if (payload === null || payload === undefined) {
return err({ code: "EMPTY_PAYLOAD", reason: "载荷为空" });
}
if (!isDevicePayload(payload)) {
return err({ code: "PARSE_FAIL", reason: "字段缺失或类型错误" });
}
const devices = normalizeDevices(payload);
devices.forEach(d => this.deviceRepo.add(d));
this.emit("hub:stats", this.getStats());
return ok({ count: devices.length });
}
/** 触发告警 */
raiseAlert(alert: AlertRecord): void {
this.alertRepo.add(alert);
this.emit("hub:alert", alert);
}
/** 统计(Record + 状态联合的实战)*/
getStats(): DeviceStats {
const list = this.deviceRepo.list();
return {
total: list.length,
running: list.filter(d => d.status === "running").length,
fault: list.filter(d => d.status === "fault").length
};
}
/** 瘦身查询:Pick 派生 */
getBriefs(): Pick<Device, "id" | "name" | "status">[] {
return this.deviceRepo.list().map(d => ({
id: d.id, name: d.name, status: d.status
}));
}
/** 按状态过滤(Repository.findBy 复用)*/
getByStatus(status: Device["status"]): Device[] {
return this.deviceRepo.findBy("status", status);
}
}
// ===== 类型守卫与归一化(D4 复习)=====
function isDevicePayload(p: unknown): p is RawDevicePayload[] {
return (
Array.isArray(p) &&
p.every(
item =>
typeof item === "object" && item !== null &&
"id" in item && "name" in item && "temp" in item
)
);
}
interface RawDevicePayload {
id: string;
name: string;
temp: number;
}
function normalizeDevices(raw: RawDevicePayload[]): Device[] {
return raw.map(item => ({
id: item.id,
name: item.name,
temp: item.temp,
battery: 100,
status: item.temp > 80 ? "fault" : "running",
createdAt: Date.now()
}));
}
// ===== 使用示例 =====
const hub = new DataHub();
const r = hub.ingest([
{ id: "CNC-001", name: "机床", temp: 65 },
{ id: "AGV-002", name: "搬运车", temp: 85 }
]);
if (!r.ok) {
console.error(`[${r.error.code}] ${r.error.reason}`);
} else {
console.log(`已接入 ${r.value.count} 台设备`);
}
hub.on("hub:stats", s => console.log(`总计 ${s.total} 台`));
hub.on("hub:alert", a => console.log(`告警:${a.reason}`));
hub.raiseAlert({
id: "alert-1", level: 2, reason: "主轴过热", ts: Date.now()
});
文件结构(每文件不超 200 行原则):
datahub/
├── types.ts # 实体与事件映射表
├── repository.ts # 泛型仓库
├── event-bus.ts # 泛型事件总线
├── datahub.ts # DataHub 主体
└── typed-utils/
├── utils.ts # Result / ok / err / Paged / TreeNode
└── structs.ts # 15+ 工具类型
六、验收清单
战役 A 验收
[ ]
typed-utils的 15+ 个类型全部手写(不是复制粘贴文档,是合上文档默写 + 卡壳回看)[ ] 每个类型至少 1 条正向验证(悬停看推导)+ 1 条反向验证(错误示例爆红)
[ ]
MyPartial<Device>悬停显示与官方Partial<Device>完全一致[ ]
DeepReadonly对三层嵌套配置生效(任意深度赋值都爆红)[ ]
StrictOmit拼错键时编译报错(对比官方 Omit 的静默)[ ]
MyAwaited<Promise<Promise<Device[]>>>递归解包出Device[]
战役 B 验收
[ ]
DeviceStore/AlertStore手写类已删除,全部由Repository<T>接管[ ]
ingest返回Result,调用方 if/else 分发后 value/error 各自类型精确[ ] 事件总线泛型化,新建
UiEvents总线只需 2 行[ ]
getBriefs返回Pick<Device, ...>派生类型,实体加字段自动同步[ ] 嵌套配置对象应用
DeepReadonly后任意深度只读[ ] 全项目无
any(as any也不允许;守卫 + 泛型覆盖所有 unknown 边界)
收尾动作
[ ] git 提交:
feat(typescript): week2 boss battle - typed-utils + generic datahub[ ] 写当日博客(见下一节指引)
七、博客写作指引
7.1 标题候选
主推:《我手写了天天在用的工具类型,从此看官方源码像看白话文》
备选1:《TypeScript 泛型两周通关:从 <T> 到类型体操的完整路径》
备选2:《把 null 扔进垃圾桶:Result 模式的类型安全错误处理》
7.2 内容骨架(1200-2000 字)
1. 起因(150 字)
Day 11 用官方工具类型时的疑问:Exclude 凭什么能删联合成员?
2. 原理揭秘(400 字,配代码)
- 分配律 + never 吸收 → Exclude 的 5 行心路
- 映射类型 + as never → PickByType 的过滤魔法
- 配一张手绘的"条件类型分配过程图"
3. 实战:typed-utils 的 3 个最有成就感的类型(400 字)
- DeepReadonly(递归映射)
- StrictOmit(对官方的安全修正)
- Result + ok/err(Rust 风格错误处理)
每个:签名 → 验证用例 → 一句话原理
4. 实战:dataHub 泛型重构前后对比(400 字)
- 重构前:两个 Store 类 + null 返回值 + 专用总线的代码行数
- 重构后:Repository/Result/EventBus 的行数与收益
- 用表格列"痛点 → 方案 → 泛型知识点"
5. 心得(150 字)
- 官方工具类型不再神秘
- "框架感"的来源:泛型约束 + 模式复用
- 两周前的自己 vs 现在的自己
7.3 必放的代码块
// 博客里最值得展示的一段(读者点赞发动机):
type MyExclude<T, U> = T extends U ? never : T;
// MyExclude<"running" | "fault" | "offline", "fault">
// 第 1 步(分配律拆开):
// "running" extends "fault" ? never : "running" → "running"
// "fault" extends "fault" ? never : "fault" → never ⭐
// "offline" extends "fault" ? never : "offline" → "offline"
// 第 2 步(never 吸收):"running" | "offline"
// 官方 Exclude 的全部秘密,就这两步。
八、第二周知识总复盘
8.1 六天知识 → BOSS 战的映射
8.2 一图流:类型系统四件套
泛型 <T> —— 类型参数化(函数的参数)
约束 extends —— 给类型立规矩(函数的前置条件)
条件类型 ? : —— 类型分支(函数的 if/else)
└ 分配律 —— 联合的逐成员遍历(for 循环)
└ never 吸收 —— 联合过滤的删除器
infer —— 模式捕获(正则的捕获组)
映射类型 [K in] —— 键的遍历与重构(map/transform)
└ as 重映射 —— 改名 + 过滤
模板字面量 —— 字符串运算(类型的字符串 API)
四件套组合 = 类型层面的"图灵完备"
官方 17 个工具类型 = 四件套的 17 种组合套路
8.3 重构收益量化(写进简历的话术素材)
// 重构前(第 1 周 dataHub):
// - DeviceStore + AlertStore:约 80 行重复 CRUD
// - ingest 返回 null:失败原因靠 console.log 考古
// - 专用 EventBus:换项目整类复制
// - getBriefs 返回类型手写:实体改字段不同步
// 重构后(本周泛型版):
// - Repository<T>:40 行服务全项目所有实体(CRUD 代码 -50%)
// - Result<T, E>:错误码类型化,编译器保证不漏处理
// - EventBus<M>:新总线 2 行接入
// - Pick/Partial 派生:实体与视图类型永远同步
// - 全项目 0 个 any
九、常见坑点与最佳实践
坑点 1:手写类型验证时忘记对照官方
// 手写完 MyPartial 直接用?可能同态性出了问题自己不知道:
type MyPartial<T> = { [K in keyof T]?: T[K] };
// 必做动作:双悬停对比
type A = MyPartial<{ readonly url: string }>; // url?: string(readonly 保留了吗?)
type B = Partial<{ readonly url: string }>; // 官方行为基准
// A 和 B 不一致 = 实现有偏差(大概率是非同态写法混入)
坑点 2:Repository.update 的浅合并陷阱
update(id: string, patch: Partial<T>): T | undefined {
const item = this.store.get(id);
if (!item) return undefined;
const updated = { ...item, ...patch }; // ⚠️ 浅合并!
this.store.set(id, updated);
return updated;
}
// 嵌套字段的 patch 会被整体覆盖而不是深合并:
// update("1", { config: { theme: "dark" } }) ← config 里其他子字段全丢
// 解法 A:业务上约定 patch 只支持顶层字段(文档写清楚)
// 解法 B:DeepPartial<T> + 深合并函数(成本高,非必要不做)
坑点 3:Result 模式忘记穷尽处理
const r = hub.ingest(payload);
// ⚠️ 只处理了成功分支
if (r.ok) { console.log(r.value.count); }
// else 分支漏了 —— TS 不会强制你处理失败!(这不是 D3 的 exhaustive check 场景)
// 严谨做法:消费 Result 的函数返回类型也 Result 化,形成"错误传播链"
function syncDevices(payload: unknown): Result<number, ParseError> {
const r = hub.ingest(payload);
if (!r.ok) return r; // 失败向上传播
return ok(r.value.count); // 成功转成调用方要的形状
}
坑点 4:泛型重构时的兼容性回归
// 重构 getBriefs 时返回类型改了:
// 旧:{ id: string; name: string; status: string }[](status 被手写宽了)
// 新:Pick<Device, "id" | "name" | "status">[](status 是字面量联合)
// 下游如果写了:
briefs.forEach(b => {
b.status = "anything"; // 旧版:不报错(string);新版:❌ 爆红
});
// 正确姿势:逐步重构 + 每步跑编译,让类型收紧暴露的隐患逐个修掉
// 这些爆红不是"重构引入的 bug",而是"旧类型宽泛掩盖的 bug"
最佳实践清单
手写 → 对照官方 → 再用:三个动作一个不能少
泛型类的文件独立:Repository/EventBus 单独文件,依赖只有 types
Result 一旦采用就贯彻到底:半 Result 半 null 的项目最混乱
重构小步走:每改一个模块跑一次编译,别攒大 bomb
DeepReadonly 用于"快照/配置",普通 Readonly 用于"接口入参":按需选深度
提交信息带上知识点:方便未来回溯"这周学了什么"
十、总结与知识图谱
第二周 BOSS 战(泛型知识的集中检阅)
│
├── 战役 A:typed-utils 工具类型库
│ ├── 结构变换:Partial / Required / Readonly / Mutable(同态映射)
│ ├── 字段选择:Pick / Omit / StrictOmit(安全修正版)
│ ├── 联合运算:Exclude / Extract / NonNull(分配律 + never)
│ ├── 深层操作:DeepReadonly / DeepPartial(递归映射)
│ ├── 按值过滤:PickByType / OmitByType / MethodsOf(as never)
│ ├── 函数反解:ReturnType / Parameters / Awaited(infer + 递归)
│ └── 业务结构:TreeNode<T> / Paged<T> / Result<T, E> + ok/err
│
├── 战役 B:泛型 dataHub 四大改造
│ ├── Repository<T extends HasId> ← 手写 Store(CRUD -50%)
│ ├── Result<T, E> ← null 返回(错误信息类型化)
│ ├── EventBus<M> ← 专用总线(新总线 2 行接入)
│ └── Pick 派生 ← 手写视图类型(永远同步)
│
├── 验证方法论
│ ├── 正向:悬停对比官方(MyPartial vs Partial)
│ └── 反向:错误示例必须爆红(StrictOmit 拼错键)
│
└── 收益量化(简历素材)
├── CRUD 代码 -50%(Repository 复用)
├── 0 any 全覆盖(守卫 + 泛型)
└── 类型派生代替手写(一处修改处处同步)
一句话总结:战役 A 证明"官方工具类型你能造",战役 B 证明"泛型架构你能落地"——两周前的 <T> 还是天书,现在它是你手里最锋利的工程武器。
下一步
第二周(泛型)正式通关。第 3 周进入类型体操(type-challenges easy/medium 精选 + 进阶类型编程:递归、变长元组、协变逆变)——今天的 SnakeToCamel、DeepAwaited 就是体操的热身动作。BOSS 战的 typed-utils 会成为体操训练的"基准工具箱"。
学完泛型最重要的标志:看到任何官方工具类型,你的第一反应是"我手写过"。
每天花 2 小时,28 天通关 TypeScript 深入。第二周通关,下一站:类型体操场见!