【第2242期】TypeScript类型编写指南之下篇
前言
今日前端早读课文章由百度@沈毅分享,由@羡辙授权分享。
恭喜读者@hhh获得尾宿奖《前端函数式演进》签名版一本,加情封vx:zhgb_f2er领取。
正文从这开始~~
类型编写建议
接下来会列一些比较常见的建议,作为手册帮助大家在具体代码中决定如何写类型。
尽量避免使用any
关于为什么要避免使用any在前面有大致介绍,我们应该尽量将any的使用限制在非常局部的代码中。
对于一个通用的类,比如一个容器类,也可能存在类型不明确的时候,其中存的数据可能是任何类型,这个时候应该优先考虑泛型,如果泛型无法解决则将类型声明为unknown。
为什么使用unkown而非any
unkown跟any一样,是 TypeScript 最顶层的类型,可以被转换成任意类型,但是unknown跟any的区别是unknown类型的变量不能被使用,举个例子:
declare const foo: unknown;
const baz = foo.bar; // 报错,无法被访问
这个特性也保证了unknown不会具有传染特性,在使用前必须通过as类型断言成具体的类型
允许有节制的使用any的场景
下面是两个可以使用any的场景,在其它情况下,我们应该避免使用any。
1、一些非常通用的变量类型判断方法参数类型可以为any
比如isObject, isArray等变量判断方法入参可能是任何类型的值,这个时候参数可以是any
// 这里返回值的类型断言可以用来用于类型收窄
declare function isArray(data: any): value is any[]
但是要注意的是,有些方法入参也可能是任何类型的值,但是返回值的类型是根据入参类型推导的,比如clone,这种方法应该使用泛型让返回类型得到正确的推导。
// Bad
declare function clone(data: any): any
// Good
declare function clone<T>(data: T): T
2、在必须的时候可以使用as any临时转类型
在极少数情况下我们可能也会碰到解决不了的类型报错,比如在 ECharts 中经常会有类似下面这样的代码。
interface Style {
color: string
borderWidth: number
}
function copyStyle(sourceStyle: Style, targetStyle: Style, keys: (keyof Style)[]) {
keys.forEach(key => {
// 这里 TypeScript 检查会报 Type 'string | number' is not assignable to type 'never'
// 这个是因为 TypeScript 这里判断 key 可能是 'color' 和 'borderWidth',赋值的类型也判断了可能是不兼容
sourceStyle[key] = targetStyle[key];
// 因为我们确定key肯定是sourceStyle的属性,而且赋值的类型也是相同的
// 因此可以临时将 sourceStyle 转为 as any
(sourceStyle as any)[key] = targetStyle[key];
});
}
当然转的前提是这部分代码的类型是非常确定的,不太会出问题的,any也不会污染到其它代码。绝不能碰到类型错误就盲目使用as any。像下面这样使用as any是绝对不行的。
// 这里 color 也会被推导成 any
const color = (style as any).color;
同时,为了保证使用as any部分代码的类型的可确定性,建议将使用了as any的代码像上面copyStyle封装到一个简短的辅助函数中,函数提供更加精确的类型。
有更好的方案的来取代any的场景
1、容器类使用泛型
对于一个像Map, LRUCache这样的容器类,其中存的值可能是任意类型,这个时候建议使用泛型定义来定义值类型。
// Bad
class LRUCache {
add(key: string, value: any) {}
get(key: string): any {}
}
// Good
class LRUCache<T> {
add(key: string, value: T) {}
get(key: string): T {}
}
2、在对象上可以挂任意属性
对 Vue 比较熟悉的同学可能知道 Vue 的对象下可以挂载任意对象。在我们的项目中可能也存在类似的情况,比如
export interface AppOptions {
// 可以在 App 配置对象中传入任意 xxx 属性,在回调函数中可以通过 this.xxx 访问
[name: string]: any;
onInit: () => void;
onUpdate: () => void;
onDestroy: () => void;
}
// ThisType 是为了保证 appOpts 里的回调函数在 this 上下文的类型都是 AppOptions.
export function createApp(appOpts: AppOptions & ThisType<AppOptions>) {
appOpts.onInit.call(appOpts);
....
}
这种情况下[name: string]: any;是比较直观的写法,但是带来的问题是内部对appOpts的不存在的属性访问也会不会报类型的错误,比如又是下面这个经典手误。
// 手误将 onUpdate 写成了 onUdpate,但是类型检查并不会报错。
appOpts.onUdpate.call(appOpts);
但是其实模块内部并不会访问这些外部传入的额外属性,所以更好的做法是隔离内部使用的类型和对外暴露的类型,内部采用更严格的类型控制。
interface InnerAppOptions {
onInit: () => void;
onUpdate: () => void;
onDestroy: () => void;
}
function innerCreateApp(appOpts: InnerAppOptions) {
// 实际初始化 App 的地方
appOpts.onInit.call(appOpts);
....
}
export interface AppOptions extends InnerAppOptions {
// 对外暴露的配置采用更宽松的类型,可以添加任意多的属性。
[name: string]: any;
}
export function createApp(appOpts: AppOptions & ThisType<AppOptions>) {
innerCreateApp(appOpts as InnerAppOptions);
}
如果需要上层使用createApp的代码也要有比较严格的类型限制,我们可以还使用泛型:
export interface createApp<T extends InnerAppOptions>(appOpts: T & ThisType<T>) {}
示例完整代码[1]
[1]: http://t.cn/A6czalpw
采用更精确的类型定义
使用字面量类型而非string类型
在前面章节已经提到过,如果参数或者属性类型可以枚举字面量,应该使用字面量类型而非宽泛的string类型
// Bad
function sendMessage(type: string) {}
// Good
function sendMessage(type: 'init' | 'update') {}
// Bad
interface Option {
type: string
}
// Good
interface Option {
type: 'init' | 'update'
}
使用更精确的结构体定义而非object类型
跟前面的类似,我们应该避免对参数或者属性使用宽泛的object类型,尽量将每个属性的类型都定义出来
// Bad
function init(opts: object) {}
// Good
interface InitOpts {
foo: string;
bar: number
}
function init(opts: InitOpts) {}
使用字符串模板类型限制字符串类型
TypeScript 从 4.1 开始支持了字符串模板类型,我们可以利用该特性对字符串类型的参数做一些更严格的检查。
利用字符串模板做名字映射
type EventName = 'click' | 'mouseover' | 'mouseup' | 'mousemove';
declare function addEventListener(EventName: string, handler: EventCallback): void;
// Bad
type EventNameKey = 'onclick' | 'onmouseover '| 'onmouseup' | 'onmousemove';
// Good
type EventNameKey = `on${EventName}`;
type Handlers = {
[key in EventNameKey]: EventCallback
}
示例完整代码[2]
[2]: http://t.cn/A6czaYGs
实现字符串 Query 的类型推导
我们也可以利用字符串模板实现字符串 Query 的类型推导,比如大家比较熟悉的model.get('foo.bar')这样的数据链式读写
interface ComponentData {
foo: {
bar: string
},
baz: number
}
interface Model<TDataDef> {
// 根据之前说的,返回的类型最好是 unknown 而非 any
get(key: string): unknown
set(key: string, val: any): void
}
declare const model: Model<ComponentData>;
const baz = model.get('baz') as number; // 只能显示的声明类型因为不知道取得的值是什么类型
const bar = model.get('foo.baz') as string; // 这里 bar 误写成了 baz 但是也无法报错
我们可以通过字符串模板对这个类型推导做优化
interface ComponentData {
foo: {
bar: string
},
baz: number
}
type PropType<T, P extends string> =
string extends P ? unknown :
P extends keyof T ? T[P] :
P extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
unknown;
interface Model<TDataDef> {
// 根据之前说的,返回的类型最好是 unknown 而非 any
get<T extends string>(key: T): PropType<TDataDef, T>
set<T extends string>(key: T, val: PropType<TDataDef, T>): void
}
declare const model: Model<ComponentData>;
const baz = model.get('baz'); // baz 为 number 类型
const bar = model.get('foo.bar') // bar 为 string 类型
const bar2 = model.get('foo.baz'); // 写错了属性名,返回 unknown 后面再继续使用会报错
示例完整代码[3]
[3]: http://t.cn/A6czaHYx
这个类型代码似乎有点类型体操的感觉了,看起来并不好理解,但是正如前面所说,这是一个非常基础的模块,上层所有的Component都需要频繁的对这个Model进行读写,所以花费一定的时间写一个可靠的类型对于提高上层的开发效率和类型检查的正确性是非常有必要的。
使用?:表示可选属性和参数
对于有可能是undefined的属性或者函数参数使用?:,比如
interface Option {
// 等同于 type: string | undefined
type?: string
}
// 等同于 option: Option | undefined
function init(option?: Option) {}
同时在tsconfig中开启strictNullCheck,防止代码中对于有可能是undefined的变量的访问。在开启该检查后
function init(option?: Option) {
// 编译时报错,因为 option 可能为 undfined
if (option.type === 'foo') {}
// 编译时报错,因为 option 和 option 中的 type 都可能是 undefined
const typeUpperCase = option.type.toUpperCase();
// 使用 Optional Chaining 访问,得到的 typeUpperCase 可能是 string 或者 undefined
const typeUpperCase = option?.type?.toUpperCase();
}
这个检查很大程度保证了 TypeScript 在编译器的空安全(Null Safety)。
Nullable 的属性
很多时候,属性或者参数还可能会被赋值为null。
interface Option {
// 等同于 type: string | undefined | null
type?: string | null
}
或者提供一个Nullable类型函数
type Nullable<T> = T | null | undefined;
上面提到的 Optional Chaining 访问也会对值是否为null做检查。
入参使用宽松的类型,出参使用严格的类型
我们前面提到过类型应该尽量贴合代码,在这点上,出参严格使用跟代码返回一致的类型比较容易理解。那为什么入参要使用宽松的类型,我们先举下面这个例子:
// 有一个用(x, y)来表示向量的类,可以用`len()`计算向量
class Vector {
x: number = 0;
y: number = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
len(): number {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
}
// 有一个方法求向量点乘
function dot(a: Vector, b: Vector): number {
return a.x * b.x + a.y * b.y;
}
这个应该是一个非常规整的写法,尤其是对于写过 Java,C++ 等其它静态类型语言的同学来说。但是在 JS / TS 这样动态的语言中,就有可能存在下面这样的写法:
// 报错,因为传入的参数对象没有 len() 方法。
dot({ x: 1, y: 0}, { x: -1, y: 0});
但是我们作为实现dot方法的人,知道dot其实并没有用到len方法。dot里面的计算只是用到了向量的x, y属性。所以更好的方式是我们再定义一个VectorLike。
interface VectorLike {
x: number;
y: number;
}
function dot(a: VectorLike, b: VectorLike): number {
return a.x * b.x + a.y * b.y;
}
能这么做的原因是因为 TypeScript 使用的是结构化类型(Structural Typing)而非名义类型(Nominal Typing),也就是在做类型检验的时候只检查有哪些属性以及每个属性的类型是否匹配。对于这里就是只要有x, y属性而且类型为number的对象都可以视作VectorLike。这也更符合 JS 中常见的 Duck Typing 的设计风格。
示例完整代码[4]
[4]: http://t.cn/A6cza88N
当然这么设计的前提是你的代码确实只用到了x, y属性,如果你想要方法接受的参数类型更严格也完全可以的,只是这样可能会上层的使用不那么便捷。比如每次都需要转为Vector类。
再举一个可能更常见的例子。数组的克隆
interface ArrayLike<T> {
readonly length: number;
// 可以数字下标访问
readonly [n: number]: T;
}
function cloneArray<T>(arr: ArrayLike<T>): Array<T> {
const out: T[] = [];
for (let i = 0; i < arr.length; i++) {
out.push(arr[i]);
}
return out;
}
const newArr = cloneArray(new Float32Array([1, 2, 3]));
完整示例代码[5]
[5]: http://t.cn/A6czaEF5
这个例子就同时体现了入参宽松,出参严格,入参可以接受普通的数组,也可以是Float32Array这样的静态类型数组,亦或者arguments, NodeList这样可以遍历但是又没有slice方法的伪数组,因此入参类型定义成了拥有length属性,而且可以数字下标访问的对象。而出参因为都转成数组了,所以类型为Array
使用类型收紧(Narrowing)
类型收紧是指对于一个宽松的类型(往往是 Union Type),通过条件判断,在条件分支中可以推导成更严格的类型。
这里建议多使用类型收紧来得到更精确的类型而非通过as转换类型。因为as往往会造成实现和类型的不一致。
下面具体举几个类型收窄的做法,前面提到的null/undefined检查其实就是类型收窄的一种做法,在收窄后去掉了null/undefined类型。
使用instanceof, typeof
instanceof和typeof应该是最常用的两个用来判断类型收紧的方法了:
declare const foo: number | string;
const bar = foo.toFixed(2) // 报错,因为 foo 有可能为字符串,不存在 toFixed 方法
if (typeof foo === 'number') {
// 这里已经确定 foo 的类型为 number,可以正常调用 toFixed
const bar = foo.toFixed(2);
}
完整示例代码[6]
[6]: http://t.cn/A6czamK5
使用'type'判断
如果类型是个对象,并且对象拥有一个类别的属性(这个属性可以是任意名字,这里我们姑且叫 type),我们可以通过判断这个类别来做类型收紧
interface MyMouseEvent {
type: 'mouse';
x: number;
y: number;
}
interface MyKeyboardEvent {
type: 'keyboard',
keyCode: number
}
declare const myEvent: MyMouseEvent | MyKeyboardEvent;
myEvent.keyCode; // 报错,因为 myEvent 可能是 MouseEvent 而没有 keyCode
if (myEvent.type === 'keyboard') {
// 这里确定 myEvent 的类型是 KeyboardEvent
myEvent.keyCode;
}
示例代码[7]
[7]: http://t.cn/A6czaueK
因此我们通常建议使用 Union Of Interfaces 而非 Interface of Unions.
// Bad
interface Point {
type: 'string' | 'number';
x: string | number;
y: string | number;
}
// Good
interface StringPoint {
type: 'string';
x: string;
y: string
}
interface NumberPoint {
type: 'number';
x: number;
y: number
}
type Point = StringPoint | NumberPoint;
这么写会啰嗦点,但是会有助于 TypeScript 通过type判断来收窄类型。
编写方法isXXXX来实现类型预测
上面提到的两种方式都是TypeScript类型系统通过自己推导得到收窄后的类型,有时候通过内置的类型推导无法判断出更精确的类型,比如通过x, y属性判断是否为一个VectorLike。这个时候我们可以同 TypeScript 提供的类型预测功能。
interface VectorLike {
x: number;
y: number;
}
interface SomeOtherThing {
foo: string;
}
function isVectorLike(value: any): value is VectorLike {
return value && typeof value.x === 'number' && value.y === 'number'
}
declare const mayBeVec: VectorLike | SomeOtherThing;
if (isVectorLike(mayBeVec)) {
const sum = mayBeVec.x + mayBeVec.y;
}
示例代码[8]
[8]: http://t.cn/A6cza18B
避免用变量来缓存类型
这是在刚才isVectorLike例子的基础上的
declare const mayBeVec: VectorLike | SomeOtherThing;
// Bad
const isVec = isVectorLike(mayBeVec);
if (isVec) {
// 因为无法确定 mayBeVec 的类型所以需要通过 as 来断言
const sum = (mayBeVec as VectorLike).x + (mayBeVec as VectorLike).y;
}
// Good
if (isVectorLike(mayBeVec)) {
const sum = mayBeVec.x + mayBeVec.y;
}
完整示例代码[9]
[9]: http://t.cn/A6czarzX
你可能会奇怪为什么要在代码里多此一举加一个变量判断,因为有时候代码中会需要多次的判断(或者判断很耗时),为了性能我们可能会缓存这么一个状态,但是这同样也带来了类型和逻辑代码不一致的问题。所以只有明确这段代码会有性能问题的时候才可以这么做,大部分情况下我们都要尽可能利用类型收窄来推导类型。
多通过类型别名来为string这样的基础类型附上语义
// Bad
const fill: string = 'red';
const stroke: string = 'black';
// Good
type Color = string;
const fill: Color = 'red';
const stroke: Color = 'black';
类型别名可以帮助你理解这个变量的语义,而且在未来如果想要对类型进行修改,比如颜色支持[r, g, b, a]这样的数组,我们可以非常方便的进行重构。
type Color = string | number[];
泛型的使用
在上面的例子我们已经利用了不少泛型的能力来实现更好的类型推导,这里再列举几个使用泛型时的注意事项
泛型中的类型参数尽可能使用extends约束
如果传入参数是一个字符串
// Bad
function foo<T>(value: T) {}
// Good
function foo<T extends string>(value: T) {}
extends一方面可以保证调用的参数是字符串类型的子集,另一方面方法内部实现也可以推导出param类型是个字符串。
利用函数中泛型的自动推导能力推导其它入参和出参的类型。
比如数组映射方法map的类型
declare function map<TIn, TOut, TCtx>(
arr: readonly TIn[],
cb: (this: TCtx, val: TIn, index?: number, arr?: readonly TIn[]) => TOut,
context?: TCtx
): TOut[]
// res 类型为 string[]
const res = map([1, 2, 3], function (val) {
// val 类型为 number
return val.toFixed(2);
});
类型系统会根据传入的参数,推导出三个类型参数映射前数组类型TVal, 映射后数组类型TRet, 以及回调函数上下文TCtx分别是什么,从而在回调函数参数以及map返回值中推导出正确的类型。
在底层方法上多应用这些能力,强大的类型推导可以让上层业务逻辑代码的更加简单,比如上面const res = map...这段代码其实完全不需要写任何类型,里面的变量也可以得到正确的类型。
类型参数的命名
如果只有一个类型参数,则可以直接使用T,如果有多个且函数较短,可以使用T, K, V等字符,如果函数较长或者是个大类,可以使用TValue, TKey等以T作为前缀的名字。
其它细节
在定义常量对象的时候,使用as const标记整个对象为只读
const EVENT_MAP = {
click: 'CLICK';
ready: 'READY';
} as const;
as const 可以可以防止常量对象被错误修改,其中属性类型也会被推导为字面量类型。
从常量中推导出类型
// Bad
const TYPES = ['foo', 'bar'];
type Types = 'foo' | 'bar';
// Good
const TYPES = ['foo', 'bar'] as const;
type Types = typeof TYPES[number];
处理属性的动态注入
在 JavaScript 代码中我们可能会在代码中往某个的对象动态挂载一个属性。比如:
function update(el: HTMLElement) {
// 在一堆更新后往 el 上挂一个标记表示这个已经被渲染过了
el._$updated = true;
}
但是因为HTMLElement中并不存在_$updated属性,所以上面的代码无法通过类型检查错误。
最简单的做法是用as any
// Bad
function update(el: HTMLElement) {
(el as any)._$updated = true;
}
但是通常不建议这么做,因为这样在多处使用_$updated属性的时候无法检查名字是否一致,如果名字修改了或者拼错了不能检查出错误。
更好的做法是扩展一个interface
// Good
interface ExtendedHTMLElement extends HTMLElement {
_$updated: boolean;
}
function update(el: HTMLElement) {
(el as ExtendedHTMLElement)._$updated = true;
}
jsdoc 中移除类型,避免跟 TypeScript 类型不一致
// Bad
/**
* @param number value 输入数据
*/
function foo(value: number) {}
// Good
/**
* @param value 输入数据
*/
function foo(value: number) {}
对于导出的函数显示定义参数类型
// Bad
export function init(opts: { count: number }) {}
// Good
export interface InitOption {
count: number
};
export function init(opts: InitOption) {};
显示定义并导出参数类型可以方便上层使用。
优先使用interface而非type定义结构体
// Bad
type Vector = {
x: number;
y: number;
}
// Good
interface Vector {
x: number;
y: number;
}
应用 TypeScript 的类型编程能力
通过 TypeScript 的类型编程能力,我们可以将一个(或多个)类型转换成一个新的类型。一方面减少我们写类型的工作量,另一方面也容易保证多个类型的一致。
TypeScript 已经内置了不少工具方法 用于类型的转换,比如用Pick可以从对象中提取出部分属性,用Return可以得到函数的返回值类型等等。社区也有更多的工具方法 帮助我们进行类型编程。
这个例子[10] 演示了怎么将一个配置对象转为foo.bar.on这种事件注册的方式。
[10]: https://u.nu/1v1wr
关于本文
为你推荐
欢迎自荐投稿,前端早读课等你来