【TS】day06-keyof-typeof-indexed-access

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

TypeScript keyof / typeof / 索引访问 — 类型钥匙,一次讲透

到今天为止,我们写的类型都是"平铺"的——要描述什么就手写什么。但真实工程里,类型之间充满派生关系:“设备的字段名集合”、“这个常量对象的键”、“配置对象里某个字段的具体类型”。keyof(取键)、typeof(取值的类型)、索引访问(取字段类型)就是类型世界的三把钥匙——它们让你从一个已有的类型/值出发,推导出新类型,而不是每次都从零手写。这是通往泛型和类型体操的必经之路。


目录


一、为什么需要"从已有类型派生新类型"?

1.1 重复手写的维护灾难

先看不用派生的世界——每个类型都是孤岛:

// 设备数据类型(第一处定义)
interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
}

// 想写一个"字段名"类型 —— 手写一份 ⚠
type DeviceField = "id" | "name" | "temp" | "status";

// 想写一个"单字段更新"的函数参数 —— 又手写一份 ⚠
interface DeviceUpdate {
  id?: string;
  name?: string;
  temp?: number;
  status?: "running" | "standby" | "fault";
}

// 三个月后,DeviceData 加了字段 voltage: number
// ⚠ DeviceField 忘改 → 字段名联合缺失
// ⚠ DeviceUpdate 忘改 → 更新函数收不到新字段
// 手写的每一份"衍生类型",都是未来的一个 bug

1.2 派生的思路

类型系统其实已经知道所有信息——DeviceData 里躺着完整的字段名和类型清单。我们缺的只是"取出来"的语法:

interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
}

type DeviceField = keyof DeviceData;      // "id" | "name" | "temp" | "status" ✅ 自动
type DeviceTemp = DeviceData["temp"];     // number ✅ 自动

// DeviceData 加字段 → 上面两个类型自动跟着变,一处不改全部同步

这就是今天的三把钥匙要解决的问题:单一真相源,类型层不重复

1.3 先分清两个世界:值的世界 vs 类型的世界

const device = { id: "CNC-001", temp: 65 };
//    ↑ 值

interface DeviceData { id: string; temp: number }
//          ↑ 类型

// ⭐ 关键区分(今天最容易混淆的点):
typeof device   // 类型查询:从【值】拿到【类型】→ { id: string; temp: number }
keyof DeviceData // 键提取:从【类型】拿到【键的联合】→ "id" | "temp"

// typeof 后面跟值(device),keyof 后面跟类型(DeviceData)
// 值和类型活在两个世界,typeof 是唯一官方"值 → 类型"的桥

记忆锚点typeof 是桥(值世界 → 类型世界);keyof 和索引访问是类型世界内部的检索工具。


二、typeof 类型查询:从值拿到类型

2.1 基本语法

const device = {
  id: "CNC-001",
  name: "数控机床",
  temp: 65,
  tags: ["cnc", "line-1"]
};

// ⚠ typeof 后面跟的是【值 device】,不是类型
type Device = typeof device;
// Device = {
//   id: string;
//   name: string;
//   temp: number;
//   tags: string[];
// }

// 用派生出来的类型标注别的变量
const backup: Device = {
  id: "CNC-002",
  name: "备用机床",
  temp: 40,
  tags: []
};

2.2 ⚠ 两个 typeof:表达式 vs 类型

JS 里也有 typeof(运行时运算符),和 TS 的类型 typeof两码事

const device = { temp: 65 };

// JS 的 typeof:值的运算,运行时执行,返回字符串
if (typeof device === "object") {       // "object"
  console.log(typeof device.temp);       // "number"
}

// TS 的 typeof:类型的位置使用,编译期生效,返回类型
type T = typeof device;   // { temp: number }

// 区分方法:看位置
// - 出现在 if 条件、赋值语句右侧 → JS 运行时 typeof
// - 出现在 type 定义、类型标注的位置 → TS 类型 typeof

辨析练习

const colors = ["#00ff88", "#ffaa00"];

console.log(typeof colors);        // ❓ JS typeof → "object"(数组是 object!)
type C = typeof colors;            // ❓ TS typeof → string[]

type FirstC = C[number];           // ❓ 索引访问 → string(第六节细讲)

2.3 typeof + as const:第 5 天三剑客的完整解释

第 5 天我们用过 type Level = (typeof LEVELS)[number],现在能完全看懂了:

const LEVELS = ["info", "warn", "error"] as const;

// 第一步:typeof LEVELS —— 从值取类型
type T1 = typeof LEVELS;   // readonly ["info", "warn", "error"](as const 的功劳)

// 第二步:T1[number] —— 索引访问,取"数字索引对应的元素类型"
type Level = (typeof LEVELS)[number];  // "info" | "warn" | "error"

