Skip to content

Instantly share code, notes, and snippets.

@pissang
Last active May 10, 2024 08:45
Show Gist options
  • Save pissang/4d1cced7b7d32de41f9a815c27e4490e to your computer and use it in GitHub Desktop.
Save pissang/4d1cced7b7d32de41f9a815c27e4490e to your computer and use it in GitHub Desktop.
TypeScript 类型编写指南

TypeScript 类型编写指南

前言

本文主要作为平时在 TypeScript 代码中编写类型以及对 TypeScript 代码进行 review 时候的参考手册,并非强制执行的规范,也不涉及纯代码风格以及代码逻辑上的指导。

前置阅读

本文内容参考了下面几个手册,所以强烈建议能够同时阅读完这几个手册。如果大家对 TypeScript 的一些基础用法和特性还不熟悉,也建议先阅读第一个 TypeScript 手册。

为什么我们要添加类型

首先是最重要的,完善的类型能够帮助我们提前在编译期发现很多低级或者隐蔽的错误(比如拼错单词,少传参数,参数类型传错等),避免把这些错误遗留到后面单测,回归测试,甚至线上的时候才被发现从而提高排查成本。

尤其是在代码重构的时候,诸如方法重命名,参数增减,参数类型调整,对象属性调整等都有可能因为改漏了部分代码而带来一些很隐蔽的 bug,而类型的加入就可以帮助我们避免这些问题。进一步的也可以降低我们平时写代码和重构代码时候的心智负担。

最后这些类型也可以像 jsdoc 一样帮助我们理解代码,比如参数对象中有哪些属性,这些属性分别是什么类型的等等,工具精确的智能提示也可以提高我们写代码的效率。我们经常会存在将一个参数对象在各个方法中互相传递,到最后已经搞不清楚参数对象长什么样的情况了,而良好的类型声明可以有效的帮助我们能避免这种情况。类型相比于在 jsdoc 更好的一点在于,jsdoc 容易在代码发生变化时忘记同步更新从而误导后面阅读代码的开发者,而类型因为存在编译器的检查所以不会存在这个情况。

完善项目中的类型

刚才我们提了很多添加类型的好处,能够最大化利用这些好处的前提是项目中的代码都拥有了比较完善的类型。

为了能够让之前大量的 JavaScript 存量代码逐渐过渡和迁移到 TypeScript 而不会让项目产生问题,TypeScript 中就有像any这样的设计让类型检查不会那么严格,但是这些不那么严格检查的设计存在也让类型的收益大打折扣。

那么怎么样的类型才算是比较完善了,当然最健全的情况是具体实现的逻辑代码能够完全处理声明的类型,从而能够在编译期就能够发现因为代码未处理到的输入而可能出现的潜在 bug。

但是在项目中追求完全健全的类型是一件很困难甚至不现实的事,因此我们要做的是尽可能让类型贴合实际实现的逻辑代码,从而后期重构的时候任何接口定义等类型上的改动,在相关的代码上都可以通过报错反应出来,保证重构的可靠性。

如何让类型尽量贴合实际实现,我们举个简单的例子:

现在有发送和接受消息的方法:

declare function sendMessage(type: string, message: object): void
declare function onMessage(type: string, message: object): void

最基础的类型定义就如上面代码,消息类型为字符串,消息携带的参数为一个对象。但是实际上处理的消息类型以及消息参数通常是有限的可以枚举的。比如这个消息类型可能为'init' 或者 'update',那像下面这样的拼写错误就没法被类型检查系统发现。

sendMessage('udpate', {});

这个错误的原因是因为函数声明的类型范围比函数实际能处理的范围更广,所以导致一些错误的输入无法被处理最终产生 bug。

因此更好的方法是能够限制type的类型,并且定义第二个参数message对象的属性。

type MessageType = 'init' | 'update'
interface MessageParams {
    initData?: string;	// init 时候要用到的数据
    updateData?: string; // update 时候要用到的数据
}
declare function sendMessage(type: MessageType, message: MessageParams): void
declare function onMessage(type: MessageType, message: MessageParams): void

