首页 TypeScript 理解 TypeScript 类型系统

理解 TypeScript 类型系统

0 1.8K

本文将介绍 TypeScript 的类型基础,以及 TypeScript 是如何管理类型的。本文将包含类型断言、类型接口、类型联合、类型守卫、结构类型以及其它你需要了解的相关概念。

类型接口 Type Inference

在 TypeScript 中,使用一个值初始化一个变量,不需要为这个变量提供数据类型。TypeScript 编译器会通过赋给的值的类型 type 和形状 shape 来推断(infer)变量的类型。

let b = true; // let b: boolean
let n = 1; // let n: number
let s = "Hello World"; // let s: string
let o = { name: 'Ross geller' }; // let o: { name: string; }
let f = ( a = 1 ) => a + 1; // let f: (a?: number) => number
let a = [ 1, 2, 3 ]; // let a: number[]

在上面的例子中,我们声明了一些变量,并没有给这些变量显式指定类型,而是给出了初始值。当你鼠标滑过这些变量名时,TypeScript 会告诉你它推断出来的类型,正如注释中显示的那样。

仔细看一下,你会发现,通过默认参数值以及return语句,TypeScript 能够识别出函数参数类型和返回值的类型。这相当聪明。

通过初始值推断或猜测类型的过程称为类型推断 type inference。TypeScript 可以通过初始值,推断出变量或者常量的类型;通过默认值,推断出参数的类型;通过返回值,推断出函数返回值的类型。TypeScript 还可以在其它场景推断别的类型,我们会在后面的章节中看到。

但是,TypeScript 并不足够聪明到能够推断出所有复杂类型。例如,仅仅通过对象的形状,TypeScript 并不能推断出对象的接口类型。我们需要预先给出额外的信息。然而,这并不是必须的,我们会在后面学习到为什么会有这种情况。

类型断言 Type Assertion

大部分编程语言中,类型造型 type casting 可以将值从一种格式转换成另外一种格式。例如,一个字符串类型的值,比如"3",可以通过parseInt()函数转换为数字类型的值3。由于这也是将值从一种数据类型转换为另外一种数据类型,因此也可以称为类型转换 type conversion。

注:虽然原文使用了 type casting 和 type conversion 进行区分,但二者区别并不像原文中说的那么显而易见。比如,C# 文档中,将显式类型转换定义为 cast,这就与上文有所区别。而 MDN 则直接写“Type conversion (or typecasting) means transfer of data from one data type to another”,显然,MDN 认为二者并无不同。

类型断言是编译期的特性。在 TypeScript 中,我们使用类型断言要求 TypeScript 编译器,将一个值的类型视为与原类型不同的另外一种类型。例如,我们告诉 TypeScript 编译器,将3当做字符串,那么,TypeScript 就会把它看作字符串,并在此基础上提供自动补全和智能感知等。这并不会影响运行时值的类型,因为编译器并不会改变值。

在某些场景下,这种特性非常有用。比如,当一个值的类型是any,但我们确切地知道它的类型就是某种特定类型,我们就想让 IDE 基于那种类型给这个值提供自动补全以及智能感知。类型断言就可以使用在类似的场景,但要小心。

在 TypeScript 中,我们使用value as Type语法告诉编辑器,将值value视为Type类型。我们也可以使用<Type>value语法,二者是等价的。在某些场景中,我们可能需要使用括号,例如(<number>someNumStr).toFixed(n)或者(someNumStr as number).toFixed(n)

// return a value
let getStuff = ( type: string ): any => {
    switch( type ) {
        case 'string': return 'Apple';
        case 'number': return 3.1415926;
        case 'boolean': return false;
    }
}

// get some values
let apple = getStuff( 'string' );
let pi = getStuff( 'number' );
let isApplePie = getStuff( 'boolean' );

// let apple: any
console.log( apple.toFixed( 2 ) );
// 🔴 TypeError: apple.toFixed is not a function

// let pi: any
console.log( pi.toUpperCase( 2 ) );
// 🔴 TypeError: pi.toUpperCase is not a function

// let isApplePie: any
console.log( isApplePie + 1 );

在上面的例子中,getStuff()函数返回类型为any的值。any是最乏味的类型之一,因为它不包含任何有关值的信息。因此,当我们使用apple调用toFixed()函数时,由于apple在运行时是string类型,而编译期是any类型,TypeScript 编译器不会有任何警告,因为any类型可以是任何类型。

上面的程序可以通过编译,但当你运行时就会崩溃,TypeError错误如例子中所示。此时,我们可以使用类型断言修正这些错误。

// return a value
let getStuff = ( type: string ): any => {
    switch( type ) {
        case 'string': return 'Apple';
        case 'number': return 3.1415926;
        case 'boolean': return false;
    }
}

// get some values
let apple = getStuff( 'string' );
let pi = getStuff( 'number' );
let isApplePie = getStuff( 'boolean' ) as boolean;


// Compilation Error: Property 'toFixed' does not exist on type 'string'.
console.log( ( apple as string ).toFixed() );

// Compilation Error: Property 'toUpperCase' does not exist on type 'number'.
console.log( ( <number>pi ).toUpperCase( 2 ) );

// Compilation Error: Operator '+' cannot be applied to types 'boolean' and 'number'.
console.log( isApplePie + 1 );

在这个例子中,在调用特定类型的函数之前,我们显式地断言applestring类型,pinumber类型。利用这些信息,IDE 和 TypeScript 编译器就知道正在处理的是什么类型了。

类型断言非常有用的地方之一是,当它与类型联合 type unions 一同使用的时候。类型联合是一种抽象类型,我们会在后面详细介绍。类型断言非常强大,但能力越大,责任越大。无条件要求 TypeScript 编译器相信某个值就是某种类型,但实际却不是,有时会出现毁灭性的后果。

字面量类型 Literal Types

我们已经见到过stringnumberboolean以及其它基本类型 primitive types,例如interfacefunction等抽象类型。我们刚刚介绍过,TypeScript 可以从初始值推断类型。现在,我们一直使用的是let关键字声明变量。但是,const关键字也是同样的处理吗?

// collective types
var str = 'hello'; // var str: string
var num = 100; // var num: number
let bool = false; // let bool: boolean

// unit (literal) types
const s = "Hello"; // const s: "Hello"
const n = 100; // const n: 100
const b = false; // const b: false

