typescript 类型系统从无知到失智

changcongying 2019-06-28

类型系统是 typescript 最吸引人的特性之一,但它的强大也让我们又爱又恨,每个前端同学在刚从 javascript 切换到 typescript 时都会有一段手足无措的时光,为了不让编译器报错恨不得把所有变量都标注成 any 类型,而后在不断地填坑中流下悔恨的泪水。

谨以此文记录我在学习 typescript 类型系统使用方法的过程中遇到的一些问题,以供大家参考,避免大脑进水。

(本文默认所有读者熟知活动所有 ES6 语法及特性)

Change Log

2018-11-14

  1. 改正了枚举与 .d.ts 文件的说明

2018-11-13

  1. 修正了 .d.ts 一节中 class 在 .d.ts 文件中定义时使用了 export default 语法的问题
  1. 增加了 .d.ts 文件的使用注意事项
  1. 增加了枚举(enum)不能定义在 .d.ts 文件中的说明
  1. 增加了枚举(enum)部分对于为什么要使用 type xxx = 'a' | 'b' 来替换枚举进行了说明

0x00 无知

类型声明

基础类型

直接将类型附在变量声明之后,并以冒号分隔。

const count: number = 3;

const name: string = 'apples';



const appleCounts: string = count + name;



assert.strictEqual(appleCounts, '3apples');

引用类型

稍微复杂一点的,如数组 & 对象,按照其内成员结构进行声明。

const object: { a: number } = { a: 1, };

const array1: number[] = [1,];

const array2: Array<number> = [2,];

函数类型

写法极像箭头函数。

const someFn: (str: string) => number = function (str: string) {

    return +str;

}

typescript 扩展的辅助类型

any

啥都能放,但并不建议大量使用,如果所有变量都是 any,那 typescript 跟 javascript 又有什么区别呢。

const variable: any = '5';

void

空类型,一般用来表示函数没有返回值。

function someFn (): void {
    1 + 1;
}

类型并联

当一个变量可能是多种类型时,使用 | 进行多个不同类型的分隔:

const nos: number | string = '1';

在引用类型中使用

错误用法

一定,一定,一定 不要用 , 作分隔符。

const object: { a: [number, string] } = { a: 1, };

正确用法

const object: { a: number | string } = { a: 1, };
const array1: (number | string)[] = [ 1, ];
const array2: Array<number | string> = [ 2, ];

type numberOstring = number | string;
const object: { a: numberOstring } = { a: 'yes', };
const array1: numberOstring[] = [ 'yes', ];
const array2: Array<numberOstring> = [ 'yes', ];

在函数入参 & 出参中声明类型

函数的入参类型跟声明变量的类型时差不多,直接在变量名后跟类型名称就可以了。

返回值的类型则跟在参数的括号后面,冒号后面跟一个返回值的类型。

function someFn (arg1: string, arg2: number): boolean {
    return +arg1 > arg2;
}

箭头函数

参数与返回值的声明方法与普通函数无二。

setTimeout((): void => {

    console.log('six six six');

}, 50);

在类中声明类型

实例属性记得一定要初始化。

class SomeClass {

    a: number = 1;

    b: string;

    static a: boolean;



    constructor () {
        this.b = 'str';
    }



    method (str: string): number {

        return +str;

    }

}

类型转换

当你在声明一个普通的对象(其他类型也有可能,此处仅使用对象作为例子)时,typescript 并不会自动为你添加上对应的类型,这会造成你在赋值时触发 TS2322: Type 'xxx' is not assignable to type 'xxx'. 错误,此时就需要使用显式类型转换来将两边的类型差异抹平。

类型转换的前提是当前类型是真的可以转换为目标类型的,任何必选属性的缺失,或是根本无法转换的类型都是不允许的。

<type> 尖括号转换

并不推荐这种方法,因为有时编辑器会把它当成 jsx 处理,产生不必要的 warning。

const object1: object = {
    a: 1,
};
const object2: { a: number } = <{ a: number }>object1;

as 表达式转换

const object1: object = {
    a: 1,
};
const object2: { a: number } = object1 as { a: number };

0x01 理智

自定义类型

相当于联结多个不同类型,并为他们创造一个假名,平时写多个类型并联实在是太累了的时候可以试试这个方法。

它的值可以是类型,也可以是具体的变量值。

type NumberOrString = number | string;

type Direction = 'Up' | 'Right' | 'Down' | 'Left';



const num: NumberOrString = 1;

const dir: Direction = 'Up';

枚举

官方

注意:枚举内部赋值时如果为数字,可以只赋值第一个,后面几个会随之递增,如果为字符串,则需要全部赋值,否则就会报错。

