【TS】day13-mapped-types

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

TypeScript 映射类型与模板字面量类型 — 类型的遍历与字符串运算,一次讲透

昨天的条件类型解决了"类型层面的 if/else",今天的映射类型解决"类型层面的 for 循环"——[K in keyof T] 一个语法遍历对象的所有键,批量生成新字段。再加上模板字面量类型(字符串的类型运算),你将亲手写出天天在用的 Partial、Pick、Omit——官方工具类型不再神秘,因为它们就是几行映射 + 条件类型。


目录


一、为什么需要映射类型

1.1 类型层面的"批量操作"痛点

// 值层面:遍历对象,把每个字段值翻倍 —— for in 一行搞定
for (const key in obj) { obj[key] = obj[key] * 2; }

// 类型层面的需求:把 Device 的【每个字段】都变成可选
interface Device {
  id: string;
  name: string;
  temp: number;
  status: "running" | "fault";
}

// ❌ 手写:字段多了就是体力活,而且实体加字段时容易忘同步
interface DevicePatch {
  id?: string;
  name?: string;
  temp?: number;
  status?: "running" | "fault";
}

// ❌ 还想全只读?再手写一份?
interface FrozenDevice {
  readonly id: string;
  readonly name: string;
  // ……
}

Day 11 你学会了官方的 Partial<T> 解决这个问题。今天的问题是:Partial 自己是怎么实现的? 答案就是映射类型。

1.2 类型层面的"派生"需求

// 工业场景:后端蛇形命名 → 前端驼峰
// 后端返回 { device_id: "CNC-001", device_temp: 65 }
// 前端类型想要 { deviceId: string; deviceTemp: number }

// 手写一遍映射?字段一多就是灾难
// 期望:一个类型工具自动"改键名" —— 这需要映射类型的 as 重映射

二、映射类型基本语法:[K in keyof T]

2.1 最小示例

// 值的世界:for (const key in obj)
// 类型的世界:[K in keyof T]

/** 把每个字段的类型都变成 string */
type Stringify<T> = {
  [K in keyof T]: string;
};

interface Device {
  id: string;
  temp: number;
  status: boolean;
}

type S = Stringify<Device>;
// {
//   id: string;
//   temp: string;      ⭐ number 变 string
//   status: string;    ⭐ boolean 变 string
// }

读法[K in keyof T] = “遍历 T 的每一个键 K”——冒号右边是"每个新字段的类型怎么算"。

2.2 同态克隆:T[K] 取原值

/** 每个字段原样复制 —— 类型克隆 */
type Clone<T> = {
  [K in keyof T]: T[K];
};

type C = Clone<Device>;
// 与 Device 完全同构

// T[K]:索引访问(第 6 天学过)—— K 是"正在遍历的键",T[K] 取对应字段类型
// 相当于值层面的:
// for (const key in obj) { newObj[key] = obj[key]; }

2.3 遍历的键不必是 keyof T

// 映射类型的 [K in X] —— X 可以是任何"键的联合"

/** 遍历指定的字面量联合 */
type OnlyAxes = {
  [K in "x" | "y" | "z"]: number;
};
// { x: number; y: number; z: number }  ⭐ 这就是 Record 的原理!

/** 遍历另一个类型的键(键来自 T,值来自 U) */
type Relabel<T, U> = {
  [K in keyof T]: U;
};
type R = Relabel<Device, boolean>;
// { id: boolean; temp: boolean; status: boolean }

三、同态与非同态:修饰符的秘密

3.1 一个隐蔽的行为差异

interface Config {
  readonly url: string;    // 只读字段
  timeout?: number;        // 可选字段
}

// 疑问:映射 Config,readonly 和 ? 还在吗?

// 实验 1:非同态映射(值的类型做了"运算")
type Mapped1 = { [K in keyof Config]: Config[K] };
// {
//   url: string;         ⚠️ readonly 丢了!
//   timeout: number;     ⚠️ ? 丢了!
// }

// 实验 2:官方 Partial / Readonly —— 修饰符保留
type P = Partial<Config>;    // { readonly url?: string; timeout?: number }
// ⭐ readonly 被保留了!