在上面的例子中,我们定义了具有string值的常量s,具有number值的常量n。如果你把鼠标放在这些常量名字上面,就会看到一些奇怪的类型。它们推断出的类型看起来很奇怪。这些类型就像上面注释中显示的那样。

使用varlet,TypeScript 编译器知道这些值可能会在程序运行期间被改变。因此,推断为string或者number是合适的。然而,常量永远不会改变,因此推断为集合类型 collective types 没有任何意义。

这些也被称为单元类型 unit types,因为它们表示的是单一值,而不是一个字面量无限集。我们可以定义stringnumberboolean的字面量类型,但boolean只能有两个单元类型。

这并不仅仅是const声明的情形。你可以强制变量、函数参数或者函数返回值使用字面量类型。来看下面的例子。

// Type: let executeSafe: (task: string) => 0
let executeSafe = ( task: string ): 0 => {
    console.log( `executing: ${ task }` );
    return 0;
};

// Error: This condition will always return 'false' since the types '1' and '0' have no overlap.
console.log( 1 == executeSafe( 'MY_TASK' ) );

在上面的例子中,我们定义了一个函数executeSafe,这个函数始终返回0,作为任务执行的状态码。这意味着任务始终成功,并返回状态码0

所以,除了将返回值设置为number类型,我们还可以将其设置为0类型。在下面的程序中,我们试着将函数返回值与1比较,但1这个返回值是非法的,因为这种情形永远不会发生。

对于期望得到一个单元类型,但实际却给出了一个集合类型,例如string的情况,TypeScript 编译器会发出警告,即便这个变量的值就是这个单元类型的值也不行。这是因为变量在运行时可以包含任意值。为了解决这个问题,我们需要使用类型断言,如下面的代码所示。

// function that accepts unit type argument
let sayHello = ( prefix: 'Hello', name: string ): void => {
    console.log( `${ prefix }, ${ name }.` );
};

// legal: since literal value ('Hello') provided
sayHello( 'Hello', 'Ross Geller' );

/*-----------*/

// let monicaPrefix: 'Hello'
let monicaPrefix: 'Hello' = 'Hello';
let monica = 'Monica Geller';

// legal: since `monicaPrefix` has type of 'Hello'
sayHello( monicaPrefix, monica );

/*-----------*/

// let rachelPrefix: string
let rachelPrefix = 'Hello';
let rachel = 'Rachel Green';

// Error: Argument of type 'string' is not assignable to parameter of type ''Hello''.
sayHello( rachelPrefix, rachel );

// workaround: legal but misleading
sayHello( (rachelPrefix as 'Hello'), rachel );

字面量类型在定义可选值集合时更有用。这可以在后文中的类型联合得以看到。

在某些情形下,比起集合类型,TypeScript 更倾向于使用单元类型。例如,如果你给类型为string的参数传递一个字符串值'hello',TypeScript 会使用字面量类型'hello'作为函数调用的类型。在之后运行时,根据函数参数的类型,这个类型可能被强制转换到一个更宽泛的类型,比如string或者any。这种将类型从一种集合类型收窄到一个单元类型的过程被称为类型收窄 type narrowing。

类型联合 Type Union

类型联合就是两个或多个类型合并为一个。理解联合的最简单的方式就是想象一个由所有的可选字面量类型组成的集合。例如,性别gender变量的可能值为'male''demale''other',直接使用string类型意味着我们需要在运行时检查值是否合法。

// define function to set gender
let setGender = ( gender: string ): void | never => {
    if( gender !== 'male' && gender !== 'female' && gender !== 'other' ) {
        throw new Error( 'Please provide a correct gender.' );
    }

    // set gender
    // ....
};

// call `setGender` function with wrong gender value
setGender( 'true' );

在上面的例子中,函数setGender接受一个类型为stringgender值,然后对其进行处理。但是,由于gender只能是上面说的三个值其中之一,所以我们需要检查用户提供的值是不是合法的。

为了避免这一问题,我们可以使用字面量类型。gender参数可以接受的独立的字面量类型是'male''female''other'。因为我们需要gender参数值是'male'或者'female'或者'other',单一的单元类型已经不能满足要求了。此时我们需要引入联合类型。

管道运算符 (|) 将一个或多个类型组合为新的类型。这是一种逻辑或运算,只不过是运用在类型上面的逻辑或。现在,我们可以使用这些字面量类型的联合修改上面的程序。

// define function to set gender
let setGender = ( gender: 'male' | 'female' | 'other' ): void => {
    // set gender
    // ...
};

// call `setGender` function with wrong gender value
setGender( 'true' );

现在,在修改后的代码中,gender参数是联合类型'male' | 'female' | 'other'。利用这一信息,当传入值'true'时,由于'true'的类型是'true'(这是因为类型收窄),不属于特定的联合类型的值,TypeScript 编译器就会报错。这也帮助我们避免了在运行时验证gender参数值,因为其它非法值都不能通过编译。

联合类型可以由几乎所有类型创建。例如,如果一个变量的类型在运行时既可以是string,又可以是number,那么,你可以创建一个string | number类型,允许接受一个number或者string的值。

联合类型也可以由两个或多个接口创建。只要满足在联合中的接口之一,这样的值就可以通过。但是,有时候事情会有点复杂。

// `Student` interface
interface Student {
    name: string;
    marks: number;
}

// `Student` interface
interface Player {
    name: string;
    score: number;
}

// this function prints info
let printInfo = ( person: Student | Player ): void => {
    // Error: Property 'marks' does not exist on type 'Student | Player'.
    // Error: Property 'marks' does not exist on type 'Player'.
    console.log( `${ person.name } received ${ person.marks } marks.` );
};

在这个例子中,我们定义了Student接口和Player接口。这两个接口都有name属性。printInfo函数的参数可以是Student,也可以是Player

问题在于,我们传入一个值时,值的类型可以是Student或者Player,但是,marks参数却不存在于Player,因此,编译器就会报错。如果使用的是name属性就没有问题,因为name属性在两种类型都存在。

为了解决这个问题,我们可以使用类型断言。我们可以告诉 TypeScript 编译器,参数值在运行时可以是某一种类型,但这也有一些问题。看下面的例子。

// `Student` interface
interface Student {
    name: string;
    marks: number;
}

// `Player` interface
interface Player {
    name: string;
    score: number;
}

