类型声明
let foo: string;
function toString(num: number): string {
return String(num);
}
类型推断
类型声明并不是必需的,如果没有,TypeScript 会自己推断类型。
let foo = 123;
上面示例中,变量foo并没有类型声明,TypeScript 就会推断它的类型。由于它被赋值为一个数值,因此 TypeScript 推断它的类型为number。
后面,如果变量foo更改为其他类型的值,跟推断的类型不一致,TypeScript 就会报错。
let foo = 123;
foo = "hello"; // 报错
any 类型
any 类型表示没有任何限制,该类型的变量可以赋予任意类型的值。
let x: any;
x = "foo"; // 正确
x = true; // 正确
变量类型一旦设为any,TypeScript 实际上会关闭这个变量的类型检查。即使有明显的类型错误,只要句法正确,都不会报错。
类型推断问题
对于开发者没有指定类型、TypeScript 必须自己推断类型的那些变量,如果无法推断出类型,TypeScript 就会认为该变量的类型是any。
污染问题
any类型除了关闭类型检查,还有一个很大的问题,就是它会“污染”其他变量。它可以赋值给其他任何类型的变量(因为没有类型检查),导致其他变量出错。
let x: any = "hello";
let y: number;
y = x; // 不报错
y * 123; // 不报错
y.toFixed(); // 不报错
上面示例中,变量x的类型是any,实际的值是一个字符串。变量y的类型是number,表示这是一个数值变量,但是它被赋值为x,这时并不会报错。然后,变量y继续进行各种数值运算,TypeScript 也检查不出错误,问题就这样留到运行时才会暴露。
污染其他具有正确类型的变量,把错误留到运行时,这就是不宜使用any类型的另一个主要原因。
unknown 类型
为了解决any类型“污染”其他变量的问题,TypeScript 3.0 引入了unknown类型。它与any含义相同,表示类型不确定,可能是任意类型,但是它的使用有一些限制,不像any那样自由,可以视为严格版的any。
unknown跟any的相似之处,在于所有类型的值都可以分配给unknown类型。
let x: unknown;
x = "Hello World"; // 正确
unknown类型跟any类型的不同之处在于,它不能直接使用。主要有以下几个限制。
首先,unknown类型的变量,不能直接赋值给其他类型的变量(除了any类型和unknown类型)。
let v: unknown = 123;
let v1: boolean = v; // 报错
上面示例中,变量v是unknown类型,赋值给any和unknown以外类型的变量都会报错,这就避免了污染问题,从而克服了any类型的一大缺点。
其次,不能直接调用unknown类型变量的方法和属性。
let v1: unknown = { foo: 123 };
v1.foo; // 报错
上面示例中,直接调用unknown类型变量的属性和方法,或者直接当作函数执行,都会报错。
再次,unknown类型变量能够进行的运算是有限的,只能进行比较运算(运算符==、===、!=、!==、||、&&、?)、取反运算(运算符!)、typeof运算符和instanceof运算符这几种,其他运算都会报错。
let a: unknown = 1;
a + 1; // 报错
a === 1; // 正确
上面示例中,unknown类型的变量a进行加法运算会报错,因为这是不允许的运算。但是,进行比较运算就是可以的。
那么,怎么才能使用unknown类型变量呢?
答案是只有经过“类型缩小”,unknown类型变量才可以使用。所谓“类型缩小”,就是缩小unknown变量的类型范围,确保不会出错。
let a: unknown = 1;
if (typeof a === "number") {
let r = a + 10; // 正确
}
上面示例中,unknown类型的变量a经过typeof运算以后,能够确定实际类型是number,就能用于加法运算了。这就是“类型缩小”,即将一个不确定的类型缩小为更明确的类型。
总之,unknown可以看作是更安全的any。一般来说,凡是需要设为any类型的地方,通常都应该优先考虑设为unknown类型。
在集合论上,unknown也可以视为所有其他类型(除了any)的全集,所以它和any一样,也属于 TypeScript 的顶层类型。
never 类型
为了保持与集合论的对应关系,以及类型运算的完整性,TypeScript 还引入了“空类型”的概念,即该类型为空,不包含任何值。
由于不存在任何属于“空类型”的值,所以该类型被称为never,即不可能有这样的值。
let x: never;
TypeScript 的类型系统
TypeScript 继承了 JavaScript 的类型,在这个基础上,定义了一套自己的类型系统。
- boolean
- string
- number
- bigint
- symbol
- object
- undefined
- null
TypeScript 继承了 JavaScript 的类型设计,以上 8 种类型可以看作 TypeScript 的基本类型。
注意,上面所有类型的名称都是小写字母,首字母大写的Number、String、Boolean等在 JavaScript 语言中都是内置对象,而不是类型名称。
另外,undefined 和 null 既可以作为值,也可以作为类型,取决于在哪里使用它们。
这 8 种基本类型是 TypeScript 类型系统的基础,复杂类型由它们组合而成。
包装对象类型
包装对象的概念
JavaScript 的 8 种类型之中,undefined和null其实是两个特殊值,object属于复合类型,剩下的五种属于原始类型(primitive value),代表最基本的、不可再分的值。
- boolean
- string
- number
- bigint
- symbol
上面这五种原始类型的值,都有对应的包装对象(wrapper object)。所谓“包装对象”,指的是这些值在需要时,会自动产生的对象。
Object 类型与 object 类型
TypeScript 的对象类型也有大写Object和小写object两种。
大写的Object类型代表 JavaScript 语言里面的广义对象。所有可以转成对象的值,都是Object类型,这囊括了几乎所有的值。
小写的object类型代表 JavaScript 里面的狭义对象,即可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。
值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”。
联合类型
联合类型(union types)指的是多个类型组成的一个新类型,使用符号|表示。
联合类型A|B表示,任何一个类型只要属于A或B,就属于联合类型A|B。
let x: string | number;
x = 123; // 正确
x = "abc"; // 正确
上面示例中,变量x就是联合类型string|number,表示它的值既可以是字符串,也可以是数值。
联合类型可以与值类型相结合,表示一个变量的值有若干种可能。
let setting: true | false;
let gender: "male" | "female";
let rainbowColor: "赤" | "橙" | "黄" | "绿" | "青" | "蓝" | "紫";
上面的示例都是由值类型组成的联合类型,非常清晰地表达了变量的取值范围。其中,true|false其实就是布尔类型boolean。
交叉类型
交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号&表示。
交叉类型A&B表示,任何一个类型必须同时属于A和B,才属于交叉类型A&B,即交叉类型同时满足A和B的特征。
let x: number & string;
上面示例中,变量x同时是数值和字符串,这当然是不可能的,所以 TypeScript 会认为x的类型实际是never。
交叉类型的主要用途是表示对象的合成。
let obj: { foo: string } & { bar: string };
obj = {
foo: "hello",
bar: "world",
};
上面示例中,变量obj同时具有属性foo和属性bar。
交叉类型常常用来为对象类型添加新属性。
type A = { foo: number };
type B = A & { bar: number };
上面示例中,类型B是一个交叉类型,用来在A的基础上增加了属性bar。
type 命令
type命令用来定义一个类型的别名。
type Age = number;
let age: Age = 55;
上面示例中,type命令为number类型定义了一个别名Age。这样就能像使用number一样,使用Age作为类型。
别名不允许重名,别名的作用域是块级作用域。这意味着,代码块内部定义的别名,影响不到外部。
type Color = "red";
if (Math.random() < 0.5) {
type Color = "blue";
}
type World = "world";
type Greeting = `hello ${World}`;
上面示例中,别名Greeting使用了模板字符串,读取另一个别名World。
type命令属于类型相关的代码,编译成 JavaScript 的时候,会被全部删除。
typeof 运算符
JavaScript 语言中,typeof 运算符是一个一元运算符,返回一个字符串,代表操作数的类型。
typeof "foo"; // 'string'
上面示例中,typeof运算符返回字符串foo的类型是string。
注意,这时 typeof 的操作数是一个值。
JavaScript 里面,typeof运算符只可能返回八种结果,而且都是字符串。
typeof undefined; // "undefined"
typeof true; // "boolean"
typeof 1337; // "number"
typeof "foo"; // "string"
typeof {}; // "object"
typeof parseInt; // "function"
typeof Symbol(); // "symbol"
typeof 127n; // "bigint"
上面示例是typeof运算符在 JavaScript 语言里面,可能返回的八种结果。
TypeScript 将typeof运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型。
const a = { x: 0 };
type T0 = typeof a; // { x: number }
type T1 = typeof a.x; // number
上面示例中,typeof a表示返回变量a的 TypeScript 类型({ x: number })。同理,typeof a.x返回的是属性x的类型(number)。
这种用法的typeof返回的是 TypeScript 类型,所以只能用在类型运算之中(即跟类型相关的代码之中),不能用在值运算。
块级类型声明
TypeScript 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效。
if (true) {
type T = number;
let v: T = 5;
} else {
type T = string;
let v: T = "hello";
}
上面示例中,存在两个代码块,其中分别有一个类型T的声明。这两个声明都只在自己的代码块内部有效,在代码块外部无效。
类型的兼容
TypeScript 的类型存在兼容关系,某些类型可以兼容其他类型。
type T = number | string;
let a: number = 1;
let b: T = a;
上面示例中,变量a和b的类型是不一样的,但是变量a赋值给变量b并不会报错。这时,我们就认为,b的类型兼容a的类型。
TypeScript 为这种情况定义了一个专门术语。如果类型A的值可以赋值给类型B,那么类型A就称为类型B的子类型(subtype)。在上例中,类型number就是类型number|string的子类型。
TypeScript 的一个规则是,凡是可以使用父类型的地方,都可以使用子类型,但是反过来不行。
数组类型
TypeScript 数组有一个根本特征:所有成员的类型必须相同,但是成员数量是不确定的,可以是无限数量的成员,也可以是零成员。
数组的类型有两种写法。第一种写法是在数组成员的类型后面,加上一对方括号。
let arr: (number | string)[] = [1, 2, 3];
这个例子里面的圆括号是必须的,否则因为竖杠|的优先级低于[],TypeScript 会把number|string[]理解成number和string[]的联合类型。
如果数组成员可以是任意类型,写成any[]。当然,这种写法是应该避免的。
数组类型的第二种写法是使用 TypeScript 内置的 Array 接口。
let arr: Array<number | string> = [1, 2, 3];;
数组的类型推断
如果数组变量没有声明类型,TypeScript 就会推断数组成员的类型。这时,推断行为会因为值的不同,而有所不同。
如果变量的初始值是空数组,那么 TypeScript 会推断数组类型是any[]。
// 推断为 any[]
const arr = [];
后面,为这个数组赋值时,TypeScript 会自动更新类型推断。
arr.push(123);
arr; // 推断类型为 number[]
arr.push("abc");
arr; // 推断类型为 (string|number)[]
上面示例中,数组变量arr的初始值是空数组,然后随着新成员的加入,TypeScript 会自动修改推断的数组类型。
但是,类型推断的自动更新只发生初始值为空数组的情况。如果初始值不是空数组,类型推断就不会更新。
只读数组,const 断言
JavaScript 规定,const命令声明的数组变量是可以改变成员的。
const arr = [0, 1];
arr[0] = 2;
上面示例中,修改const命令声明的数组的成员是允许的。
但是,很多时候确实有声明为只读数组的需求,即不允许变动数组成员。
TypeScript 允许声明只读数组,方法是在数组类型前面加上readonly关键字。
const arr: readonly number[] = [0, 1];
arr[1] = 2; // 报错
上面示例中,arr是一个只读数组,删除、修改、新增数组成员都会报错。
TypeScript 将readonly number[]与number[]视为两种不一样的类型,后者是前者的子类型。
这是因为只读数组没有pop()、push()之类会改变原数组的方法,所以number[]的方法数量要多于readonly number[],这意味着number[]其实是readonly number[]的子类型。
我们知道,子类型继承了父类型的所有特征,并加上了自己的特征,所以子类型number[]可以用于所有使用父类型的场合,反过来就不行。
let a1: number[] = [0, 1];
let a2: readonly number[] = a1; // 正确
a1 = a2; // 报错
上面示例中,子类型number[]可以赋值给父类型readonly number[],但是反过来就会报错。
function getSum(s: number[]) {
// ...
}
const arr: readonly number[] = [1, 2, 3];
getSum(arr); // 报错
上面示例中,函数getSum()的参数s是一个数组,传入只读数组就会报错。原因就是只读数组是数组的父类型,父类型不能替代子类型。这个问题的解决方法是使用类型断言getSum(arr as number[]),详见《类型断言》一章。
注意,readonly关键字不能与数组的泛型写法一起使用。
// 报错
const arr: readonly Array<number> = [0, 1];
上面示例中,readonly与数组的泛型写法一起使用,就会报错。
实际上,TypeScript 提供了两个专门的泛型,用来生成只读数组的类型。
const a1: ReadonlyArray<number> = [0, 1];
const a2: Readonly<number[]> = [0, 1];
上面示例中,泛型ReadonlyArray<T>和Readonly<T[]>都可以用来生成只读数组类型。两者尖括号里面的写法不一样,Readonly<T[]>的尖括号里面是整个数组(number[]),而ReadonlyArray<T>的尖括号里面是数组成员(number)。
只读数组还有一种声明方法,就是使用“const 断言”。
const arr = [0, 1] as const;
arr[0] = [2]; // 报错
上面示例中,as const告诉 TypeScript,推断类型时要把变量arr推断为只读数组,从而使得数组成员无法改变。
多维数组
TypeScript 使用T[][]的形式,表示二维数组,T是最底层数组成员的类型。
var multi: number[][] = [
[1, 2, 3],
[23, 24, 25],
];
上面示例中,变量multi的类型是number[][],表示它是一个二维数组,最底层的数组成员类型是number。
元组类型
元组(tuple)是 TypeScript 特有的数据类型,JavaScript 没有单独区分这种类型。它表示成员类型可以自由设置的数组,即数组的各个成员的类型可以不同。
元组必须明确声明每个成员的类型。
const s: [string, string, boolean] = ["a", "b", true];
元组成员的类型可以添加问号后缀(?),表示该成员是可选的。注意,问号只能用于元组的尾部成员,也就是说,所有可选成员必须在必选成员之后。
let a: [number, number?] = [1];
只读元组
元组也可以是只读的,不允许修改,有两种写法。
type t = readonly [number, string];
type t = Readonly<[number, string]>;
跟数组一样,只读元组是元组的父类型。所以,元组可以替代只读元组,而只读元组不能替代元组。
成员数量的推断
如果没有可选成员和扩展运算符,TypeScript 会推断出元组的成员数量(即元组长度)。
如果包含了可选成员,TypeScript 会推断出可能的成员数量。
function f(point: [number, number?, number?]) {
if (point.length === 4) {
// 报错
// ...
}
}
上面示例会报错,原因是 TypeScript 发现point.length的类型是1|2|3,不可能等于4。
如果使用了扩展运算符,TypeScript 就无法推断出成员数量。
const myTuple: [...string[]] = ["a", "b", "c"];
if (myTuple.length === 4) {
// 正确
// ...
}
上面示例中,myTuple只有三个成员,但是 TypeScript 推断不出它的成员数量,因为它的类型用到了扩展运算符,TypeScript 把myTuple当成数组看待,而数组的成员数量是不确定的。
一旦扩展运算符使得元组的成员数量无法推断,TypeScript 内部就会把该元组当成数组处理。
扩展运算符与成员数量
扩展运算符(...)将数组(注意,不是元组)转换成一个逗号分隔的序列,这时 TypeScript 会认为这个序列的成员数量是不确定的,因为数组的成员数量是不确定的。
这导致如果函数调用时,使用扩展运算符传入函数参数,可能发生参数数量与数组长度不匹配的报错。
const arr = [1, 2];
function add(x: number, y: number) {
// ...
}
add(...arr); // 报错
上面示例会报错,原因是函数add()只能接受两个参数,但是传入的是...arr,TypeScript 认为转换后的参数个数是不确定的。
interface 接口
interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了某个模板的对象,就拥有了指定的类型结构。
interface Person {
firstName: string;
lastName: string;
age: number;
}
上面示例中,变量p的类型就是接口Person,所以必须符合Person指定的结构。
方括号运算符可以取出 interface 某个属性的类型。
interface Foo {
a: string;
}
type A = Foo["a"]; // string
上面示例中,Foo['a']返回属性a的类型,所以类型A就是string。
interface 可以表示对象的各种语法,它的成员有 5 种形式。
- 对象属性
- 对象的属性索引
- 对象方法
- 函数
- 构造函数
(1)对象属性
interface Point {
x: number;
y: number;
}
上面示例中,x和y都是对象的属性,分别使用冒号指定每个属性的类型。
属性之间使用分号或逗号分隔,最后一个属性结尾的分号或逗号可以省略。
如果属性是可选的,就在属性名后面加一个问号。
interface Foo {
x?: string;
}
如果属性是只读的,需要加上readonly修饰符。
interface A {
readonly a: string;
}
(2)对象的属性索引
interface A {
[prop: string]: number;
}
(3)对象的方法
对象的方法共有三种写法。
// 写法一
interface A {
f(x: boolean): string;
}
// 写法二
interface B {
f: (x: boolean) => string;
}
// 写法三
interface C {
f: { (x: boolean): string };
}
属性名可以采用表达式,所以下面的写法也是可以的。
const f = "f";
interface A {
[f](x: boolean): string;
}
类型方法可以重载。
interface A {
f(): number;
f(x: boolean): boolean;
f(x: string, y: string): string;
}
interface 里面的函数重载,不需要给出实现。但是,由于对象内部定义方法时,无法使用函数重载的语法,所以需要额外在对象外部给出函数方法的实现。
(4)函数
interface 也可以用来声明独立的函数。
interface Add {
(x: number, y: number): number;
}
const myAdd: Add = (x, y) => x + y;
上面示例中,接口Add声明了一个函数类型。
(5)构造函数
interface 内部可以使用new关键字,表示构造函数。
interface ErrorConstructor {
new (message?: string): Error;
}
上面示例中,接口ErrorConstructor内部有new命令,表示它是一个构造函数。
TypeScript 里面,构造函数特指具有constructor属性的类,详见《Class》一章。
interface 的继承
interface 可以继承其他类型,主要有下面几种情况。
interface 继承 interface
interface 可以使用extends关键字,继承其他 interface。
interface 允许多重继承。
interface Style {
color: string;
}
interface Shape {
name: string;
}
interface Circle extends Style, Shape {
radius: number;
}
如果子接口与父接口存在同名属性,那么子接口的属性会覆盖父接口的属性。注意,子接口与父接口的同名属性必须是类型兼容的,不能有冲突,否则会报错。
interface Foo {
id: string;
}
interface Bar extends Foo {
id: number; // 报错
}
上面示例中,Bar继承了Foo,但是两者的同名属性id的类型不兼容,导致报错。
多重继承时,如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错。
interface Foo {
id: string;
}
interface Bar {
id: number;
}
// 报错
interface Baz extends Foo, Bar {
type: string;
}
上面示例中,Baz同时继承了Foo和Bar,但是后两者的同名属性id有类型冲突,导致报错。
interface 继承 type
interface 可以继承type命令定义的对象类型。
type Country = {
name: string;
capital: string;
};
interface CountryWithPop extends Country {
population: number;
}
注意,如果type命令定义的类型不是对象,interface 就无法继承。
interface 继承 class
interface 还可以继承 class,即继承该类的所有成员。关于 class 的详细解释,参见下一章。
class A {
x: string = "";
y(): boolean {
return true;
}
}
interface B extends A {
z: number;
}
接口合并
多个同名接口会合并成一个接口。
interface Box {
height: number;
width: number;
}
interface Box {
length: number;
}
上面示例中,两个Box接口会合并成一个接口,同时有height、width和length三个属性。
如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型。
interface 与 type 的异同
interface命令与type命令作用类似,都可以表示对象类型。
很多对象类型即可以用 interface 表示,也可以用 type 表示。而且,两者往往可以换用,几乎所有的 interface 命令都可以改写为 type 命令。
interface 与 type 的区别有下面几点。
(1)type能够表示非对象类型,而interface只能表示对象类型(包括数组、函数等)。
(2)interface可以继承其他类型,type不支持继承。
(3)同名interface会自动合并,同名type则会报错。也就是说,TypeScript 不允许使用type多次定义同一个类型。
(4)interface不能包含属性映射(mapping),type可以,详见《映射》一章。
(5)this关键字只能用于interface。
(6)type 可以扩展原始数据类型,interface 不行。
(7)interface无法表达某些复杂类型(比如交叉类型和联合类型),但是type可以。