3.2 同态(Homomorphic)的定义

// 同态映射 = [K in keyof T] 直接映射"同一个 T"的键
// TS 对这种写法有特殊待遇:自动保留原类型的 readonly 和 ? 修饰符

type Homo<T> = {
  [K in keyof T]: T[K];       // ✅ 同态:保留修饰符
};

// 非同态 = 遍历的键不是 keyof T,或做了"包装运算"
type NonHomo = {
  [K in keyof Config]: Config[K] | null;   // ⚠️ 值类型变了 → 非同态
  // 修饰符同样会丢!
};
// { url: string | null; timeout: number | null }  readonly 和 ? 都没了

官方的 Partial/Required/Readonly 都是同态映射——所以它们保留修饰符。记住这个差异,坑点一节会再遇到它。


四、修饰符操作:+ 与 -

4.1 加可选:? (Partial 的原理)

/** 每个字段加 ? */
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

interface Device { id: string; temp: number; status: string }

type P = MyPartial<Device>;
// { id?: string; temp?: number; status?: string }

4.2 减可选:-?(Required 的原理)

/** 每个字段去掉 ? —— 减号语法是映射类型独有的 */
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

interface Options { width?: number; height?: number }

type R = MyRequired<Options>;
// { width: number; height: number }  ⭐ 强制必填

4.3 加只读 / 减只读

/** 加 readonly(Readonly 的原理)*/
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

/** 减 readonly(官方没有,但很常用)*/
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

interface Frozen { readonly id: string; readonly temp: number }

type M = Mutable<Frozen>;
// { id: string; temp: number }  ⭐ 可写了

const m: M = { id: "CNC-001", temp: 65 };
m.temp = 80;   // ✅ 合法

4.4 修饰符语法总表

写法

含义

对应官方工具

[K in keyof T]?

加可选(+ 可省略)

Partial<T>

[K in keyof T]-?

去可选

Required<T>

readonly [K in keyof T]

加只读

Readonly<T>

-readonly [K in keyof T]

去只读

(无官方,常用自定义 Mutable

记忆+ 是默认(省略不写),- 是删除——-?-readonly 是映射类型独有的删除语法。


五、键重映射:as 子句

TS 4.1 引入的杀手级特性:映射时给键改名过滤键

5.1 改键名

/**
 * 每个键前面加 ws_ 前缀
 * 场景:WebSocket 消息字段与本地字段隔离
 */
type PrefixKeys<T> = {
  [K in keyof T as `ws_${string & K}`]: T[K];
};

type Original = { temp: number; status: string };
type Prefixed = PrefixKeys<Original>;
// { ws_temp: number; ws_status: string }

// 语法拆解:
// [K in keyof T as 新键名]  —— as 后面是"新键怎么算"
// string & K —— 把 K 收窄为 string(keyof 可能含 symbol)

5.2 过滤键:as never

/**
 * 只保留值类型为 number 的字段
 * as never 的字段直接从结果中消失!
 */
type PickByType<T, U> = {
  [K in keyof T as T[K] extends U ? K : never]: T[K];
};

interface Device {
  id: string;
  temp: number;
  battery: number;
  name: string;
}

type OnlyNumbers = PickByType<Device, number>;
// { temp: number; battery: number }  ⭐ id/name 被 never 干掉了

// 原理:as 后面的表达式返回 never → 该键被丢弃
// (昨天条件类型 + never 吸收的映射版应用)

5.3 组合:改名 + 过滤 + 条件

/**
 * 蛇形转驼峰(工业接口最常见需求)
 * 下划线后面的字母大写:device_temp → deviceTemp
 */
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
    : S;

type Camelized<T> = {
  [K in keyof T as SnakeToCamel<string & K>]: T[K];
};

type Backend = { device_id: string; device_temp: number };
type Frontend = Camelized<Backend>;
// { deviceId: string; deviceTemp: number }  ⭐

SnakeToCamel 用到了模板字面量 + 递归——第七节详解,先看效果。)