如果没有 as consttypeof LEVELSstring[][number] 取出来就是 string——字面量联合就退化没了。这就是 as const 三剑客环环相扣的原因。

2.4 typeof 的工程用途:给配置对象建类型

// 大屏图表配置(真实项目里就是一个大常量对象)
const chartConfig = {
  tempTrend: { type: "line", smooth: true, color: "#00ff88" },
  statusPie: { type: "pie", radius: ["40%", "70%"], color: "#ffaa00" },
  loadBar:   { type: "bar", stack: true, color: "#3897f0" }
};

// ✅ 一行拿到配置的类型(不用手写 interface)
type ChartConfig = typeof chartConfig;

// 字段类型精确派生
type TempTrendCfg = typeof chartConfig.tempTrend;
// { type: string; smooth: boolean; color: string }

2.5 typeof 函数:拿到函数类型

function createDevice(id: string, temp: number) {
  return { id, temp, online: true };
}

type CreateDevice = typeof createDevice;
// (id: string, temp: number) => { id: string; temp: number; online: boolean }

// 用途:函数引用的类型标注
const factory: CreateDevice = createDevice;   // ✅

// ⚠ typeof 类名:拿到的是【类的构造函数类型】,不是实例类型!
class Device {
  id = "";
}
type D1 = typeof Device;   // typeof Device 是构造函数类型
type D2 = Device;          // 实例类型(类的名字本身可以直接当类型用)

const ctor: typeof Device = Device;      // ✅ Device 类本身
const instance: Device = new ctor();     // ✅ 实例

2.6 练习

// 练习 1:定义常量 person = { name: "蜗牛", age: 30, hobbies: ["coding", "writing"] }
// 用 typeof 派生 Person 类型,并声明另一个 Person 变量

// 练习 2:定义函数 getDevice(): { id: string; temp: number | null }
// 用 typeof 派生其函数类型,并标注一个同签名变量

// 练习 3:验证 typeof SomeClass 和 SomeClass(作为类型)的区别

三、keyof:拿到对象的所有键

3.1 基本语法

interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
}

// keyof 类型:取 DeviceData 的全部键,组成字面量联合
type DeviceKey = keyof DeviceData;
// "id" | "name" | "temp" | "status"

let k: DeviceKey;
k = "temp";     // ✅
k = "status";   // ✅
k = "voltage";  // ❌ 编译错误:不在键联合中

读法keyof DeviceData 读作"DeviceData 的键"——它的值域恰好是 DeviceData 的所有字段名。

3.2 keyof 作用于不同类型的结果

// 普通对象接口:全部字段的字面量联合
interface A { x: number; y: number }
type KA = keyof A;              // "x" | "y"

// 索引签名对象:string | number(不是具体键!)
interface B { [k: string]: number }
type KB = keyof B;              // string | number

// 数组:number(数字索引)+ 数组方法名
type KC = keyof string[];       // number | "length" | "push" | "map" | ...

// 元组:数字字面量 + 方法名
type KD = keyof [string, number];  // "0" | "1" | "length" | ...(数字索引转字符串键)

// any / never
type KE = keyof any;            // string | number | symbol(所有可能的键)
type KF = keyof never;          // never

⚠️ 注意 keyof string[] 的陷阱:数组的 keyof 包含所有数组方法名("push" 也在里面!)——想取"元素类型"别用 keyof,用第六节的 [number] 索引访问。

3.3 keyof 最经典的应用:类型安全的字段访问器

interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
}

/**
 * 读取设备任意字段的值(类型安全版 get)
 * ⭐ key: keyof DeviceData 保证字段名拼不错
 */
function getField(device: DeviceData, key: keyof DeviceData) {
  return device[key];
}

getField(device, "temp");      // ✅
getField(device, "tmep");      // ❌ 编译错误:拼错立刻爆红(对比 1.1 的裸字符串灾难)

// 返回值类型:string | number | "running" | "standby" | "fault"
// (所有字段类型的联合——精确但"宽",进阶解法在泛型,预告第 2 周)

3.4 keyof + typeof 组合:从值出发拿键

keyof 后面跟类型。那常量对象(值)的键怎么拿?——先 typeof 转成类型:

const THEME = {
  dark:  { bg: "#0a1628", accent: "#00ff88" },
  light: { bg: "#f5f7fa", accent: "#00b368" }
} as const;

type ThemeName = keyof typeof THEME;
// "dark" | "light" ✅(第 5 天见过的写法,现在彻底明白原理)

function setTheme(name: ThemeName): void {
  applyTheme(THEME[name]);   // ✅ 键类型安全,值自动收窄
}
setTheme("dark");    // ✅
setTheme("midnight"); // ❌ 编译错误

组合公式keyof typeof 值 = 从常量对象提取键联合。这是 as const 三剑客的第四种形态(前三种见第 5 天第六节)。

