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 const,typeof LEVELS 是 string[],[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 值 ────────┘(一步到位拿键)
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 最著名的黄金组合——今天先把 keyof 和 T[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;取字段用方括号;函数返回 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 拿到的是默认泛型实例化后的类型
// 这两种场景优先手动导出明确类型
最佳实践清单
衍生类型优先于手写:字段名联合、值类型、部分更新,全部 keyof/索引访问派生
常量配置配 as const,再用 typeof/keyof 反解——单一真相源
事件总线 / 存储封装用映射表 +
K extends keyof Map(键与类型锁定)数组元素类型用
T[number],别用 keyof函数返回值用
ReturnType<typeof fn>,异步用Awaited<ReturnType<...>>嵌套类型一层层索引,必要时定义中间类型提升可读性
索引签名接口的 keyof 是宽的——需要精确键就别用索引签名
能导出明确 interface 的场景,别过度依赖反解(可读性优先)
十二、自测挑战
Q1:TS 的 typeof 和 JS 的 typeof 有什么区别?如何从代码位置区分?
Q2:keyof DeviceData 的结果是什么形态?keyof { [k: string]: number } 呢?
Q3:写出从常量对象 STATUS 提取键联合的完整写法,为什么需要两步?
Q4:DeviceData["temp"] 和 device["temp"](值取属性)分别在哪个世界生效?
Q5:type E = [string, number][number] 的结果是什么?keyof [string, number] 呢?
Q6:DeviceData["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] 起到了什么作用?
Q10:typeof SomeClass 和 SomeClass(类型位置)分别拿到什么?
Q11:写一个类型安全的 localStorage 封装(键 → 值类型映射),set/get 签名怎么写?
Q12:keyof (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 深入 系列的第 6 天。接下来的学习路线:
第 7 天:第一关 BOSS 战 — 综合实战(本周 6 天知识融成一个工业数据处理模块:类型定义 → 守卫校验 → 枚举体系 → 派生类型,完整走一遍)
学编程就像蜗牛往上爬,慢一点没关系,关键是不停下来。
每天花 2 小时,28 天通关 TypeScript 深入。加油!