// this function prints info
let printInfo = ( person: Student | Player ): void => {
    console.log(
        `${ person.name } received ${ (person as Student).marks } marks.`
    );
};

// print info of a `Student`
const ross: Student = { name: 'Ross Geller', marks: 98 };
printInfo( ross );

在上面的例子中,(person as Student)类型断言将Student | Player类型的person强制转换成Student类型。但如果我们传入的是Player类型,原本就没有marks属性呢?程序就会崩溃。

你当然可以编写更好的逻辑去规避这种情况。但归根结底,你需要利用类型断言语法来告诉 TypeScript 编译器,你究竟需要哪种类型。

可识别联合 Discriminated Unions

TypeScript 提供了一种灵活的方式用于自动识别联合类型中的类型,而不需要使用类型断言。我们所需要做的,就是给联合中的所有接口增加一个共有的属性,用来识别每种类型。这么属性必须是唯一的字面量类型。

// `Student` interface
interface Student {
    type: 'student'; // discriminant
    name: string;
    marks: number;
}

// `Player` interface
interface Player {
    type: 'player'; // discriminant
    name: string;
    score: number;
}

// this function prints info
let printInfo = ( person: Student | Player ): void => {
    switch( person.type ) {
        case 'student': {
            // (parameter) person: Student
            return console.log(
                `${ person.name } received ${ person.marks } marks.`
            );
        }
        case 'player': {
            // (parameter) person: Player
            return console.log(
                `${ person.name } scored ${ person.score }.`
            );
        }
    }
};

// log info of the `Student` and `Player` objects
printInfo( { type: 'student', name: 'Ross Geller', marks: 98 } );
printInfo( { type: 'player', name: 'Monica Geller', score: 100 } );

在上面的代码中,我们使用接口中的type属性作为标识,给 TypeScript 编译器提供必要的信息,用于区分联合中的不同类型。然而,我们需要使用类型守卫 type guard(后文会详细介绍)来启用这种功能。

在这个例子中,我们使用switch语句作为类型守卫,根据person.type的值去识别类型。TypeScript 编译器利用这些信息区分类型,给case块提供正确的类型,正如上面注释中显示的那样。当鼠标滑过case块中的person参数时,就能显示这些类型。这同样能够支持智能感知。

keyof操作符

TypeScript 引入了keyof操作符,可以由接口的键创建一个字符串字面量类型的联合。当一个值必须是某一对象的键时,这是非常有用的。下面的代码解释这一问题。

// `Person` interface
interface Person {
    name: string;
    age: number;
}

// keyof Person => "firstName" | "lastName" | "age"
let printPersonValue = ( p: Person, key: keyof Person ): void => {
    // (parameter) p: Person
    // (parameter) key: "name" | "age"

    console.log( p[ key ] );
};

// legal
printPersonValue( { name: 'Ross Geller', age: 30 }, 'name' );

// illegal / Error: Argument of type '"profession"' is not assignable to parameter of type '"name" | "age"'
printPersonValue( { name: 'Monica Geller', age: 30 }, 'profession' );

一个值在运行时可以是多种类型时,类型联合会变得很有用。然而,类型联合的语法可能会变得又长又难以管理。因此,像下面这一创建一个类型别名的方式更好一些。

type Person = Student | Teacher | Coach

现在,我们得到了一个Person类型,从而不需要每次都写Student | Teacher | Coach这样的类型了。这里,Person就是一个类型别名 type alias。

类型守卫 Type Guards

在上面的例子中,我们使用switch/case语句来判断person参数的类型,究竟是Student还是Player。我们需要在每一个case块中,利用person参数的可识别字段 discriminant field 来获得正确的类型。

通过可识别字段的值,TypeScript 编译器能够将person的类型从Student | Player联合收窄到Student或者Player。这种将值的类型从一个可能值的集合收窄到一个确定的类型的过程,称为类型收窄 type narrowing,用于实现类型收窄的表达式被称为类型守卫 type guard。

类型守卫的职责是在某一范围内定义一个值的确切类型。如果我们知道怎么使用类型守卫,那么,我们就可以在编译程序时,避免不必要的和危险的类型断言。通过类型守卫,TypeScript 编译器能够自己确定一个值的类型,保证程序运行时的安全执行。

使用可识别字段

正如之前我们看到的那样,我们可以在switch/case语句中,使用接口的字面量类型字段,将一个值的类型从一个集合(联合)中识别出来。下面的例子,switch语句使用可识别字段创建了一个类型守卫。

// `Student` interface
interface Student {
    type: 'student'; // discriminant
    name: string;
    marks: number;
}

// `Player` interface
interface Player {
    type: 'player'; // discriminant
    name: string;
    score: number;
}

// this function prints info
let printInfo = ( person: Student | Player ): void => {
    switch( person.type ) {
        case 'student': {
            // (parameter) person: Student
            return console.log(
                `${ person.name } received ${ person.marks } marks.`
            );
        }
        case 'player': {
            // (parameter) person: Player
            return console.log(
                `${ person.name } scored ${ person.score }.`
            );
        }
    }
};

// log info of the `Student` and `Player` objects
printInfo( { type: 'student', name: 'Ross Geller', marks: 98 } );
printInfo( { type: 'player', name: 'Monica Geller', score: 100 } );

使用in操作符

如果联合中的某一类型拥有别的类型都没有的属性,那么,这个属性就可以作为识别类型的标识。我们可以使用 JavaScript 的in操作符,来检查对象是否包含一个属性。

如果我们在if/else语句中使用in操作符检查值是否含有联合中的类型的唯一属性,TypeScript 编译器就会在if块中收窄类型。

// some interfaces
interface Student { name: string; marks: number; }
interface Player { name: string; score: number; }
interface Racer { name: string; points: number; }

// this function prints info
let printInfo = ( person: Student | Player | Racer ): void => {
    if( 'marks' in person ) {
        // (parameter) person: Student
        console.log(
            `${ person.name } received ${ person.marks } marks.`
        );
    } else if( 'score' in person ) {
        // (parameter) person: Player
        console.log(
            `${ person.name } scored ${ person.score }.`
        );
    } else {
        // (parameter) person: Racer
        console.log(
            `${ person.name } gained ${ person.points } points.`
        );
    }
};