3.5 keyof 的排错实验

// 实验:接口有可选字段,keyof 照样包含它
interface Config {
  url: string;
  token?: string;
}
type K = keyof Config;   // "url" | "token"(token 可选但键存在)

// 实验:联合类型的 keyof = 各成员键的交集(很少用到,认识即可)
type KA2 = keyof ({ a: 1 } | { a: 2; b: 3 });  // "a"(只取公共键)

3.6 练习

// 练习 1:定义 interface Point { x, y, z: number }
// 写函数 getAxis(p: Point, axis: keyof Point): number

// 练习 2:定义常量 COLORS = { primary, success, warning, danger } as const
// 用 keyof typeof 推导 ColorName 类型,写 getColor(name: ColorName): string

// 练习 3:验证 keyof string[] 包含 "push",理解为什么(数组方法的键也是键)

四、索引访问类型:拿到某个字段的类型

4.1 基本语法

索引访问类型(Indexed Access Type)类型["键"],像对象取值一样从类型里取字段类型:

interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
  tags: string[];
}

type TempType = DeviceData["temp"];      // number
type StatusType = DeviceData["status"];  // "running" | "standby" | "fault"
type IdType = DeviceData["id"];          // string

⚠️ 注意与 JS 取值的区别

// JS:值[键] —— 运行时取属性的值
const v = device["temp"];        // 65

// TS:类型[键] —— 编译期取字段的类型
type T = DeviceData["temp"];     // number

// 位置决定含义:出现在类型位置(type 定义、泛型参数)就是索引访问

4.2 用 keyof 做键:全部字段的类型联合

// keyof 做索引 → 取出所有字段类型的联合
type AllFieldTypes = DeviceData[keyof DeviceData];
// string | number | ("running" | "standby" | "fault") | string[]
// 化简:string | number | "running" | "standby" | "fault"

// 语义:任意字段的值可能是哪些类型
function readAnyField(d: DeviceData, k: keyof DeviceData): AllFieldTypes {
  return d[k];   // ✅ 返回类型精确匹配
}

4.3 嵌套索引访问:一层层往里钻

interface Dashboard {
  header: { title: string; height: number };
  panels: {
    left: { width: number; charts: string[] };
    right: { width: number; charts: string[] };
  };
}

// 链式索引,直达嵌套深处的类型
type Title = Dashboard["header"]["title"];         // string
type LeftPanel = Dashboard["panels"]["left"];      // { width: number; charts: string[] }
type Charts = Dashboard["panels"]["left"]["charts"]; // string[]

// 先存中间类型再深入(可读性更好)
type Panels = Dashboard["panels"];
type Panel = Panels["left" | "right"];             // 联合做索引 → 见第七节

4.4 索引访问 + typeof:从常量取精确类型

const STATUS = {
  running: { text: "运行中", color: "#00ff88", level: 0 },
  standby: { text: "待机",   color: "#ffaa00", level: 1 },
  fault:   { text: "故障",   color: "#ff4444", level: 2 }
} as const;

type StatusKey = keyof typeof STATUS;             // "running" | "standby" | "fault"
type StatusCfg = (typeof STATUS)[StatusKey];      // 三种配置的联合(第 5 天写过)
type RunningCfg = (typeof STATUS)["running"];     // 只取 running 的精确类型
// { readonly text: "运行中"; readonly color: "#00ff88"; readonly level: 0 }

// 联合键取公共结构
type AnyCfg = (typeof STATUS)[keyof typeof STATUS];
// { text: string; color: string; level: 0|1|2 } 的联合形态

4.5 索引访问不能是"动态计算"的

// ✅ 键必须是类型层已知的东西:字面量 / keyof / 联合
type A1 = DeviceData["temp"];              // ✅ 字面量
type A2 = DeviceData[keyof DeviceData];    // ✅ keyof

// ❌ 不能用"值"做键(类型世界不认识运行时的值)
const key = "temp";
type A3 = DeviceData[key];   // ❌ 编译错误:key 是值不是类型
// 如果 key 是 const key = "temp" as const 也不行 —— 值终究是值

// ✅ 但 const + typeof 有特殊通道(罕见,认识即可):
const key2 = "temp";
type A4 = DeviceData[typeof key2];   // ✅ typeof 把值的类型取出来("temp" 字面量)

4.6 练习

// 练习 1:定义 interface User { id: number; profile: { name: string; tags: string[] } }
// 派生:NameType(string)、TagsType(string[])、ProfileType(整个嵌套对象)

// 练习 2:定义常量 LEVELS = { info: {...}, warn: {...} } as const(含 text/priority 字段)
// 派生:LevelKey、LevelCfg(联合)、InfoCfg(仅 info 的精确类型)

// 练习 3:验证 DeviceData[keyof DeviceData] 和手写联合的一致性