六、手写工具类型全家桶(今日主菜)

昨天用了 17 个官方工具类型,今天逐个手写。每写完一个,用悬停对比官方版本——结果一致的那一刻,是 TS 学习曲线最有成就感的时刻。

6.1 结构五兄弟

// ===== 1. Partial:全可选 =====
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// ===== 2. Required:全必填 =====
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

// ===== 3. Readonly:全只读 =====
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

// ===== 4. Pick:挑字段 =====
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
  // ⭐ 关键:遍历的是 K(你指定的键子集),不是 keyof T!
  // 值通过 T[P] 从原类型取
};

// ===== 5. Omit:删字段(= Pick + Exclude 组合)=====
type MyOmit<T, K extends keyof any> = MyPick<T, Exclude<keyof T, K>>;
// 原理拆解:
// 第一步:Exclude<keyof T, K> —— 从全部键里排除不要的
// 第二步:MyPick<T, 剩下的键> —— 挑出剩下的

验证环节(在 Playground 里做):

interface Device { id: string; name: string; temp: number; status: string }

type A = MyPartial<Device>;              // 悬停:全可选 ✅
type B = MyRequired<{ a?: string }>;     // 悬停:{ a: string } ✅
type C = MyReadonly<Device>;             // 悬停:全只读 ✅
type D = MyPick<Device, "id" | "name">;  // 悬停:{ id: string; name: string } ✅
type E = MyOmit<Device, "id">;           // 悬停:{ name: string; temp: number; status: string } ✅

// 终极对比:与官方版本逐字段一致
type A2 = Partial<Device>;
// MyPartial<Device> 和 Partial<Device> 悬停显示完全一样!

6.2 联合三兄弟(纯条件类型,复习昨天)

// ===== Exclude:从联合排除 =====
type MyExclude<T, U> = T extends U ? never : T;

// ===== Extract:从联合提取(交集)=====
type MyExtract<T, U> = T extends U ? T : never;

// ===== NonNullable:去 null/undefined =====
type MyNonNullable<T> = T extends null | undefined ? never : T;

// 验证:
type Status = "running" | "standby" | "fault";
type X1 = MyExclude<Status, "fault">;        // "running" | "standby"
type X2 = MyExtract<Status, "running">;      // "running"
type X3 = MyNonNullable<number | null>;      // number

6.3 函数反解三兄弟(纯 infer,复习昨天)

// ===== Parameters:参数元组 =====
type MyParameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

// ===== ReturnType:返回值 =====
type MyReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : never;

// ===== Awaited:解 Promise(官方支持递归解多层)=====
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;

// 验证:
function fetchDevices(url: string, page: number): Promise<Device[]> { /* ... */ }

type P1 = MyParameters<typeof fetchDevices>;   // [url: string, page: number]
type R1 = MyReturnType<typeof fetchDevices>;   // Promise<Device[]>
type D1 = MyAwaited<R1>;                       // Device[]

6.4 Record:键值全映射

// ===== Record:遍历键联合,值统一 =====
type MyRecord<K extends keyof any, V> = {
  [P in K]: V;
};

// 验证(第 5 天的状态配置表):
type DeviceStatus = "running" | "standby" | "fault" | "offline";

const STATUS_CONFIG: MyRecord<DeviceStatus, { text: string; color: string }> = {
  running: { text: "运行中", color: "#00ff88" },
  standby: { text: "待机", color: "#ffaa00" },
  fault:   { text: "故障", color: "#ff4444" },
  offline: { text: "离线", color: "#7a8ba0" }
};
// ⭐ 少写一个状态 → 编译报错(完整性由类型系统保证)

6.5 手写清单打卡

工具类型

核心机制

状态

MyPartial

映射 + ?

MyRequired

映射 + -?

MyReadonly

映射 + readonly

MyMutable

映射 + -readonly

MyPick

映射遍历 K

MyOmit

Pick + Exclude 组合

MyRecord

映射遍历键联合

MyExclude

条件 + never 吸收

MyExtract

条件 + never 吸收

MyNonNullable

条件 + never 吸收

MyParameters