printInfo( { name: 'Ross Geller', marks: 98 } );
printInfo( { name: 'Monica Geller', score: 100 } );
printInfo( { name: 'Rachel Green', points: 100 } );

在上面的例子中,我们有StudentPlayerRacer接口,用于描述对象类型。我们可以看出,Student接口有marks属性,而其它两种类型都没有。类似的,PlayerRacer分别有scorepoints属性。

printInfo函数接受Student | Player | Racer类型的参数person,这意味着,在运行时,person可以是三者之一。这里,我们使用类型守卫识别person的类型。

我们使用if/else块和in运算符收窄person的类型。每个if块利用属性名字获得person的类型。

TypeScript 编译器在执行if/else语句时,从联合中排除已经处理过的类型。因此,当执行到else块时,联合中只剩下了Racer,所以,person的类型,在else块中就是Racer

if/else语句之外,person参数的类型依然是联合类型,除非if块中有return语句。如果我们在if块中添加return语句,我们就可以知道在此之后的代码只可能是类型联合中已经检查过剩下的那些类型。因此,在上面的代码中,如果每个if块都有return语句,那么else块就不需要了。

使用instanceof操作符

在上面的例子中,类型守卫可以使用in操作符,利用接口独特的属性区分类型。然而,在某些场景中,对象具有相似的属性,或者根本没有这种可以用于识别的属性。此时,使用in操作符的类型收窄就无能为力了。

如果你处理的是实例,你可以使用instanceof操作符,检查某一对象是否某一类的实例。

class Student {
    constructor(
        public name: string, private marks: number
    ) {}

    getMarks() { return this.marks; }
}

class Player {
    constructor(
        public name: string, private score: number
    ) {}

    getScore(){ return this.score; }
}

// this function prints info
let printInfo = ( person: Student | Player ): void => {
    if( person instanceof Student ) {
        // (parameter) person: Student

        console.log(
            `${ person.name }: ${ person.getMarks() }`
        );
    } else {
        // (parameter) person: Player

        console.log(
            `${ person.name }: ${ person.getScore() }`
        );
    }
};

printInfo( new Student( 'Ross Geller', 98 ) );
printInfo( new Player( 'Monica Geller', 100 ) );

在上面的例子中,printInfo函数接受StudentPlayer类型的参数personif块使用instanceof操作符检查person是不是Student的实例。如果检查通过,那么在整个块中,person的类型就是Student。在else块中,person的类型是Player,因为这是在第一个if收窄之后,Student | Player类型联合剩下的唯一的可能值。

使用typeof操作符

我们还可以利用 JavaScript 运行时的类型识别类型。我们知道,在 JavaScript 中,可以使用typeof操作符检查一个值的类型。如果我们在if/else块使用这些操作符,TypeScript 编译器就可以收窄类型。

// this function prints marks
let printMarks = ( marks: number | string ): void => {
    if( typeof marks === 'string' ) {
        // (parameter) marks: string

        const value = marks.toUpperCase(); // legal
        console.log( `MARKS: ${ value } grade` );
    } else {
        // (parameter) marks: number

        const value = marks.toFixed( 0 ); // legal
        console.log( `MARKS: ${ value } out of 10.` );
    }
};

// print marks
printMarks( 'b' );
printMarks( 8.621 );

在上面的例子中,printMarks函数接受marks参数,其类型是stringnumber,这些都是 JavaScript  原生类型 nativetypes。因此,如果我们在if块中使用typeof操作符检查属性值的类型(这会返回该值的原生类型),TypeScript 就可以在那个if块中识别出属性的类型。

类似的,TypeScript 也可以检查接口属性的类型。在下面的例子中,person.marks在每一个if或者else块中都有确定的类型。

interface Student {
    name: string;
    marks: number | string;
}

// this function prints info
let printInfo = ( person: Student ): void => {
    if( typeof person.marks === 'string' ) {
        // (property) Student.marks: string

        const name = person.name;
        const value = person.marks.toUpperCase();
        console.log( `${ name } -> ${ value } grade.` );
    } else {
        // (property) Student.marks: number

        const name = person.name;
        const value = person.marks.toFixed( 0 );
        console.log( `${ name } -> ${ value }/10.` );
    }
};

// print info
printInfo( { name: 'Ross Geller', marks: 'b' } );
printInfo( { name: 'Monica Geller', marks: 8.621 } );

用户定义的类型守卫

对于以上情况都不满足的情形,我们还可以使用 TypeScript 提供的is关键字实现类型守卫。is关键字返回一个值的类型是不是指定的类型

// type predicate function
let predicateString = (
    arg: number | string
): arg is string => {
    return typeof arg === 'string';
} 

// this function prints marks
let printMarks = ( marks: number | string ): void => {
    if( predicateString( marks ) ) {
        // (parameter) marks: string

        const value = marks.toUpperCase(); // legal
        console.log( `MARKS: ${ value } grade` );
    } else {
        // (parameter) marks: number

        const value = marks.toFixed( 0 ); // legal
        console.log( `MARKS: ${ value } out of 10.` );
    }
};

// print marks
printMarks( 'b' );
printMarks( 8.621 );

在上面的例子中,如果arg参数的类型是string,则predicateString函数返回true。这个函数的返回值类型是arg is string。这将指示 TypeScript 编译器,如果返回值arg的类型是string,那么,就将返回值的类型强制转换为string

arg is string表达式称为类型谓词 type predicate。printInfo函数在if/else语句中使用predicateString函数。由于predicateString函数的返回值类型是一个类型谓词,TypeScript 编译器可以根据返回值确定marks的类型,就想使用typeof操作符实现的类型守卫。

TypeScript 还可以使用value == null表达式收窄类型。如果值的类型是null | Student,那么,你就可以在if块中使用这个表达式,else块中该值的类型自动识别为Student

类型的交集

接口章节,我们知道,接口可以继承自其它接口。利用这种特性,我们可以将两个或多个类型合并起来,这对于混合模式 mixins pattern 非常有用。然而,通过扩展两个或多个接口创建一个新的接口,有时候并不合实际。

比如联合,一个值可以是联合中的任意给定类型。类型交集也可以将两个或多个类型合为一个。

interface Person {
    firstName: string;
    lastName: string;
}

interface Player {
    score: number;
}

interface Student {
    marks: number;
}

/*------*/