enum Direction {

    Up = 1,

    Right,

    Down,

    Left

}



const dir: Direction = Direction.Up;

我的方法

如果你的代码中准备使用 enum 作为右值,那请不要把 enum 声明在 .d.ts 文件中,这是因为 ts 在编译的时候 .d.ts 文件是作为类型文件用的,并不会生成实体输出,自然也就没有地方会定义这个枚举,这个时候用在代码里作为右值的枚举值就会因为找不到整个枚举的定义,从而触发 'xxx is undefined' 错误。

这也使得它在使用过程中给我们造成了各种各样的麻烦(在 .d.ts 的 interface 声明中使用 enum 真的是再正常不过的事情了),比如:

// my-types.d.ts
declare const enum Direction {
    Up = 0,
    Right = 1,

    Down = 2,

    Left = 3,
}
// usage.ts
class SomeClass {
    dir = Direction.Up;
}

编译后的结果是:

// .d.ts 文件当场消失
// usage.js
function SomeClass () {
    this.dir = Direction.up;
}

浏览器在运行时根本找不到 Direction 定义的位置,自然就报 'Direction' is not defined 的错了,但 type Direction = 'Up' | 'Right' | 'Down' | 'Left' 的方法就不会有这种问题,具体使用方式如下:

// my-types.d.ts

type Direction = 'Up' | 'Right' | 'Down' | 'Left';
// usage.ts
const dir: Direction = 'Up';

