【TS】day14-boss-battle-typed-utils-datahub

作者:mario 发布时间: 2026-08-28 阅读量:6 评论数:0

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 验收标准

每一条验证都要做两个动作

  1. 正向:悬停看推导结果,与注释里的预期一致

  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 后任意深度只读

  • [ ] 全项目无 anyas 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 战的映射

知识点

学习日

在 BOSS 战中的位置

泛型基础 <T> / 推断

Day 8

所有工具类型的参数化基础

extends 约束 / keyof / T[K]

Day 9

Repository、findBy、EventBus

泛型接口/类 / 默认参数

Day 10

Repository、Result、Paged、TreeNode

内置工具类型

Day 11

Pick 瘦身、Partial patch(被手写版替换)

条件类型 / 分配律 / infer

Day 12

Exclude/Extract/ReturnType 系列

映射类型 / as / 模板串

Day 13

Partial/Pick/Omit 系列、Camelize

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"

最佳实践清单

  1. 手写 → 对照官方 → 再用:三个动作一个不能少

  2. 泛型类的文件独立:Repository/EventBus 单独文件,依赖只有 types

  3. Result 一旦采用就贯彻到底:半 Result 半 null 的项目最混乱

  4. 重构小步走:每改一个模块跑一次编译,别攒大 bomb

  5. DeepReadonly 用于"快照/配置",普通 Readonly 用于"接口入参":按需选深度

  6. 提交信息带上知识点:方便未来回溯"这周学了什么"


十、总结与知识图谱

第二周 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 精选 + 进阶类型编程:递归、变长元组、协变逆变)——今天的 SnakeToCamelDeepAwaited 就是体操的热身动作。BOSS 战的 typed-utils 会成为体操训练的"基准工具箱"。


学完泛型最重要的标志:看到任何官方工具类型,你的第一反应是"我手写过"。

每天花 2 小时,28 天通关 TypeScript 深入。第二周通关,下一站:类型体操场见!


评论