这样像刚才那样的拼写错误就在编译的时候就会被提前发现(实际上因为代码的自动补全很难再会有这样的拼写错误),而且message参数的属性也被限制了,调用的时候不能传任意的参数。

这样的类型已经可以避免出现大部分低级错误了,而且重构的时候(比如将initDataupdateData属性改为了一个对象)也可以顺利的检查出所有没更新的使用代码。但是还是不够健壮,假如我们像下面这样在'init'消息中传入了一个updateData参数,类型系统就没法检查出来。

sendMessage('init', { updateData: 'foo' });

只从这段代码来看大家可能会觉得这个从命名上就很容易看出来问题,应该使用initData而非updateData,但是有时候这个命名的区别并没有这么明显,或者这个消息参数是从别处传过来的,使用的时候无法确定里面是initData还是updateData。因此我们需要对参数类型以及对应的参数属性作进一步的约束。

我们可以用函数重载来实现不同参数类型的约束:

interface InitMessageParams {
    initData?: string;
}
interface UpdateMessageParams {
    updateData?: string;
}
declare function sendMessage(type: 'update', message: UpdateMessageParams): void
declare function sendMessage(type: 'init', message: InitMessageParams): void
declare function onMessage(type: 'update', message: UpdateMessageParams): void
declare function onMessage(type: 'init', message: InitMessageParams): void

更通用的方式是利用函数泛型的自动推导功能,在lib.dom.d.ts中对于addEventListener的类型定义就是这么做的。

interface MessageParamsMap {
    init: InitMessageParams;
    update: UpdateMessageParams;
}
type MessageType = keyof MessageParamsMap;
declare function sendMessage<T extends MessageType>(type: T, message: MessageParamsMap[T]): void
declare function onMessage<T extends MessageType>(type: T, message: MessageParamsMap[T]): void

这里的类型参数T会根据根据你调用时候第一个参数传入的类型自动推导出来,推导出来是'init'还是'update',从而进一步索引出第二个参数message的类型。

// 下面这行代码会报类型错误
sendMessage('init', { updateData: 'foo' });

示例完整代码

这是一个比较常见和基础的例子,用来说明如何让类型更加健全。更多细节的建议会在后面类型编写建议段落中一一列出。

any 的使用

我们经常能看到对于TypeScript中要避免使用any的说法。但是这不是意味着我们看到有any的代码就觉得这个一定是洪水猛兽,就要批判一番。实际上any最大的坏处并不是当前这个变量失去了类型,而是其带来的传染性导致后续其它访问到这个变量的代码都可能会被自动推导成any,而且我们往往很难意识到这些相关的代码也失去了类型。比如下面这个例子

declare const options: {
    [key: string]: any;
};
const value = options.value;	// value 类型是 any
const valueStr = value.toFixed(2);   // valueStr 类型也是 any

因此如果一些基础的变量类型是any,那么上层使用这些变量的代码也都变成了any,而这个代码中其它部分的类型写得再精确也失去了其意义。

所以我们在使用any的时候一定要非常小心这个any是不是可能会被传染,把any限制在非常局部的自己了解并且能够控制的代码内。实际上更建议在所有要写any的地方,把这个any先改成unknown避免未知类型的传染。

是否需要经常做类型体操?

大家经常调侃 TypeScript 需要做类型体操去处理一些很复杂的类型,这些类型体操写起来的难度并不比逻辑代码小,有时候甚至更费脑子,那么我们是否会因为引入 TypeScript 所以经常需要做类型体操,导致开发成本反而变高。

首先类型体操往往存在于一些比较基础和底层的方法的类型定义中,这些方法或者模块往往面向的上层场景比较广,处理参数比较通用,使用也比较频繁,所以需要通过泛型,类型重载等复杂的方式让函数的输入和输出都拥有准确的类型,从而上层在使用这些方法或者模块的时候能够顺利推导出准确的类型。我们可以这么说,底层方法的类型越完备,上层业务逻辑代码就越不不需要操心类型。

而对于更多的业务逻辑代码中,类型往往是通过推导或者简单的声明得到,并不需要写太多复杂的类型。