缺点是没有类型提示,不能定义枚举的内部值,判断的时候也必须用对应的字符串进行字符串比对(汗。

接口

基础

声明一种类型的对象,该类型的变量都必须满足该结构要求。

interface SomeInterface {

    str: string;

    num: number;

}



const object: SomeInterface = {

    str: 'str',

    num: 1,

};



class SomeClass implements SomeInterface {

    num = 1;



    constructor () {

        this.str = 'str';

    }

}

多重实现

同一个类可以实现多个不同的接口,但前提是该类一定要实现每个接口所要求的属性。

interface Interface1 {

    str: string;

}



interface Interface2 {

    num: number;

}



class SomeClass implements Interface1, Interface2 {

    num = 1;



    constructor () {

        this.str = 'str';

    }

}

声明合并

在多个不同文件,或是相同文件的不同位置声明的同名接口,将会被合并成一个接口,名称不变,成员变量取并集。

interface SomeInterface {

    str: string;

}



interface SomeInterface {

    num: number;

}

// 必须全部实现

const someInterface: SomeInterface = {

    str: 'str',

    num: 1,

};

函数接口

基础使用

interface InterfaceFn {

    (str: string): boolean;

}



const fn1: InterfaceFn = (str: string): boolean => {

    return 10 < str.length;

};

当该类函数还具有成员变量和方法时

interface InterfaceFn {
    (str: string): boolean;
    standard: string;
    someFn(num: number): string;
}


// 必须进行显式类型转换
let fn1: InterfaceFn = function (str: string): boolean {
    return 10 < str.length;
} as InterfaceFn;


fn1.standard = 'str';


fn1.someFn = function (num: number): string {
    return `${num}`;
};

继承

接口可以继承类或是另一个接口,与 ES6 继承方法语法一样,在此不再赘述。

函数 & 接口的缺省值及可选项

当该参数为可选项时,可以在名称与类型表达式的冒号之间加一个问号 ? 用来表示该参数为 __可选项__。

function someFn (arg1: number, arg2?: string): void {}



someFn(1);

当该参数在不传的时候有 缺省值 时,可以使用 = 来为其赋予 __缺省值__。

function someFn (arg1: number, arg2: number = 1): number {

    return arg1 + arg2;

}



someFn(1);    // 2

可选项与 缺省值 可以混搭。

function someFn (arg1: number, arg2: string = 'str', arg3?: string): void {}



someFn(1);

可选项 参数后不可跟任何 非可选项 参数。(以下代码当场爆炸)

function someFn (arg1: number, arg2?: string, arg3: string = 'str'): void {}


someFn(1);

可选项与 缺省值 不可同时使用在同一个值上。(以下代码当场爆炸)

function someFn (arg1: number, arg2?: string = 'str'): void {}


someFn(1);

可选项 也可用在接口的声明中(__缺省值__ 不行,因为接口是一种类型的声明,并非具体实例的实现)。

泛型

泛型函数

基础类型

function someFn<T> (arg:T): T {

    return arg;

}



const str1: string = someFn<string>('str1');

const str2: string = someFn('str2');

同时使用多个泛型

function someFn<T, U> (arg1: T, arg2: U): T | U {
    return arg1;
}


const num1: string | number = someFn<string, number>('str1', 1);
const str2: string | number = someFn('str2', 2);

箭头函数 & 泛型类型

const someFn: <T>(arg: T) => T = <T>(arg: T): T => {
    return arg;
};


const str: string = someFn('str');

泛型接口

基础用法
interface InterfaceFn {
    <T>(arg: T): T;
}
const someFn: InterfaceFn = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');
泛型字面量
const someFn: { <T>(arg: T): T; } = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');
接口泛型
interface InterfaceFn<T> {
    (arg: T): T;
}
const someFn: InterfaceFn<string> = <T>(arg: T): T => {
    return arg;
};
const str: string = someFn('str');

泛型类

原理与 接口泛型 一样。

class SomeClass<T> {

    someMethod (arg: T): T {

        return arg;

    }

}

泛型约束

function someFn<T extends Date>(arg: T): number {
    return arg.getTime();
}
const date = new Date();

keyofRecordPickPartial 太过复杂,真有需求还请自行查阅文档。

.d.ts 文件

在实际编码过程中,我们经常会定义很多自定义的 接口 与 __类__,如果我们在声明变量的类型时需要用到它们,就算我们的代码中并没有调用到它们的实例,我们也必须手动引入它们(最常见的例子是各种包装类,他们并不会处理参数中传入的变量,但他们会在接口上强规范参数的类型,并且会将该变量透传给被包装的类的对应方法上)。

// foo.ts



export default class FooClass {

    propA: number = 5;

}
// bar.ts

import FooClass from './foo.ts';



export class BarClass {

     foo?: FooClass;

}

这种使用方法在调用数量较少时尚且可以接受,但随着业务的成长,被引用类型的数量和引用类型文件的数量同时上升,需要付出的精力便会随其呈现出 o(n^2) 的增长趋势。

这时我们可以选择使用 .d.ts 文件,在你的业务目录中创建 typings 文件夹,并在其内新建属于你的 xxx.d.ts 文件,并在其中对引用较多的类和接口进行声明(.d.ts 文件是用来进行接口声明的,不要在 .d.ts 文件里对声明的结构进行实现)。

注意:.d.ts 文件只是用于分析类型声明及传参校验,如果需要进行调用,还请直接 import 对应模块。

// my-types.d.ts



declare class FooClass {

    propA: number;



    methodA (arg1: string): void;

}

foo.ts 文件略

// bar.ts

// 不需要再 import 了



export default class BarClass {

    foo?: FooClass;

}

其他类型的声明方式

// my-types.d.ts

// 接口(没有任何变化)

interface InterfaceMy {

    propA: number;

}

// 函数

function myFn (arg1: string): number;

// 类型

type myType = number | string;

使用注意

  1. .d.ts 文件是类型声明文件,不是具体业务模块,不会产生具体的代码实体,所以请在声明模块时使用 declare 关键字,且不要使用 export 语句(如果使用了 export,该文件就会变成实体 ts 文件,不会被 ts 的自动类型解析所识别,只能通过 import 使用)
  1. 枚举(enum)一定不要定义在 .d.ts 里,这样根本就引用不到(大概因为 typescript 的编译过程是 文件 to 文件 的,所以本身作为数据类型辅助文件的 .d.ts 文件不会有翻译实体的产出,自然也就无法在其内定义一个右值字段(枚举的每个值是可以作为右值使用的),那么其他引用这个字段的地方自然也就无法引用到这个值,继而报错了)

0x02 反思

本来想写一写常见的 TS 编译错误及造成这些错误的原因来着,后来想了想,编译出错了都不会查,还写什么 TS 啊,

0xFF 失智

以下几点是我在使用 typescript 类型系统过程中遇到的一些智障问题与未解的疑问,欢迎大家一起讨论。

为 window 变量增添属性的各种姿势

错误的为 window 增添属性的姿势:

window.someProperty = 1;

会触发 TS2339: Property 'xxx' does not exist on type 'Window' 错误。

类型转换法

(window as any).someProperty = 1;

(<any>window).someProperty = 1;

接口扩展法

利用接口可以多处声明,由编译器进行合并的特性进行 hack。

interface Window {

    someProperty: number;

}



window.someProperty = 1;

为什么对象类型最后不能跟尾逗号

下面的代码当场爆炸(因为 c: number, 最后的这个逗号)。

const someObject: { a: number, b: number, c: number, } = { a: 1, b: 2, c: 3, };

函数重载

当一个函数在入参不同时有较大的行为差距时,可以使用函数重载梳理代码结构。

注意:参数中有回调函数时,回调函数的参数数量变化并不应该导致外层函数使用重载,只应当在当前声明函数的参数数量有变时才使用重载。

当同时声明多个重载时,较为准确的重载应该放在更前面。

使用说明

重载的使用方法比较智障,需要先 声明 这个函数的不同重载方式,然后紧接着再对这个函数进行定义。

定义时的参数个数取不同重载方法中参数个数最少的数量,随后在其后追加 ...args: any[](或者更为准确的类型定义写法),用于接收多余的参数。

定义的返回值为所有重载返回值的并集。

而后在函数体内部实现时,通过判断参数类型,自行实现功能的分流。

问题

神奇的是 typescript 并不会校验重载的实现是否会真的在调用某个重载时返回这个重载真正要求的类型的值,下方例子中即使无论触发哪个重载,都会返回 number,也不会被 typescript 检查出来。

猜想:多次声明一次实现难道是受制于 javascript 既有的语言书写格式?

函数重载 of 类成员方法

class SomeClass {
    someMethod (arg1: number, arg2: string, arg3: boolean): boolean;
    someMethod (arg1: number, arg2: string): string;
    someMethod (arg1: { arg1: number, arg2: string, }): number;


    someMethod (x: any, ...args: any[]): string | number | boolean {
        if ('object' === typeof x) {
            return 1;
        } else if (1 === args.length) {
            return 1;
        } else {
            return 1;
        }
    }
}

函数重载 of 函数

function someFn (arg1: number, arg2: string, arg3: boolean): boolean;
function someFn (arg1: number, arg2: string): string;
function someFn (arg1: { arg1: number, arg2: string, }): number;


function someFn (x: any, ...args: any[]): string | number | boolean {
    if ('object' === typeof x) {
        return 1;
    } else if (1 === args.length) {
        return 1;
    } else {
        return 1;
    }
}

对象 key 的类型定义(索引标记)

可以使用 typeinterfaceclass 对象 key,但是使用方法十分麻烦,而且语法还不太一样(type 使用 ininterfaceclass 使用 :)。

注意:索引值只可以使用数字与字符串。

type 法

限定 key 的数据类型

其实就是放开了限制,让该类型的实例上可以添加各种各样的属性。

这里冒号 : 形式的不允许使用问号(可选项),但 in 形式的允许使用问号(可选项)。

但其实带不带结果都一样,实例都可以为空。

type SomeType1 = {
    [key: string]: string;
}


type SomeType2 = {
    [key in string]?: string;
}



const instance1: SomeType1 = {};
const instance2: SomeType2 = {};

限定 key 只可以使用特定值

这里其中的 key 就成了必选项了,问号(可选项)也有效果了。

type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType1 = {
    [key in audioTypes]: string;
}


type SomeType2 = {
    [key in audioTypes]?: string;
}



const instance5: SomeType1 = {
    'ogg': 'ogg',
    'mp3': 'mp3',
    'wma': 'wma',
};

const instance6: SomeType2 = {};

interface 法

限定 key 的数据类型

不可以用问号。

interface SomeInterface {
    [key: string]: string;
}


const instance: SomeInterface = {};

限定 key 只可以使用特定值

只能通过 extends 已定义的 type 来实现。

type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType = {
    [key in audioTypes]: string;
}


interface SomeInterface extends SomeType {}


const instance: SomeInterface = {

    ogg: 'ogg',
    mp3: 'mp3',
    wma: 'wma',
};

class 中的使用

限定 key 的数据类型

同样也不可以使用问号(可选值)。

class SomeClass {
    [key: string]: string;
}
const instance: SomeClass = new SomeClass();

限定 key 只可以使用特定值

通过 implements 其他的 interfacetype 实现(多重实现可以合并)。

请记得 interface 只是数据格式规范,implements 之后要记得在 class 里写实现

type audioTypes = 'ogg' | 'mp3' | 'wma';


type SomeType = {
    [key in audioTypes]: string;
}


interface SomeInterface {
    [key: string]: string;
}


class ClassExtended implements SomeInterface, SomeType {
    ogg = 'ogg';
    mp3 = 'mp3';
    wma = 'wma';
    [key: string]: string;
}


const instance = new ClassExtended();

如何初始化函数类变量?

  1. 初始化函数类变量时,是否需要既给左值声明类型,也给右值声明类型?
  2. 这样的语句应如何断句换行 & 换行后如何缩进?
const someFn: (input: number, target: object) => SomeClass = (input: number, target: object): SomeClass => {

    // ... do sth

};

类型声明 & npm 包版本

我们在平时使用一些类库时,某一生态环境下的多个包,可能会依赖同一个基础包。同一个生态环境下的包,更新节奏或快或慢,此时便可能会存在基础包版本不同的问题,npm 的解决方案是多版本共存,每个包引用自己对应版本的基础包。因为 typescript 的类型是基于文件进行定义的,内部结构完全相同的两个同名类型,在不同的文件中声明便成了不同的类型。

此处以 @forawesome 项目组下的 fontawesome 库进行举例,具体示例如下:

当我们在 vue 中使用 fortawesome 时,需要把图标文件从对应的包中导出(如免费基础包:@fortawesome/free-solid-svg-icons、免费公司 logo 包:@fortawesome/free-brands-svg-icons),并使用 @fortawesome/fontawesome-svg-core 模块的 library 方法导入到 vue 的运行环境中。

import {

    faVolumeUp,
    faPlay,
    faPause,

} from '@fortawesome/free-solid-svg-icons';



import {
    faWeibo,
    faWeixin,
} from '@fortawesome/free-brands-svg-icons';



library.add(

    faVolumeUp,

    faPlay,

    faPause,

    faWeibo,

    faWeixin

);

但我再刚开始开发时只使用了基础包,公司 logo 包是我在开发途中用到时才引入的,但这时 fortawesome 官方对整个库进行了版本升级,具体功能并没有什么改变,只是 fix 了一些 bug,版本号也只升级了一个小版本。

但在编译时 library.add 这里报告了错误:

TS2345: Argument of type 'IconDefinition' is not assignable to parameter of type 'IconDefinitionOrPack'.
  Type 'IconDefinition' is not assignable to type 'IconPack'.`

经过跟进发现:

@forawesome/fontawesome-svg-corelibrary.add 的参数所要求的 IconDefinition 类型来自顶层 node_modules 安装的公用的 @fortawesome/fontawesome-common-types 包的 index.d.ts 文件。

@fortawesome/free-brands-svg-icons 中字体的类型 IconDefinition 来自 @fortawesome/free-brands-svg-icons 自身内部 node_modules 里安装的高版本的 @fortawesome/fontawesome-common-typesindex.d.ts 文件。

虽然两个类型的定义一模一样,但因为不是同一个文件定义的,所以是完全不同的两种类型,因而造成了类型不匹配,无法正常编译。

遇到这种问题时,升级对应包的版本就可以了。

复杂泛型嵌套的生成方法与可读性

talk is cheap, show you the dunce.

节选自 vue/types/vue.d.ts,我已经看晕了,调用方想要查错的时候到底怎么看呢。

export interface VueConstructor<V extends Vue = Vue> {
  new <Data = object, Methods = object, Computed = object, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): CombinedVueInstance<V, Data, Methods, Computed, Record<PropNames, any>>;
  // ideally, the return type should just contains Props, not Record<keyof Props, any>. But TS requires Base constructors must all have the same return type.
  new <Data = object, Methods = object, Computed = object, Props = object>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): CombinedVueInstance<V, Data, Methods, Computed, Record<keyof Props, any>>;
  new (options?: ComponentOptions<V>): CombinedVueInstance<V, object, object, object, Record<keyof object, any>>;
  extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
  extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  extend<PropNames extends string = never>(definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
  extend<Props>(definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
  extend(options?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;
  nextTick(callback: () => void, context?: any[]): void;
  nextTick(): Promise<void>
  set<T>(object: object, key: string, value: T): T;
  set<T>(array: T[], key: number, value: T): T;
  delete(object: object, key: string): void;
  delete<T>(array: T[], key: number): void;
  directive(
    id: string,
    definition?: DirectiveOptions | DirectiveFunction
  ): DirectiveOptions;
  filter(id: string, definition?: Function): Function;
  component(id: string): VueConstructor;
  component<VC extends VueConstructor>(id: string, constructor: VC): VC;
  component<Data, Methods, Computed, Props>(id: string, definition: AsyncComponent<Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  component<Data, Methods, Computed, PropNames extends string = never>(id: string, definition?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
  component<Data, Methods, Computed, Props>(id: string, definition?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  component<PropNames extends string>(id: string, definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
  component<Props>(id: string, definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
  component(id: string, definition?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;
  use<T>(plugin: PluginObject<T> | PluginFunction<T>, options?: T): void;
  use(plugin: PluginObject<any> | PluginFunction<any>, ...options: any[]): void;
  mixin(mixin: VueConstructor | ComponentOptions<Vue>): void;
  compile(template: string): {
    render(createElement: typeof Vue.prototype.$createElement): VNode;
    staticRenderFns: (() => VNode)[];
  };
  config: VueConfiguration;
}

泛型(嵌套) + 合并声明 + 混入

0 === san;

相关推荐