五、三把钥匙的组合连招

5.1 连招总览

值 ──typeof──▶ 类型 ──keyof──▶ 键联合 ──索引访问──▶ 字段类型
 │                                   │
 └────────── keyof typeof 值 ────────┘(一步到位拿键)

目标

写法

从值拿类型

typeof 值

从类型拿键联合

keyof 类型

从值拿键联合

keyof typeof 值

从类型拿字段类型

类型["字段名"]

从值拿字段类型

(typeof 值)["字段名"]

全部字段类型的联合

类型[keyof 类型]

5.2 连招实战:Partial 的手工版(预告泛型工具)

interface DeviceData {
  id: string;
  name: string;
  temp: number;
}

// 需求:更新函数只传部分字段
// ❌ 手写版本:与 DeviceData 重复维护
interface DeviceUpdate {
  id?: string;
  name?: string;
  temp?: number;
}

// 泛型工具 Partial<T> 一行解决(原理用到今天的知识,第 2 周拆解)
const update: Partial<DeviceData> = { temp: 80 };
// Partial<DeviceData> = { id?: string; name?: string; temp?: number }

// 今天先用 keyof 手工模拟"单字段更新"类型:
type FieldUpdate<K extends keyof DeviceData> = { field: K; value: DeviceData[K] };
// K 被约束为字段名,value 的类型跟着 K 联动 —— 泛型的威力初见
const u1: FieldUpdate<"temp"> = { field: "temp", value: 80 };      // ✅
const u2: FieldUpdate<"temp"> = { field: "temp", value: "80" };    // ❌ value 必须是 number
const u3: FieldUpdate<"status"> = { field: "status", value: 1 };   // ❌ 键不存在

K extends keyof T + T[K] 是 TS 最著名的黄金组合——今天先把 keyofT[K] 的地基打牢,第 2 周泛型篇会有"回环镖"式的重逢。

5.3 连招实战:类型安全的遍历器

const STATUS = {
  running: { text: "运行中", color: "#00ff88" },
  standby: { text: "待机",   color: "#ffaa00" },
  fault:   { text: "故障",   color: "#ff4444" }
} as const;

type StatusKey = keyof typeof STATUS;

/**
 * 遍历状态表(键类型收窄版)
 * Object.entries 的键是 string(宽),这里手动收窄回字面量联合
 */
function eachStatus(fn: (key: StatusKey, cfg: (typeof STATUS)[StatusKey]) => void): void {
  (Object.keys(STATUS) as StatusKey[]).forEach(key => {
    fn(key, STATUS[key]);
  });
}

eachStatus((key, cfg) => {
  console.log(key, cfg.text, cfg.color);   // ✅ 全程类型精确
});

5.4 练习

// 练习:定义常量 METRICS = { temp: "°C", speed: "rpm", pressure: "kPa" } as const
// 派生 MetricKey、UnitOf = (typeof METRICS)[keyof typeof METRICS]
// 写 formatMetric(m: MetricKey, v: number): string → "65°C" 形式

六、数组与元组的索引访问

6.1 T[number]:取数组/元组的元素类型

// 数组:T[number] → 元素类型
type Nums = number[];
type NumElem = Nums[number];        // number

const devices = [{ id: "A", temp: 1 }, { id: "B", temp: 2 }];
type Devices = typeof devices;      // { id: string; temp: number }[]
type Device = Devices[number];      // { id: string; temp: number } ✅ 从数组反解元素类型

// 元组:T[number] → 所有元素的联合
type Tuple = [string, number, boolean];
type Elem = Tuple[number];          // string | number | boolean

[number] 的读法:“用 number(任意数字索引)去索引这个类型,得到所有可能的元素类型”。数组的任意位置都是同一类型 → 直接得到它;元组各位置类型不同 → 得到联合。

6.2 元组的字面量索引:精确取某个位置

type Position = [number, number, number];  // 3D 坐标

type X = Position[0];   // number(这里恰好都是 number)
type Len = Position["length"];  // 3 ⭐ 字面量 3(元组长度也是类型!)

// 混合元组才能看出价值
type Mixed = [string, number, { lat: number; lng: number }];
type Coord = Mixed[2];          // { lat: number; lng: number }
type CoordLat = Mixed[2]["lat"]; // number(索引访问可以链下去)

6.3 从数组常量反解元素(配合 as const)

// 大屏系列色(运行时常量)
const SERIES_COLORS = ["#00ff88", "#ffaa00", "#3897f0"] as const;

type SeriesColor = (typeof SERIES_COLORS)[number];
// "#00ff88" | "#ffaa00" | "#3897f0" ⭐ 精确到每个字面量

// 取色函数:入参被锁定在三个色值内
function nextColor(i: number): SeriesColor {
  return SERIES_COLORS[i % SERIES_COLORS.length];
}