infer 捕获参数

MyReturnType

infer 捕获返回值

MyAwaited

infer + 递归

13 个全部手写并验证通过 = 明天 BOSS 战战役 A 的基础盘。


七、模板字面量类型:字符串的类型运算

7.1 基本语法

// 值层面:模板字符串拼接
const event = `device:${action}`;

// 类型层面:模板字面量类型 —— 对字符串字面量类型的拼接
type Greeting = `hello ${"world" | "ts"}`;
// "hello world" | "hello ts"

// 与联合组合 —— 笛卡尔积
type DomEvent = `on${"Click" | "Change" | "Hover"}`;
// "onClick" | "onChange" | "onHover"

// 大小写组合
type Genders = `${"m" | "f"}${"ale" | "emale"}`;   // 开玩笑的,但语法合法

7.2 占位符里能放什么

type A = `id-${number}`;     // id-1、id-100…… 所有 "id-数字" 形式
type B = `v${string}`;       // v 开头的任意字符串
type C = `${string}@${string}.com`;   // 邮箱形状(宽松版)

// 场景:路由参数的类型约束
type Route = `/${string}`;
const r1: Route = "/devices";    // ✅
const r2: Route = "devices";     // ❌ 必须以 / 开头

// 场景:版本号格式
type Version = `v${number}.${number}.${number}`;
const v: Version = "v1.2.3";     // ✅
const v2: Version = "1.2.3";     // ❌ 缺 v 前缀

7.3 infer 在模板串中:捕获字符串片段

/** 提取冒号前的前缀 */
type ExtractDomain<E extends string> =
  E extends `${infer D}:${string}` ? D : never;

type D1 = ExtractDomain<"device:update">;   // "device"
type D2 = ExtractDomain<"hub:create">;      // "hub"
type D3 = ExtractDomain<"invalid">;         // never(不匹配)

/** 提取前缀和后缀两段 */
type Split<E extends string> =
  E extends `${infer Head}:${infer Tail}` ? [Head, Tail] : never;

type S1 = Split<"device:update">;   // ["device", "update"]

/** 字符串递归:逐字符拆开成元组(类型体操预告) */
type Chars<S extends string> =
  S extends `${infer C}${infer Rest}` ? [C, ...Chars<Rest>] : [];

type C1 = Chars<"abc">;   // ["a", "b", "c"]

八、内置字符串工具类型

TS 4.7 内置了四个"字符变换"工具类型(注意:只对字面量类型生效):

type A = Uppercase<"running">;      // "RUNNING"
type B = Lowercase<"RUNNING">;      // "running"
type C = Capitalize<"cnc">;         // "Cnc"(首字母大写)
type D = Uncapitalize<"Cnc">;       // "cnc"(首字母小写)

// ⚠️ 对非字面量的 string 无效:
type E = Uppercase<string>;   // string(不报错,但也没有变换效果)

// 实战场景:常量命名转换
type ScreamingSnake<S extends string> = Uppercase<S>;
type EnvKey = ScreamingSnake<"hubUrl">;   // "HUBURL"(注意:中间不会加下划线)

// 实战场景:Vue 风格事件名(kebab → camel)
type KebabToCamel<S extends string> =
  S extends `${infer Head}-${infer Tail}`
    ? `${Head}${Capitalize<KebabToCamel<Tail>>}`
    : S;

type K1 = KebabToCamel<"device-update">;   // "deviceUpdate"
type K2 = KebabToCamel<"alert-raise-now">; // "alertRaiseNow"

九、工业实战场景全覆盖

9.1 场景一:后端蛇形 → 前端驼峰(接口层转换)

/**
 * 大屏项目真实痛点:后端 Python 服务返回蛇形命名
 * 手写转换函数类型不安全?用映射 + 模板串自动生成类型
 */
type SnakeToCamel<S extends string> =
  S extends `${infer Head}_${infer Tail}`
    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
    : S;

/** 整个对象的键批量转驼峰 */
type Camelize<T> = {
  [K in keyof T as SnakeToCamel<string & K>]: T[K];
};

// 后端接口文档抄来的类型(蛇形)
interface DeviceApiRaw {
  device_id: string;
  device_name: string;
  work_temp: number;
  is_online: boolean;
}

// 前端类型(驼峰)—— 一行派生
type DeviceDto = Camelize<DeviceApiRaw>;
// {
//   deviceId: string;
//   deviceName: string;
//   workTemp: number;
//   isOnline: boolean;
// }

// 配套的运行时转换函数也有了精确类型签名:
function camelizeDevice(raw: DeviceApiRaw): DeviceDto {
  return {
    deviceId: raw.device_id,
    deviceName: raw.device_name,
    workTemp: raw.work_temp,
    isOnline: raw.is_online
  };
}

9.2 场景二:事件名的自动生成与载荷注册表

/**
 * 事件域 × 动作 = 全部合法事件名(编译期展开)
 * 手写要写 9 个字符串字面量,类型生成只要 2 行
 */
type Domain = "device" | "alert" | "hub";
type Action = "update" | "remove" | "create";

type EventName = `${Domain}:${Action}`;
// "device:update" | "device:remove" | "device:create"
// | "alert:update" | "alert:remove" | "alert:create"
// | "hub:update" | "hub:remove" | "hub:create"  —— 9 个

/**
 * 事件名 → 载荷类型的注册表(映射 + 条件 + infer 组合)
 */
type EventPayloads = {
  [E in EventName]: E extends `device:${infer A}`
    ? A extends "update" ? Device : { id: string }
    : E extends `alert:${infer A}`
      ? AlertRecord
      : { time: number };
};

// 2 行类型代码生成 9 个事件的精确载荷 —— 手写要写 9 个接口

// 配合 Day 9 的泛型事件总线:
const bus = new EventBus<EventPayloads>();
bus.on("device:update", d => console.log(d.temp));   // ✅ d: Device
bus.emit("alert:remove", { id: "1" });               // ✅ 载荷校验

9.3 场景三:表单状态自动生成

/**
 * 表单场景:编辑设备的表单状态 = 每个字段变 "值 + 错误信息 + 是否触碰过"
 * 手写三个字段还好,20 个字段的项目呢?
 */
type FormState<T> = {
  [K in keyof T]: {
    value: T[K];              // 当前值(类型与实体字段联动)
    error: string | null;     // 校验错误
    touched: boolean;         // 是否触碰过
  };
};

interface Device {
  id: string;
  temp: number;
  status: "running" | "fault";
}

type DeviceForm = FormState<Device>;
// {
//   id: { value: string; error: string | null; touched: boolean };
//   temp: { value: number; error: string | null; touched: boolean };
//   status: { value: "running" | "fault"; error: string | null; touched: boolean };
// }

// 初始化函数的返回类型精确锁定:
function initForm<T>(entity: T): FormState<T> {
  const result = {} as FormState<T>;
  for (const key in entity) {
    const k = key as keyof T;
    result[k] = { value: entity[k], error: null, touched: false };
  }
  return result;
}

const form = initForm(device);
form.temp.value.toFixed(1);      // ✅ number(类型联动保持)
form.temp.error = "温度超限";     // ✅

9.4 场景四:只读配置快照(DeepReadonly 预告)

/**
 * 大屏主题配置:嵌套结构,一旦生成不允许任何修改
 * 普通 Readonly 只管一层 —— 深层字段照样能改!
 */
const theme = {
  colors: {
    primary: "#00ff88",
    danger: "#ff4444"
  },
  chart: {
    line: { width: 2, smooth: true }
  }
};

type ReadonlyTheme = Readonly<typeof theme>;
const t: ReadonlyTheme = theme;
// t.colors.primary = "#000";   // ✅ 竟然不报错!Readonly 只锁了第一层

// 深度只读需要递归映射(明天 BOSS 战手写):
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }   // 每层递归加 readonly
  : T;

type DeepTheme = DeepReadonly<typeof theme>;
const t2: DeepTheme = theme;
// t2.colors.primary = "#000";   // ❌ 编译错误!任意深度都被锁住 ✅

