跳到正文

我所理解的 TypeScript 最佳实践(3):状态、映射与泛型

我所理解的 TypeScript 最佳实践

  1. 为什么选择 TypeScript
  2. 严格模式
  3. 状态、映射与泛型(当前)
  4. 模块边界与 TypeScript 脚本
  5. 类型检查、Lint 与测试
  6. TypeScript 7 升级与工具链兼容

请求状态、状态对应的按钮行为,以及组件回传的选中项,是三种不同的约束。先判断要表达的是值域、完整映射还是入参与出参的对应关系,再选类型写法。

先决定值会在哪里使用

固定、封闭且由项目掌控的状态,我喜欢给成员一个名字。需要在运行时生成选项或校验输入,就用普通字符串 enum 或 as const 对象。只在应用内部比较成员,且已经确认转译链路支持时,可以用 const enum。内部数字枚举从 0 开始时,0 是合法状态,空态要和 null 或 undefined 明确区分。协议、持久化、日志或样式选择器使用的值则应有稳定、可识别的表示。

有些公开 API 适合让调用方直接传字符串。下面的弹窗内部可以用 DialogMode.Prompt 判断,调用方仍然能写 mode="prompt"。如果双方都在同一条 TypeScript 业务链路上,也可以直接传枚举成员,省去额外的值类型。

const enum DialogMode {
  Alert = 'alert',
  Confirm = 'confirm',
  Prompt = 'prompt',
}

type DialogModeValue = `${DialogMode}`;

interface DialogProps {
  mode: DialogModeValue;
}

const dialog: DialogProps = { mode: 'prompt' };

const enum 的产物取决于编译方式。项目如果依赖内联来控制产物大小,要检查实际构建结果。发布 npm 包时尤其要避开向消费者暴露 ambient const enum,消费者可能在编译时内联一个版本的值,运行时却使用另一版本。Vue 的 <script setup> 可以把顶层绑定直接用于模板,能否引用枚举成员也应通过实际的模板类型检查和构建确认。公开 API 和模板用法的完整例子见用 const enum 和字符串值类型保留公开 API 的自然写法。语言和发布边界见 TypeScript Handbook 的 const enum。

映射表声明它是否必须完整

审核接口有待审核、已通过、已拒绝三种状态。用字符串 enum 给成员命名,再把文案放进 Record。状态增加时,satisfies 会检查文案表有没有补齐,同时保留表里每条文案的字面量类型。

enum ReviewStatus {
  Pending = 'pending',
  Approved = 'approved',
  Rejected = 'rejected',
}

const reviewLabels = {
  [ReviewStatus.Approved]: '已通过',
  [ReviewStatus.Pending]: '待审核',
  [ReviewStatus.Rejected]: '已拒绝',
} as const satisfies Record<ReviewStatus, string>;

function getReviewLabel(rawStatus: string) {
  const status = Object.values(ReviewStatus).find((value) => value === rawStatus);
  return status === undefined ? '未知' : reviewLabels[status];
}

普通 enum 有运行时值,因此可以先用成员值校验接口字符串。'approved' 匹配后取到「已通过」,新版接口多出的 'archived' 暂时显示「未知」。在已校验的内部逻辑里,可以直接用 reviewLabels[status]。若内部只比较成员,且编译链已经验证支持 const enum,也可以使用 const enum,外部输入的校验需要另外准备可在运行时读取的数据。TypeScript Handbook 的枚举运行时行为 · satisfies 运算符

映射允许缺项时,可以用 Partial<Record<...>> 并明确缺省值。每个状态需要执行多步操作时,可以用 switch,或者在表里存函数并只调用命中的一项。多个检查条件可以同时成立,而且有明确优先级时,按优先级写卫语句,让返回和副作用发生的位置一目了然。

状态选项按显式数组排列

筛选器要按「待审核、已通过、已拒绝」展示,顺序由产品决定。把它写成数组,文案表仍可按键组织。生成选项时逐项取文案。

const reviewStatusOrder = [
  ReviewStatus.Pending,
  ReviewStatus.Approved,
  ReviewStatus.Rejected,
] as const satisfies readonly ReviewStatus[];

const reviewOptions = reviewStatusOrder.map((value) => ({ label: reviewLabels[value], value }));

这里 satisfies readonly ReviewStatus[] 检查数组里的值是否合法,Record 检查文案是否覆盖全部状态。选项数组可以有意只列出允许筛选的状态。对象属性的排列可以按项目约定整理,调整文案表的字段顺序不会影响选项。参见《让对象字段保持有序》。

泛型保存调用点之间的关系

通用选择组件接收一组对象,选中后把同一种对象交还给父组件,这时泛型有用。父组件传的是 Player[],selected 和 select 事件里的值都应该是 Player。如果选手和战队都只需要展示一个标题,且组件不会传回原对象,用一个简单的展示接口通常已经足够。

<script setup lang="ts" generic="T extends { id: string }">
defineProps<{ items: T[]; selected: T | null }>();
const emit = defineEmits<{ select: [item: T] }>();
</script>

<template>
  <button v-for="item in items" :key="item.id" :aria-pressed="selected?.id === item.id" @click="emit('select', item)">
    {{ item.id }}
  </button>
</template>

组件再包一层时,外层也要保留同一个类型参数,否则这条关系会在包装处变宽。模板确实推断不出时可以用 @vue-generic 指定类型。泛型组件实例的 ref 可以用 vue-component-type-helpers 的 ComponentExposed 来取暴露类型。完整例子见 Vue 的 script setup 泛型组件到底解决了什么,语法见 Vue 的泛型组件文档。

让实现保留业务原有的顺序

假如列表里的候选项原来按 A、B 排列,用户依次选中 B、A,页面也要按 B、A 展示。写成 players.filter(...) 会跟着 players 原来的顺序走。这个例子里,顺序属于选择记录,代码应该从 selectedIds 出发。

const playerById = new Map(players.map((player) => [player.id, player]));
const selectedPlayers = selectedIds.map((id) => playerById.get(id)).filter((player) => player !== undefined);

这里假设 players 是候选列表,selectedIds 是按用户选择顺序保存的 ID。被删除或失效的 ID 可以从展示列表中略过。如果它需要显示成「已失效」,就显式保留一个缺项状态。收窄时检查具体的缺失值,尤其是数组里可能有合法的 0、false 或空字符串时。TypeScript 5.5 起能推断一部分简单的类型谓词,旧版本或复杂条件可以写成具名的类型谓词。参考 推断类型谓词。

单一返回模型通常用一个对象字面量表达,读者按字段就能找到计算来源。条件可能导致提前失败,或者几种返回结果代表不同业务分支时,就保留多个 return,必要时用带 kind 的联合类型。异步流程默认用 async 和 await 表达顺序。无依赖的元素转换可以并行,采集限速、顺序写入和依赖上一项结果的任务则保留串行循环。

局部函数的返回类型通常交给推断,公开库 API、递归或框架需要固定签名时再显式标注。名字用动作说明调用的效果。parseContract 表示它会读取并校验输入,getBadgeColorByStatus 表示按状态派生结果。statusText 更适合一个值或 computed。中间变量和常量在解释业务语义、复用值或承载生命周期时有用,单次使用的简单表达式留在消费处就够了。