// 对比:不 as const 的话
const colors = ["#00ff88", "#ffaa00"];
type C = (typeof colors)[number];   // string(退化了!)

6.4 keyof 数组的陷阱回顾

type K = keyof ["a", "b"];
// "0" | "1" | "length" | "push" | ... ⚠ 包含数组方法名

// 想要"下标联合"(很少需要):用数字字面量索引访问更直接
type Idx = ["a", "b"][number];     // "a" | "b"(元素联合)
// 元组的"位置"枚举一般用别的方案(生成 0|1|2 属于类型体操,暂不展开)

6.5 练习

// 练习 1:定义 const ALARMS = [{ level: "warn", msg: "过热" }, { level: "error", msg: "停机" }] as const
// 派生 AlarmElem(元素类型)、AlarmLevel(level 字段的联合)

// 练习 2:定义 type Vec3 = [x: number, y: number, z: number](具名元组)
// 取 Vec3["length"]、Vec3[1],理解具名元组只是可读性更好

七、索引访问的进阶:联合做索引

7.1 键是联合 → 结果是联合

interface DeviceData {
  id: string;
  name: string;
  temp: number;
  status: "running" | "standby" | "fault";
  tags: string[];
}

// 单键
type A = DeviceData["temp"];     // number

// 联合键:分别取每个键的类型,再并成联合
type B = DeviceData["id" | "name"];            // string | string → string
type C = DeviceData["temp" | "status"];        // number | "running" | "standby" | "fault"

// keyof 本质就是键联合,所以这俩等价:
type D = DeviceData[keyof DeviceData];         // 全部字段类型联合
type E = DeviceData["id" | "name" | "temp" | "status" | "tags"];

7.2 用联合索引"批量提取"嵌套结构

const THEME = {
  dark:  { bg: "#0a1628", text: "#e0e8f0" },
  light: { bg: "#f5f7fa", text: "#1a2a3a" },
  blue:  { bg: "#0a1a3a", text: "#d0e8ff" }
} as const;

// 只想要 bg:联合键 + 嵌套索引
type Bg = (typeof THEME)[keyof typeof THEME]["bg"];
// "#0a1628" | "#f5f7fa" | "#0a1a3a"

// 展开理解:
// (typeof THEME)[keyof typeof THEME]  → 三个主题对象的联合
// ...["bg"]                            → 对联合里的每个成员取 bg,再并起来

7.3 联合索引的数学本质(了解)

T[K1 | K2] = T[K1] | T[K2]

// 索引访问对联合键是"分配"的
// 就像:盒子里的("苹果"|"香蕉") = 盒子里的苹果 | 盒子里的香蕉

这个"分配律"是类型体操的核心机制之一(条件类型的 distributes 特性同源)。今天记住形态即可。

7.4 练习

// 练习:定义 interface Point { x, y, z: number } 和 interface Size { w, h: number }
// 计算 keyof (Point | Size)(提示:联合的 keyof 是交集)、
// 以及 type Mixed = Point | Size 后 Mixed["x"] 是否合法,为什么

八、函数返回值的索引访问:ReturnType

8.1 问题:从函数"扣出"返回值类型

function fetchDevices() {
  return [
    { id: "CNC-001", temp: 65 },
    { id: "AGV-002", temp: 42 }
  ];
}

// 想标注"fetchDevices 的返回值类型"——不想手写数组元素类型
type Devices = ReturnType<typeof fetchDevices>;
// { id: string; temp: number }[] ✅

// 又是组合连招:typeof(拿函数类型)→ ReturnType(取返回值类型)
const cached: Devices = fetchDevices();

8.2 ReturnType 的原理拆解(预告泛型+infer)

// ReturnType 的源码(看不懂没关系,感受结构):
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

// 读法:
// 1. T 被约束为函数类型
// 2. T extends (...args) => infer R:模式匹配,R 是返回值位置的"占位符"
// 3. 匹配成功 → 返回 R(返回值类型)
// infer(推断)是第 2 周泛型进阶的主角,今天埋个种子

8.3 相关的内置工具(同一家族)

function createDevice(id: string, temp: number) { return { id, temp }; }

// Parameters:取参数类型 → 元组
type Params = Parameters<typeof createDevice>;   // [id: string, temp: number]
type FirstArg = Params[0];                       // string ⭐ 索引访问元组!

// ReturnType:取返回值类型
type Result = ReturnType<typeof createDevice>;   // { id: string; temp: number }

// Awaited:解 Promise(异步场景刚需)
async function fetchDevicesAsync() {
  return [{ id: "CNC-001", temp: 65 }];
}
type Data = Awaited<ReturnType<typeof fetchDevicesAsync>>;
// { id: string; temp: number }[] ⭐ 把 Promise 剥掉

8.4 工程高频场景:hook/api 函数的类型复用

