02 - TS常见案例与技巧
案例一 枚举如何写得更安全
场景
需要定义一组固定状态。 比如方向。 比如权限。
常见写法
enum Direction {
Up,
Down,
Left,
Right
}可能的问题
数字枚举会产生反向映射。 编译后的 JS 对象包含了意料之外的键值对。
// 编译后的方向枚举大概长这样
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
0: "Up", // 反向映射
1: "Down",
2: "Left",
3: "Right"
}这会导致运行时对象更复杂。 同时如果遍历枚举对象的键,容易被这些数字键干扰导致误用。
例如权限判断和路由守卫。 如果状态值不够收敛。 后续可能出现其他字符串。 或者出现未经约束的值。
推荐写法
const Direction = {
// as const 会把 value 固定成字面量类型
// 这里不是宽泛 string
Up: 'UP',
Down: 'DOWN',
Left: 'LEFT',
Right: 'RIGHT'
} as const
// 先取对象类型
// 再取键联合
// 最后用索引访问取 value 联合
type Direction = typeof Direction[keyof typeof Direction]
function move(dir: Direction): void {
// 入参在函数边界就被限制住
// 这里不会再收到无效方向
console.log('move:', dir)
}
move(Direction.Up)
// move('XXX') // 不在联合类型内,会直接报错常量值集合可以被完整推断。 同时避免数字枚举的反向映射边界。
案例二 条件类型的分配行为
场景
要从联合类型里筛出一部分成员。 比如筛出字符串。
推荐写法
type ExtractString<T> = T extends string ? T : never
// TS 会把联合类型拆开计算
// string 命中条件
// number 不命中条件
// boolean 不命中条件
type A = ExtractString<string | number | boolean>
// string常见误区
可能以为这只是一次判断。 其实会分配。 联合类型每个成员都会单独计算。
这个误区的根源在于。 类型层判断和运行时判断不是一回事。 运行时只走一次 if。 类型层可能会拆成员逐个算。
关闭分配的方法
type IsOnlyString<T> = [T] extends [string] ? true : false
// 放进元组后
// TS 不再拆开联合成员
// 直接整体判断
type B = IsOnlyString<string | number>
// false
// 这里通过方括号关闭分配行为,判断的是整体而不是逐项在实际使用里可以这样记。
- 需要筛选成员时,使用分配行为。
- 需要整体判断时,用方括号包住类型参数。
这个技巧常用于工具类型封装。 例如判断一个联合类型是否整体满足某个约束。 如果不关掉分配行为。 最后结果往往和预期不一致。
案例三 unknown any undefined 的取舍
场景
接口返回结构不稳定。 或者 JSON.parse 后类型未知。
常见写法
let raw: any = '{"name":"kuro"}'
raw.notExist.call()可能风险: 编译器失效。 运行时容易炸。
更隐蔽的问题是, any 会沿着调用链继续扩散。 一旦核心请求层返回 any。 下游很多模块都会失去静态保护。
推荐写法
function parse(json: string): unknown {
// parse 结果先标记为 unknown
// 强制调用方先做判断再使用
return JSON.parse(json)
}
const value = parse('{"name":"kuro"}')
if (
// 第一步 过滤 null
// 第二步 过滤原始值
typeof value === 'object' &&
value !== null &&
// 第三步 确认键存在
'name' in value
) {
// 进入这个分支后,才可以继续做更细的类型断言
// 在这个分支里
// TS 已经把 value 缩到 object
const name = (value as { name: string }).name
console.log(name)
}这三个类型的定位可以这样区分。
any。 放弃检查。unknown。 先收窄再使用。undefined。 表示缺失值。 常与联合类型配合。
在接口不稳定阶段。 这个策略会比直接 as 更稳。 因为它把不确定性显式写进了代码流程。
案例四 never 做穷尽检查
场景
前端状态机。 请求状态。 支付状态。 后续常会加新分支。
这类代码最容易出现的线上问题是。 新增状态后。 某个页面没有同步处理分支。 结果显示空白。 或者逻辑走默认值。
推荐写法
type LoadState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: string[] }
| { kind: 'error'; message: string }
function assertNever(x: never): never {
// 这个函数不会正常返回
// 只用于兜底报错
throw new Error(`Unexpected state: ${JSON.stringify(x)}`)
}
function render(state: LoadState): string {
switch (state.kind) {
case 'idle':
return '等待加载'
case 'loading':
return '加载中'
case 'success':
return state.data.join(',')
case 'error':
return state.message
default:
// 如果上面的分支漏了
// state 就不会是 never
// 编译器会在这里提示
return assertNever(state)
}
}随着状态分支增加。 未覆盖的分支会在编译阶段被立即发现。
这个模式非常适合。 订单状态。 支付状态。 任务状态。 任何需要强一致分支覆盖的场景。
案例五 typeof 和 keyof 联合使用
场景
想复用对象结构。 不想重复声明类型。
当对象字段比较多时。 手写一份类型很容易和实际对象脱节。 尤其是在字段频繁调整阶段。
推荐写法
const kuroUser = {
id: 1,
name: 'kuro',
active: true
}
// 这里拿到的是值的静态类型
type User = typeof kuroUser
// 这里拿到的是键的联合类型
type UserKey = keyof User
function pick(user: User, key: UserKey) {
// key 被限制在 'id' | 'name' | 'active'
// user[key] 会自动推断成对应值的联合
return user[key]
}typeof用来提取值的类型。keyof用来提取类型的键。- 组合后可减少重复类型声明。
这个方案的好处是。 类型和数据结构共用一份来源。 修改对象结构后。 类型会同步变化。
案例六 工具类型
场景
同一实体在多个场景使用。 创建。 编辑。 列表。
同一个实体在不同接口里。 通常只使用部分字段。 如果每次都新建接口。 重复代码会很快失控。
推荐写法
interface KuroUser {
id: number
name: string
age: number
email: string
}
// 创建接口不需要 id
// 用 Omit 去掉系统字段
type CreateUserDTO = Omit<KuroUser, 'id'>
// 更新接口通常允许部分字段修改
type UpdateUserDTO = Partial<CreateUserDTO>
// 列表页通常只展示轻量字段
type UserPreview = Pick<KuroUser, 'id' | 'name'>
// 只读视图避免误改
type ReadonlyUser = Readonly<KuroUser>这类组合方式可以稳定降低重复代码。 实体变更时,派生类型也能同步收敛。
如果团队开始做 API 分层。 可以把这组工具类型做成约定。 例如 DTO 层统一使用 Pick Omit Partial。
案例七 接口扩展与声明合并
场景
给第三方模块补充类型。 但不改第三方源码。
这个场景在项目升级时很常见。 本地临时改 node_modules 不可持续。 一旦重装依赖就会丢。
推荐写法
// kuro-module-augmentation.ts
import 'kuro-module'
declare module 'kuro-module' {
// 这里是类型层补充
// 不会改第三方源码
interface Person {
level: number
}
}- 这是类型层增强。
- 运行时对象要确认真的有该字段。
换句话说。 类型扩展解决的是开发体验和静态检查。 运行时实现仍然要有对应逻辑支撑。
案例八 Vue3 中 Props 与 Emit 的类型约束
场景
组件协作时。 参数和事件最容易写错。
组件协作里最常见的问题是。 父组件传错字段。 子组件 emit 参数类型漂移。 这些问题如果只靠约定。 后期排查成本很高。
推荐写法
<script lang="ts" setup>
import { ref } from 'vue'
interface Props {
title: string
initialCount?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update', count: number): void
}>()
// count 的类型是 Ref<number>
// 和 emit 参数约束保持一致
const count = ref<number>(props.initialCount ?? 0)
function increment(): void {
count.value += 1
// 事件名和参数都受到 TS 约束
emit('update', count.value)
}
</script>在组件协作里。 事件契约会更清楚。 参数错误也会在编码阶段直接提示。
这类约束对多人协作特别有用。 因为它把约定变成了编译器可检查规则。
案例九 不可变数据的类型护栏
场景
状态管理里误改对象。 导致追踪困难。
状态管理里一旦出现隐式修改。 问题常常不是当场爆发。 而是在后续某个页面出现连锁异常。
推荐写法
type DeepReadonly<T> = {
// 映射每一个键
// 并给每个键加 readonly
readonly [K in keyof T]: T[K] extends object
// 如果当前字段还是对象
// 继续递归
? DeepReadonly<T[K]>
// 如果是原始值
// 直接返回
: T[K]
}
interface Profile {
name: string
meta: {
age: number
}
}
const profile: DeepReadonly<Profile> = {
name: 'kuro',
meta: { age: 12 }
}
// profile.meta.age = 20JS 层也可配合 Object.freeze。 类型与运行时双保险。
这类模式常用于。 缓存快照。 配置对象。 只读上下文。
