简述
通过概述,我们已经了解到Typescript就是给ES6加入了强类型,这样可以在代码转译时候进行类型检查(type-check)。
从开发者角度,就是编写程序时候,通过声明,将代码中的变量、函数等的类型告知TypeScript编译器,在变量的使用或者函数的调用/类的实例化和访问属性等操作代码中,编译器就能发现潜在的问题。
我们看下面的示例
let a: number = 1;
上面代码声明了一个数值类型的变量a,并用1来初始化。:number
叫类型注解,就是告诉TypeScript该变量类型。如果变量类型和赋值类型不对,TypeScript会报错,一个变量从声明之后,类型就是固定的,后面不能改变。
let a: number = 1;
a = ''; // error TS2322: Type 'string' is not assignable to type 'number'.
再比如
let a: number;
a.split(','); // error TS2339: Property 'split' does not exist on type 'number'.
下面看函数如何声明其类型
function add(a: number, b: number): number {
return a + b;
}
add(1); // error TS2554: Expected 2 arguments, but got 1.
上面代码声明并定义了一个add函数,使用注解声明了函数参数和返回值的类型。
如果我们调用函数时候参数不对,TypeScript会编译报错。通常项目中会存在调用错误的情况,在运行时候才会发现,如果我们使用TypeScript编写程序,就能在开发阶段避免这类错误。
除了函数调用参数列表错误,还有很多其他的类型错误可以通过TypeScript在开发阶段就排查出来。这在很大程度上提升了项目质量。
另外,当我们定义了这个函数之后,在其他模块使用这个函数时候,TypeScript会根据函数的类型声明给出提示,除此之外,我们定义好一个类之后,在其他模块使用对象,也会有属性的提示,这极大的提升了代码的可读性。
我们知道ES6中的数据类型有布尔、数值、字符串、对象、函数、undefined。TypeScript为了提供强大的类型检查能力,它的语法内容主要包括几个方面:
- 增加和扩展数据类型
- 增加了模块语法
- 增强类型表达能力
- TypeScript还通过一些语法特性让开发者可以控制类型检查的过程
增加和扩展基础类型
ES6是弱类型语言,只有几个基础的类型和宽泛的引用类型,TypeScript在ES6基础上增加了很多类型以更精确地进行类型描述。
基本类型
布尔
let isDone: boolean = false;
数值
let decLiteral: number = 6; // 10进制
let hexLiteral: number = 0xf00d; // 16进制
let binaryLiteral: number = 0b1010; // 2进制
let octalLiteral: number = 0o744; // 8进制
字符串
let name: string = "bob";
name = "smith";
null和undefined
null和undefined这两个类型对应的取值只能是null/undefined。值null的类型是null;值undefined的类型是undefined。
默认情况下null和undefined是所有类型(除了never)的子类型,即可以将null和undefined赋值给任意类型的变量。如果使用了编译参数--strictNullChecks
,则这两个值只能赋值给各自的类型(undefined还是可以赋值给void)。
实际项目中推荐使用--strictNullChecks
。
数组
元素类型+“[]”
let list: number[] = [1, 2, 3];
数组泛型
let list: Array<number> = [1, 2, 3];
TypeScript中的数组中的元素必须是相同类型,否则会报错
let list: Array<number> = [1, 2, 3];
list.push('4'); // error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
元组
元组类型允许表示一个已知元素数量和类型的向量,各元素类型不必相同,但要求元素数量和每个index对应的类型固定。
let x: [string, number];
x = [1, 2]; // error TS2322: Type 'number' is not assignable to type 'string'.
枚举
枚举类型是对JavaScript标准数据类型的一个补充
enum Color {Red, Green, Blue}
let c: Color = Color.Green;
默认从0开始编号,可以手动指定起始编号
enum Color {Red = 1, Green, Blue}
let c: Color = Color.Green;
也可以全部手动赋值
enum Color {Red = 1, Green = 2, Blue = 4}
let c: Color = Color.Green;
枚举支持根据取值访问名字
enum Color {Red = 1, Green, Blue}
let colorName: string = Color[2];
alert(colorName); // 'Green'
any
有时候,我们会想要为那些在编程阶段还不清楚类型的变量指定一个类型。 这些值可能来自于动态的内容,比如来自用户输入或第三方代码库。 这种情况下,我们不希望类型检查器对这些值进行检查而是直接让它们通过编译阶段的检查。 那么我们可以使用<font style="color:rgb(34, 34, 34);">any</font>
类型来标记这些变量:
let notSure: any = 4;
notSure = "maybe a string instead";
notSure = false;
或者一个长度和类型都不确定的数组
let list: any[] = [1, true, 'free'];
list[1] = 100;
list.push(2);
unknown
在typescript中,当我们不知道一个变量是什么类型的时候,可以给它声明为unknown类型。
unknown类型是一切类型的父类型,所以可以把任意类型赋值给unknown类型的变量,但不能把unknown类型的变量赋值给其他类型的变量(左父右子)。
unknown和any有什么区别呢?
在不知道一个变量的类型时候,既可以声明其为unknown类型也可以声明其为any类型。
但两者有几点区别:
- 含义不同:any表示任意类型,unknown表示未知类型。
- 编译处理不同:any变量不会对其进行类型校验,可以对any变量进行任何操作,如给一个any变量赋值、把any变量赋值给其他变量、调用any变量的任意方法。但是unknown不同,首先不能把unknown类型变量赋值给其他变量,另外如果没有断言,对unknown变量无法进行其他操作。
- 类型安全性不同,any由于没有任何类型检查,所以不安全,unknown由于有类型检查,更安全一些。
在实际使用typescript时候,如果不知道一个变量的类型,推荐使用unknown而非any,因为unknown是类型安全的。
void
某种程度上来说,void类型像是与any类型相反,它表示没有任何类型。 一个函数如果没有返回值,它的返回值类型就是void
function logString(message: string): void {
console.log(message);
}
也可以给一个变量声明为void类型,这时候,它的取值只能是undefined/null。当指定编译参数--strictNullChecks
时候,取值只能是undefined。
let a: void = null; // error TS2322: Type 'null' is not assignable to type 'void'.
never
函数有一个不可达端点时候,返回值为never
// 返回never的函数必须存在无法达到的终点
function error(message: string): never {
throw new Error(message);
}
// 返回never的函数必须存在无法达到的终点
function infiniteLoop(): never {
while (true) {
}
}
变量被收窄(narrowing)为never时候,可以声明为never类型
参考这个回答:TypeScript中的never类型具体有什么用? ——尤雨溪
function handleValue(val: string | number): void {
switch (typeof val) {
case 'string':
// 这里 val 被收窄为 string
break;
case 'number':
// val 在这里是 number
break;
default:
// val 在这里是 never
const exhaustiveCheck: never = val;
break;
}
}
如果迭代过程中,val的类型被改为string | number | boolean
,但是没有增加逻辑,那么default分支就没有收窄到never,编译时候会报错。这样就保证了每种情况都能够有对应的处理逻辑。
字面量
字面量类型允许一个变量取几个固定的值。
let a: 1 | '2' = 1;
a = '2';
a = 3; // error TS2322: Type '3' is not assignable to type '1 | "2"'.
接口
简述
TypeScript的接口(interface)用于描述类型的结构,它可以用来给对象、函数、数组等声明类型。
“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。” 意思就是: 一个东西究竟是不是鸭子,取决于它能不能满足鸭子的工作。
接口使用鸭式辩型法(duck typing)检查类型,鸭式辩型法是动态类型的一种风格。在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由”当前方法和属性的集合”决定。
描述类型
对象
interface LabelledValue {
label: string;
}
function printLabel(labelledObj: LabelledValue) {
console.log(labelledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);
上面代码用interface定义的接口LabelledValue
来声明函数的参数,函数参数是一个有着label属性的对象。
也可以不用interface关键字
function printLabel(labelledObj: {label: string}) {
console.log(labelledObj.label);
}
可选属性:interface定义的对象的属性可以通过问号“?”标记为可选的
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {color: "white", area: 100};
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});
只读属性:可以在属性前加readonly
关键字来指定其只读
interface Point {
readonly x: number;
readonly y: number;
}
let p1: Point = { x: 10, y: 20 };
p1.x = 5; // error TS2540: Cannot assign to 'x' because it is a read-only property.
函数
接口也可以用来描述函数类型,函数类型包括参数列表和返回值。
interface SearchFunc {
(source: string, subString: string): boolean;
}
let mySearch: SearchFunc;
mySearch = function(source: string, subString: string) {
let result = source.search(subString);
return result > -1;
}
混合类型
JavaScript中允许一个对象同时作为函数使用,这也可以通过接口来声明类型
即这个接口描述的类型,即是一个函数也是一个对象。
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;
索引类型
接口还可以用来声明“索引”类型,这包括Array和Map两种
我们也可以使用接口描述那些能够“通过索引得到”的类型,比如a[10]或ageMap[“daniel”]
接口可以声明数组类型
interface StringArray {
[index: number]: string;
}
let myArray: StringArray;
myArray = ["Bob", "Fred"];
let myStr: string = myArray[0];
接口也可以用来声明map,其key须是number、string、symbol中的一种。
**注意**:可以同时使用number和string两种类型的索引,**但是数字索引的返回值必须是字符串索引返回值类型的子类型**。 这是因为当使用number来索引时,JavaScript会将它转换成string然后再去索引对象。 也就是说用100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog; // error TS2413: 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
}
interface Okay {
[x: number]: Dog;
[x: string]: Animal;
}
keyof
keyof关键字用来提取类型中的key,作为类型使用
interface User {
userName: string;
userAge: number;
}
let key: keyof User = 'userName'; // keyof User的类型是 'userName' | 'userAge'
let key1: keyof User = 'userSex'; // error TS2322: Type '"userSex"' is not assignable to type 'keyof User'.
class Person {
sex = 'famale';
}
let str: keyof Person = 'sex';
类
interface可以定义一个类所符合的规则,当一个类继承一个接口时候,这个类需要实现接口中的所有属性和方法。注意:类的静态方法和私有方法不会被检查,只有公共方法会被TypeScript检查。
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}
class Clock implements ClockInterface {
currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}
const c = new Clock(10, 10);
若仅仅是静态方法或者私有方法实现了接口,而公有方法未实现,则该类不会通过类型检查
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}
// error TS2576: Property 'currentTime' does not exist on type 'Clock'. Did you mean to access the static member 'Clock.currentTime' instead?
class Clock implements ClockInterface {
static currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}
interface ClockInterface {
currentTime: Date;
setTime(d: Date): void;
}
// error TS2420: Class 'Clock' incorrectly implements interface 'ClockInterface'. Property 'currentTime' is private in type 'Clock' but not in type 'ClockInterface'.
class Clock implements ClockInterface {
private currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}
const c = new Clock(10, 10);
注意,类implements一个接口时候,TypeScript不会校验公有方法和静态方法,构造函数也属于静态部分,因此TypeScript不会检查类的构造函数。
interface ClockInterface {
new(hour: number);
}
// error TS2420: Class 'Clock' incorrectly implements interface 'ClockInterface'.
// Type 'Clock' provides no match for the signature 'new (hour: number): any'.
class Clock implements ClockInterface {
constructor(hour: number) { }
}
const c = new Clock(10);
上面代码中,对Clock类,TypeScript不检查其构造函数,因此认为Clock没有实现new方法,所以会报错。
但是TypeScript对作为函数参数的类的构造函数是会检查的。
interface ClockInterface {
new(hour: number);
}
function createClock(ctor: ClockInterface): void {
new ctor(1);
}
class Clock {
constructor(hour: string) { }
}
// error TS2345: Argument of type 'typeof Clock' is not assignable to parameter of type 'ClockInterface'.
// Types of parameters 'hour' and 'hour' are incompatible.
// Type 'number' is not assignable to type 'string'.
const c = createClock(Clock);
继承
接口可以继承类和其他接口,以此实现类型的复用。
接口继承接口
interface Shape {
color: string;
}
interface Square extends Shape { // 继承其他接口
sideLength: number;
}
let square = <Square>{}; // 断言语法,后续会讲到
square.color = "blue";
square.sideLength = 10;
接口继承类
当接口继承了一个类类型时,它会继承类的成员但不包括其实现。
接口同样会继承到类的private和protected成员。 这意味着当你创建了一个接口继承了一个拥有私有或受保护的成员的类时,这个接口类型只能被这个类或其子类所实现(implement)。
class Control {
private state: any;
}
interface SelectableControl extends Control {
select(): void;
}
class Button extends Control implements SelectableControl {
select() {}
}
类
TypeScript在ES6基础上对类的语法进行了扩展,让程序员可以更好地编写面向对象的程序。
下面介绍TypeScript在ES6的类语法上增加的语法,ES6的类语法可以参考其他教程。
原型方法和私有方法
注意,ES6的方法有实例方法和原型的区别
class Logger {
log() {} // 原型方法
warn = () => {}; //实例方法
}
上面代码会被TypeScript编译为
var Logger = /** @class */ (function () {
function Logger() {
this.warn = function () {}; //实例方法
}
Logger.prototype.log = function () { }; // 原型方法
return Logger;
}());
由于原型方法在定义后不能改变,因此要求在定义一个类的时候必须实现原型方法,如果只声明不定义,会报错。
class Logger {
log(): void // error TS2391: Function implementation is missing or not immediately following the declaration.
}
而实例属性和方法可以只声明不定义,但是一个属性/方法只有声明的话,TypeScript编译后,该类不会有这个属性/方法,等待后续动态赋值。
class Logger {
warn: () => void
}
const logger = new Logger();
logger.warn = () => {console.warn('test')};
上面代码会被编译为
var Logger = /** @class */ (function () {
function Logger() {
}
return Logger;
}());
var logger = new Logger();
logger.warn = function () { console.warn('test'); };
如果warn赋值的类型和声明类型不符合,TypeScript编译时候会报错。
访问修饰符
和大部分传统的面向对象语言一样,TypeScript支持了public
、private
和proteced
的访问属性。
public
修饰的属性是可以在类的外部访问的。
private
修饰的属性只能在类内部访问。
protected
修饰的属性只能在类的内部或者子类的内部访问到。
readonly修饰符
使用readonly关键字修饰的属性是只读的,必须在声明时候或者构造函数里面初始化。
class Octopus {
readonly name: string;
readonly numberOfLegs: number = 8;
constructor (theName: string) {
this.name = theName;
}
}
let dad = new Octopus("Man with the 8 strong legs");
dad.name = "Man with the 3-piece suit"; // error TS2540: Cannot assign to 'name' because it is a read-only property.
参数属性
参数属性语法允许我们在类的构造函数的参数中声明属性,这样做可以在实例化类的时候,从构造函数中传入参数来初始化属性。
这样的语法能让我们更简洁地声明和初始化属性。
class Person {
constructor(private name: string, public age: number) { }
log(): void {
console.log(this.name, this.age);
}
}
抽象类
抽象类做为其它派生类的基类使用,不能直接实例化。
抽象类可以包含成员的实现细节,也可以包含抽象的属性和抽象的方法。
abstract关键字可以用来定义抽象类和抽象类内部的抽象属性。
用abstract关键字修饰的属性,在派生类中必须实现。
抽象的属性和方法不能初始化和定义,只能声明其类型。
abstract class Department {
constructor(public name: string) {
}
printName(): void {
console.log('Department name: ' + this.name);
}
abstract printMeeting(): void; // 必须在派生类中实现
}
实现接口
上面接口部分已经说明。
函数
JavaScript的函数声明有“函数声明”和“函数表达式”两种方式,下面看一下这两种声明方式如何指定类型
函数声明
function add(x: number, y: number): number {
return x + y;
}
函数表达式
// 完整的函数表达式声明
let add: (x:number, y:number) => number =
function(x: number, y: number): number { return x + y; };
// 简写,省略注解
let add = function(x: number, y: number): number { return x + y; };
// 简写,省略表达式中的类型
let add: (x: number, y: number) => number = function(x, y) { return x + y; };
可选参数
TypeScript会对函数调用进行类型检查,如果传入的参数和参数列表不匹配,则报错。
function add(x: number, y: number): number {
return x + y;
}
add(1, 2);
add(1); // error TS2554: Expected 2 arguments, but got 1.
add(1, '2'); // error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
通过在参数后面加“?”标识参数可选。在调用时候可以传,也可以不传
function add(x: number, y?: number): number {
return x + (y || 0);
}
注意,所有的可选参数都需要位于必传参数之后
function add(y?: number, x: number): number {
return x + (y || 0);
}
// error TS1016: A required parameter cannot follow an optional parameter.
函数重载
js因为是动态类型,本身不需要支持重载,直接对参数进行类型判断即可,但是ts为了保证类型安全,支持了函数签名的类型重载。 声明函数重载需要声明多个overload signatures和一个implementation signatures。 implementation signatures是对多个overload signatures的汇总。也可以使用any来汇总类型
function add(x: string, y: string): string; // 重载签名
function add(x: number, y: number): number; // 重载签名
function add(x: string | number, y: number | string): number | string { // 实现签名
return +x + +y;
}
TypeScript会选择第一个匹配到的重载当解析函数调用的时候。 当前面的重载比后面的“普通”,那么后面的被隐藏了不会被调用。所以,应该排序重载令精确的排在一般的之前。
function add(x: string, y: string): string;
function add(x: number, y: number): number;
function add(x: any, y: any): any {
return +x + +y;
}
装饰器
Javascript里的装饰器目前处在建议征集的第二阶段,但在TypeScript里已做为一项实验性特性予以支持。 启用实验特性的装饰器语法,需要设置编译选项experimentalDecorators
。
# 模块
在TypeScript变量,函数,类,类型别名或接口都可以导入和导出。
## 命名空间
现代前端项目,通常我们使用ESM模块化开发,很少使用全局变量,命名空间是为了兼容老的全局变量的语法而设计的。
使用namespace关键字可以声明一个全局变量。
namespace下面可以声明私有变量和暴露的变量(使用export关键字)。
typescript
namespace util {
const defaultValue = 100; // 私有属性
export function add(x: number, y: number): number { // 对外暴露的属性
return (x + y) || defaultValue;
}
}
util.add(1, 2);
上面的代码会被编译为
typescript
var util;
(function (util) {
function add(x, y) {
return x + y;
}
util.add = add;
})(util || (util = {}));
util.add(1, 2);
## ESM
TypeScript支持ES6中的模块。
typescript
// util.ts
// export后接一个声明语句
export const throttle = () => {};
// export后接一个声明语句
export const debouce = () => {};
export default {log: () => {}};
typescript
// index.ts
import util, {debouce, throttle} from './util';
util.log();
引入时候也可以整体引入
typescript
import * as util from './util';
util.debouce();
util.throttle();
util.default.log();
也可以使用大括号导出,好处是能够一眼看出导出了哪些变量。
typescript
// util.ts
const throttle = () => {};
const debouce = () => {};
export {
throttle,
debouce
}
export default {log: () => {}};
可以在导出模块时候重命名
typescript
// util.ts
const throttle = () => {};
export {
throttle as jieliu,
}
import时候也可以指定别名
typescript
// index.ts
import {jieliu as throttle} from './util;
当需要重新导出一个模块,即先引入再导出时候,可以用export和import一起使用来实现
typescript
export {throttle, debouce, default} from './util';
// 等同于
export * from './util';
对于一些有副作用的模块,模块的引入者不关心其导出,可以使用下面的方法导入
typescript
import 'path/to/module';
## CommonJS和AMD
TypeScript增加了和export= 语法来支持传统的CommonJS和AMD的模块模式。
使用export =
导出一个模块
// util.ts
const throttle = () => {};
const debouce = () => {};
export = {
throttle, debouce
};
相应地,用import = require()
来引入模块
import util = require('./util');
util.debouce();
util.throttle();
通常我们开发项目时候,习惯使用ESM模块的语法,但是对于第三方模块需要一定的处理,第三方模块有使用ESM模块语法声明模块的类型,也有的第三方模块使用export =
语法来声明模块的类型(例如React库,被编译成CommonJS模块,声明文件是CommonJS模块格式)。那么我们应该如何引用第三方模块呢?可以根据导出模块的语法,如果是ESM,我们就用ESM的引入模块的语法,如果导出模块的语法是CommonJS/AMD,我们就用export =
语法引用。
但是这样比较麻烦,我们希望项目中都用ESM来导入。这需要配置编译选项esModuleInterop
为true来实现。
为什么需要这个编译选项呢?
ESM和CommonJS模块本身是不兼容的。ESM只能导出模块的属性,无法导出顶层的模块。import moduleName form 'path';
实际是引入名为default的属性。如果想导入整个模块只能使用import * as moduleName form 'path';
但CommonJS可以导出整个模块。所以用ESM引入语法导入CommonJS导出的模块,只能用import * as moduleName form 'path';
而不能用import moduleName form 'path';
因为后者导入的是模块的“default”属性,而CommonJS模块可能未没有default属性。
如果我们希望用ESM更为习惯的的语法import moduleName form 'path';
来引入CommonJS的第三方模块,需要在转译import语句时候判断一下引入的是ESM模块还是CommonJS模块,如果是ESM模块则正常引入如果是CommonJS需要把顶层模块赋值给default属性。而esModuleInterop
编译选项就是用来做这个事情的。
如果设置了esModuleInterop
选项,TypeScript会对代码进行如下编译。
import util from './util';
console.log(util);
编译为
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
exports.__esModule = true;
var util_1 = __importDefault(require("./util"));
console.log(util_1["default"]);
而整体导入的语法
import * as util from './util';
console.log(util);
编译为
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
exports.__esModule = true;
var util = __importStar(require("./util"));
console.log(util);
这样就可以在项目使用习惯的ESM语法来引入第三方模块了。
参考文章
为了更深刻理解TypeScript模块语法和esModuleInterop
编译选项,可以使用TypeScript编译导入和导出的代码,通过观察分析编译后的代码来加深理解。
高级类型
TypeScript提供了一些高级类型来提升类型的表达能力。
泛型
泛型就是“模板”,更准确地说,泛型是接口、函数和类的模板。它提供了复用数据结构和代码逻辑成的支持,也使我们构建大型的类型系统成为可能。
为什么需要泛型呢,举个例子,我们希望实现一个方法,返回它传入的参数。
那么这个方法有两个特征:
- 可能传入各种类型的参数
- 传入的参数和返回值类型相同
这可以使用泛型来实现
function identity<T>(arg: T): T {
return arg;
}
它是一个“模板”,当我们要使用这个方法时候,也就是具体化这个方法时候,给它传入具体的类型,然后调用即可。
let output = identity<string>("myString");
函数泛型
定义一个函数泛型
function identity<T>(arg: T): T {
return arg;
}
使用函数泛型
let output = identity<string>("myString");
简写(类型推断)
let output = identity("myString");
使用接口定义函数泛型
interface GenericIdentityFn {
<T>(arg: T): T;
}
function identity<T>(arg: T): T {
return arg;
}
let myIdentity: GenericIdentityFn = identity;
借助接口定义的函数泛型具体化一个函数泛型
interface GenericIdentityFn<T> {
(arg: T): T;
}
function identity<T>(arg: T): T {
return arg;
}
let myIdentity: GenericIdentityFn<number> = identity;
类泛型
类泛型即类的模板,类的属性和方法可以是“泛泛、抽象的类型”,即先定义类的数据结构和方法逻辑,一些属性和方法参数、返回值等数据类型作为参数在使用的时候再具体化。
泛型类使用尖括号“<>”括起泛型类型,跟在类名后面。
// 定义类泛型,注意这里的属性和方法都是实例方法,因此只需要声明
class GenericNumber<T> {
zeroValue: T;
add: (x: T, y: T) => T;
}
// 使用number类型具体化类泛型,并用具体化后的类实例化一个对象
let myGenericNumber = new GenericNumber<number>();
// 给实例属性动态赋值,值的类型必须是number
myGenericNumber.zeroValue = 0;
// 给实例方法动态赋值,add方法的参数x, y和返回值已经具体化为number
myGenericNumber.add = function(x, y) { return x + y; };
// error TS2322: Type '(x: string, y: number) => string' is not assignable to type '(x: number, y: number) => number'.
myGenericNumber.add = function(x: string, y) { return x + y; };
接口泛型
和函数泛型及类泛型类似,接口泛型在接口名字后面的尖括号内定义类型变量,在接口实现中使用类型变量。在使用接口泛型时候,也是需要通过尖括号指定具体的类型,即给类型变量赋值。
interface IPerson<T> {
id: T
numList: T[],
getID:( value: T) => void;
}
const p: IPerson<number> = {
id: 1,
numList: [99, 10, 10],
getID: function(id: number) {
console.log(id)
}
}
typescript中内置的Pick就是一个接口泛型,也可以自己实现
type MyPick<T, K extends keyof T> = {
[key in K]: T[key]
}
interface TState {
name: string;
age: number;
like: string[];
}
interface TSingleState extends MyPick<TState, "name" | "age"> {};
let a: Pick<TState, "name" | "age"> = {name: '', age: 1};
泛型约束
在上面的示例中我们发现,作为模板的类型参数的“T”比较抽象,代表任何类型,但有时候我们希望给这个抽象的类型加一些约束,以便让TypeScript对代码进行更好的检查。
对类型约束通过extends关键字来实现,T extends EmbedType,T就被约束为具体的EmbedType类型。
例如,我们希望实现一个方法,接受一个数组作为参数,打印数组的长度,并返回这个参数。
如果不对抽象类型进行约束,TypeScript会报错
// 由于T抽象类型不一定具有"length"属性,因此不会通过TypeScript的类型检查
function loggingIdentity<T>(arg: T): T {
console.log(arg.length); // error TS2339: Property 'length' does not exist on type 'T'.
return arg;
}
对T进行类型约束,就可以避免错误
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length);
return arg;
}
泛型也可以约束不同的抽象类型之间的关系,例如一个抽象类型是另一个抽象类型的索引的关系
function getProperty<T, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
let x = {a: 1, b: 2};
getProperty(x, 'a');
getProperty(x, 'm'); // error TS2345: Argument of type '"m"' is not assignable to parameter of type '"a" | "b"'.
上面代码中getProperty的两个参数obj和key通过泛型约束,限定key是obj的索引,如果实际调用方法时候,key不是obj的索引,将不会通过类型检查。
交叉类型
交叉类型是将多个类型合并为一个类型,合并后的类型是现有类型的父类型,即如果有3个类型A
、B
、C
,那么交叉后的类型A & B & C
,即是A
,也是B
,也是C
。
对于接口,交叉后的类型必须包含每个接口中声明的属性。
interface A {
name: string;
}
interface B {
age: number;
}
let a: A = {name: 'a'};
let b: B = {age: 1};
// A & B类型必须包含A和B中的每个属性
let c: A & B = {name: 'c', age: 2};
a = c; // A是C的子类型
对于基础类型,交叉后是never
,因为不可能有类型即是number
,又是string
// error TS2322: Type 'number' is not assignable to type 'never'.
let a: string & number = 1;
联合类型
联合类型也是将多个类型进行运算得到的新类型,联合后的类型,可以是多个类型中的一个。
例如有两个类型A、B,那么它们联合后的类型 A | B
,可以是A
或者B
中的一个。
对于接口定义的对象类型,联合类型只要兼容任意一个接口即可
interface A {
name: string;
}
interface B {
age: number;
}
let a: A = {name: 'a'};
let b: B = {age: 1};
// A | B中只要兼容A或者B其中任意一个即可
let c: A | B = {age: 2};
// A类型兼容 A | B
c = a;
对于基础类型,联合后可以是多个类型中的任意一个
let a: string | number = 1;
a = '';
映射类型
映射类型可以简单理解为“类型的函数”,给它传入类型作为参数,它会返回一个类型。
设想两个常见的场景,将已知类型的每个属性都变成可选的。例如我们将接口Person
变成PersonPartial
interface Person {
name: string;
age: number;
}
// 可选属性版的Person
interface PersonPartial {
name?: string;
age?: number;
}
再比如我们想让已知类型的每个属性变成只读的,例如我们将接口Person
变成PersonReadonly
interface Person {
name: string;
age: number;
}
// 只读属性版的Person
interface PersonReadonly {
readonly name: string;
readonly age: number;
}
更具通用性地,我们可以提供一个映射类型,将每个类型映射到一个可选或者可读版的新类型
// 将传入的类型的属性都转成可选的
type Partial<T> = {
[P in keyof T]?: T[P];
}
// 将传入的类型的属性都转成只读的
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
上面我们看到,使用type
关键字,加映射类型名,再加尖括号“<>”(尖括号中是映射类型的“形参”),然后在大括号内通过key:value形式指定从旧类型属性到新类型属性的映射关系。
类型映射的使用和调用函数类似。
type PersonPartial = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;
TypeScript标准库中内置了几个常用的映射类型
// 可选
type Partial<T> = {
[P in keyof T]?: T[P];
}
// 只读
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
// 选择
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
// 记录,和Map类似
type Record<K extends string, T> = {
[P in K]: T;
}
语法特性
除了需要掌握必要的TypeScript语法,还需要了解TypeScript的一些语法特性,这些语法特性涉及TypeScript在进行类型检查的过程,了解特性可以让我们写出更简洁易读的TypeScript代码,并且能在TypeScript报错时候更快地搞清楚问题出在哪里。
类型兼容性
类型兼容性用于确定一个类型是否能够赋值给另一个类型。例如一个类型为A的变量x和一个类型为B的变量y,编译赋值语句时候,就会检查类型兼容性,以判断该语句是否合法
let x: A;
let y: B;
x = y; // 如果B兼容A,则该语句合法
TypeScript里的类型兼容性是基于结构子类型的。
结构类型是一种只使用其成员来描述类型的方式。 它正好与名义(nominal)类型形成对比。
在基于名义类型的类型系统中,数据类型的兼容性或等价性是通过明确的声明和/或类型的名称来决定的。这与结构性类型系统不同,它是基于类型的组成结构,且不要求明确地声明。
看下面的例子
interface Named {
name: string;
}
class Person {
name: string;
}
let p: Named;
// OK, because of structural typing
p = new Person();
在使用基于名义类型的语言,比如C#或Java中,这段代码会报错,因为Person类没有明确说明其实现了Named接口。
TypeScript的结构性子类型是根据JavaScript代码的典型写法来设计的。 因为JavaScript里广泛地使用匿名对象,例如函数表达式和对象字面量,所以使用结构类型系统来描述这些类型比使用名义类型系统更好。
结构子类型指结构上是其子集就是子类型,除了结构子类型,一些基础的类型也有子类型的关系:null和undefined是任何类型的子类型。
接口类型兼容性
当一个类型B中包含类型A的所有属性时候,B兼容A,B类型的变量可以赋值给A类型的变量。
interface A {
name: string;
age: number;
}
interface B {
name: string;
age: number;
sex: string
}
let a: A = {name: 'Ann', age: 2};
let b: B = {name: 'Sam', age: 1, sex: 'famale'};
a = b;
函数类型兼容性
函数类型的兼容性要从参数列表和返回值两个方面考虑。当参数列表和返回值都兼容,函数才能兼容。
当函数x的参数列表中的参数在函数y中都能找到时候,x的参数列表兼容y的参数列表。
函数返回值的兼容性和变量的兼容性一样。
可以简单记忆:如果x参数列表是y的子集,y的返回值是x的子集,x兼容y。当然这里的“子集”不是严格意义的概念。
下面先看参数列表兼容性的例子。
官网的例子
let x = (a: number) => 0;
let y = (b: number, s: string) => 0;
y = x; // x的参数列表兼容y的参数列表
x = y; // Error y的参数列表没有兼容x的参数列表
为什么参数列表的兼容性是这样的呢?可以通过下面的例子理解
let add: (x: number, y: number) => number =
function(x: number): number { return x; };
add(1, 2);
add(1); // error TS2554: Expected 2 arguments, but got 1.
我们看到TypeScript会根据声明的函数的参数列表进行类型检查,如果类型中的参数列表能够完全包括实现的参数列表,那么调用时候多传入的参数可以不处理,不会发生运行时错误,但是如果声明的函数参数列表中不能完全包含实现的参数列表,比如
let add: (x: number) => number =
function(x: number, y: number): number { return x + y; };
add(1);
这样的话调用时候会发现少参数,因此,实际的函数参数列表必须包含在在声明的函数类型的参数列表中。
看下面的例子来理解函数返回值的兼容性
let x = (b: number): {name: string, age: number} => ({name: "Ann", age: 1});
let y = (a: number): {name: string} => ({name: 'Jam'});
y = x; // x的返回值兼容y的参数列表
x = y; // Error y的返回值没有兼容x的参数列表
返回值的兼容性比较好理解,因为TypeScript会根据声明的类型检查调用,函数实现的返回值必须兼容函数
声明的返回值的类型,否则调用时候就可以根据类型发现赋值错误。
let add: () => {name: string, age: number} =
function(): {name: string, age: number, sex: string} {
return {name: '', age: 1, sex: 'male'};
};
// 如果函数实现的返回值类型不兼容声明类型(例如函数实现的返回值类型是{name: string}),则没办法检测出来调用函数的赋值错误
const result: {name: string, age: number} = add();
枚举类型兼容性
枚举类型与数字类型兼容,并且数字类型与枚举类型兼容。不同枚举类型之间是不兼容的。
枚举变量的类型就是枚举类型本身
enum State { Ready, Waiting };
let state = State.Ready;
// 等价于
let state: State = State.Ready;
因此,将state赋值给其他枚举类型不会通过类型检查
enum State { Ready, Waiting };
enum Color { Red, Blue, Green };
let state = State.Ready;
state = Color.Green; // error TS2322: Type 'Color.Green' is not assignable to type 'State'.
枚举类型与数值类型兼容
enum State { Ready, Waiting };
enum Color { Red, Blue, Green };
let state: number = State.Ready; // ok
state = Color.Green; // ok
类类型兼容性
如果一个类A兼容另一个类B,①那么A必须包含B的所有公有的成员。②如果B中有私有成员,A还必须包含这些私有成员并且私有成员的来源和B相同。
私有成员的同源限定,说明子类可以赋值给父类,但是不能赋值给其他有同样类型的类。
先看第一点
class Animal {
feet: number;
// 静态成员和构造函数不会作为根据来判断类型兼容性
constructor(name: string, numFeet: number) { }
}
class Size {
feet: number;
constructor(numFeet: number) { }
}
let a: Animal;
let s: Size;
a = s; //OK
s = a; //OK
再来看第二点
class Animal {
private feet: number;
}
class Dog extends Animal {
mouse: number;
}
let a: Animal;
let d: Dog;
a = d; // OK
d = a; // error TS2741: Property 'mouse' is missing in type 'Animal' but required in type 'Dog'.
上面代码很容易理解,因为TypeScript基于结构子类型,子类在父类上面进行扩展,所以子类包含了父类的全部内容,因此能够兼容父类。
如果两个类都有相同名字的私有变量,但并不同源,那么它们不兼容
class Animal {
private feet: number;
}
class Size {
private feet: number;
}
let a: Animal;
let s: Size;
a = s; // error TS2322: Type 'Size' is not assignable to type 'Animal'. Types have separate declarations of a private property 'feet'.
s = a; // error TS2322: Type 'Animal' is not assignable to type 'Size'. Types have separate declarations of a private property 'feet'.
泛型兼容性
泛型并没有兼容性的概念。泛型具体化后的类型才会存在兼容性问题。
泛型包括泛型本身的结构和具体化时候传入的类型,这两个因素共同决定了具体化后的类型的兼容性。
interface A<T> {
name: T
}
interface B<T> {
name: T
}
let x: A<number>;
let y: B<number>;
// 结构和类型参数都相同,两个类型兼容
x = y;
interface A<T> {
age: T
}
interface B<T> {
name: T
}
let x: A<number>;
let y: B<number>;
// Error 结构不同,类型参数相同,两个类型不兼容
x = y;
interface A<T> {
name: T
}
interface B<T> {
name: T
}
let x: A<number>;
let y: B<string>;
// Error 结构相同,类型参数不同,两个类型不兼容
x = y;
要注意一种特殊情况
interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;
// 虽然两个类型参数不同,但泛型的结构决定了类型参数不影响泛型的具体化结果,因此两个类型兼容
// 就像两个参数列表不同的、返回值类型都是void的函数,不管给它们传入什么参数,返回值都是undefined
x = y;
类型推断
我们已经了解TypeScript是给JavaScript增加了强类型,即我们告诉TypeScript类型,TypeScript帮助我们进行类型检查。
有些时候,我们可以简化代码,省略类型注解,TypeScript会自动推断类型。
例如
// TypeScript会推断出a是number类型
let a = 1;
当需要从几个表达式中推断类型时候,会使用这些表达式的类型来推断出一个最合适的通用类型。例如
// x会被推断为number类型的数组,因为null是number的子类型
let x = [0, 1, null];
如果没有通用的则被推断为联合的
// x被推断为number | string | boolean
let x = [0, '1', true];
函数返回值也可以被推断出来
// add返回值会被推断为number
function add(x: number, y: number) {
return x + y;
}
函数泛型的类型参数也可以根据调用时候的传参推断出来
function identity<T>(arg: T): T {
return arg;
}
// 类型参数T被推断为string
let output = identity("myString");
断言
简单的说,断言就是一种告诉TypeScript“某个变量可以被当做某种类型来处理,不用对其进行类型检查了”的语法。
有两种语法,一种是尖括号,另一种是as语法。
注意JSX中不能使用尖括号,只能有as语法。
有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。
通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。类型断言的形式类似其他语言里的类型转换,但是TypeScript不进行特殊的数据检查和解构。它没有运行时的影响,只是在编译阶段起作用。
—— 官方文档
看下面的例子,由于只有string
类型具有length
属性,而联合类型string | number
中不一定有length
属性,因此类型检查报错。
function getLength(val: string | number): number {
// error TS2339: Property 'length' does not exist on type 'string | number'. Property 'length' does not exist on type 'number'.
if (val.length) {
// error TS2339: Property 'length' does not exist on type 'string | number'. Property 'length' does not exist on type 'number'.
return val.length;
}
else {
return val.toString().length;
}
}
这时候我们可以通过断言告诉TypeScript,这个可以按照string类型来处理没问题。
function getLength(val: string | number): number {
if ((<string>val).length) {
return (<string>val).length;
}
else {
return val.toString().length;
}
}
也可以这样写
function getLength(val: string | number): number {
if ((val as string).length) {
return (val as string).length;
}
else {
return val.toString().length;
}
}
类型保护
类型保护可以解决某个变量是联合类型时候,对这个变量进行某种类型的处理导致TypeScript类型检查不通过的问题。
例如
function getLength(val: string | number): number {
// error TS2339: Property 'length' does not exist on type 'string | number'. Property 'length' does not exist on type 'number'.
if (val.length) {
// error TS2339: Property 'length' does not exist on type 'string | number'. Property 'length' does not exist on type 'number'.
return val.length;
}
else {
return val.toString().length;
}
}
从类型断言一节,我们知道可以通过断言来避免TypeScript报错问题,但我们需要在每个使用变量的地方都加断言(就像上面代码)。
通过类型保护可以在一个代码区域内让TypeScript收窄类型,从而通过类型检查。比如上面的代码报错问题可以这样解决。
function getLength(val: string | number): number {
if (typeof val === 'string') {
return val.length;
}
else {
return val.toString().length;
}
}
通过判断val类型,让TypeScript知道在这个代码块内val就是string类型,所以对其进行string相关的处理是没问题的。当然TypeScript也知道在else中val是number类型的。
类型保护有几种方法:
自定义类型保护
自定义一个函数,函数返回值声明为<参数> is <类型>
,通过这个函数来进行类型保护。例如下面代码
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
function playPet(pet: Fish | Bird) {
// error TS2339: Property 'swim' does not exist on type 'Fish | Bird'. Property 'swim' does not exist on type 'Bird'.
if (pet.swim) {
// error TS2339: Property 'swim' does not exist on type 'Fish | Bird'. Property 'swim' does not exist on type 'Bird'.
pet.swim();
}
else {
// error TS2339: Property 'fly' does not exist on type 'Fish | Bird'. Property 'fly' does not exist on type 'Fish'.
pet.fly();
}
}
由于联合类型做了特殊处理,导致TypeScript类型检查不通过,我们可以自定义保护类型
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}
上面定义函数可以用来判断传入的pet是什么类型。由于定义了返回值是pet is Fish
类型,TypeScript会根据这个类型进行收窄。
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
function isFish(pet: Fish | Bird): pet is Fish {
return (<Fish>pet).swim !== undefined;
}
function playPet(pet: Fish | Bird) {
// 收窄为Fish,所以调用swim方法不会报错
if (isFish(pet)) {
pet.swim();
}
else {
pet.fly();
}
}
typeof类型保护
对于基本类型,每次都声明一个自定义的保护类型方法太麻烦,可以直接使用typeof关键字,TypeScript会根据结果来进行收窄。
可以参考上面提到的例子:
function getLength(val: string | number): number {
if (typeof val === 'string') {
return val.length;
}
else {
return val.toString().length;
}
}
instance类型保护
和typeof类似,只是场景是对类的类型的收窄。
class Bird {
fly() {}
}
class Fish {
swim() {}
}
function playPet(pet: Fish | Bird) {
if (pet instanceof Fish) {
pet.swim();
}
else {
pet.fly();
}
}
in类型保护
in
关键字在JavaScript中用来判断一个对象中是否包含某个属性,TypeScript可以通过它来进行类型收窄。
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
function playPet(pet: Fish | Bird) {
if ('swim' in pet) {
// pet是Fish | Bird类型,因为pet中有swim属性,因此pet收窄为Fish类型
pet.swim();
}
else {
pet.fly();
}
}
接口的type收窄
如果联合类型的每个接口中有相同的属性,并且值不同,可以用来区分不同接口,TypeScript在这种场景下也能够收窄类型,参考前面的一个例子
function handleValue(val: string | number): void {
switch (typeof val) {
case 'string':
// 这里 val 被收窄为 string
break;
case 'number':
// val 在这里是 number
break;
default:
// val 在这里是 never
const exhaustiveCheck: never = val;
break;
}
}
声明合并
“声明合并”是指编译器将针对同一个名字的两个独立声明合并为单一声明。 合并后的声明同时拥有原先两个声明的特性。
看下面的例子
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = {height: 5, width: 6, scale: 10};
我们看到两个同名的接口声明之后,接口的属性包含了两个声明中的所有属性声明。
TypeScript支持声明合并特性,是因为JavaScript作为一个动态语言,经常会动态修改一些值的属性,为了支持动态修改属性的场景,TypeScript就支持声明的合并。
上面代码展示了接口的声明合并,TypeScript还支持命名空间的声明合并(全局对象的动态添加属性)、命名空间和类的声明合并(类可以动态添加属性)、命名空间和函数的声明合并(函数可以动态添加属性)、命名空间和枚举的声明合并。
命名空间的声明合并
namespace Animals {
export class Zebra { }
}
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Dog { }
}
// 等价于
namespace Animals {
export interface Legged { numberOfLegs: number; }
export class Zebra { }
export class Dog { }
}
命名空间和类的声明合并
class Album {
label: Album.AlbumLabel;
}
namespace Album {
// 给类动态添加AlbumLabel属性
export class AlbumLabel { }
}
命名空间和函数的声明合并
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export let suffix = "";
export let prefix = "Hello, ";
}
alert(buildLabel("Sam Smith"));