// 场景:store 的 state 类型不想导出 interface,从函数反解
function useDeviceStore() {
  const devices = ref<DeviceData[]>([]);
  const keyword = ref("");
  function load() { /* ... */ }
  return { devices, keyword, load };
}

// 组件里想标注 store 变量 → 从函数反解(Vue3 项目真实写法)
type DeviceStore = ReturnType<typeof useDeviceStore>;
// { devices: Ref<DeviceData[]>; keyword: Ref<string>; load: () => void }

// API 层同理:接口返回值类型一行反解,接口改了类型自动跟
const res: Awaited<ReturnType<typeof fetchDevicesAsync>> = await fetchDevicesAsync();

8.5 练习

// 练习 1:写函数 parsePoint(s: string): { x: number; y: number }
// 用 ReturnType 反解类型标注变量

// 练习 2:写 async function loadConfig(): Promise<{ url: string; retry: number }>
// 用 Awaited<ReturnType<...>> 取出解包后的类型

// 练习 3:用 Parameters 取 parsePoint 的参数类型,再取第一个参数

九、实战场景全覆盖

9.1 场景一:类型安全的事件总线(keyof 黄金应用)

// ===== 事件名 → 参数类型的映射表(单一真相源) =====
interface EventBusMap {
  "device:update": DeviceData;
  "device:remove": { id: string };
  "alert:trigger": { deviceId: string; temp: number; level: "warn" | "error" };
  "theme:change":  "dark" | "light";
}

// ===== 事件总线:on/emit 的参数全部类型联动 =====
class EventBus {
  private listeners: { [K in keyof EventBusMap]?: Function[] } = {};

  /** 监听事件:事件名必须是映射表的键,回调参数类型自动匹配 */
  on<K extends keyof EventBusMap>(
    event: K,
    fn: (payload: EventBusMap[K]) => void
  ): void {
    (this.listeners[event] ??= []).push(fn);
  }

  /** 触发事件:payload 类型与事件名锁定 */
  emit<K extends keyof EventBusMap>(event: K, payload: EventBusMap[K]): void {
    this.listeners[event]?.forEach(fn => fn(payload));
  }
}

const bus = new EventBus();

bus.on("device:update", d => console.log(d.temp));   // ✅ d 自动是 DeviceData
bus.on("alert:trigger", a => console.log(a.level));  // ✅ a.level: "warn" | "error"
bus.emit("theme:change", "dark");                    // ✅
bus.emit("device:remove", { id: 123 });              // ❌ id 必须是 string
bus.on("device:updates", () => {});                  // ❌ 拼错事件名爆红

这是 keyof + 索引访问的巅峰应用:事件名与载荷类型永久绑定,映射表一处维护。(K extends keyof T 泛型约束下节细讲,今天先感受"键锁类型"的形态。)

9.2 场景二:动态表单渲染器

// 表单字段配置(运行时数据)
interface FormSchema {
  deviceId: { label: string; type: "text"; required: true };
  temp:     { label: string; type: "number"; min: number; max: number };
  protocol: { label: string; type: "select"; options: string[] };
}

// ===== 派生:字段名、字段配置、值对象 =====
type FormField = keyof FormSchema;                      // "deviceId" | "temp" | "protocol"
type FieldCfg<F extends FormField> = FormSchema[F];     // 每个字段各自的配置类型
type FormValues = {
  [K in FormField]: FormSchema[K]["type"] extends "number" ? number : string;
};
// deviceId: string; temp: number; protocol: string
// (条件类型是下周内容,看不懂可先记:值类型由配置派生)

// 渲染器:字段名拼错编译报错
function renderField(field: FormField, cfg: FieldCfg<FormField>): string {
  return `<div>${cfg.label}<input type="${cfg.type}" /></div>`;
}

9.3 场景三:localStorage 类型安全封装

// ===== 存储键 → 值类型的映射 =====
interface StorageMap {
  "theme": "dark" | "light";
  "sidebar": boolean;
  "alertHistory": { id: string; temp: number }[];
}

const PREFIX = "monitor:";

function setItem<K extends keyof StorageMap>(
  key: K,
  value: StorageMap[K]
): void {
  localStorage.setItem(PREFIX + key, JSON.stringify(value));
}

function getItem<K extends keyof StorageMap>(key: K): StorageMap[K] | null {
  const raw = localStorage.getItem(PREFIX + key);
  if (raw === null) return null;
  return JSON.parse(raw) as StorageMap[K];
}

setItem("theme", "dark");        // ✅
setItem("theme", "blue");        // ❌ 值类型锁定
setItem("them", "dark");         // ❌ 键拼错爆红
const theme = getItem("theme");  // "dark" | "light" | null ✅ 精确

9.4 场景四:图表注册表(typeof + keyof + 索引访问全家桶)