// get student info
let getStudent = ( p: Person, s: Student ): Person & Student => {
    return {
        firstName: p.firstName,
        lastName: p.lastName,
        marks: s.marks
    };
};

console.log( 'getStudent() =>', getStudent(
    { firstName:'Monica', lastName:'Geller' }, // Person
    { marks: 100 }, // Student
) );

/*------*/

// create a player info
let playerInfo: Person & Player = {
    firstName: 'Ross',
    lastName: 'Geller',
    score: 98
};

console.log( 'playerInfo =>', playerInfo );

在上面的例子中,我们创建了一个Person接口,包含firstNamelastName属性。Player接口有score属性,Student接口有marks属性。

传统情况下,为了描述同时包含firstNamelastNamescore属性的对象,我们需要创建新的接口,可以是一个继承了PersonPlayer的空接口。

然而,TypeScript 提供了&操作符,可以将两种类型组合起来,返回一个新的类型,这个新的类型包含这两种类型的所有属性。你可以给这些新的类型取个别名,或者就直接例子中使用相同的表达式。

我们马上能想到的是,如果两个接口有相同的属性,该如何处理呢?同时,我们可以把原生类型组合在一起吗?比如number & string?下面来看一下。

interface Person {
    name: string;
    gender: string;
}

interface Player {
    score: number;
    gender: number;
}

// get student info
let getStudent = (): Person & Player => {
    return {
        name: 'Ross Geller',
        score: 98,
        gender: 'Male' // Type 'string' is not assignable to type 'never'.
    };
};

上面的例子中,PersonPlayer有相同的属性gender,然而,其中一个的类型是string,另外一个是number。TypeScript 编译器不会提出异议,会直接把二者组合到一起。但是,gender属性的类型被设置为never

这是因为,两个接口做交集时,它们的公共属性也会做交运算。因此,结果的接口具有从两个原始类型的交集中得到的属性。这样,在交运算之后,gender属性具有string & number类型。

但是,string & number的交集没有任何意义,并不存在这样的类型的值。因为没有什么值能够同时是stringnumber类型,所以这种情况永远不会出现。因此,TypeScript 直接给出了never类型,而不是string & number

如果我们对一个联合和原生数据类型做交运算,那么最终结果就是二者的交集。

结构化类型 Structural Typing

简单来看,TypeScript 就是让我们能够编写安全的更好的 JavaScript 程序的类型系统。当我们给某个实体添加类型,或者使用类型断言语法判断类型的时候,这些类型并不会进入运行时。一旦 TypeScript 程序编译成JavaScript,这些类型信息就会全部丢失,因此,这一过程被称为类型系统的擦除

由于 TypeScript 的值并不会有具体的类型,类型检查是通过查看值的形状来完成的。来看下面的例子。

class Person {
    constructor(
        public firstName: string,
        public lastName: string
    ) {}
}

class Student {
    constructor(
        public firstName: string,
        public lastName: string,
        public marks: number
    ) {}
}

// print fullname of a `Person` object
function getFullName( p: Person ): string {
    return `${ p.firstName } ${ p.lastName }`;
}

var ross = new Person( 'Ross', 'Geller' );
var monica = new Student( 'Monica', 'Geller', 84 );

console.log( 'Ross =>', getFullName( ross ) );
console.log( 'Monica =>', getFullName( monica ) );

在上面的例子中,PersonStudent类都有firstNamelastName属性。getFullName函数接受一个参数p,其类型是接口Person,返回值是将firstNamelastName属性值拼接而成的全名。

虽然我们并没有显式声明,Student类继承Person类,TypeScript 还是会把Student类型的实例monica当做参数p的合法值。这是因为对象monica也有string类型的firstNamelastName属性,而 TypeScript 在验证一个值是不是Person类型时,仅关心这两个属性。

这证明,TypeScript 是一种结构化类型 structurally typed的语言,通常也被称为鸭子类型 duck typing。鸭子类型来源于一个俗语,“如果它走起来像鸭子,叫起来像鸭子,游起泳来像鸭子,那它就是只鸭子”。由于Student类型完全符合Person,那么,TypeScript 就会把它当做是Person类型。

这些原则不仅适用于类。在 TypeScript 中,类类型隐式定义了一个接口,这个接口包含这个类的所有公共属性(阅读这里了解更多),因此,这个原则同样适用于接口。

interface Person {
    firstName: string;
    lastName: string;
}

interface Student {
    firstName: string;
    lastName: string;
    marks: number;
}

// print fullname of a `Person` object
function getFullName( p: Person ): string {
    return `${ p.firstName } ${ p.lastName }`;
}

var ross: Person = {
    firstName: 'Ross',
    lastName: 'Geller'
};

var monica: Student = {
    firstName: 'Monica',
    lastName: 'Geller',
    marks: 84,
};

console.log( 'Ross =>', getFullName( ross ) );
console.log( 'Monica =>', getFullName( monica ) );

上面的例子和之前的例子几乎完全一样。唯一的区别在于,在这个例子中,monicaross就是符合接口定义的普通 JavaScript 对象,而在之前的例子,它们是类的实例。

这种行为有时也被叫做结构化子类型 structural subtyping。当类型A含有类型B的所有属性时,A就会称为B子类型 subtype。这与 OOP 中的继承类似。如果A继承B,那么类A就被称为类B的子类型,因为类A含有类B的所有属性。

所以在上面的例子中,Student就是Person的子类型,因为它包含了Person所需要的一切属性。但是,结构化子类型并不是在所有情形中都是有效的。来看下面的例子。

interface Person {
    firstName: string;
    lastName: string;
}

// accept an argument of type `Person
let printPerson = ( person: Person ): void => {
    console.log( `Hello, ${ person.firstName } ${ person.lastName }.` );
};

// legal
let ross = { firstName: 'Ross', lastName: 'Geller', gender: 'Male' };
printPerson( ross );

// illegal
// Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
printPerson( { firstName: 'Ross', lastName: 'Geller', gender: 'Male' } );

// legal
let monica: Person;
let monana = { firstName: 'Monica', lastName: 'Geller', gender: 'Male' };
monica = monana;

// illegal
// Error: Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.
let p: Person = { firstName: 'Ross', lastName: 'Geller', gender: 'Male' };

TypeScript 允许使用变量引用替换子类型,但是在使用字面量的地方会报错。这种行为或许是为了避免误用。可以清晰地看到,所有的错误都出现在故意误用的地方。

你可以阅读这篇文档了解更多关于类型兼容的信息。

anyunknown类型

在前面的部分,我们学习了类型守卫可以帮助 TypeScript 编译器根据这个类型的唯一条件,将一种类型从一个可能的值的集合(联合)收窄到一个特定的类型。

在大多数情况下,你不需要为实体提供类型,因为这可以从实体的初始值推断出来,例如由函数参数的默认值。对于函数返回值,对象属性的初始值或者其它的值,都是类似的。

当 TypeScript 无法确定一个变量的类型时,它会把这个变量的类型隐式设置为any。例如,let x;表达式声明了一个变量x,但并没有提供类型信息,也没有给初始值,那么,变量x的默认类型就是any

any类型只存在于 TypeScript。它可以看作是 JavaScript 运行时中所有值的通用类型。这意味着,stringnumbersymbolnullundefined以及其它所有存在于 JavaScript 的值都是any类型的。因此,any类型有时会被称为顶级类型 top type 或者超类型 supertype。

// type Collection = any
type Collection = string | number | undefined | any;
// type Collection = any
type Collection = string & any;

由于any是所有 JavaScript 值的超类型,包含any的联合类型会被收窄到any,正如上面的代码片段显示的那样。由于交集通过两种类型的组合生成新的类型,任何类型与any组合,最终得到的都是any

这意味着,你可以声明一个any类型的变量,然后把任意 JavaScript 值一遍一遍地赋给它,TypeScript 编译器也不会有任何抱怨。TypeScript 会做得更进一步,允许你将any类型的值赋给已知类型的变量。如下面显示的这样。

let x: any;
x = 1;
x = 'one';
x = true;
let y: boolean = x; // 违反了类型原则 🙁

这会在运行时引发一个巨大的问题。由于any类型的值没有固定的形状,TypeScript 允许你在程序的任何可以的地方使用这个值。例如,TypeScript 会允许你像调用函数一样调用它,也可以像类一样,试图创建它的一个实例。如果你选择使用any,TypeScript 会完全信任你。因此,使用了any类型的程序可能不会像预期那样运行,或者遗留运行时的严重异常。

// execute the `func` function
let calculate = (
    a: number, b: number, func: any
): number => {
    return func( a, b );
};

// calculate addition of two numbers
console.log(
    "valid =>",
    calculate(
        1,
        2,
        ( a: number, b: number ) => a + b
    )
);

// calculate subtrations of two numbers
console.log(
    "invalid =>",
    calculate( 1, 2, undefined )
);

在上面的程序中,函数calculate的参数funcany类型的,因此,你可以将undefined作为合法值传递给它。此时你就会发现,这段代码不能正常运行,因为undefined不是函数。

有无数的场景可以证明,any类型可以引发灾难。例如,你期望any类型的值x在运行时是一个对象,然后,你就可以用x.a.b这样的语句去访问它的属性,TypeScript 当然允许你这么做。但是,如果x在运行时不是一个对象,这个表达式就会引发错误。

由于any类型没有形状,你就不能从 IDE 获得任何自动补全以及智能提示这样的帮助。不过,你可以使用类型断言语法,比如x as Person去断言any类型的x就是Person类型。

我们也可以使用类型守卫将any收窄到特定类型。因为any表示很多类型的集合,比如联合,TypeScript 可以利用类型守卫区别其类型。

class Student {
    constructor(
        public name: string, public marks: number
    ){ }

    getGrade(): string {
        return (this.marks / 10).toFixed( 0 );
    }
}

class Player {
    constructor(
        public name: string, public score: number
    ){}

    getRank(): string {
        return (this.score / 10).toFixed( 0 );
    }
}

const getPosition = ( person: any ) => {
    if( person instanceof Student ) {
        // (parameter) person: Student

        console.log(
            `${ person.name } =>`, person.getGrade()
        );
    } else if( person instanceof Player ) {
        // (parameter) person: Player

        console.log(
            `${ person.name } =>`, person.getRank()
        );
    }
};

getPosition( new Student( 'Ross Geller', 82 ) );
getPosition( new Student( 'Monica Geller', 71 ) );

在上面的代码中,函数getPosition的参数personany类型。在第一个if块中,instanceof类型守卫将person的类型从any收窄到Student;第二个if块则收窄到Player

注意,我们必须使用else if,而不是else。这是因为 TypeScript 不能神奇地认识到,else包含Player类型。我们在这里处理的是any类型,而any可以代表任意值,不仅仅是StudentPlayer。不要将它与联合混淆起来。

当你不知道一个值在运行时是什么形状时,any类型是很有用的。这通常发生在使用第三方 API 时,TypeScript 无法确定类型。为了规避 TypeScript 编译错误,你不得不使用any类型。

然而,TypeScript 3.0 引入了unknown类型,为了减少由any引起的一些问题。简单来说,unknown类型告诉 TypeScript 编译器,这个值的形状在编译时是未知的,但是在运行时可能是任何类型。

因此,unknown表示的值与any一致,这让它成为 TypeScript 中的另一个顶级类型或者说超类型。与any类似,包含有unknown的类型联合会被收窄到unknown,但是,如果anyunknown在一起时,any的优先级更高。如下面的代码片段所示。

// type Collection = unknown
type Collection = string | number | undefined | unknown;
// type Collection = any
type Collection = string | number | undefined | unknown | any;
// type Collection = string
type Collection = string & unknown;

另外,在类型交集中,unknown类型的行为是不同的。任意类型与unknown的交集返回该类型。这是因为交集创建两种类型组合而来的新类型,而unknown不代表任何类型,因此结果就是这样。

你可以将 JavaScript 的任意值保存到unknown类型的变量,包括any类型。然而,你不能将unknown类型的值赋值给已知类型的变量。

let x: unknown;
x = 1;
x = 'one';
x = true;
// Error: Type 'unknown' is not assignable to type 'boolean'.
let y: boolean = x;

这是因为,y只能保存boolean类型的值,而unknown类型的值在运行时可以有任意类型。你可以会想,这为什么和any不一样?这正是引入unknown的原因。

TypeScript 不允许对unknown类型的值进行任何操作,除非使用类型断言或者类型守卫将unknown类型收窄到特定类型。

let x: unknown;
// Error: Property 'a' does not exist on type 'unknown'.
console.log( x.a );
// Error: This expression is not callable.
x();
// Error: This expression is not constructable.
new x();

对比voidnever

基本类型一节,我们了解到,函数返回void类型表示没有return语句的函数。类似的,函数返回never类型表示函数不会返回任何值。这些类型在运行时不代表任何值,仅仅是为了辅助 TypeScript 类型系统。

基于此,voidnever不能表示any或者unknown所代表的任意值。然而,在包含了voidnever的类型联合中,如果里面还有anyunknown类型,这个联合则被收窄到any或者unknown

// type Collection = any
type Collection = void | never | any;
// type Collection = unknown
type Collection = void | never | unknown;

typeof关键字

如果对象需要一个确定的形状,而你需要确保这一点,使用接口就是一种有效的和安全的做法。但是,我们还没考虑反过来的情形。如果我们想要从一个对象的形状创建一个接口呢?

此时,你可以使用typeof关键字。这个关键字在类型声明的语法中具有不同的行为。当typeof关键字后面跟着一个对象时,它会返回这个对象的形状作为接口。

// create an object
// let ross: { firstName: string; lastName: string; }
let ross = { firstName: 'Ross', lastName: 'Geller' };

// `monica` must have a shape of `ross`
let monica: typeof ross = {
    firstName: 'Monica',
    lastName: 'Geller'
};

// create a type alias for reuse
type Person = typeof ross;

// create `rachel` of type `Person`
let rachel: Person = {
    firstName: 'Rachel',
    lastName: 'Green'
};

在上面的例子中,我们定义了一个ross对象,具有firstNamelastName属性。由于这两个属性都是string值,TypeScript 编译器隐式创建了一个类似下面的接口,ross变量就是这种类型。

{
    firstName: string;
    lastName: string;
}

typeof ross表达式在运行时返回'object',但在类型声明中,比如let x: typeof ross,它返回与ross变量关联的隐式接口类型。因此,我们使用这个表达式将ross的类型应用到其它值。

我们也可以像上面的例子中的Person那样,给这个接口加个别名。typeof关键字不仅可以用于对象,它可以用于导出任意值的形状,比如string或者interface

declare关键字

在编写 JavaScript 前端应用时,我们只能在运行时使用第三方库。例如,当你从CDN导入lodash时,它会向window对象注入_变量,用于访问库的 API,例如_.tail函数。

类似的情形同样发生在设备或浏览器提供的原生 API,这些 API 同样只能在运行时使用。当你编写 TypeScript 程序需要使用这样的库函数时,TypeScript 编译器会抱怨这些函数根本不存在。

// Error: Cannot find name '_'
const result = _.tail( [ 1, 2, 3 ] );

我们没办法要求 TypeScript 编译器去规避这些问题,因为我们要访问的值根本不是在程序中定义的。一种显然的选择是,定义这么一个值,然后使用这个值,但这个值仅存在于当前作用域。

var _: any;

上面的代码中,我们定义了_变量,我们访问这个对象的任意函数,TypeScript 编译器都不会抱怨,因为首先,这个值存在,其次,这个值的类型是any

然而,在运行时,我们会直接忽略库提供的全局变量_,当前作用域的变量_可能是undefined,这可不是我们所需要的。

这正是declare关键字所扮演的角色。我们可以将declare关键字放在变量声明前面,这样就不会在当前作用创建新的变量,然而,它会告诉 TypeScript 编译器,这个值存在于运行时,并且它具有声明的类型。

// declare Lodash interface
interface Lodash {
    tail( values: any[] ): any[]
}

// declare `_` constant of type `Lodash`
declare const _: Lodash;

// works in runtime
const result = _.tail( [ 1, 2, 3 ] );

在上面的代码中,我们定义了 Lodash 接口,包含了 lodash 库运行时提供的那些函数。然后,我们声明了Lodash类型的常量_,告诉 TypeScript 编译器,_ 值存在于运行时,它的形状就是 Lodash 接口。这样,程序就可以通过编译。

如果你熟悉 C 或者 C++ 语言,declare关键字类似于extern关键字,这会告诉编译器,该实体(比如一个函数)的定义在运行时是可用的。

这种使用declare关键字声明外部变量以及它的类型的方式被称为环境声明 ambient declaration。通常这些声明会保存在以d.ts为后缀名的文件中(被称为定义文件)。这些文件就是普通的 TypeScript 文件,会被 TypeScript 编译器自动隐式导入或者借助 tsconfig.json 的帮助。

TypeScript 自带有很多环境声明。如果你打开 TypeScript 安装目录下的 lib 文件夹,就会发现这里就有 Web API 的类型定义文件,比如 DOM、fetch、WebWorker 以及 JavaScript 语言特性的类型定义。这些都会被自动导入。

脚本 vs 模块

TypeScript 按照角色不同,将源代码文件(.ts)做不同处理。JavaScript 文件可以是模块或者脚本。

什么是脚本?

在浏览器中,脚本文件使用传统的<script>标签导入。在脚本文件中定义的所有值(例如变量或者函数)在全局作用域(也被称作全局命名空间)中都是有效的,因此,你可以从另外的脚本文件中访问它们。

// main.js
var ross = { firstName: 'Ross', lastName: 'Geller' };
var fullname = sayHello( ross ); // 'Hello, Ross'
--------------------------------------------------------------------
// vendor.js
var prefix = 'Hello, ';
function sayHello( person ) {
  var result = prefix + person.firstName;
  return result;
}

上面的例子中,main.js 和 vendor.js 都是普通的 JavaScript 文件。main.js 期望sayHello函数在运行时是可用的。这个函数是在 vendor.js 文件中提供的。因此,这两个文件的顺序极其重要。

<script src="./vendor.js"></script>
<script src="./main.js"></script>

在脚本文件中声明的所有值都在全局作用域。因此,vendor.js 中的变量prefix以及sayHello函数被添加到全局作用域,也就能够在 main.js 中使用。类似的,main.js 中的变量ross以及name也可以在 vendor.js 中使用。但对result变量则不是这样,因为这个变量定义在函数内部,所以它被严格限制在函数作用域。

当程序运行于浏览器环境时,所有全局作用域的值都可以通过window对象使用,例如window.sayHello()

在 VSCode 中,如果打开两个或多个脚本文件,你可以马上看到不同文件中相同名字的变量或常量或报错。这是因为 VSCode 假定这些文件都会在浏览器中被导入,它视图警告全局作用域中,多个文件包含重复声明。