十、类比记忆:流水线 vs 印章

映射类型 = 工厂流水线
┌──────────────────────────────────────┐
│  原料:T 的键一个一个上传送带           │
│  ([K in keyof T])                    │
│  每个键经过工位加工:                   │
│    工位 1:值加工(T[K] → T[K] | null)│
│    工位 2:修饰符(加 ? / 减 readonly) │
│    工位 3:键改名(as 新名字)          │
│  质检员 as never:不合格的直接下线      │
│  产出:一条全新的类型                   │
└──────────────────────────────────────┘

模板字面量 = 印章拼字
┌──────────────────────────────────────┐
│  `${Domain}:${Action}`               │
│   ↑印章有俩空位:                     │
│   空位 1 印 "device"|"alert"|"hub"    │
│   空位 2 印 "update"|"remove"|...     │
│  每种组合盖出一个字符串字面量          │
│  9 个印章 = 事件名的全集               │
└──────────────────────────────────────┘

十一、常见坑点与最佳实践

坑点 1:非同态映射丢失修饰符

interface Config { readonly url: string; timeout?: number }

// 想给每个字段加个 null —— 顺手一写:
type WithNull = { [K in keyof Config]: Config[K] | null };
// { url: string | null; timeout: number | null }
// ⚠️ readonly 和 ? 都丢了!(值类型运算 → 非同态)

// 想保修饰符 + 加工值的组合没有银弹:
// 方案:接受丢失(明确知道)或用交叉类型补回特定字段

坑点 2:Omit 的键不受约束(拼错不报错)

// Omit<T, K> 的 K extends keyof any(string | number | symbol 均可)
type A = Omit<Device, "nonexistent">;
// ✅ 不报错!拼错的键被静默忽略 —— 隐患!

// ✅ 严格版(拼错爆红):
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type B = StrictOmit<Device, "nonexistent">;   // ❌ 编译错误 ✅

坑点 3:MyPick 的 K 约束写错

// ❌ 常见错误:遍历写成 keyof T(那就变成全量克隆了)
type WrongPick<T, K extends keyof T> = {
  [P in keyof T]: T[P];    // ⚠️ 这会遍历所有键,Pick 失效!
};

// ✅ 正确:遍历 K
type RightPick<T, K extends keyof T> = {
  [P in K]: T[P];          // 只遍历指定的键子集
};

坑点 4:键重映射时 keyof 含 symbol

// keyof T 的类型是 string | number | symbol
// 模板字面量只接受 string —— 直接拼会报错:

type Bad<T> = {
  [K in keyof T as `prefix_${K}`]: T[K];   // ❌ K 可能是 symbol!
};

// ✅ 收窄:string & K
type Good<T> = {
  [K in keyof T as `prefix_${string & K}`]: T[K];   // ✅
};

坑点 5:字符串工具对非字面量无效

type A = Uppercase<"abc">;   // "ABC" ✅
type B = Uppercase<string>;  // string ⚠️ 没有变换效果!

// 启示:模板串工具链的前提是"输入是字面量类型"
// 如果泛型参数被推断成宽泛的 string,整条变换链失效
// 解法:给泛型加 extends string 约束,保持字面量细腻度

坑点 6:映射类型和索引签名混淆

// 索引签名:键无限,值统一("任何 string 键都是 number")
type IndexSig = { [key: string]: number };

// 映射类型:键有限(遍历已知的键联合)
type Mapped = { [K in "a" | "b"]: number };   // { a: number; b: number }

// 区别记忆:
// [key: string]: —— 冒号前是"一个变量名",表示任意键
// [K in X]:     —— 冒号前是"in 某个联合",表示枚举已知键

最佳实践清单

  1. 每手写一个工具类型,立即悬停对比官方版——不一致就排查

  2. 键重映射 + 条件类型是自定义工具类型的终极武器(过滤 + 改名一体)

  3. 递归映射要设终止条件T extends object ? ... : T 兜底原始类型)

  4. Omit 换 StrictOmit,把拼错键的隐患消灭在编译期

  5. 模板串变换链的输入保持字面量extends string 约束)

  6. 映射类型的键名用 P 或 K(与官方源码一致:[P in K][K in keyof T]

  7. 复杂变换拆中间类型type CamelKeys = ...; type Result = ...


十二、自测挑战

Q1[K in keyof T]{ [key: string]: T }(索引签名)的区别是什么?

Q2:手写 MyPartial<T>MyRequired<T>——减号语法 -? 是什么含义?

Q3MyPick<T, K extends keyof T> 为什么遍历的是 K 而不是 keyof T?写错会怎样?

Q4MyOmit<T, K> 的实现原理是什么(两步拆解)?为什么官方 Omit 的拼错键不报错?

Q5:什么是同态映射?为什么 Partial<Config> 保留 readonly 而 { [K in keyof Config]: Config[K] | null } 不保留?

Q6as never 在键重映射中的作用是什么?写一个 OmitByType<T, U>(剔除值类型为 U 的字段)。

Q7:写出以下类型的结果:

type Event = `${"device" | "hub"}:${"get" | "set"}`;

Q8:手写 SnakeToCamel<"work_temp_max"> 的递归执行过程(三轮各捕获什么)。

Q9:写一个 Nullable<T>——每个字段变成 T[K] | null。它会保留原字段的 readonly/? 修饰符吗?为什么?

Q10:设计 Getters<T>:键加 get 前缀并首字母大写,值变成返回该类型的函数。{ name: string } 应得 { getName: () => string }


十三、总结与知识图谱

映射类型与模板字面量类型(类型的遍历与字符串运算)
│
├── 映射类型
│   ├── 语法:[K in keyof T](类型的 for 循环)
│   ├── 同态(保留 readonly/?) vs 非同态(丢失)
│   ├── 修饰符:?(加可选)/-?(去可选)/ readonly / -readonly
│   ├── 键重映射 as:改名(as 新键)/ 过滤(as never)
│   └── 手写全家桶:Partial/Required/Readonly/Pick/Omit/Record
│
├── 模板字面量类型
│   ├── `${A}${B}`:字面量拼接(联合 → 笛卡尔积)
│   ├── 占位符:string / number / 具体字面量联合
│   ├── infer 捕获片段:`${infer D}:${string}` → D
│   └── 递归拆串:Chars<"abc"> → ["a","b","c"]
│
├── 内置字符串工具
│   ├── Uppercase / Lowercase / Capitalize / Uncapitalize
│   └── 只对字面量生效(string 无效果)
│
├── 与前两天的组合
│   ├── 映射 + 条件:PickByType / NumericKeys(字段类型过滤)
│   ├── 映射 + infer:SnakeToCamel / KebabToCamel
│   └── 递归映射:DeepReadonly / DeepPartial(明天 BOSS 战)
│
└── 工业实战
    ├── Camelize<T>:蛇形转驼峰(接口层)
    ├── EventPayloads:事件名生成 + 载荷注册表
    ├── FormState<T>:表单状态批量派生
    └── DeepReadonly:嵌套配置快照

一句话总结:映射类型是类型层面的 for 循环,as 重映射让它既能改键又能过滤键;模板字面量让字符串在类型层面可拼接可解析——加上昨天的条件类型与 infer,TS 类型系统的"图灵完备"四件套已全部到齐,明天 BOSS 战见。


延伸阅读

资源

说明

TS Handbook - Mapped Types

官方映射类型文档

TS Handbook - Key Remapping via as

as 重映射

TS Handbook - Template Literal Types

模板字面量类型

TypeScript Playground

悬停验证手写与官方一致性


下一步

明天(Day 14)是第二周 BOSS 战:把本周所有知识(泛型、约束、条件类型、infer、映射类型、模板串)集中检阅——手写 typed-utils 工具类型库(13+ 个),并用泛型重构第 1 周的 dataHub(Repository + Result 错误处理 + 泛型事件总线)。


官方工具类型不神秘:它们就是几行映射 + 条件类型,你今天全写过了。

每天花 2 小时,28 天通关 TypeScript 深入。加油!


评论