// ===== 图表实现(值) =====
const charts = {
  line: { render: (el: HTMLElement) => "折线图", defaultOption: { smooth: true } },
  pie:  { render: (el: HTMLElement) => "饼图",   defaultOption: { radius: 0.6 } },
  bar:  { render: (el: HTMLElement) => "柱状图", defaultOption: { stack: false } }
};

// ===== 类型层全部反解(零手写) =====
type ChartType = keyof typeof charts;                 // "line" | "pie" | "bar"
type ChartImpl = (typeof charts)[ChartType];          // 实现的联合类型
type ChartOption = ChartImpl["defaultOption"];        // { smooth: boolean } | { radius: number } | { stack: boolean }

// ===== 注册函数:新图表自动进联合 =====
function createChart(type: ChartType, el: HTMLElement): string {
  return charts[type].render(el);    // ✅ 键安全 + 返回类型精确
}

// charts 里加 scatter → ChartType 自动含 "scatter",没改任何类型定义 ⭐

9.5 场景五:防抖节流工具的参数透传(Parameters 应用)

// 通用 debounce:保持原函数的参数类型(第 16 周防抖的 TS 完全体)
function debounce<F extends (...args: any[]) => any>(
  fn: F,
  delay: number
): (...args: Parameters<F>) => void {
  let timer: number | undefined;
  return (...args: Parameters<F>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay) as unknown as number;
  };
}

// 使用:参数类型全自动透传
function search(keyword: string, level?: "all" | "warn") { /* ... */ }
const debouncedSearch = debounce(search, 300);

debouncedSearch("CNC", "warn");   // ✅ 参数类型与 search 完全一致
debouncedSearch(123);             // ❌ number 不能给 string

十、类比记忆:图书馆检索三件套

把类型系统想成图书馆,值和类型是两个馆区:

工具

类比

说明

typeof 值

跨馆区的借书证(从实物馆拿到图书卡)

唯一能从"值"到"类型"的官方通道

keyof 类型

馆藏目录页(列出所有书名)

拿到键的完整清单(联合)

类型["键"]

按书名查内容卡

拿到具体某个字段/位置的类型

类型[number]

从书架上任意位置抽一本看

数组 → 元素类型;元组 → 元素联合

keyof typeof 值

从实物直接生成目录页

组合技

ReturnType<typeof fn>

查这本书的"作者卡"

从函数类型里抠出返回值类型

Awaited<...>

拆包裹

把 Promise 的快递盒拆掉拿内容

辨析口诀:值用 typeof,类型用 keyof;取字段用方括号;函数返回 ReturnType。


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

坑点 1:typeof 后面跟了类型 / keyof 后面跟了值

interface Device { temp: number }

// ❌ keyof 值 —— key 是值不是类型
const device = { temp: 65 };
type K = keyof device;          // 编译错误

// ✅ 先 typeof
type K2 = keyof typeof device;  // "temp"

// ❌ typeof 类型 —— 无意义/报错
type T = typeof Device;         // ✅ 合法但拿到的是"类的构造函数"(如果 Device 是类)
// interface 不能 typeof(它是纯类型,值世界不存在)

坑点 2:混淆两个 typeof

const v = 123;

// JS 运行时(if/表达式位置)
if (typeof v === "number") {}    // 运行时判断

// TS 类型(type 定义位置)
type T = typeof v;               // number(类型)

坑点 3:keyof 数组不是"元素类型"

type K = keyof string[];   // 包含 "push" | "map" | ... ⚠

// ✅ 元素类型用索引访问
type E = string[][number]; // string

坑点 4:索引访问用了"值"当键

const key = "temp";
type T = DeviceData[key];          // ❌

// ✅ typeof 值(值类型是字面量时)
type T2 = DeviceData[typeof key];  // number(key 推断为 "temp" 字面量时才行)
// 更稳妥:直接写字面量 type T3 = DeviceData["temp"]

坑点 5:忘了 as const 导致派生退化

const arr = ["a", "b"];              // string[]
type E = (typeof arr)[number];       // string ⚠ 字面量丢了

const arr2 = ["a", "b"] as const;    // readonly ["a", "b"]
type E2 = (typeof arr2)[number];     // "a" | "b" ✅

坑点 6:索引签名的 keyof 是 string

interface Loose { [k: string]: number }
type K = keyof Loose;    // string | number(不是具体键!)

// 想要具体键 → 用精确 interface / as const 对象,别用索引签名

坑点 7:ReturnType 用于重载/泛型函数失真

// 重载函数:ReturnType 只取最后一个签名(一般够用但要知道)
// 泛型函数:ReturnType 拿到的是默认泛型实例化后的类型
// 这两种场景优先手动导出明确类型

