Skip to content

02 - TS常见案例与技巧

案例一 枚举如何写得更安全

场景

需要定义一组固定状态。 比如方向。 比如权限。

常见写法

ts
enum Direction {
	Up,
	Down,
	Left,
	Right
}

可能的问题

数字枚举会产生反向映射。 编译后的 JS 对象包含了意料之外的键值对。

typescript
// 编译后的方向枚举大概长这样
const Direction = {
    Up: 0,
    Down: 1,
    Left: 2,
    Right: 3,
    0: "Up", // 反向映射
    1: "Down",
    2: "Left",
    3: "Right"
}

这会导致运行时对象更复杂。 同时如果遍历枚举对象的键,容易被这些数字键干扰导致误用。

例如权限判断和路由守卫。 如果状态值不够收敛。 后续可能出现其他字符串。 或者出现未经约束的值。

推荐写法

ts
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') // 不在联合类型内,会直接报错

常量值集合可以被完整推断。 同时避免数字枚举的反向映射边界。


案例二 条件类型的分配行为

场景

要从联合类型里筛出一部分成员。 比如筛出字符串。

推荐写法

ts
type ExtractString<T> = T extends string ? T : never

// TS 会把联合类型拆开计算
// string 命中条件
// number 不命中条件
// boolean 不命中条件
type A = ExtractString<string | number | boolean>
// string

常见误区

可能以为这只是一次判断。 其实会分配。 联合类型每个成员都会单独计算。

这个误区的根源在于。 类型层判断和运行时判断不是一回事。 运行时只走一次 if。 类型层可能会拆成员逐个算。

关闭分配的方法

ts
type IsOnlyString<T> = [T] extends [string] ? true : false

// 放进元组后
// TS 不再拆开联合成员
// 直接整体判断
type B = IsOnlyString<string | number>
// false
// 这里通过方括号关闭分配行为,判断的是整体而不是逐项

在实际使用里可以这样记。

  • 需要筛选成员时,使用分配行为。
  • 需要整体判断时,用方括号包住类型参数。

这个技巧常用于工具类型封装。 例如判断一个联合类型是否整体满足某个约束。 如果不关掉分配行为。 最后结果往往和预期不一致。


案例三 unknown any undefined 的取舍

场景

接口返回结构不稳定。 或者 JSON.parse 后类型未知。

常见写法

ts
let raw: any = '{"name":"kuro"}'
raw.notExist.call()

可能风险: 编译器失效。 运行时容易炸。

更隐蔽的问题是, any 会沿着调用链继续扩散。 一旦核心请求层返回 any。 下游很多模块都会失去静态保护。

推荐写法

ts
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 做穷尽检查

场景

前端状态机。 请求状态。 支付状态。 后续常会加新分支。

这类代码最容易出现的线上问题是。 新增状态后。 某个页面没有同步处理分支。 结果显示空白。 或者逻辑走默认值。

推荐写法

ts
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 联合使用

场景

想复用对象结构。 不想重复声明类型。

当对象字段比较多时。 手写一份类型很容易和实际对象脱节。 尤其是在字段频繁调整阶段。

推荐写法

ts
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 用来提取类型的键。
  • 组合后可减少重复类型声明。

这个方案的好处是。 类型和数据结构共用一份来源。 修改对象结构后。 类型会同步变化。


案例六 工具类型

场景

同一实体在多个场景使用。 创建。 编辑。 列表。

同一个实体在不同接口里。 通常只使用部分字段。 如果每次都新建接口。 重复代码会很快失控。

推荐写法

ts
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 不可持续。 一旦重装依赖就会丢。

推荐写法

ts
// kuro-module-augmentation.ts
import 'kuro-module'

declare module 'kuro-module' {
	// 这里是类型层补充
	// 不会改第三方源码
	interface Person {
		level: number
	}
}
  • 这是类型层增强。
  • 运行时对象要确认真的有该字段。

换句话说。 类型扩展解决的是开发体验和静态检查。 运行时实现仍然要有对应逻辑支撑。


案例八 Vue3 中 Props 与 Emit 的类型约束

场景

组件协作时。 参数和事件最容易写错。

组件协作里最常见的问题是。 父组件传错字段。 子组件 emit 参数类型漂移。 这些问题如果只靠约定。 后期排查成本很高。

推荐写法

vue
<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>

在组件协作里。 事件契约会更清楚。 参数错误也会在编码阶段直接提示。

这类约束对多人协作特别有用。 因为它把约定变成了编译器可检查规则。


案例九 不可变数据的类型护栏

场景

状态管理里误改对象。 导致追踪困难。

状态管理里一旦出现隐式修改。 问题常常不是当场爆发。 而是在后续某个页面出现连锁异常。

推荐写法

ts
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 = 20

JS 层也可配合 Object.freeze。 类型与运行时双保险。

这类模式常用于。 缓存快照。 配置对象。 只读上下文。


<< 返回首页