TypeScript
[toc]
基础用法
1.类型声明
TypeScript 规定,变量只有赋值后才能使用,否则就会报错。
let x:number; console.log(x) // 报错
上面示例中,变量x没有赋值就被读取,导致报错。而 JavaScript 允许这种行为,不会报错,没有赋值的变量会返回undefined。
2.类型推断
类型声明并不是必需的,如果没有,TypeScript 会自己推断类型。
let foo = 123;
上面示例中,变量foo并没有类型声明,TypeScript 就会推断它的类型。由于它被赋值为一个数值,因此 TypeScript 推断它的类型为number。
后面,如果变量foo更改为其他类型的值,跟推断的类型不一致,TypeScript 就会报错。
因为TypeScript的类型推断,所以函数返回值的类型通常是省略不写的。
3.ts-node
ts-node 是一个非官方的 npm 模块,可以直接运行 TypeScript 代码。
如果只是想简单运行 TypeScript 代码看看结果,ts-node 不失为一个便捷的方法。
any类型 unknow类型 never类型
1.any类型
any 类型表示没有任何限制,该类型的变量可以赋予任意类型的值。变量类型一旦设为any,TypeScript 实际上会关闭这个变量的类型检查。即使有明显的类型错误,只要句法正确,都不会报错。
对于开发者没有指定类型、TypeScript 必须自己推断类型的那些变量,如果无法推断出类型,TypeScript 就会认为该变量的类型是any。
TypeScript 提供了一个编译选项noImplicitAny,打开该选项,只要推断出any类型就会报错。
污染问题
any类型除了关闭类型检查,还有一个很大的问题,就是它会“污染”其他变量。它可以赋值给其他任何类型的变量(因为没有类型检查),导致其他变量出错。
let x:any = 'hello'; let y:number; y = x; // 不报错 y * 123 // 不报错 y.toFixed() // 编译不报错
上面示例中,变量x的类型是any,实际的值是一个字符串。变量y的类型是number,表示这是一个数值变量,但是它被赋值为x,这时并不会报错。然后,变量y继续进行各种数值运算,TypeScript 也检查不出错误,问题就这样留到运行时才会暴露。
2.unknow类型
为了解决any类型“污染”其他变量的问题,TypeScript 3.0 引入了unknown类型。它与any含义相同,表示类型不确定,可能是任意类型,但是它的使用有一些限制,不像any那样自由,可以视为严格版的any。
unknown跟any的相似之处,在于所有类型的值都可以分配给unknown类型。
unknown类型跟any类型的不同之处在于,它不能直接使用。主要有以下几个限制:
-
首先,
unknown类型的变量,不能直接赋值给其他类型的变量(除了any类型和unknown类型) -
其次,不能直接调用
unknown类型变量的方法和属性。 -
再次,
unknown类型变量能够进行的运算是有限的,只能进行比较运算(运算符==、===、!=、!==、||、&&、?)、取反运算(运算符!)、typeof运算符和instanceof运算符这几种,其他运算都会报错。
那么,怎么才能使用unknown类型变量呢?
答案是只有经过“类型缩小”,unknown类型变量才可以使用。所谓“类型缩小”,就是缩小unknown变量的类型范围,确保不会出错。
let a:unknown = 1; if (typeof a === 'number') { let r = a + 10; // 正确 }
上面示例中,unknown类型的变量a经过typeof运算以后,能够确定实际类型是number,就能用于加法运算了。这就是“类型缩小”,即将一个不确定的类型缩小为更明确的类型。
这样设计的目的是,只有明确unknown变量的实际类型,才允许使用它,防止像any那样可以随意乱用,“污染”其他变量。类型缩小以后再使用,就不会报错。
总之,unknown可以看作是更安全的any。一般来说,凡是需要设为any类型的地方,通常都应该优先考虑设为unknown类型。
在集合论上,unknown也可以视为所有其他类型(除了any)的全集,所以它和any一样,也属于 TypeScript 的顶层类型。
3.Never类型
不存在任何属于“空类型”的值,该类型被称为never,即不可能有这样的值。
never类型的一个重要特点是,可以赋值给任意其他类型。
为什么never类型可以赋值给任意其他类型呢?这也跟集合论有关,空集是任何集合的子集。TypeScript 就相应规定,任何类型都包含了never类型。因此,never类型是任何其他类型所共有的,TypeScript 把这种情况称为“底层类型”(bottom type)。
总之,TypeScript 有两个“顶层类型”(any和unknown),但是“底层类型”只有never唯一一个。
类型系统
1.基本类型
JavaScript 语言(注意,不是 TypeScript)将值分成8种类型。
- boolean 略
- string 略
- number 略
- bigint 略
- symbol 略
- object
- undefined 略
- null 略
TypeScript 继承了 JavaScript 的类型设计,以上8种类型可以看作 TypeScript 的基本类型。
object:
根据 JavaScript 的设计,object 类型包含了所有对象、数组和函数。
const x:object = { foo: 123 }; const y:object = [1, 2, 3]; const z:object = (n:number) => n + 1;
上面示例中,对象、数组、函数都属于 object 类型。
2.包装对象类型
JavaScript 的8种类型之中,undefined和null其实是两个特殊值,object属于复合类型,剩下的五种属于原始类型,代表最基本的、不可再分的值。这五种原始类型的值,都有对应的包装对象。所谓“包装对象”,指的是这些值在需要时,会自动产生的对象。
在 JavaScript 语言中,只有对象才有方法,原始类型的值本身没有方法。
'hello'.charAt(1) // 'e'
这行代码之所以可以运行,就是因为在调用方法时,字符串会自动转为包装对象,charAt()方法其实是定义在包装对象上。
五种包装对象之中,symbol 类型和 bigint 类型无法直接获取它们的包装对象(即Symbol()和BigInt()不能作为构造函数使用),但是剩下三种可以。
Boolean() String() Number()
以上三个构造函数,执行后可以直接获取某个原始类型值的包装对象。
const s = new String('hello'); typeof s // 'object' s.charAt(1) // 'e'
上面示例中,s就是字符串hello的包装对象,typeof运算符返回object,不是string,但是本质上它还是字符串,可以使用所有的字符串方法。
注意,String()只有当作构造函数使用时(即带有new命令调用),才会返回包装对象。如果当作普通函数使用(不带有new命令),返回就是一个普通字符串。其他两个构造函数Number()和Boolean()也是如此。
3.包装对象类型与字面量类型
由于包装对象的存在,导致每一个原始类型的值都有包装对象和字面量两种情况。
'hello' // 字面量 new String('hello') // 包装对象
为了区分这两种情况,TypeScript 对五种原始类型分别提供了大写和小写两种类型。
- Boolean 和 boolean
- String 和 string
- Number 和 number
- BigInt 和 bigint
- Symbol 和 symbol
其中,大写类型同时包含包装对象和字面量两种情况,小写类型只包含字面量,不包含包装对象。
建议只使用小写类型,不使用大写类型。因为绝大部分使用原始类型的场合,都是使用字面量,不使用包装对象。而且,TypeScript 把很多内置方法的参数,定义成小写类型,使用大写类型会报错。
4.大写Object类型和小写object类型
大写的Object
大写的Object类型代表 JavaScript 语言里面的广义对象。所有可以转成对象的值,都是Object类型,这囊括了几乎所有的值。原始类型值、对象、数组、函数都是合法的Object类型。
事实上,除了undefined和null这两个值不能转为对象,其他任何值都可以赋值给Object类型。
另外,空对象{}是Object类型的简写形式,所以使用Object时常常用空对象代替。
let obj:{};
上面示例中,变量obj的类型是空对象{},就代表Object类型。
显然,无所不包的Object类型既不符合直觉,也不方便使用。
小写的Object
小写的object类型代表 JavaScript 里面的狭义对象,即可以用字面量表示的对象,只包含对象、数组和函数,不包括原始类型的值。
注意,无论是大写的Object类型,还是小写的object类型,都只包含 JavaScript 内置对象原生的属性和方法,用户自定义的属性和方法都不存在于这两个类型之中。
const o1:Object = { foo: 0 }; const o2:object = { foo: 0 }; o1.toString() // 正确 o1.foo // 报错 o2.toString() // 正确 o2.foo // 报错
上面示例中,toString()是对象的原生方法,可以正确访问。foo是自定义属性,访问就会报错。
5.undefined和null的特殊性
undefined和null既是值,又是类型。
作为值,它们有一个特殊的地方:任何其他类型的变量都可以赋值为undefined或null。
let age:number = 24; age = null; // 正确 age = undefined; // 正确
上面代码中,变量age的类型是number,但是赋值为null或undefined并不报错。
这并不是因为undefined和null包含在number类型里面,而是故意这样设计,任何类型的变量都可以赋值为undefined和null,以便跟 JavaScript 的行为保持一致。
JavaScript 的行为是,变量如果等于undefined就表示还没有赋值,如果等于null就表示值为空。所以,TypeScript 就允许了任何类型的变量都可以赋值为这两个值。
但是有时候,这并不是开发者想要的行为,也不利于发挥类型系统的优势。
const obj:object = undefined; obj.toString() // 编译不报错,运行就报错
上面示例中,变量obj等于undefined,编译不会报错。但是,实际执行时,调用obj.toString()就报错了,因为undefined不是对象,没有这个方法。
为了避免这种情况,及早发现错误,TypeScript 提供了一个编译选项strictNullChecks。只要打开这个选项,undefined和null就不能赋值给其他类型的变量(除了any类型和unknown类型)。
这个选项在配置文件tsconfig.json的写法如下。
{ "compilerOptions": { "strictNullChecks": true // ... } }
打开strictNullChecks以后,undefined和null这两种值也不能互相赋值了。
// 打开 strictNullChecks let x:undefined = null; // 报错 let y:null = undefined; // 报错
上面示例中,undefined类型的变量赋值为null,或者null类型的变量赋值为undefined,都会报错。
总之,打开strictNullChecks以后,undefined和null只能赋值给自身,或者any类型和unknown类型的变量。
6.值类型
TypeScript 规定,单个值也是一种类型,称为“值类型”。
let x:'hello'; x = 'hello'; // 正确 x = 'world'; // 报错
上面示例中,变量x的类型是字符串hello,导致它只能赋值为这个字符串,赋值为其他字符串就会报错。
TypeScript 推断类型时,遇到const命令声明的变量,如果代码里面没有注明类型,就会推断该变量是值类型。
// x 的类型是 "https" const x = 'https'; // y 的类型是 string const y:string = 'https';
上面示例中,变量x是const命令声明的,TypeScript 就会推断它的类型是值https,而不是string类型。
这样推断是合理的,因为const命令声明的变量,一旦声明就不能改变,相当于常量。值类型就意味着不能赋为其他值。
注意,const命令声明的变量,如果赋值为对象,并不会推断为值类型。
// x 的类型是 { foo: number } const x = { foo: 1 };
上面示例中,变量x没有被推断为值类型,而是推断属性foo的类型是number。这是因为 JavaScript 里面,const变量赋值为对象时,属性值是可以改变的。
7.联合类型
联合类型(union types)指的是多个类型组成的一个新类型,使用符号|表示。
联合类型A|B表示,任何一个类型只要属于A或B,就属于联合类型A|B。
let x:string|number; x = 123; // 正确 x = 'abc'; // 正确
上面示例中,变量x就是联合类型string|number,表示它的值既可以是字符串,也可以是数值。
联合类型可以与值类型相结合,表示一个变量的值有若干种可能。
let rainbowColor:'赤'|'橙'|'黄'|'绿'|'青'|'蓝'|'紫';
“类型缩小”是 TypeScript 处理联合类型的标准方法,凡是遇到可能为多种类型的场合,都需要先缩小类型,再进行处理。实际上,联合类型本身可以看成是一种“类型放大”(type widening),处理时就需要“类型缩小”(type narrowing)。
8.交叉类型
交叉类型(intersection types)指的多个类型组成的一个新类型,使用符号&表示。
交叉类型A&B表示,任何一个类型必须同时属于A和B,才属于交叉类型A&B,即交叉类型同时满足A和B的特征。
交叉类型的主要用途是表示对象的合成。
let obj: { foo: string } & { bar: string }; obj = { foo: 'hello', bar: 'world' };
9.type 命令
type命令用来定义一个类型的别名。
type Age = number; let age:Age = 55;
上面示例中,type命令为number类型定义了一个别名Age。这样就能像使用number一样,使用Age作为类型。
别名可以让类型的名字变得更有意义,也能增加代码的可读性,还可以使复杂类型用起来更方便,便于以后修改变量的类型。
别名不允许重名。
别名的作用域是块级作用域。这意味着,代码块内部定义的别名,影响不到外部。
别名支持使用表达式,也可以在定义一个别名时,使用另一个别名,即别名允许嵌套。
type World = "world"; type Greeting = `hello ${World}`;
上面示例中,别名Greeting使用了模板字符串,读取另一个别名World。
10.typeof运算符
JavaScript 里面,typeof运算符只可能返回八种结果,而且都是字符串。
TypeScript 将typeof运算符移植到了类型运算,它的操作数依然是一个值,但是返回的不是字符串,而是该值的 TypeScript 类型。
typeof 是一个很重要的 TypeScript 运算符,有些场合不知道某个变量foo的类型,这时使用typeof foo就可以获得它的类型。
11.块级类型声明
TypeScript 支持块级类型声明,即类型可以声明在代码块(用大括号表示)里面,并且只在当前代码块有效。
数组类型
JavaScript 数组在 TypeScript 里面分成两种类型,分别是数组(array)和元组(tuple)。本节进行数组的介绍.
TypeScript 数组有一个根本特征:所有成员的类型必须相同,但是成员数量是不确定的,可以是无限数量的成员,也可以是零成员。
数组的类型有两种写法:
1. 第一种写法是在数组成员的类型后面,加上一对方括号。
let arr:number[] = [1, 2, 3];
如果数组成员的类型比较复杂,可以写在圆括号里面。
let arr:(number|string)[];
这个例子里面的圆括号是必须的,否则因为竖杠
|的优先级低于[],TypeScript 会把number|string[]理解成number和string[]的联合类型。
2. 数组类型的第二种写法是使用 TypeScript 内置的 Array 接口。
let arr:Array<number> = [1, 2, 3];
这种写法对于成员类型比较复杂的数组,代码可读性会稍微好一些。
let arr:Array<number|string>;
TypeScript 允许使用方括号读取数组成员的类型。
type Names = string[]; type Name = Names[0]; // string
上面示例中,类型Names是字符串数组,那么Names[0]返回的类型就是string。
由于数组成员的索引类型都是number,所以读取成员类型也可以写成下面这样。
type Names = string[]; type Name = Names[number]; // string
上面示例中,Names[number]表示数组Names所有数值索引的成员类型,所以返回string。
数组的类型推断
如果变量的初始值是空数组,那么 TypeScript 会推断数组类型是any[]。
后面,为这个数组赋值时,TypeScript 会自动更新类型推断。
const arr = []; arr // 推断为 any[] arr.push(123); arr // 推断类型为 number[] arr.push('abc'); arr // 推断类型为 (string|number)[]
但是,类型推断的自动更新只发生初始值为空数组的情况。如果初始值不是空数组,类型推断就不会更新。
只读数组
JavaScript 规定,const命令声明的数组变量是可以改变成员的。
TypeScript 允许声明只读数组,方法是在数组类型前面加上readonly关键字。
const arr:readonly number[] = [0, 1];
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是父类型 父类型可以等于子类型,因为子类型满足父类型所有特征 a1 = a2; // 报错:这是因为a2是父类型,他没有a1所具有的东西
父类型 = 子类型: 父类型有的,子类型都可以给.
子类型 != 父类型: 子类型有的,父类型无法给.
注意,readonly关键字不能与数组的泛型写法一起使用。
实际上,TypeScript 提供了两个专门的泛型,用来生成只读数组的类型。
const a1:ReadonlyArray<number> = [0, 1]; const a2:Readonly<number[]> = [0, 1];
只读数组还有一种声明方法,就是使用“const 断言”。
const arr = [0, 1] as const;
as const告诉 TypeScript,推断类型时要把变量arr推断为只读数组,从而使得数组成员无法改变。
多维数组
TypeScript 使用T[][]的形式,表示二维数组,T是最底层数组成员的类型。
var multi:number[][] = [[1,2,3], [23,24,25]];
元组类型
元组(tuple)是 TypeScript 特有的数据类型,JavaScript 没有单独区分这种类型。它表示成员类型可以自由设置的数组,即数组的各个成员的类型可以不同。由于成员的类型可以不一样,所以元组必须明确声明每个成员的类型。
const s:[string, string, boolean] = ['a', 'b', true];
元组类型的写法,与上一章的数组有一个重大差异。数组的成员类型写在方括号外面(number[]),元组的成员类型是写在方括号里面([number])。TypeScript 的区分方法就是,成员类型写在方括号里面的就是元组,写在外面的就是数组。
使用元组时,必须明确给出类型声明(上例的[number]),不能省略,否则 TypeScript 会把一个值自动推断为数组。
// a 的类型被推断为 (number | boolean)[] let a = [1, true];
元组成员的类型可以添加问号后缀(?),表示该成员是可选的。
let a:[number, number?] = [1];
注意,问号只能用于元组的尾部成员,也就是说,所有可选成员必须在必选成员之后。
由于需要声明每个成员的类型,所以大多数情况下,元组的成员数量是有限的,从类型声明就可以明确知道,元组包含多少个成员,越界的成员会报错。
但是,使用扩展运算符(...),可以表示不限成员数量的元组。
type NamedNums = [ string, ...number[] ]; const a:NamedNums = ['A', 1, 2]; const b:NamedNums = ['B', 1, 2, 3];
扩展运算符(...)用在元组的任意位置都可以,它的后面只能是一个数组或元组。
type t1 = [string, number, ...boolean[]]; type t2 = [string, ...boolean[], number]; type t3 = [...boolean[], string, number];
元组可以通过方括号,读取成员类型。
type Tuple = [string, number]; type Age = Tuple[1]; // number
上面示例中,Tuple[1]返回1号位置的成员类型。
由于元组的成员都是数值索引,即索引类型都是number,所以可以像下面这样读取。
type Tuple = [string, number, Date]; type TupleEl = Tuple[number]; // string|number|Date
上面示例中,Tuple[number]表示元组Tuple的所有数值索引的成员类型,所以返回string|number|Date,即这个类型是三种值的联合类型。
只读元组
// 写法一 type t = readonly [number, string] // 写法二 type t = Readonly<[number, string]>
成员数量推断
如果没有可选成员和扩展运算符,TypeScript 会推断出元组的成员数量(即元组长度)。
如果包含了可选成员,TypeScript 会推断出可能的成员数量。
如果使用了扩展运算符,TypeScript 就无法推断出成员数量。
一旦扩展运算符使得元组的成员数量无法推断,TypeScript 内部就会把该元组当成数组处理。
Symbol类型
let x:symbol = Symbol(); let y:symbol = Symbol(); x === y // false
上面示例中,变量x和y的类型都是symbol,且都用Symbol()生成,但是它们是不相等的。
Unique symbol
symbol类型包含所有的 Symbol 值,但是无法表示某一个具体的 Symbol 值。
为了解决这个问题,TypeScript 设计了symbol的一个子类型unique symbol,它表示单个的、某个具体的 Symbol 值。
因为unique symbol表示单个值,所以这个类型的变量是不能修改值的,只能用const命令声明,不能用let声明。
每个声明为unique symbol类型的变量,它们的值都是不一样的,其实属于两个值类型。
const a:unique symbol = Symbol(); const b:unique symbol = Symbol(); a === b // 报错
而且,由于变量a和b是两个类型,也不能把一个赋值给另一个。
unique symbol 类型是 symbol 类型的子类型,所以可以将前者赋值给后者,但是反过来就不行。
const a:unique symbol = Symbol(); //子类型 const b:symbol = a; // 正确 const c:unique symbol = b; // 报错
unique symbol 类型的一个作用,就是用作属性名(对象的属性),这可以保证不会跟其他属性名冲突。如果要把某一个特定的 Symbol 值当作属性名,那么它的类型只能是 unique symbol,不能是 symbol。
const x:unique symbol = Symbol(); const y:symbol = Symbol(); interface Foo { [x]: string; // 正确 [y]: string; // 报错 }
上面示例中,变量y当作属性名,但是y的类型是 symbol,不是固定不变的值,导致报错。
unique symbol类型也可以用作类(class)的属性值,但只能赋值给类的readonly static属性。
class C { static readonly foo:unique symbol = Symbol(); }
上面示例中,静态只读属性foo的类型就是unique symbol。注意,这时static和readonly两个限定符缺一不可,这是为了保证这个属性是固定不变的。
类型推断
如果变量声明时没有给出类型,TypeScript 会推断某个 Symbol 值变量的类型。
let命令声明的变量,推断类型为 symbol。
// 类型为 symbol let x = Symbol();
const命令声明的变量,推断类型为 unique symbol。
// 类型为 unique symbol const x = Symbol();
但是,const命令声明的变量,如果赋值为另一个 symbol 类型的变量,则推断类型为 symbol。
let x = Symbol(); // 类型为 symbol const y = x;
let命令声明的变量,如果赋值为另一个 unique symbol 类型的变量,则推断类型还是 symbol。
const x = Symbol(); // 类型为 symbol let y = x;
函数类型
函数的类型声明,需要在声明函数时,给出参数的类型和返回值的类型。返回值的类型通常可以不写,因为 TypeScript 自己会推断出来。
void 类型允许返回undefined或null。
如果函数的类型定义很冗长,或者多个函数使用同一种类型,写法二用起来就很麻烦。因此,往往用type命令为函数类型定义一个别名,便于指定给其他变量。
type MyFunc = (txt:string) => void; const hello:MyFunc = function (txt) { console.log('hello ' + txt); };
函数的实际参数个数,可以少于类型指定的参数个数,但是不能多于,即 TypeScript 允许省略参数。
一个很有用的技巧,任何需要类型的地方,都可以使用typeof运算符从一个值获取类型。
可选参数
如果函数的某个参数可以省略,则在参数名后面加问号表示。
function f(x?:number) {...}
参数名带有问号,表示该参数的类型实际上是原始类型|undefined,它有可能为undefined。比如,上例的x虽然类型声明为number,但是实际上是number|undefined。
函数的可选参数只能在参数列表的尾部,跟在必选参数的后面。
可选参数与默认值不能同时使用。
rest参数
rest 参数表示函数剩余的所有参数,它可以是数组(剩余参数类型相同),也可能是元组(剩余参数类型不同)。
// rest 参数为数组 function joinNumbers(...nums:number[]) { // ... } // rest 参数为元组 function f(...args:[boolean, number]) { // ... }
注意,元组需要声明每一个剩余参数的类型。如果元组里面的参数是可选的,则要使用可选参数。
function f( ...args: [boolean, string?] ) {}
void类型
函数返回类型为void的f,是一个没有返回值的函数。但是实际上,f的值可以是一个有返回值的函数(返回123),编译时不会报错。这是因为,这时 TypeScript 认为,这里的 void 类型只是表示该函数的返回值没有利用价值,或者说不应该使用该函数的返回值。只要不用到这里的返回值,就不会报错。如果后面使用了这个函数的返回值,就违反了约定,则会报错。
注意,这种情况仅限于变量、对象方法和函数参数,函数字面量如果声明了返回值是 void 类型,还是不能有返回值。
never类型
never类型表示肯定不会出现的值。它用在函数的返回值,就表示某个函数肯定不会返回值,即函数不会正常执行结束。
它主要有以下两种情况。
(1)抛出错误的函数。
function fail(msg:string):never { throw new Error(msg); }
上面示例中,函数fail()会抛出错误,不会正常退出,所以返回值类型是never。
注意,只有抛出错误,才是 never 类型。如果显式用return语句返回一个 Error 对象,返回值就不是 never 类型。
(2)无限执行的函数。
const sing = function():never { while (true) { console.log('sing'); } };
上面示例中,函数sing()会永远执行,不会返回,所以返回值类型是never。
注意,never类型不同于void类型。前者表示函数没有执行结束,不可能有返回值;后者表示函数正常执行结束,但是不返回值,或者说返回undefined。
如果程序中调用了一个返回值类型为never的函数,那么就意味着程序会在该函数的调用位置终止,永远不会继续执行后续的代码。
一个函数如果某些条件下有正常返回值,另一些条件下抛出错误,这时它的返回值类型可以省略never。
原因是前面章节提到过,never是 TypeScript 的唯一一个底层类型,所有其他类型都包括了never。从集合论的角度看,number|never等同于number。这也提示我们,函数的返回值无论是什么类型,都可能包含了抛出错误的情况。
函数重载
有一些编程语言允许不同的函数参数,对应不同的函数实现。但是,JavaScript 函数只能有一个实现,必须在这个实现当中,处理不同的参数。因此,函数体内部就需要判断参数的类型及个数,并根据判断结果执行不同的操作。
function add( x:number, y:number ):number; function add( x:any[], y:any[] ):any[]; function add( x:number|any[], y:number|any[] ):number|any[] { if (typeof x === 'number' && typeof y === 'number') { return x + y; } else if (Array.isArray(x) && Array.isArray(y)) { return [...x, ...y]; } throw new Error('wrong parameters'); }
上面示例中,前两行类型声明列举了重载的各种情况。第三行是函数本身的类型声明,它必须与前面已有的重载声明兼容。
对象类型
基本
对象的方法使用函数类型描述:
const obj:{ x: number; y: number; add(x:number, y:number): number; // 或者写成 // add: (x:number, y:number) => number; } = { x: 1, y: 1, add(x, y) { return x + y; } };
注意,如果属性值是一个对象,readonly修饰符并不禁止修改该对象的属性,只是禁止完全替换掉该对象。
另一个需要注意的地方是,如果一个对象有两个引用,即两个变量对应同一个对象,其中一个变量是可写的,另一个变量是只读的,那么从可写变量修改属性,会影响到只读变量。
属性名的索引类型
如果对象的属性非常多,一个个声明类型就很麻烦,而且有些时候,无法事前知道对象会有多少属性,比如外部 API 返回的对象。这时 TypeScript 允许采用属性名表达式的写法来描述类型,称为“属性名的索引类型”。
索引类型里面,最常见的就是属性名的字符串索引。
type MyObj = { [property: string]: string }; const obj:MyObj = { foo: 'a', bar: 'b', baz: 'c', };
上面示例中,类型MyObj的属性名类型就采用了表达式形式,写在方括号里面。
[property: string]的property表示属性名,这个是可以随便起的,它的类型是string,即属性名类型为string。也就是说,不管这个对象有多少属性,只要属性名为字符串,且属性值也是字符串,就符合这个类型声明。
JavaScript 对象的属性名(即上例的property)的类型有三种可能,除了上例的string,还有number和**symbol**。
type T1 = { [property: number]: string }; type T2 = { [property: symbol]: string };
上面示例中,对象属性名的类型分别为number和symbol。
对象可以同时有多种类型的属性名索引,比如同时有数值索引和字符串索引。但是,数值索引不能与字符串索引发生冲突,必须服从后者,这是因为在 JavaScript 语言内部,所有的数值属性名都会自动转为字符串属性名。
type MyType = { [x: number]: boolean; // 报错 [x: string]: string; }
同样地,可以既声明属性名索引,也声明具体的单个属性名。如果单个属性名符合属性名索引的范围,两者不能有冲突,否则报错。
type MyType = { foo: boolean; // 报错 [x: string]: string; }
上述代码发生错误是因为foo作为一个具体的属性,他的类型是boolean类型的,但是后面的索引类型[x: string]: string;中也包含了foo作为字符串的类型.本着必须服从后者的特性,foo是会报错的.
属性的索引类型写法,建议谨慎使用,因为属性名的声明太宽泛,约束太少。
另外,属性名的数值索引不宜用来声明数组,因为采用这种方式声明数组,就不能使用各种数组方法以及length属性,因为类型里面没有定义这些东西。==> 声明的是伪数组.
解构赋值
解构赋值的类型写法,跟为对象声明类型是一样的。
const {id, name, price}:{ id: string; name: string; price: number } = product;
注意,目前没法为解构变量指定类型,因为对象解构里面的冒号,JavaScript 指定了其他用途:用作别名.
结构类型原则
只要对象 B 满足 对象 A 的结构特征,TypeScript 就认为对象 B 兼容对象 A 的类型,这称为“结构类型”原则(structural typing)。
type A = { x: number; }; type B = { x: number; y: number; };
上面示例中,对象A只有一个属性x,类型为number。对象B满足这个特征,因此兼容对象A,只要可以使用A的地方,就可以使用B。
这种设计有时会导致令人惊讶的结果。
type myObj = { x: number, y: number, }; function getSum(obj:myObj) { let sum = 0; for (const n of Object.keys(obj)) { const v = obj[n]; // 报错 sum += Math.abs(v); } return sum; }
上面示例中,函数getSum()要求传入参数的类型是myObj,但是实际上所有与myObj兼容的对象都可以传入。
例如:下列代码中的myObj2就是myObj的子类型,他就兼容myObj.可以向getSum里面传入.
type myObj2 = { x: number, y: number, z: string, };
这会导致const v = obj[n]这一行报错,原因是obj[n]取出的属性值不一定是数值(number),使得变量v的类型被推断为any。如果项目设置为不允许变量类型推断为any,代码就会报错。写成下面这样,就不会报错。
type MyObj = { x: number, y: number, }; function getSum(obj:MyObj) { return Math.abs(obj.x) + Math.abs(obj.y); }
上面示例就不会报错,因为函数体内部只使用了属性x和y,这两个属性有明确的类型声明,保证obj.x和obj.y肯定是数值。虽然与MyObj兼容的任何对象都可以传入函数getSum(),但是只要不使用其他属性,就不会有类型报错。
严格字面类型检查
如果对象使用字面量表示,会触发 TypeScript 的严格字面量检查。如果字面量的结构跟类型定义的不一样(比如多出了未定义的属性),就会报错。
const point:{ x:number; y:number; } = { x: 1, y: 1, z: 1 // 报错 };
如果等号右边不是字面量,而是一个变量,根据结构类型原则,是不会报错的
const myPoint = { x: 1, y: 1, z: 1 }; const point:{ x:number; y:number; } = myPoint; // 正确
TypeScript 对字面量进行严格检查的目的,主要是防止拼写错误。一般来说,字面量大多数来自手写,容易出现拼写错误,或者误用 API。
最小可选属性规则
根据“结构类型”原则,如果一个对象的所有属性都是可选的,那么其他对象跟它都是结构类似的。
为了避免这种情况,TypeScript 2.4 引入了一个“最小可选属性规则”.
type Options = { a?:number; b?:number; c?:number; }; const opts = { d: 123 }; const obj:Options = opts; // 报错
上面示例中,对象opts与类型Options没有共同属性,赋值给该类型的变量就会报错。
报错原因是,如果某个类型的所有属性都是可选的,那么该类型的对象必须至少存在一个可选属性,不能所有可选属性都不存在。这就叫做“最小可选属性规则”。
空对象
空对象作为类型,其实是Object类型的简写形式。
let d:{}; // 等同于 // let d:Object; d = {}; d = { x: 1 }; d = 'hello'; d = 2;
上面示例中,各种类型的值(除了null和undefined)都可以赋值给空对象类型,跟Object类型的行为是一样的。
interface 接口
interface 是对象的模板,可以看作是一种类型约定,中文译为“接口”。使用了某个模板的对象,就拥有了指定的类型结构。
interface 可以表示对象的各种语法,它的成员有5种形式:
- 对象属性
- 对象的属性索引
- 对象方法
- 函数
- 构造函数
(1)对象属性
interface Point { x: number; y: number; }
上面示例中,x和y都是对象的属性,分别使用冒号指定每个属性的类型。
(2)对象的属性索引
interface A { [prop: string]: number; }
上面示例中,[prop: string]就是属性的字符串索引,表示属性名只要是字符串,都符合类型要求。
属性索引共有string、number和symbol三种类型。
(3)对象的方法
对象的方法共有三种写法。
// 写法一 interface A { functionName (x: boolean): string; } // 写法二 interface B { functionName: (x: boolean) => string; } // 写法三 interface C { functionName: { (x: boolean): string }; }
(4)函数
interface 也可以用来声明独立的函数。
interface Add { (x:number, y:number): number; } const myAdd:Add = (x,y) => x + y;
(5)构造函数
interface 内部可以使用new关键字,表示构造函数。
interface ErrorConstructor { new (message?: string): Error; }
interface 继承 interface
interface 可以使用extends关键字,继承其他 interface。
interface Shape { name: string; } interface Circle extends Shape { radius: number; }
上面示例中,Circle继承了Shape,所以Circle其实有两个属性name和radius。这时,Circle是子接口,Shape是父接口。
interface 允许多重继承。
如果子接口与父接口存在同名属性,那么子接口的属性会覆盖父接口的属性。注意,子接口与父接口的同名属性必须是类型兼容的,不能有冲突,否则会报错。
多重继承时,如果多个父接口存在同名属性,那么这些同名属性不能有类型冲突,否则会报错。
interface 继承 type
interface 可以继承type命令定义的对象类型。
type Country = { name: string; capital: string; } interface CountryWithPop extends Country { population: number; }
上面示例中,CountryWithPop继承了type命令定义的Country对象,并且新增了一个population属性。
注意,如果type命令定义的类型不是对象,interface 就无法继承。
interface 继承 class
interface 还可以继承 class,即继承该类的所有成员。关于 class 的详细解释 参见下一章
class A { x:string = ''; y():boolean { return true; } } interface B extends A { z: number }
上面示例中,B继承了A,因此B就具有属性x、y()和z。
接口合并
多个同名接口会合并成一个接口。
interface Box { height: number; width: number; } interface Box { length: number; }
上面示例中,两个Box接口会合并成一个接口,同时有height、width和length三个属性。
为什么会有接口合并这样一个功能呢?
举例来说,Web 网页开发经常会对windows对象和document对象添加自定义属性,但是 TypeScript 会报错,因为原始定义没有这些属性。解决方法就是把自定义属性写成 interface,合并进原始定义。
interface Document { foo: string; } document.foo = 'hello';
上面示例中,接口Document增加了一个自定义属性foo,从而就可以在document对象上使用自定义属性。同名接口合并时,同一个属性如果有多个类型声明,彼此不能有类型冲突。
同名接口合并时,如果同名方法有不同的类型声明,那么会发生函数重载。而且,后面的定义比前面的定义具有更高的优先级。
这个规则有一个例外。同名方法之中,如果有一个参数是字面量类型,字面量类型有更高的优先级。
如果两个 interface 组成的联合类型存在同名属性,那么该属性的类型也是联合类型。
interface与type的异同
很多对象类型既可以用 interface 表示,也可以用 type 表示。而且,两者往往可以换用,几乎所有的 interface 命令都可以改写为 type 命令。
它们的相似之处,首先表现在都能为对象类型起名。
interface 与 type 的区别有下面几点:
(1)type能够表示非对象类型,而interface只能表示对象类型(包括数组、函数等)。
(2)interface可以继承其他类型,type不支持继承。
继承的主要作用是添加属性,
type定义的对象类型如果想要添加属性,只能使用&运算符进行交叉合并.
(3)同名(这里指的是类型名不是属性名)interface会自动合并,同名type则会报错。也就是说,TypeScript 不允许使用type多次定义同一个类型。
这表明,interface 是开放的,可以添加属性,type 是封闭的,不能添加属性,只能定义新的 type。
(4)interface不能包含属性映射(mapping),type可以. 详见《映射》一章
(5)this关键字只能用于interface。
(6)interface无法表达某些复杂类型(比如交叉类型和联合类型),但是type可以。
综上所述,如果有复杂的类型运算,那么没有其他选择只能使用type;一般情况下,interface灵活性比较高,便于扩充类型或自动合并,建议优先使用。
class类
类(class)是面向对象编程的基本构件,封装了属性和方法,js中ES6实现了class类的写法,ts则是对js中class进行的全面升级.
类的属性可以在顶层声明,也可以在构造方法内部声明。
对于顶层声明的属性,可以在声明时同时给出类型。
class Point { x:number; y:number; }
如果声明时给出初值,可以不写类型,TypeScript 会自行推断属性的类型。
readonly只读属性
属性名前面加上 readonly 修饰符,就表示该属性是只读的。实例对象不能修改这个属性。
构造方法可以修改和设置只读属性的值。如果两个地方都设置了只读属性的值,以构造方法为准。在其他方法修改只读属性都会报错。
方法的类型
类的方法就是普通函数,类型声明方式与函数一致。
另外,构造方法不能声明返回值类型,否则报错,因为它总是返回实例对象。
存取器方法
存取器(accessor)是特殊的类方法,包括取值器(getter)和存值器(setter)两种方法。
它们用于读写某个属性,取值器用来读取属性,存值器用来写入属性。
class C { _name = ''; get name() { return this._name; } set name(value) { this._name = value; } }
get name(): 是取值器,其中get是关键词,**name**是属性名。外部读取name属性时,实例对象会自动调用这个方法,该方法的返回值就是name属性的值。
set name():是存值器,其中set是关键词,**name**是属性名。外部写入name属性时,实例对象会自动调用这个方法,并将所赋的值作为函数参数传入。
TypeScript 对存取器有以下规则:
(1)如果某个属性只有get方法,没有set方法,那么该属性自动成为只读属性。该属性则不能再被重新赋值.
(2)get方法与set方法的可访问性必须一致,要么都为公开方法,要么都为私有方法。
类的属性索引
class MyClass { [s:string]: boolean | ((s:string) => boolean); get(s:string) { return this[s] as boolean; } }
上面示例中,[s:string]表示所有属性名类型为字符串的属性,它们的属性值要么是布尔值,要么是返回布尔值的函数。
注意,由于类的方法是一种特殊属性(属性值为函数的属性),所以属性索引的类型定义也涵盖了方法。如果一个对象同时定义了属性索引和方法,那么前者必须包含后者的返回值类型。
就如同上述例子中,属性索引s的本身作为属性的类型是string,s的类型是boolean和方法.包含了后者的get方法,所以不会报错.
类的interface接口
interface 接口或 type 别名,可以用对象的形式,为 class 指定一组检查条件。然后,类使用 implements 关键字,表示当前类满足这些外部类型条件的限制。
interface Country { name:string; capital:string; } // 或者 type Country = { name:string; capital:string; } class MyCountry implements Country { name = ''; capital = ''; }
interface 只是指定检查条件,如果不满足这些条件就会报错(如果interface声明了的属性你没有,就会报错)。它并不能代替 class 自身的类型声明,而且,类可以定义接口没有声明的方法和属性。
也就是说,虽然你 implements 了一个接口,但是你仍然需要在你的类中做类型声明.
implements关键字后面,**不仅可以是接口,也可以是另一个类。**这时,后面的类将被当作接口。
另外,interface 描述的是类的对外接口,也就是实例的公开属性和公开方法,不能定义私有的属性和方法。
实现多个接口
类可以实现多个接口(其实是接受多重限制),每个接口之间使用逗号分隔。
class Car implements MotorVehicle, Flyable, Swimmable { // ... }
上面示例中,Car类同时实现了MotorVehicle、Flyable、Swimmable三个接口。这意味着,它必须部署这三个接口声明的所有属性和方法,满足它们的所有条件。
但是,同时实现多个接口并不是一个好的写法,容易使得代码难以管理,可以使用两种方法替代:
- 第一种方法是类的继承。
- 第二种方法是接口的继承。
注意,一个接口同时实现多个接口时,不同接口不能有互相冲突的属性。
类与接口的合并
TypeScript 不允许两个同名的类,但是如果一个类和一个接口同名,那么接口会被合并进类。
class A { x:number = 1; } interface A { y:number; } let a = new A(); a.y = 10; a.x // 1 a.y // 10
class本身也是一种类型 ==疑惑==
实例类型
TypeScript 的类本身就是一种类型,但是它代表该类的实例类型,而不是 class 的自身类型。
只有该class的实例对象才能用这个class类型.
class Color { name:string; constructor(name:string) { this.name = name; } } const green:Color = new Color('green');
// 错误 function createPoint( PointClass:Point, x: number, y: number ) { return new PointClass(x, y); }
上面示例中,函数createPoint()的第一个参数PointClass,需要传入 Point 这个类,但是如果把参数的类型写成Point就会报错,因为Point描述的是实例类型,而不是 Class 的自身类型。
类的自身属性
要获得一个类的自身类型,一个简便的方法就是使用 typeof 运算符。
代表我不是实例对象,只是一个简单的对象,但是我需要这个class类型.
function createPoint( PointClass:typeof Point, x:number, y:number ):Point { return new PointClass(x, y); }
总结一下,类的自身类型就是一个构造函数,可以单独定义一个接口来表示。
结构类型原则
Class 也遵循“结构类型原则”。一个对象只要满足 Class 的实例结构,就跟该 Class 属于同一个类型。
class Foo { id!:number; } function fn(arg:Foo) { // ... } const bar = { id: 10, amount: 100, }; fn(bar); // 正确
上面示例中,对象bar满足类Foo的实例结构,只是多了一个属性amount。所以,它可以当作参数,传入函数fn()。
如果两个类的实例结构相同,那么这两个类就是兼容的,可以用在对方的使用场合。
class Person { name: string; } class Customer { name: string; } // 正确 const cust:Customer = new Person();
如果写成这样:
class Person { name: string; age: number; } class Customer { name: string; } // 正确 const cust:Customer = new Person(); // 报错 const cust:Person = new Customer();
因为根据“结构类型原则(父子原则)”,只要Person类具有name属性,就满足Customer类型的实例结构,所以可以代替它。反过来就不行,如果Customer类多出一个属性,就会报错。
类的继承
类(这里又称“子类”)可以使用 extends 关键字继承另一个类(这里又称“基类”)的所有属性和方法。
class A { greet() { console.log('Hello, world!'); } } class B extends A { } const b = new B(); b.greet() // "Hello, world!"
上面示例中,子类B继承了基类A,因此就拥有了greet()方法,不需要再次在类的内部定义这个方法了。
根据结构类型原则,子类也可以用于类型为基类的场合。
子类可以覆盖基类的同名方法。
但是,子类的同名方法不能与基类的类型定义相冲突。(即:子类方法中的参数个数,参数类型要与基类的相同.)
class A { greet() { console.log('Hello, world!'); } } class B extends A { // 报错 greet(name:string) { console.log(`Hello, ${name}`); } } //但是这样是可以的 class B extends A { greet(name?: string) { if (name === undefined) { super.greet(); } else { console.log(`Hello, ${name}`); } } }
注意,extends关键字后面不一定是类名,可以是一个表达式,只要它的类型是构造函数就可以了。
可访问性修饰符
类的内部成员的外部可访问性,由三个可访问性修饰符(access modifiers)控制:public、private和protected。
这三个修饰符的位置,都写在属性或方法的最前面。
private修饰符表示私有成员,只能用在当前类的内部,类的实例和子类都不能使用该成员。注意,子类不能定义父类私有成员的同名成员。
严格地说,private定义的私有成员,并不是真正意义的私有成员。
一方面,编译成 JavaScript 后,private关键字就被剥离了,这时外部访问该成员就不会报错。另一方面,由于前一个原因,TypeScript 对于访问private成员没有严格禁止,使用方括号写法([])或者in运算符,实例对象就能访问该成员。因此建议不使用private,改用 ES2022 的写法,获得真正意义的私有成员。==> ES2022 的私有成员写法#propName
protected修饰符表示该成员是保护成员,只能在类的内部使用该成员,实例无法使用该成员,但是子类内部可以使用。
子类不仅可以拿到父类的保护成员,还可以定义同名成员。
实例属性的简写形式
实际开发中,很多实例属性的值,是通过构造方法传入的。
class Point { x:number; y:number; constructor(x:number, y:number) { this.x = x; this.y = y; } }
这样的写法等于对同一个属性要声明两次类型,一次在类的头部,另一次在构造方法的参数里面。这有些累赘,TypeScript 就提供了一种简写形式。
class Point { constructor( public x:number, public y:number ) {} }
上面示例中,构造方法的参数x前面有public修饰符,这时 TypeScript 就会自动声明一个公开属性x,不必在构造方法里面写任何代码,同时还会设置x的值为构造方法的参数值。注意,这里的public不能省略。
除了public修饰符,构造方法的参数名只要有private、protected、readonly修饰符,都可以添加。
readonly还可以与其他三个可访问性修饰符,一起使用。
静态成员
类的内部可以使用static关键字,定义静态成员。
静态成员是只能通过类本身使用的成员,不能通过实例对象使用。
class MyClass { static x = 0; static printX() { console.log(MyClass.x); } } MyClass.x // 0 MyClass.printX() // 0
static关键字前面可以使用 public、private、protected 修饰符。
抽象类 抽象成员
TypeScript 允许在类的定义前面,加上关键字abstract,表示该类不能被实例化,只能当作其他类的模板。这种类就叫做“抽象类”(abstract class)。
abstract class A { id = 1; } const a = new A(); // 报错
抽象类只能当作基类使用,用来在它的基础上定义子类。
abstract class A { id = 1; } class B extends A { amount = 100; } const b = new B(); b.id // 1 b.amount // 100
抽象类的子类也可以是抽象类,也就是说,抽象类可以继承其他抽象类。
抽象类的内部可以有已经实现好的属性和方法,也可以有还未实现的属性和方法。后者就叫做“抽象成员”(abstract member):即属性名和方法名有abstract关键字,表示该方法需要子类实现。如果子类没有实现抽象成员,就会报错。
如果抽象类的方法前面加上abstract,就表明子类必须给出该方法的实现。
另外:
(1)抽象成员只能存在于抽象类,不能存在于普通类。
(2)抽象成员不能有具体实现的代码。
(3)抽象成员前也不能有private修饰符,否则无法在子类中实现该成员。
(4)一个子类最多只能继承一个抽象类。
this问题
类中的很多地方都需要用到this,但是如果:
class A { name = 'A'; getName() { return this.name; } } const a = new A(); a.getName() // 'A' const b = { name: 'b', getName: a.getName }; b.getName() // 'b'
那么A中的getName()函数输出的就不是实例对象的值了.
typeScript允许函数增加一个名为this的参数,放在参数列表的第一位,用来检查函数内部的this关键字的类型。
class A { name = 'A'; getName(this: A) { return this.name; } } const a = new A(); const b = a.getName;
上面示例中,类A的getName()添加了this参数,如果直接调用这个方法,this的类型就会跟声明的类型不一致,从而报错。
this参数的类型可以声明为各种对象,不一定非得是上述中的A.
function foo( this: { name: string } ) { this.name = 'Jack'; this.name = 0; // 报错 } foo.call({ name: 123 }); // 报错
在类的内部,this本身也可以当作类型使用,表示当前类的实例对象,但是this类型不允许应用于静态成员。
泛型
泛型主要用在四个场合:函数、接口、类和别名。尽量少用泛型。
函数的泛型写法
function关键字定义的泛型函数,类型参数放在尖括号中,写在函数名后面。
function id<T>(arg:T):T { return arg; }
那么对于箭头函数的写法:==注意,当泛型类型只有一个的时候要加一个逗号","==
let myId = <T,>(id:T):T => { return id; }
接口的泛型写法
interface 也可以采用泛型的写法。
interface Box<Type> { contents: Type; } let box:Box<string>;
类的泛型接口
泛型类的类型参数写在类名后面。
class Pair<K, V> { key: K; value: V; }
类型别名的泛型写法
type Nullable<T> = T | undefined | null;
类型参数的默认值
类型参数可以设置默认值。使用时,如果没有给出类型参数的值,就会使用默认值。
function getFirst<T = string>( arr:T[] ):T { return arr[0]; }
但是,因为 TypeScript 会从实际参数推断出T的值,从而覆盖掉默认值,所以下面的代码不会报错。
getFirst([1, 2, 3]) // 正确
一旦类型参数有默认值,就表示它是可选参数。如果有多个类型参数,可选参数必须在必选参数之后。
<T = boolean, U> // 错误 <T, U = boolean> // 正确
数组的泛型表示
数组类型有一种表示方法是Array<T>。这就是泛型的写法,Array是 TypeScript 原生的一个类型接口,T是它的类型参数。声明数组时,需要提供T的值。
let arr:Array<number> = [1, 2, 3];
事实上,在 TypeScript 内部,数组类型的另一种写法number[]、string[],只是Array<number>、Array<string>的简写形式。
其他的 TypeScript 内部数据结构,比如Map、Set和Promise,其实也是泛型接口,完整的写法是Map<K, V>、Set<T>和Promise<T>。
TypeScript 默认还提供一个ReadonlyArray<T>接口,表示只读数组。
类型参数的约束条件
很多类型参数并不是无限制的,对于传入的类型存在约束条件。
TypeScript 提供了一种语法,允许在类型参数上面写明约束条件,如果不满足条件,编译时就会报错。
类型参数的约束条件采用下面的形式:
<TypeParameter extends ConstraintType>
上面语法中,TypeParameter表示类型参数,extends是关键字,这是必须的,ConstraintType表示类型参数要满足的条件,即类型参数应该是ConstraintType的子类型。
类型参数可以同时设置约束条件和默认值,前提是默认值必须满足约束条件。
type Fn<A extends string, B extends string = 'world'> = [A, B]; type Result = Fn<'hello'> // ["hello", "world"]
function comp<T extends { length: number }>( a: T, b: T ) {...}
上面示例中,T extends { length: number }就是约束条件,表示类型参数 T 必须满足{ length: number },否则就会报错。
<T extends T> // 报错 <T extends U, U extends T> // 报错
上面示例中,T的约束条件不能是T自身。同理,多个类型参数也不能互相约束(即T的约束条件是U、U的约束条件是T),因为互相约束就意味着约束条件就是类型参数自身。
Enum类型
实际开发中,经常需要定义一组相关的常量, 就是 Enum 结构,用来将相关常量放在一个容器里面,方便使用。
enum Color { Red, // 0 Green, // 1 Blue // 2 }
上面示例声明了一个 Enum 结构Color,里面包含三个成员Red、Green和Blue。第一个成员的值默认为整数0,第二个为1,第三个为2,以此类推。
使用时,调用 Enum 的某个成员,与调用对象属性的写法一样,可以使用点运算符,也可以使用方括号运算符。
let c = Color.Green; // 1 // 等同于 let c = Color['Green']; // 1
Enum 结构本身也是一种类型。比如,上例的变量c等于1,它的类型可以是 Color,也可以是number。
let c:Color = Color.Green; // 正确 let c:number = Color.Green; // 正确
Enum 结构的特别之处在于,它既是一种类型,也是一个值。绝大多数 TypeScript 语法都是类型语法,编译后会全部去除,但是 Enum 结构是一个值,编译后会变成 JavaScript 对象,留在代码中。
// 编译前 enum Color { Red, // 0 Green, // 1 Blue // 2 } // 编译后 let Color = { Red: 0, Green: 1, Blue: 2 };
上面示例是 Enum 结构编译前后的对比。由于 TypeScript 的定位是 JavaScript 语言的类型增强,所以官方建议谨慎使用 Enum 结构,因为它不仅仅是类型,还会为编译后的代码加入一个对象。
Enum 成员的值
Enum 成员默认不必赋值,系统会从零开始逐一递增,按照顺序为每个成员赋值,比如0、1、2……
但是,也可以为 Enum 成员显式赋值。
- 成员的值可以是任意数值,可以是小数,但不能是大整数(Bigint)
- 成员的值甚至可以相同.
- 如果只设定第一个成员的值,后面成员的值就会从这个值开始递增。
- Enum 成员值都是只读的,不能重新赋值。为了让这一点更醒目,通常会在 enum 关键字前面加上
const修饰,表示这是常量,不能再次赋值。
enum Color { Red = 90, Green = 0.5, Blue = 7n // 报错 }
同名 Enum 的合并
- 多个同名的 Enum 结构会自动合并。
- Enum 结构合并时,只允许其中一个的首成员省略初始值,否则报错。
- 同名 Enum 合并时,不能有同名成员,否则报错。
字符串 Enum
Enum 成员的值除了设为数值,还可以设为字符串。也就是说,Enum 也可以用作一组相关字符串的集合。
注意,字符串枚举的所有成员值,都必须显式设置。如果没有设置,成员值默认为数值,且位置必须在字符串成员之前。
Enum 成员可以是字符串和数值混合赋值。
除了数值和字符串,Enum 成员不允许使用其他值(比如 Symbol 值)。
keyof 运算符... 反向映射 ...
类型断言
对于没有类型声明的值,TypeScript 会进行类型推断,很多时候得到的结果,未必是开发者想要的。
type T = 'a'|'b'|'c'; let foo = 'a'; let bar:T = foo; // 报错
上面示例中,最后一行报错,原因是 TypeScript 推断变量foo的类型是string,而变量bar的类型是'a'|'b'|'c',前者是后者的父类型。父类型不能赋值给子类型,所以就报错了。
TypeScript 提供了“类型断言”这样一种手段,允许开发者在代码中“断言”某个值的类型,告诉编译器此处的值是什么类型。TypeScript 一旦发现存在类型断言,就不再对该值进行类型推断,而是直接采用断言给出的类型。
type T = 'a'|'b'|'c'; let foo = 'a'; let bar:T = foo as T; // 正确
上面示例中,最后一行的foo as T表示告诉编译器,变量foo的类型断言为T,所以这一行不再需要类型推断了,编译器直接把foo的类型当作T,就不会报错了。
类型断言有两种语法。
// 语法一:<类型>值 <Type>value // 语法二:值 as 类型 value as Type
推荐使用语法二: let bar:T = foo as T;
类型断言的一大用处是,指定 unknown 类型的变量的具体类型。类型断言也适合指定联合类型的值的具体类型。
类型断言的条件
expr as T
上面代码中,expr是实际的值,T是类型断言,它们必须满足下面的条件:expr是T的子类型,或者T是expr的子类型。
也就是说,类型断言要求实际的类型与断言的类型兼容,实际类型可以断言为一个更加宽泛的类型(父类型),也可以断言为一个更加精确的类型(子类型),但不能断言为一个完全无关的类型。
as const 断言
TypeScript 提供了一种特殊的类型断言as const,用于告诉编译器,推断类型时,可以将这个值推断为常量,即把 let 变量断言为 const 变量,从而把内置的基本类型变更为值类型。
let s = 'JavaScript' as const; setLang(s); // 正确
上面示例中,变量s虽然是用 let 命令声明的,但是使用了as const断言以后,就等同于是用 const 命令声明的,变量s的类型会被推断为值类型JavaScript。
使用了as const断言以后,let 变量就不能再改变值了。
注意,as const断言只能用于字面量,不能用于变量。
let s = 'JavaScript'; setLang(s as const); // 报错
另外,as const也不能用于表达式。 ==> let s = ('Java' + 'Script') as const; // 报错
数组字面量使用as const断言后,类型推断就变成了只读元组。
非空断言
对于那些可能为空的变量(即可能等于undefined或null),TypeScript 提供了非空断言,保证这些变量不会为空,写法是在变量名后面加上感叹号!。
非空断言在实际编程中很有用,有时可以省去一些额外的判断。
断言函数
断言函数是一种特殊函数,用于保证函数参数符合某种类型。如果函数参数达不到要求,就会抛出错误,中断程序执行;如果达到要求,就不进行任何操作,让代码按照正常流程运行。
function isString(value:unknown):void { if (typeof value !== 'string') throw new Error('Not a string'); }
上面示例中,函数isString()就是一个断言函数,用来保证参数value是一个字符串,否则就会抛出错误,中断程序的执行。
下面是它的用法。
function toUpper(x: string|number) { isString(x); return x.toUpperCase(); }
传统的断言函数isString()的写法有一个缺点,它的参数类型是unknown,返回值类型是void(即没有返回值)。单单从这样的类型声明,很难看出isString()是一个断言函数。
为了更清晰地表达断言函数,TypeScript 3.7 引入了新的类型写法。
function isString(value:unknown):asserts value is string { if (typeof value !== 'string') throw new Error('Not a string'); }
上面示例中,函数isString()的返回值类型写成asserts value is string,其中asserts和is都是关键词,value是函数的参数名,string是函数参数的预期类型。它的意思是,该函数用来断言参数value的类型是string,如果达不到要求,程序就会在这里中断。
使用了断言函数的新写法以后,TypeScript 就会自动识别,只要执行了该函数,对应的变量都为断言的类型。
另外,断言函数的asserts语句等同于void类型,所以如果返回除了undefined和null以外的值,都会报错。