最佳实践清单

  1. 衍生类型优先于手写:字段名联合、值类型、部分更新,全部 keyof/索引访问派生

  2. 常量配置配 as const,再用 typeof/keyof 反解——单一真相源

  3. 事件总线 / 存储封装用映射表 + K extends keyof Map(键与类型锁定)

  4. 数组元素类型用 T[number],别用 keyof

  5. 函数返回值用 ReturnType<typeof fn>,异步用 Awaited<ReturnType<...>>

  6. 嵌套类型一层层索引,必要时定义中间类型提升可读性

  7. 索引签名接口的 keyof 是宽的——需要精确键就别用索引签名

  8. 能导出明确 interface 的场景,别过度依赖反解(可读性优先)


十二、自测挑战

Q1:TS 的 typeof 和 JS 的 typeof 有什么区别?如何从代码位置区分?

Q2keyof DeviceData 的结果是什么形态?keyof { [k: string]: number } 呢?

Q3:写出从常量对象 STATUS 提取键联合的完整写法,为什么需要两步?

Q4DeviceData["temp"]device["temp"](值取属性)分别在哪个世界生效?

Q5type E = [string, number][number] 的结果是什么?keyof [string, number] 呢?

Q6DeviceData["id" | "temp"] 的结果如何计算(分配律)?

Q7:如何从函数 fetchDevices 反解返回值类型?异步函数要加什么?

Q8:为什么 const arr = ["a", "b"](typeof arr)[number] 是 string?怎么修复?

Q9:事件总线的 on<K extends keyof EventBusMap>(event: K, fn: (p: EventBusMap[K]) => void) 中,EventBusMap[K] 起到了什么作用?

Q10typeof SomeClassSomeClass(类型位置)分别拿到什么?

Q11:写一个类型安全的 localStorage 封装(键 → 值类型映射),set/get 签名怎么写?

Q12keyof (A | B)(联合类型的 keyof)的结果是什么规律?(提示:交集)


十三、总结与知识图谱

keyof / typeof / 索引访问(类型派生三把钥匙)
│
├── 两个世界
│   ├── 值的世界(运行时:常量、函数、类本身)
│   └── 类型的世界(编译期:interface、type、泛型)
│   └── typeof 是唯一的官方桥(值 → 类型)
│
├── typeof(类型查询)
│   ├── typeof 值 → 值的类型(对象/数组/函数/类构造器)
│   ├── ⚠ 与 JS 运行时 typeof 的位置区分
│   └── 高频:as const + typeof(字面量保留)
│
├── keyof(键提取)
│   ├── keyof 类型 → 键的字面量联合
│   ├── keyof typeof 值 → 常量对象的键联合
│   ├── ⚠ keyof 数组含方法名;索引签名 → string | number
│   └── 高频:getField(d, key: keyof T)、K extends keyof Map
│
├── 索引访问(类型["键"])
│   ├── 类型["字段"] → 字段类型
│   ├── 类型[keyof 类型] → 全字段类型联合(分配律)
│   ├── T[number] → 数组元素 / 元组元素联合
│   ├── 元组[0] / 元组["length"] → 位置类型 / 长度字面量
│   └── 嵌套链式:A["b"]["c"]["d"]
│
├── 函数反解家族
│   ├── Parameters<typeof fn> → 参数元组(可再索引)
│   ├── ReturnType<typeof fn> → 返回值类型
│   └── Awaited<ReturnType<typeof asyncFn>> → 解 Promise
│
├── 组合连招
│   ├── keyof typeof 值(常量 → 键联合)
│   ├── (typeof 值)[number](常量数组 → 字面量联合)
│   ├── K extends keyof T + T[K](键锁类型 ⭐ 事件总线模式)
│   └── 派生链:值 →typeof→ 类型 →keyof→ 键 →索引→ 字段类型
│
└── 工程心法
    ├── 单一真相源:类型能派生就不手写
    ├── as const 是字面量派生的前提
    └── 可读性优先:复杂反解适当定义中间类型

一句话总结:typeof 是从值世界到类型世界的桥,keyof 是键的目录页,索引访问是按目录取内容——三把钥匙组合,让类型从"手写孤岛"变成"自动派生链"。


延伸阅读

资源

说明

TypeScript Handbook - keyof 类型操作符

官方 keyof 文档

TypeScript Handbook - typeof 类型操作符

官方 typeof 文档

TypeScript Handbook - 索引访问类型

官方索引访问文档

TypeScript Handbook - 工具类型

ReturnType/Parameters/Awaited 家族

TypeScript Playground

验证所有派生结果


下一步

本文是 TypeScript 深入 系列的第 6 天。接下来的学习路线:

  • 第 7 天:第一关 BOSS 战 — 综合实战(本周 6 天知识融成一个工业数据处理模块:类型定义 → 守卫校验 → 枚举体系 → 派生类型,完整走一遍)


学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。

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


评论