02 typeof、keyof 与索引访问:从配置派生类型

核心目标:既保存一份运行时可用的配置,又让类型跟随这份配置变化。

1. 同一个 typeof,两个使用位置

const statuses = ["pending", "active", "banned"] as const;

console.log(typeof statuses); // JavaScript 表达式:运行时输出 "object"
type Statuses = typeof statuses; // TS 类型查询:得到这个变量的静态类型

第一句计算值;第二句定义类型,编译后消失。判断关键不是拼写,而是它位于“值表达式”还是“类型位置”。

JS 的 typeof 不会输出 TS 联合类型;TS 的 typeof 也不会在运行时检查数组。

TS 类型查询通常引用变量或属性,而不是任意调用表达式。需要函数返回类型时用 ReturnType,见 09 章。

2. 逐步拆开你的写法

const statuses = ["pending", "active", "banned"] as const;
type Status = (typeof statuses)[number];

第一步:从值拿到类型。

type Statuses = typeof statuses;
// readonly ["pending", "active", "banned"]

第二步:通过数字索引查询元素类型。

type FirstStatus = Statuses[0]; // "pending"
type SecondStatus = Statuses[1]; // "active"
type AnyStatus = Statuses[number]; // "pending" | "active" | "banned"

这里 number 是类型,不是名为 number 的变量,也不是“取第 number 项”。它表示所有数字索引可能对应的元素类型。

合起来就是:

typeof statuses:数组/元组类型
            ↓
[number]:取其所有元素类型
            ↓
"pending" | "active" | "banned"

这个定义里没有执行数组遍历。联合类型是编译期表达,运行时仍需要 statuses 才能做 includes 等校验。

3. 不使用 as const 会怎样?

const statuses = ["pending", "active", "banned"];
type Status = (typeof statuses)[number]; // string

typeof 拿到的是 string[],元素类型自然是 string。[number] 不能恢复已经被扩大丢掉的具体值信息。

这也是“先构造精确配置,再派生类型”比事后大量断言更清楚的原因。

4. keyof:拿到属性名,不是属性值

interface CustomerSummary {
  customer_id: number;
  first_name: string;
  active: boolean;
}

type CustomerKey = keyof CustomerSummary;
// "customer_id" | "first_name" | "active"

type CustomerId = CustomerSummary["customer_id"]; // number
type NameOrActive = CustomerSummary["first_name" | "active"];
// string | boolean

keyof 是类型操作符,不能写 console.log(keyof CustomerSummary)。interface 也不能直接放进 Object.keys,它不是运行时对象。

带索引签名的类型,其 keyof 可能是 string/number 等宽范围;不要认为所有 keyof 都是一串有限字符串。

5. keyof typeof 配置对象

const sort_columns = {
  id: "customer_id",
  name: "last_name",
  created: "create_date",
} as const;

type SortKey = keyof typeof sort_columns;
// "id" | "name" | "created"

type SortColumn = (typeof sort_columns)[SortKey];
// "customer_id" | "last_name" | "create_date"

客户传的是 API 排序选项 id/name/created,内部映射成数据库列名;可以不把数据库列名直接暴露为请求契约。

function getSortColumn(key: SortKey): SortColumn {
  return sort_columns[key];
}

这只是新的教学映射,不要求修改当前项目使用 customer_id/last_name/create_date 的排序接口。

6. 动态字符串不能随意索引固定对象

function getSortColumn(input: string) {
  return sort_columns[input]; // 错误:任意 string 不保证是合法键
}

不要马上把对象写成 Record<string, string>;这会扩大键范围,反而丢掉固定配置的限制。

在输入边界做真实检查:

function isSortKey(value: unknown): value is SortKey {
  return typeof value === "string"
    && Object.prototype.hasOwnProperty.call(sort_columns, value);
}

function getSortColumnFromInput(input: unknown): SortColumn {
  if (!isSortKey(input)) throw new Error("不支持的排序选项");
  return sort_columns[input];
}

这个类型守卫依赖 sort_columns 是由程序控制、键集合稳定的对象。运行时校验与类型收窄分别完成自己的工作。

如果用 input in sort_columns,还要知道 in 会查看原型链;固定白名单检查自身键更明确。

7. 为什么不能从“动态数据”推导固定联合?

const statuses_from_db: string[] = ["active", "pending"];
type Status = (typeof statuses_from_db)[number]; // string

TS 检查时数据库还没返回数据。编译期固定配置能推导字面量联合,运行时获取的任意字符串数组通常不能。

也不能对任意变量 statuses_from_db as const,让 TS 把未来的数据库内容变成固定元组。as const 有适用表达式限制,且没有读取数据库的能力。

动态数据要用运行时验证、业务定义或代码生成流程,不是把断言写得更复杂。

8. 这能减少哪些重复?

export const account_statuses = ["pending", "active", "banned"] as const;
export type AccountStatus = (typeof account_statuses)[number];

同一份固定值集合可用于类型、验证白名单、状态选项。但前端和后端是否真的共享该模块,还取决于项目打包与契约设计,不会因为 export 就自动同步部署。

反过来,若只有一个类型别名 type AccountStatus = ...,运行时没法直接枚举它。应根据“是否需要一份值配置”决定方向,不是一切都必须数组驱动。

9. 索引访问类型与真正访问值的区别

type Element = (typeof account_statuses)[number]; // 类型定义
const first = account_statuses[0]; // 运行时取值

T[number] 查询元素类型,不表示“任意下标一定存在”。普通数组运行时越界仍会得到 undefined;项目开启的 noUncheckedIndexedAccess 会在实际元素访问中提醒这一点。

不要把“元素类型不含 undefined”误读成“任意索引都不会越界”。

10. 练习

  1. 从允许的排序列数组派生 SortColumn,不再手写联合。
  2. 从状态到中文文案的对象派生键类型,解释键联合与值联合。
  3. 去掉 as const,观察派生的值类型是否扩大。
  4. 写一个接收 unknown 的排序检查函数,拒绝不支持的字符串。

下一章:标注、as 与 satisfies。参考:Typeof、Indexed Access。