但是有时候我们也要谨防底层写出太过复杂的类型体操代码,太过复杂的类型代码可能会导致报错信息很晦涩和难定位,这个时候需要大家自己权衡(TypeScript 版本升级也可能会改善报错信息)。

为已有的 JavaScript 项目添加类型

在我们将一个 JavaScript 重构成 TypeScript 的时候,需要按照从下至上的顺序依次将各模块改成 TypeScript 并且添加上完善的类型。也就是完成底层的通用模块的类型添加,然后再将上层的模块改造成类型。

这么做的原因主要也是因为刚才我们提到的any的传染性,假如我们是从上往下的方式给各个模块添加类型,上层使用到的底层模块因为还没添加类型,所以类型都是any,这样会导致上层模块类型都加上了,但是业务逻辑代码中推导得到的类型还都是any导致类型检查失去了作用。

原则:

  • 在给代码添加类型的过程中,如果碰到使用到的类,方法还没有添加类型,则需要优先给这些未添加类型的类和方法添加类型
  • 在添加类型的时候避免同步修改代码逻辑

类型编写建议

接下来会列一些比较常见的建议,作为手册帮助大家在具体代码中决定如何写类型。

尽量避免使用any

关于为什么要避免使用any在前面有大致介绍,我们应该尽量将any的使用限制在非常局部的代码中。

对于一个通用的类,比如一个容器类,也可能存在类型不明确的时候,其中存的数据可能是任何类型,这个时候应该优先考虑泛型,如果泛型无法解决则将类型声明为unknown

为什么使用unkown而非any

unkownany一样,是 TypeScript 最顶层的类型,可以被转换成任意类型,但是unknownany的区别是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>) {}

示例完整代码

采用更精确的类型定义

使用字面量类型而非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
}

示例完整代码

实现字符串 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 后面再继续使用会报错

示例完整代码

这个类型代码似乎有点类型体操的感觉了,看起来并不好理解,但是正如前面所说,这是一个非常基础的模块,上层所有的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 的设计风格。

完整示例代码

当然这么设计的前提是你的代码确实只用到了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]));

示例代码

这个例子就同时体现了入参宽松,出参严格,入参可以接受普通的数组,也可以是Float32Array这样的静态类型数组,亦或者arguments, NodeList这样可以遍历但是又没有slice方法的伪数组,因此入参类型定义成了拥有length属性,而且可以数字下标访问的对象。而出参因为都转成数组了,所以类型为Array<T>

使用类型收紧(Narrowing)

类型收紧是指对于一个宽松的类型(往往是 Union Type),通过条件判断,在条件分支中可以推导成更严格的类型。

这里建议多使用类型收紧来得到更精确的类型而非通过as转换类型。因为as往往会造成实现和类型的不一致。

下面具体举几个类型收窄的做法,前面提到的null/undefined检查其实就是类型收窄的一种做法,在收窄后去掉了null/undefined类型。

使用instanceof, typeof

instanceoftypeof应该是最常用的两个用来判断类型收紧的方法了:

declare const foo: number | string;
const bar = foo.toFixed(2)	// 报错,因为 foo 有可能为字符串,不存在 toFixed 方法
if (typeof foo === 'number') {
    // 这里已经确定 foo 的类型为 number,可以正常调用 toFixed
    const bar = foo.toFixed(2);
}

示例代码

使用'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;
}

示例代码

因此我们通常建议使用 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;
}

示例代码

避免用变量来缓存类型

这是在刚才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;
}

示例完整代码

你可能会奇怪为什么要在代码里多此一举加一个变量判断,因为有时候代码中会需要多次的判断(或者判断很耗时),为了性能我们可能会缓存这么一个状态,但是这同样也带来了类型和逻辑代码不一致的问题。所以只有明确这段代码会有性能问题的时候才可以这么做,大部分情况下我们都要尽可能利用类型收窄来推导类型。

多通过类型别名来为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可以得到函数的返回值类型等等。社区也有更多的工具方法 帮助我们进行类型编程。

这个例子 演示了怎么将一个配置对象转为foo.bar.on这种事件注册的方式。

@skychx
Copy link

skychx commented Mar 12, 2021

写的真好!撒花🎉

@zhongkai
Copy link

写的不错,顶一下!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment