本文将介绍 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 );
在这个例子中,在调用特定类型的函数之前,我们显式地断言apple
是string
类型,pi
是number
类型。利用这些信息,IDE 和 TypeScript 编译器就知道正在处理的是什么类型了。
类型断言非常有用的地方之一是,当它与类型联合 type unions 一同使用的时候。类型联合是一种抽象类型,我们会在后面详细介绍。类型断言非常强大,但能力越大,责任越大。无条件要求 TypeScript 编译器相信某个值就是某种类型,但实际却不是,有时会出现毁灭性的后果。
字面量类型 Literal Types
我们已经见到过string
、number
、boolean
以及其它基本类型 primitive types,例如interface
、function
等抽象类型。我们刚刚介绍过,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
。如果你把鼠标放在这些常量名字上面,就会看到一些奇怪的类型。它们推断出的类型看起来很奇怪。这些类型就像上面注释中显示的那样。
使用var
或let
,TypeScript 编译器知道这些值可能会在程序运行期间被改变。因此,推断为string
或者number
是合适的。然而,常量永远不会改变,因此推断为集合类型 collective types 没有任何意义。
这些也被称为单元类型 unit types,因为它们表示的是单一值,而不是一个字面量无限集。我们可以定义string
、number
和boolean
的字面量类型,但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
接受一个类型为string
的gender
值,然后对其进行处理。但是,由于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 } );
在上面的例子中,我们有Student
、Player
和Racer
接口,用于描述对象类型。我们可以看出,Student
接口有marks
属性,而其它两种类型都没有。类似的,Player
和Racer
分别有score
和points
属性。
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
函数接受Student
或Player
类型的参数person
。if
块使用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
参数,其类型是string
或number
,这些都是 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
接口,包含firstName
和lastName
属性。Player
接口有score
属性,Student
接口有marks
属性。
传统情况下,为了描述同时包含firstName
、lastName
和score
属性的对象,我们需要创建新的接口,可以是一个继承了Person
和Player
的空接口。
然而,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'. }; };
上面的例子中,Person
和Player
有相同的属性gender
,然而,其中一个的类型是string
,另外一个是number
。TypeScript 编译器不会提出异议,会直接把二者组合到一起。但是,gender
属性的类型被设置为never
。
这是因为,两个接口做交集时,它们的公共属性也会做交运算。因此,结果的接口具有从两个原始类型的交集中得到的属性。这样,在交运算之后,gender
属性具有string & number
类型。
但是,string & number
的交集没有任何意义,并不存在这样的类型的值。因为没有什么值能够同时是string
和number
类型,所以这种情况永远不会出现。因此,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 ) );
在上面的例子中,Person
和Student
类都有firstName
和lastName
属性。getFullName
函数接受一个参数p
,其类型是接口Person
,返回值是将firstName
和lastName
属性值拼接而成的全名。
虽然我们并没有显式声明,Student
类继承Person
类,TypeScript 还是会把Student
类型的实例monica
当做参数p
的合法值。这是因为对象monica
也有string
类型的firstName
和lastName
属性,而 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 ) );
上面的例子和之前的例子几乎完全一样。唯一的区别在于,在这个例子中,monica
和ross
就是符合接口定义的普通 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 允许使用变量引用替换子类型,但是在使用字面量的地方会报错。这种行为或许是为了避免误用。可以清晰地看到,所有的错误都出现在故意误用的地方。
你可以阅读这篇文档了解更多关于类型兼容的信息。
any
和unknown
类型
在前面的部分,我们学习了类型守卫可以帮助 TypeScript 编译器根据这个类型的唯一条件,将一种类型从一个可能的值的集合(联合)收窄到一个特定的类型。
在大多数情况下,你不需要为实体提供类型,因为这可以从实体的初始值推断出来,例如由函数参数的默认值。对于函数返回值,对象属性的初始值或者其它的值,都是类似的。
当 TypeScript 无法确定一个变量的类型时,它会把这个变量的类型隐式设置为any
。例如,let x;
表达式声明了一个变量x
,但并没有提供类型信息,也没有给初始值,那么,变量x
的默认类型就是any
。
any
类型只存在于 TypeScript。它可以看作是 JavaScript 运行时中所有值的通用类型。这意味着,string
、number
、symbol
、null
、undefined
以及其它所有存在于 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
的参数func
是any
类型的,因此,你可以将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
的参数person
是any
类型。在第一个if
块中,instanceof
类型守卫将person
的类型从any
收窄到Student
;第二个if
块则收窄到Player
。
注意,我们必须使用else if
,而不是else
。这是因为 TypeScript 不能神奇地认识到,else
包含Player
类型。我们在这里处理的是any
类型,而any
可以代表任意值,不仅仅是Student
和Player
。不要将它与联合混淆起来。
当你不知道一个值在运行时是什么形状时,any
类型是很有用的。这通常发生在使用第三方 API 时,TypeScript 无法确定类型。为了规避 TypeScript 编译错误,你不得不使用any
类型。
然而,TypeScript 3.0 引入了unknown
类型,为了减少由any
引起的一些问题。简单来说,unknown
类型告诉 TypeScript 编译器,这个值的形状在编译时是未知的,但是在运行时可能是任何类型。
因此,unknown
表示的值与any
一致,这让它成为 TypeScript 中的另一个顶级类型或者说超类型。与any
类似,包含有unknown
的类型联合会被收窄到unknown
,但是,如果any
和unknown
在一起时,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();
对比void
和never
在基本类型一节,我们了解到,函数返回void
类型表示没有return
语句的函数。类似的,函数返回never
类型表示函数不会返回任何值。这些类型在运行时不代表任何值,仅仅是为了辅助 TypeScript 类型系统。
基于此,void
和never
不能表示any
或者unknown
所代表的任意值。然而,在包含了void
和never
的类型联合中,如果里面还有any
或unknown
类型,这个联合则被收窄到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
对象,具有firstName
和lastName
属性。由于这两个属性都是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 假定这些文件都会在浏览器中被导入,它视图警告全局作用域中,多个文件包含重复声明。
什么是模块?
模块是一种类似沙盒的文件,它声明的值不会在全局作用域,除非显式指定。所有这些值都会被限制在文件本身,在文件外部都不可见。
由于模块不暴露值,模块也就不能看到别的模块定义的值。在模块之间共享值的唯一方式是使用import
和export
关键字。
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 都是模块,都使用了import
和export
语句。这个例子中,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 看作脚本文件,因此它们不包含import
和export
语句。该项目的 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 在使用。由于这些文件都被编译成脚本文件(没有import
和export
语句),TypeScript 假定这些文件在浏览器中通过传统的<script>
标签加载。
下面看看如果我们添加了import
和export
语句会发生什么。
// 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 形成了循环依赖。如果你的模块加载器不能处理这种循环依赖,那就不要在生产环境中使用。