什么是模块?

模块是一种类似沙盒的文件,它声明的值不会在全局作用域,除非显式指定。所有这些值都会被限制在文件本身,在文件外部都不可见。

由于模块不暴露值,模块也就不能看到别的模块定义的值。在模块之间共享值的唯一方式是使用importexport关键字。

export关键字使得一个值可以被导入,但并不会将它添加到全局作用域。因此,另外的模块需要使用import关键字来导入值。我们直接来看一个例子。

// main.js
import { sayHello } from './vendor';
export var ross = { firstName: 'Ross', lastName: 'Geller' };
var fullname = sayHello( ross ); // 'Hello, Ross'
--------------------------------------------------------------------
// vendor.js
var prefix = 'Hello, ';
export function sayHello( person ) {
  var result = prefix + person.firstName;
  return result;
}

在上面的例子中,vendor.js 和 main.js 都是模块,都使用了importexport语句。这个例子中,vendor.js 不能访问变量ross,因为它没有被导入到 vendor.js;同样,变量prefix也不能在 main.js 中被使用,因此它没有在 vendor.js 中导出。这是由模块提供的隐式抽象,防止全局作用域被意外污染。

我们已经看到,在这种情形下,模块不能污染全局作用域。将值添加到全局作用域的唯一方法是,在浏览器环境下使用window对象,或者在跨平台环境下使用globalThis对象。

// main.js
import { sayHello } from './vendor';
export var ross = { firstName: 'Ross', lastName: 'Geller' };
var fullname = sayHello( ross ); // 'Hello, Ross'
console.log( window.prefix ); // 'Hello, '
--------------------------------------------------------------------
// vendor.js
var prefix = 'Hello, ';
window.prefix = prefix;
export function sayHello( person ) {
  var result = prefix + person.firstName;
  return result;
}

在上面的例子中,prefix在 main.js 中通过window.prefix可见。因为 vendor.js 显式将prefix变量添加到了全局作用域(window)。

对 TypeScript 有何影响?

TypeScript 是 JavaScript 的超集,所以它会支持所有 JavaScript 特性。所以,模块的行为与 JavaScript 表现一致。同时,这种行为也会影响到类型。

现在,我们创建一个项目,包含两个文件 mai.ts 和 vendor.ts。首先,我们将 main.ts 和 vendor.js 看作脚本文件,因此它们不包含importexport语句。该项目的 tsconfig.json 的最简单形式如下所示。

{
    "files": [
        "./main.ts",
        "./vendor.ts",
    ],
    "compilerOptions": {
        "outDir": "dist"
    }
}
// vendor.ts
var prefix: string = 'Hello, ';

function sayHello( person: Person ): string {
  var result = prefix + person.firstName;
  return result;
}

// main.ts
interface Person {
    firstName: string;
    lastName: string;
}

var ross: Person = {
    firstName: 'Ross',
    lastName: 'Geller'
};
var fullname: string = sayHello( ross );

在上面的例子中,vendor.ts 中的函数sayHello可以在 main.ts 中访问,而 main.ts 中的类型Person也可以在 vendor.ts 在使用。由于这些文件都被编译成脚本文件(没有importexport语句),TypeScript 假定这些文件在浏览器中通过传统的<script>标签加载。

下面看看如果我们添加了importexport语句会发生什么。

// vendor.ts
var prefix: string = 'Hello, ';

function sayHello( person: Person ): string {
  var result = prefix + person.firstName;
  return result;
}

export {}

// main.ts
interface Person {
    firstName: string;
    lastName: string;
}

var ross: Person = {
    firstName: 'Ross',
    lastName: 'Geller'
};
var fullname: string = sayHello( ross );

export {}

仅仅通过添加export {}语句(这个语句什么都没做),TypeScript 就假定这些文件是模块。因此,main.ts 不能访问到 vendor.ts 的sayHello函数了。

如果你仔细检查,会发现 main.ts 中定义的Person同样不能在 vendor.ts 中直接访问,因为 main.ts 也是一个模块。所以,如果你需要在模块之间共享值和类型,就必须添加显式的导入导出语句。

// vendor.ts
import { Person } from './main';

var prefix: string = 'Hello, ';

export function sayHello( person: Person ): string {
  var result = prefix + person.firstName;
  return result;
}

// main.ts
import { sayHello } from './vendor';

export interface Person {
    firstName: string;
    lastName: string;
}

var ross: Person = {
    firstName: 'Ross',
    lastName: 'Geller'
};
var fullname: string = sayHello( ross );

在上面的例子中,main.ts 导入了sayHello函数,而 vendor.ts 则导入了Person接口。

// vendor.ts
import { Person } from './main';

var prefix: string = 'Hello, ';
window.prefix = prefix;

export function sayHello( person: Person ): string {
  var result = prefix + person.firstName;
  return result;
}

// main.ts
import { sayHello } from './vendor';

export interface Person {
    firstName: string;
    lastName: string;
}

var ross: Person = {
    firstName: 'Ross',
    lastName: 'Geller'
};
var fullname: string = sayHello( ross );
console.log( window.prefix );

上面的例子,我们试图在 vendor.ts 和 main.ts 之间强制共享prefix变量,然而却失败了。当我们编译程序时,TypeScript 编译器会报错:

Property 'prefix' does not exist on type 'Window & typeof globalThis'.

这是因为window的类型是Window接口(由 TypeScript 标准库提供),它并没有prefix属性。因此,我们需要手动添加这个属性。

// vendor.ts
import { Person } from './main';

declare global {
  var prefix: string;
}

var prefix: string = 'Hello, ';
window.prefix = prefix;

export function sayHello( person: Person ): string {
  var result = prefix + person.firstName;
  return result;
}

// main.ts
import { sayHello } from './vendor';

export interface Person {
    firstName: string;
    lastName: string;
}

var ross: Person = { 
    firstName: 'Ross',
    lastName: 'Geller'
};

var fullname: string = sayHello( ross );
console.log( window.prefix );

在上面的例子中,我们使用declare global语句显式将prefix属性添加到了全局作用域。

注意,上面的几个例子,main.js 和 vendor.js 形成了循环依赖。如果你的模块加载器不能处理这种循环依赖,那就不要在生产环境中使用。

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2