问题是什么?
TS 的进阶部分——类型操作,到底哪些部分是在业务开发中用得上的技巧?我们来列举实际问题来看看。
类型变换
「枚举」变成「联合」
当我们制作组件的时候,为了避免重复,一些字符类型的变量,用枚举来创建是十分合适的。比如一个日期组件里定义星期一到三:
enum Weekday {
MON = 'monday',
TUE = 'tuesday',
WED = 'wednesday'
}
这样无论在渲染还是计算的时候,我们都能用 Weekday.MON 来避免重复和写错单词。但是在使用组件的时候,导出的属性却不能正确地提示类型:
interface DayProps{
name: Weekday
}
<Day name='monday'/> // ⚠️ String 'monday' cannot be used to enum type Weekday
此时,除了从组件库导出 Weekday 的方式之外,还能通过创建“字符串字面量联合类型(String literal union type from enum)”的方式解决:
interface DayProps{
name: `${Weekday}`
}
<Day name='monday'/> // Day.name: ('monday'|'tuesday'|'wednesday')
「对象」变成「联合」
静态集合的最佳选择,但很多时候,我们需要动态集合。 比如我们需要多个配置,且它们又能合成另一个配置的时候,就只能用对象了。比如
const WORKDAYS = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5
}
const weekends = {
Sat: 6,
Sun: 7
}
const weekdays = { ...WORKDAYS, ...weekends }
然后要正确地提示到“星期几”的值,可以先用 keyof 封装一个 valueOf<T>,方便我们的操作。
type valueOf<T> = T[keyof T];
type Weekday = valueOf<typeof weekdays> // Weekday: string
从上面可以看到,Weekday 只解析成了 string,并不是我们期待的,精确的取值范围 1|2|3...|7。原来是 TS 只能对 readonly 的类型或者数据进行精确解析,所以我们需要定义变量的时候,声明它们是只读的类型。
const WORKDAYS = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5
} as const // 转变为“只读”类型,让其值可以被正确地解析
const WEEKENDS = {
Sat: 6,
Sun: 7
} as const
const weekdays = { ...WORKDAYS, ...WEEKENDS }
type Weekday = valueOf<typeof weekdays> // Weekday: (1|2|3|4|5|6|7)
「数组」变成「联合」
原理同上,不再赘述。
const WORKDAYS = [1, 2, 3, 4, 5] as const
const WEEKENDS = [6, 7] as const
const weekdays = [...WORKDAYS, ...WEEKENDS] // 若仅此处使用 as const,则无效
type Weekday = typeof weekdays[number] // 1 | 2 | 3 | 4 | 5 | 6 | 7
「数组」变成「interface」
当我们创建一个,各种元素并不怎么相关,仅仅只是用途相同的集合的时候,会用到字符串数组。比如 icon 图片的数组
const icons = ['banana.png', 'avata.svg', 'water.jpg']
// 由于图片自带名字,所以直接来生成对象使用
const iconCollection = icons.reduce((acc, path) => {
const name = path.split('.')[0]
return Object.assign(acc, { [name]: path })
}, Object())
/*{
banana: "banana.png",
avata: "avata.svg",
water: "water.jpg"
}*/
然后我们想正确地提示 iconCollection 的类型,是否可以用上面 as const 的技巧,把它转换成只读类型呢?
这是不行的!因为它是动态地生成的对象,无法被静态地解析。要解析,只能是对静态的 icons 数组下手,把它转换成 interface。
const icons = ['banana.png', 'avata.svg', 'water.jpg'] as const // Trans to readonly
type SplitName<T> = T extends `${infer P}.${string}` ? P : never; // 利用类型推导(infer),得到文件名 P
type IconCollection = Record<SplitFileName<(typeof icons)[number]>, string> // IconCollection: {banana: string, avata: string, water: string}
函数的精确类型提示
重载
一个好的函数,最好就是单一职责,且一种输入,对应一种输出。但有时候确实会有,一个功能处理不同数据类型的情况,比如以下这个函数
/**
* 改变参数类型,数字转字符串,字符串则转数字
* @param x
*/
function changeType(x: string|number): number|string {
return typeof x === 'string' ? Number(x) : String(x)
}
这个类型声明虽然没有错误,但并不能得到精准的提示。我们希望类型提示,与函数描述完全一致,这时候重载就上场了。
function changeType(x: number): string;
function changeType(x: string): number;
/**
* 改变参数类型,数字转字符串,字符串则转数字
* @param x
*/
function changeType(x) {
return typeof x === 'string' ? Number(x) : String(x)
}
changeType(123) // function changeType( x: number): string
changeType('456') // function changeType( x: string): number
类型分发
继续沿用上面的例子,我们可以用类型分发(distribution)来根据参数类型,推导输出类型。
/**
* 改变参数类型,数字转字符串,字符串则转数字
* @param x
*/
function changeType<T>(x: T): T extends number ? string : T extends string ? number : never {
return typeof x === 'string' ? Number(x) : String(x)
}
解答一些疑问
- Q:有的人对对象类型,都用 type 进行定义,而不是 interface,理由是更简洁。这样做对吗?
- A:不对。不论是定义上、功能上、性能上,都应该用 interface 来定义对象类型。
- interface 是 “shape that values have”,也叫“duck typing”。type 叫做“别名”。 也就是说,type 只是在 interface 的基础上增加了别名,观感上少打几个字,实际上画蛇添足。
- interface 功能接近对象,可以合并、继承,语义化更好。
- interface 具有 flat 的特性(ref: Performance Doc),使得它们的关系可以被 cached,不同于 type 进行交集(如 A&B)处理时候会重复运算。
- Q:有人用 Object 或者 object 来定义一般对象(如 const fruits: object = {})正确吗?
- A:错误的。在 TypeScript 的 Type Hierarchy 中,它们的定位都不是单纯的“对象类型”。
- object 是指非原始类型,所以它还能指代 Array, Function 等。
- Object 是一个全局构造函数,它可以指代任何类型的值。
- 建议方案:使用 Record<string, any>,或者定义一个全局类型 type Obj = Record<PropertyKey, any>。
- Q:我想定义一个字符串集合“大、小、其他”,如何定义?
- A:需要进行品牌化(branding)处理。
- type Size = 'mini' | 'large' | (string & {}); // 'mini' | 'large' | string
- 不能定义为 'mini' | 'large' | string,这样会让提示省略掉前面的预定义,最终得到的仅有 string。
最后
我发现的业务项目中常见的 TS 进阶用法就以上这些,大家还有什么补充的呢?欢迎评论。