TypeScript编程 读书笔记
@
TypeScript编程 读书笔记
TypeScript概述
关于编译器
TS在js语言的基础上加上了类型定义,使js运行前暴露更多可能的错误,运行时更安全;
TS将使用tsc(ts编译器)将ts文件编译成js,而类型检查会发生在编译之前,且类型检查的结果并不会影响编译的js文件。
类型系统
类型系统大概分为两类:通过显示语句告诉编译器所有值的类型和自动推导值的类型;typescript身兼两种类型系统。
javascript和typescript类型系统比较
类型系统特性 | javascript | typescript |
---|---|---|
类型是如何绑定的? | 动态 | 静态 |
是否自动转换类型? | 是 | 否(多数时候) |
何时检查类型? | 运行时 | 编译时 |
何时报告错误? | 运行时(多数时候) | 编译时(多数时候) |
类型是如何绑定的?
javascript动态绑定类型,因此必须运行程序才知道类型。在运行程序之前,javascript对类型一无所知。
typescript是渐进式类型语言。在编译时知道所有类型能让typescript充分发挥作用,但在编译程序之前,并不知道全部类型。即便是没有类型的程序,typescript也能推导出部分类型,捕获部分错误,但这并不全面,大量错误可能会暴露给用户。
是否自动转换类型?
javascript是弱类型语言,很多操作存在隐式转换,这种转换可能导致难以追踪的错误;
typescript会根据类型判断,如果执行可能不正确的操作,typescript会报错;如果明确表明意图,typescript则不会做出阻拦。
简而言之,如果必须转换类型,typescript需要你明确表明你的意图。
何时检查类型?
多数情况下,javascript不在乎你使用的是什么类型,它会尽自己所能把你提供的值转换成预期的类型。
而typescript会在编译时对代码做类型检查,因此不用运行代码就能看到代码中的error;typescript会对代码做静态分析,找出这类错误,在运行之前反馈给你;如果代码不能编译,很有可能就表明代码中有错误,在运行之前要修正。
何时报告错误?
javascript在运行时抛出异常或在代码解析后(运行之)抛出明显语法错误和部分缺陷;意味着,必须真正运行程序才能知道有些操作是无效的。
typescript在编译时报告语法和类型相关的错误。实际上,这些错误会在代码编辑器中显示,输入代码后立即就有反馈。
尽管如此,还有大量错误是typescript在编译时无法捕获的,例如堆栈益处、网络连接等,这些属于运行时异常。typescript所能做的是把纯javascript代码中那些运行时错误提前到编译时报告。
类型全解
什么是类型
类型:一系列值以及可以对其执行的操作;
例如:
booleam类型:包括所有布尔值(只有2个:true和false),以及可以对布尔值执行的操作(例如||、&&和!)
number类型:包括所有数字,以及可以对数字执行的操作(例如+、-、*、/、%等),还有可以在数字上调用的方法(例如 .toFixed等)
string类型:包括所有字符串,以及可以对字符串执行的操作(例如+、||和&&),还有可以在字符串上调用的方法(例如 .concat等)
对于T类型的值来说,我们不仅知道值的类型是T,还知道可以(及不可以)对该值做什么操作。最终是由类型检查去阻止你做的无效操作。而类型检查器则通过使用的类型和具体用法判断操作是否有效。
typescript的类型层次结构
注意:
any
是任意类型的父类型,同时也任意类型的子类型;unknown
也是任意类型的父类型,所以any
也是unknown
的子类。
类型术语
function squareOf(n:number){
return n*n
}
squareOf(2) // 求值结果为4
squareOf("z") // Error TS2345: Argument of type "z" is not assignable to parameter of type "number".
调用squareof时如果传入数字之外的值,typescript将立即报错。
如果没有类型注解,squareof的参数不受约束,可以传入任何类型的值。一旦加上约束,typescript将检查每次对该函数的调用,确保传入兼容的参数。在示例中,2的类型是number,可以赋值给使用number注解的参数,因此typescript接受我们的代码。但是'z'的类型是string,不可以赋值给number类型的参数,所以typescript报错。
另外,可以把类型注解理解为某种界限。示例中,我们告诉typescript,n的上限是number,因此传给squareof的值至多是一个数字。如果传入超过数字的值,那就不能赋值给n。
约束
:squareOf的参数n被约束为number。
可赋值性
:值可以赋值给number(即与number兼容)。
界限
: 可以把类型注解理解为某种界限
类型浅谈
逐一介绍typescript支持的类型,各类型包含的值,以及可以对类型执行的操作。
any
any是类型的教父
在typescript中,编译是一切都要有类型,如果你(程序员)和ts(类型检查器)无法确定类型是什么,默认为any。这是兜底类型,应该尽量避免使用。
由于类型的定义(一系列值及可以对其执行的操作),any包含所有制,而且可以对其做任何操作(从类型检查器的角度来看一切皆可)。这意味着,any类型的值得可以做加法,可以做乘法,可以调用任意方法,一切皆可。
any类型的值就像常规的javascript一样,类型检查器完全发挥不了作用。
在极少的情况下需要使用any,如果确实需要使用,一定要显示注解。
let a: any = 666 // any
let b: any = ['danger'] // any
let c = a+b // any
正常情况下第三个语句将报错,但是却没有,因为我们告诉typescript,想加的两个值都是any类型。
如果想使用any,一定要显示注解。倘若ts推导出值的类型为any(例如忘记注解函数的参数,或者引入没有类型的javascript模块),将抛出运行时异常,在编辑器中显示一条红色波浪线。显示把a和b的类型注解为any,ts不会抛出异常,因为这样做就是告诉ts,自己知道自己在做什么。
|TSC标志:noImplicitAny
默认情况下,ts很宽容,在推导出类型为any时不会报错。如果想让ts在遇到隐式any类型时报错,请在tsconfig.json中启用 noImplicitAny标志。
noImplicitAny隶属于TSC的strict标志家族,如果已经在tsconfig.json中启用strict,那就不用专门设置该标志。
unknown
少数情况下,如果你确实无法预知一个值的类型,不要使用any,应该使用unkown。与any类似,unknown也表示任何值,但是ts会要求你再做检查,细化类型。
支持的操作有:比较(==、===、||、&&和?),可以否定(!),(与其它任何类型一样)可以使用javascript的typeOf和instanceof运算符细化。
let a: unknown = 30 // unknown
let b = a === 123 // boolean
let c = a + 10 // Error TS2571:object is of type 'unknown'.
if(typeof a === 'number'){
let d = a + 10 // number
}
用法说明
- ts不会把任何值推导为unknown类型,必须显示注解(a)。
- unkown 类型的值可以比较(b)。
- 但是执行操作时不能假定 unkown 类型的值为某种特定类型(c),必须先向 typescript 证明一个值确实是某个类型(d)
关于 any 和 unknow 的一个重要区别:
.
any
是任意类型的父类型,同时也任意类型的子类型.
unknown
是任意类型的父类型,但仅此而已。
boolean
boolean类型有两个值:true和 false;
可以进行的操作:比较(==、===、||、&&和?)、否定(!)
let a = true // boolean
var b = false // boolean
const c = true // true
let d: boolean = true // boolean
let e: true = true // true
let f: true = false // Error TS2322: type 'false' is not assignable to type 'true'
用法说明
-
可以让ts推导出值的类型为boolean (a和b)。
-
可以让ts推导出值为某个具体的布尔值(c)。
-
可以明确告诉ts,值的类型为boolean(d)。
-
可以明确告诉ts,值为某个具体的布尔值(e和f)。
一般来说,我们在程序中采用第一种或第二种方式。极少数情况下使用第四种方式,仅当需要额外提升类型安全时。第三种方式几乎从不使用。
第二种和第四种情况比较特殊,虽然也算直观,但是很少有其他编程语言支持,那个例子的意思是,“酶! TypeScript,看到变量e了吗? e可不是普通的boolean类型,而是只为true的boolean类型。” 把类型设为某个值,就限制了e和f在所有布尔值中只能取指定的那个值。这个特性称为类型字面量(typeliteral)
类型字面两
仅表示为一个值的类型
第四种情况使用类型字面量显式注解了变量,而第二种情况则由TypeScrlpt推导出一个字面量类型,因为这里使用的是const,而不是let或var。使用const声明的基本类型的值,赋值之后便无法修改,因此TypeScript推导出的是范围最窄的类型。所以,在第二种情况中TypeScript推导出的C的类型为true,而不是boolean。
number
number包括所有数字:整数、浮点数、正数、负数、Infinity、 NaN等。
数字可以做算术运算,例如加(+)、减(ˉ) 、求模(%)和比较(〈)。
let a = 1234 // number
let b = Infinity * 0.10 // number
const c = 5678 // 5678
let d = a < b // boolean
let e: number = 100 // number
let f: 26.218 = 26.218 // 26.218
let g: 26.218 = 10 // Error TS2322: type '10' is not assignable to type '26.218'.
用法说明
- 可以让TypeScript推导出值的类型为number(a和b)。
- 可以使用const,让TypeScrlpt推导出值为某个具体的数字(c)。
- 可以明确告诉TypeScript,值的类型为number (e)。
- 可以明确告诉TypeScript,值为某个具体的数字(f和g)。
与boolean类型相同的—点是,我们通常让TypeScript自己推导类型(第—种方式)。偶尔,我们可能会做些巧妙的编程设计,要求数字的类型限制为特定的值(第二种方式或第四种方式)。如果没有特殊原因,不要把值的类型显式注解为number(第三种方式)。
// 处理较长的数字时,为了便于辨识数字,建议使用数字分隔符.在类型和值所在的位置上都可以使用数字分隔符.
let oneMillion = 1_000_000 // 等同于1000000
let twoMillion: 2_000_000 = 2_000_000
bigint
bigint是JavaScIipt和TypeScript新引人的类型,在处理较大的整数时,不用再担心舍人误差。number类型表示的整数最大为2的53次方,bigint能表示的数比这大得多。bigint类型包含所有BlgInt数,支持加(+)、减(-)、乘(*)、除(/)和比较(<)。
let a = 1234n // bigint
const b = 5678n // 5678n
var c = a + b // bigint
let d = a < 1235 // boolean
let e = 88.5n // Error TS1353: A bigint literral must be an integer.
let f: bigint = 100n // bigint
let g: 100n = 100n // 100n
let h: bigint = 100 // Error TS2322: type '100' is not assignable to type 'bigint'.
用法说明
与boolean和number一样,声明为bigint类型也有四/种方式。尽量让TypeScript推导bigint类型。
string
string包含所有字符串,以及可以对字符串执行的操作,例如拼接(+)、切片(.slice)等。
let a = 'hello' //string
var b = 'billy' //string
const c = '!' //'!'
let d = a + '' + b + c //string
let e:string = 'zoom' //string
let f: 'john' = 'john' //'john'
let g: 'john' = 'zoe' // Error TS2322: type 'zoe' is not assignable to type 'john'。
用法说明
与boolean和number一样,声明为string类型也有四种方式。尽量让TypeScript推导string类型。
symbol
symbol不常用,经常用于代替对象和映射的字符串键,确保使用正确的已知键,以防键被意外设置,例如设置对象的默认迭代器(Symbol.iterator) ,或者在运行时覆盖不管对象是什么的实例(Symol.hasInstance) 。符号的类型为symbol。
let a = Symbol('a') //symbol
let b:symbol = Symbol('a') //symbol
var c = a === b //boolean
let d = a + 'x' //Error ts2469: the '+' operator cannot be applied to type 'symbol'.
在JavaScript中,Symbol("a")使用指定的名称新建一个符号,这个符号是唯一的,不与其他任何符号相等(使用=或==比较),即便再使用相同的名称创建一个符号也是如此。使用let声明的值27将推导为number类型,而使用const声明时则为具体的数字27。类似地,符号经推导得到的类型是symbol,此外也可以显式声明为unique symbol类型。
const e = Symbol('e') // typeof e
const f:unique symbol = Symbol('f') // typeof f
let g: unique symbol = Symbol('f') //Error TS1332: A veriable whose type is a 'unique symbol' type must be 'const'。
let h = e === e //boolean
let i = e === f //Error TS2367:This condition will always return 'false' since the types 'unique symbol' and 'unique symbol' have no overlap。
关于unique symbol
unique symbol与其它字面量类型其实是一样的,比如1、true或“literal”、是创建表示特定符号的类型的方式。
字面量能够表示一个固定值。例如,数字字面量“3”表示固定数值“3”;字符串字面量“’up’”表示固定字符串“’up’”。symbol类型不同于其他原始类型,它不存在字面量形式。symbol类型的值只能通过“
Symbol()”
和“Symbol.for()”
函数来创建或直接引用某个“Well-Known Symbol”值(已知的symbol值)。
为了能够将一个Symbol值视作表示固定值的字面量,TypeScript引入了
“unique symbol”
类型。“unique symbol”类型使用“unique symbol”关键字来表示。
它是symbol的子类型,这种类型的值只能用于常量的定义和用于属性名。需要注意,定义unique symbol类型的值,必须用 const 而不能用let来声明。 ( g报错的原因 )
用法说明
- 使用const(而不是let或var)声明的符号,TypeScript推导为 unique symbol类型。在代码编辑器中显示为typeof yourVariableName,而不是unique symbol。
- 可以显式注解const变量的类型为unique symbol。
- unique symbol类型的值始终与自身相等。
- typeScript在编译时知道一个unique symbol的值绝不会与另—个unique symbol类型的值相等。
对象
TypeScript的对象类型表示对象的结构。
注意:通过对象类型无法区分不同的简单对象(使用{}创建)或复杂的对象(使用new 构造函数或者类 创建)。
JavaScript一般采用结构化类型,TypeScript直接沿用,而没有采用名义化类型。
结构化类型
只关心对象有哪些属性,而不管属性使用什么名称(名义化类型)。在某些语言中也叫鸭子类型(即不以貌取人)。
// 值声明为 object类型
let a: object = {
b: 'x'
}
a.b // Error TS2339: property 'b' does not exist on type 'object'.
把—个值声明为object类型,却做不了任何操作;objeect仅比 any 的范围窄—些,但也窄不了多少。object对值知之甚少,只能表示该值是—个JavaScript对象( 而且不是null )。
// 使用对象字面量语句声明类型:利用ts自己推导对象结构,或者在{}内明确描述类型结构
let a = {
b: 'x'
} //{b: string}
let a: {b: number} = {
b: 12
} // {b : number}
// const 声明对象 不会缩窄推导
const a: {b: number} = {
b: 12
} // {b : number}
// 当已经声明类型后,添加额外的属性或者缺少必要的属性时 ts会报错
let a : {b: number} // error TS2741: Property 'b' is missing in type '{}' but required in type '{ b: number; }'.
a = {}
a = {
b:1,
c: 2
} // error TS2322: Type '{ b: number; c: number; }' is not assignable to type '{ b: number; }'.
// Object literal may only specify known properties, and 'c' does not exist in type '{ b: number; }'.
// 对象类型声明中的 ? 和 readonly 修饰符以及 索引签名
let a :{
b: number
c?: string // 1
readonly o: string //该字段标记为只读,即指明为字段赋予初始值之后无法再修改,类似于使用 const 声明对象的属性;
[key: number]:boolean // 2
}
注意:
在 1 中: a 可能有个类型为 string 的属性 c ,如果有属性 c,其值可以为 undefined。c 的值的类型为 string | undefined
在 2 中:a 可能有任意多个数字属性,其值为 boolean值。
使用co∩st声明对象时的类型推导
目前所讲的基本类型(boolean、number、 bigint、string 和 symbol )不同,使用const声明对象不会导致TypeScript把推导的类型缩窄。这是因为JavaScript对象是可变的,所以在TypeScript看来,创建对象之后你可能会更新对象的字段。
明确赋值
// 如果先声明变量,然后再初始化,TypeScript将确保在使用该变量时已经明确为其赋值了:
let i: number
let j = i*3 // error ts2454: Variable 'i' is used before being assigned。
// 即使没有显示注解类型,typescript也会强制检查:
let i
let j = i*3 // error ts2532: Object is possibly 'undefined'
属性检查
默认情况下,TypeScript对对象的属性要求十分严格。如果声明对象有个类型为number的属性b,TypeScript将预期对象有这么一个属性,而且只有这个属性。如果缺少b属性,或者多了其他属性, TypeScript将报错。可用 ? 修饰赋或者 索引签名灵活设置对象属性。
索引签名
[key: T]: U 语法称为索引签名,我们通过这种方式告诉TypeScript,指定的对象可能有更多的键。这种语法的意思是,“在这个对象中,类型为 T 的键对应的值为U类型。"借助索引签名,除显式声明的键之外,可以放心添加更多的键。索引签名还有一条规则要遵守:键的类型(T)必须可ˉ赋值给number或string。因为JavaScript对象的键为字符串,数组是特珠的对象,键为数字。
设置对象类型为空对象 ( {} )和 Object
设置对象类型为空对象类型({}),除 null 和 undefined 之外的任何类型都可以赋值给空对象类型,使用起来比较复杂。请尽量避免使用空对象类型。0bject 这与{}的作用基本一样,最好也避免使用。
用法说明
在TypeScript中声明对象类型有四种方式:
- 对象字面量表示法(例如{ a: string }),也称对象的结构 。如果知道对象有哪些字段,或者对象的值都为相同的类型,使用这种方式。
- 对象字面量表示法({})。尽量避免使用这种方式。
- object类型。如果需要一个对象,但对对象的字段没有要求,使用这种方式。
- Object类型。尽量避免使用这种方式。
在TypeScript程序中,应该坚持使用第一种和第三种方式。第二种和第四种方式应尽量避免使用。
类型别名、并集、交集
对—个值,在类型允许的情况下,我们可以对其执行特定的操作。例如:可以使用+计算两个数字的和,可以调用.toUpperCase方法把字符串变成大写。
其实,在类型自身上也可以执行—些操作。这里先介绍别名、并集、交集。
类型别名
我们可以使用变量声明(let、const 和 var)为值声明别名,类似地,还可以为类型声明别名。
type Age = number
type Person = {
name: string
age: Age
}
用法说明
- TypeScript无法推导类型别名,因此必须显式注解。
- 使用类型别名的地方都可以替换成源类型,程序的含义不受影响。
- 与JavaScript中的变量声明(let, const 和 var)一样,同一类型不能声明两次。
- 同样与 let 和 const 一样的是,类型别名采用块级作用域。每一块代码和每一个函数都有自己的作用域,内部的类型别名将遮盖外部的类型别名。
并集类型和交集类型
给定两个事物A和B,它们的并集是二者的内容之和(包括在 A、B中,以及同时在二者中的内容),它们的交集是二者共有的内容(既在 A 中也在 B中)。
为了处理类型的并集和交集,TypeScript 提供了特殊的类型运算符:并集使用 | ,交集使用 & 。
type Cat = {name: string, purrs: boolean}
type Dog = {name: string, barks;boolean, wags: boolean}
type CatOrDogOrBoth = Cat | Dog
type CatAndDog = Cat & Dog
关于并集
一个并集类型( | )的值不一定属于并集中的某一个成员,还可以同时属于每个成员。如果并集不相交,那么值只能属于并集类型中的某个成员,不能同时属于每个成员。让TypeScript 知道并集不相交的方法参见 “ 辨别并集类型 ” 一节
赋值给 Cat0r0og0rBoth 类型的变量:可以是cat类型的值,也可以是Dog类型的值,还可以二者兼具。
// Cat
let a: CatOrDogOrBoth = {
name:'bonkers',
purrs:true
}
// Dog
a = {
name: 'domino',
barks: true,
wags: true
}
// 二者兼具
a = {
name: 'donkers',
barks: true,
purrs: true,
wags: true
}
关于交集
需要包含所有类型属性,如类型是 CatAndDog 的变量,包含 name、purr、bark、wag属性。
数组
与在JavaScrlpt中一样,TypeScript中的数组也是特殊类型的对象,支持拼接、推入、搜索和切片等操作。
let a = [1, 2, 3] // number[]
var b = ['a', 'b'] // string[]
let c: string[] = ['a'] // string[]
let d = [1, 'a'] // (string | number)[]
const e = [2 , 'b'] // (string | number)[]
let f = ['red']
f.push('blue')
f.push(true) // Error TS2345 : argument of type 'true' is not assignable to parameter of type 'string'.
let g = [] // any[]
g.push(1) // number[]
g.push('red') // (string | number)[]
let h: number[] = [] //number[]
h.push(1) // number[]
h.push('red') // Error TS2345 : argument of type 'red' is not assignable to parameter of type 'number'.
TypeScript支持两种注解数组类型的语句:T[ ] 和 Array<T> 。二中的作用和性能无异。
使用说明
-
一般情况下,数组应该保持同质。设计程序时要规划好,保证数组中的每个元素都具有相同的类型。 如 f 和 d 在初始化时确定了数组的类型,当传入的新的元素,typescript会检查新元素的类型。
-
与对象一样,使用const声明数组不会导致TypeScript推导出范围更窄的类型。鉴于此,TypeScript推导出的d和e的类型均为 number|string。
-
初始化空数组时,TypeScript不知道数组中元素的类型,推导出的类型为any。向数组中添加元素后,TypeScript开始拼凑数组的类型。当数组离开定义时所在的作用域后,TypeScript将最终确定一个类型,不再扩张。比如 g 。
function buildArray(){ let a = [] // any[] a.push(1) // number[] a.push('x') // (string | number)[] return a } let myArray = buildArray() // (string | number)[] myArray.push(true) // Erroe 2345 : argument of type 'red' is not assignable to parameter of type 'string | number'.
元组
元组是 array 的子类型,是定义数组的一种特殊方式,长度固定,各索引位上的值具有固定的已知类型。与其他多数类型不同,声明元组时必须显式注解类型。这是因为,创建元组使用的句法与数组相同(都使用方括号) ,而TypeScript遇到方括号,推导出来的是数组的类型。
let a: [number] = [1]
// [名,姓,出生年份] 形式的元组
let b: [string, string, number] = ['malcolm', 'gladwell', 1963]
b = ['queen', 'elizabeth', 'ii', 1926] // Error ts2322: type 'string' is not assignable to type 'number'.
// 可选元组
// 火车票数组,不同的方向价格有时不同
let trainFares: [number, number?][] =[
[3.75],
[8.25, 7.70],
[10.50]
]
// 等价于
let moreTrainFares: ([number]|[number, number])[] = [
// ...
]
// 剩余元素
// 字符串列表,至少一个元素
let friends: [string, ...string[]] = ['sara','tali','chloe','claire']
// 元素类型不同的列表
let list: [number, boolean, ...string[]] = [1, false, 'a', 'b', 'c']
用法说明
- 元组也支持可选的元素。与在对象的类型中一样,?表示"可选"。
- 元组也支持剩余元素,即为元组定义最小长度。元组类型能正确定义元素类型不同的列表,还能知晓该种列表的长度。这些
特性使得元组比数组安全得多,应该经常使用。
只读数组和元组
常规的数组是可变的(可以使用 push 方法把元素推入数组、使用 .splice 编接数组,还可以就地更新数组),这也是多数时候我们想要的行为,不过有时我们希望数组不可变,修改之后得到新的数组,而原数组没有变化。
TypeScript原生支持只读数组类型,用于创建不可变的数组。只读数组与常规的数组没有多大差别,只是不能就地更改。若想创建只读数组,要显式注解类型注解类型;若想更改只读数组,使用非变型方法,例如 .concat 和 .slice, 不能使用可变型方法,例如 .push 和 .splice。
let as: readonly number [] = [1, 2, 3] // readonly number[]
let bs: readonly number [] = as.concat(4) // readonly number[]
let thre = bs[2] // number
as[4] = 5 // Error ts2542: index signature in type 'readonly number[]' onli permits reading .
as.push(6) // Error ts2339: property 'push' does not exist on type 'readonly number[]'.
用法说明
type A = readonly string [] // readonly string[]
type B = ReadonlyArray<string> // readonly string[]
type C = Readonly<string[]> // readonly string[]
type D = readonly [number, string] // readonly [number, string]
type E = Readonly<number, string> // readonly [number, string]
使用简洁的 readonly 修饰符,还是使用较长的 Readonly 或 ReadonlyArray 句法,全凭个人喜好。
注意:只读数组不可变的特性能让代码更易于理解,不过其背后提供支持的仍是常规的JavaScrjpt数组。这意味着,即便只对数组做小小的改动,也要先复制整个原数组,如有不慎,会影响应用的运行性能。对小型数组来说,影响微乎其微,但是对大型数组可能会造成极大的影响。
null、undefined、void和never
在JavaScript中,有两个值表示缺少什么: null 和undefined。TypeScript也支持这两个值,而且各自都有类型 ;undefined类型只有 undefined 一个值,null 类型只有一个 null 一个值。
JavaScript程序员往往不区分二者,但是它们在语义上有细微的差别:undefined 的意思是尚未定义,而 null 表示缺少值(例如在计算—个值的过程中遇到了错误)。
除了 null 和 undefined 之外,TypeScript还有 void 和 never类型。这两个类型有明确的特殊作用,进一步划分不同情况下的 "不存在" : void 是函数没有显式返回任何值(例如 console.log)时的返回类型,而 never 是函数根本不返回(例如函数抛出异常,或者永远运行下去)时使用的类型。
如果说 unknown 是其他每个类型的父类型,那么 never 就是其他每个类型的子类型。我们可以把 never 理解为“兜底类型”。这意味着,never类型可赋值给其他任何类型,在任何地方都能放心使用never类型的值。这一点基本上只有具有理论意义。
// a 一个返回数字或者null 的函数
function a (x:number){
if(x < 10){
return 10
}
return null
}
// b 一个返回 undefined 的函数
function b(){
return undefined
}
// c 一个返回 void 的函数
function c(){
let a = 2 + 2
let b = a*a
}
// d 一个返回 never 的函数
function d(){
throw TypeError(' i always error')
}
// e 另一个返回 never 的函数
function e(){
while (true){
dosomethion()
}
}
用法说明
- a 和 b分别显示返回 null 和 undefined。
- c 返回 undefined,但是没有使用return语句明确指定,越是我们说改函数返回 void。
- d 抛出异常,e 一直运行,因此二者都不返回,所以我们说这两个函数的返回类型为never。
表示缺少什么的类型
类型 | 含义 |
---|---|
null | 缺少值 |
undefined | 尚未赋值的变量 |
void | 没有return语句的函数 |
never | 永不返回的函数 |
严格检查null
在旧版TypeScript中(或者把TSC的 strictNullChecks 选项设为于false) , null 的行为稍有不同: null 是除never 之外所有类型的子类型。这意味着,每个类型的值都可能为null,因此必须首先检查是否为 null 值,否则不能信任任何类型的值。
枚举
枚举的作用是列举类型中包含的各个值。这是一种无序数据结构,把键映射到值上。枚举可以理解为编译时键固定的对象,访问键时,TypeScript将检查指定的键是否存在。
枚举分为两种:字符串到字符串之间的映射和字符串到数字之间的映射。
enum Language {
English = 0 ,
Spanish = 1 ,
Russian = 2
}
// 枚举访问
let myFirstLanguage = Language.Russian // Language
let mySecondLanguage = Language['English'] // Language
// 分开声明枚举
enum Language {
English = 0 ,
Spanish = 1 ,
}
enum Language {
Russian = 2
}
// 成员的值可以经计算得出,而且不必为所有成员赋值
enum Language {
English = 100 ,
Spanish = 200 + 300 ,
Russian // TypeScript 推导出的值为501(即 500 后的下一个数)
}
// 枚举值可以为数字和字符串,甚至混用
enum Color{
Red = '#c10000',
Blue = '#007ac1',
Pink = 0xc10050, // 十六进制字面量
White = 255 // 十进制字面量
}
let red = Color.Red // Color
let ping = Color.Pink // Color
// 枚举既允许通过值访问枚举,也允许通过键访问,不过这样极易导致问题
let a = Color.Red // Color
let b = Color.Green // Error ts2339: property 'green' dones not exist on type 'typeof color'
let c = Color[0] // string
let d = Color[6] // string(!!!) 其实Color[6]不存在,但是TypeScript 并不阻止你这么做
// 利用const enum 重写
enum Language {
English,
Spanish,
Russian
}
// 访问一下有效的枚举键
let a = Language.English // Language
// 访问一下无效的枚举键
let b = Language.Tagalog // Error ts2339: porperty 'Tagalog' does not exit on type 'typeof Language'.
// 访问一个有效的枚举键
let c = Language[0] // Error ts2476: a const enum member can only be accessed using a string literal.
// 访问一下无效的枚举值
let d = Language[6] // Error ts2476: a const enum member can only be accessed using a string literal.
用法说明
- TypeScript 可以自动为枚举中的各个成员推导对应的数字,你也可以自己手动设置。
- 枚举中的值使用点号或者方括号表示法访问,就像方位常规对象中的值一样。
- 一个枚举可以分为几次声明,TypeScript 将自动把各部分合并在一起。注意,如果分开声明枚举,TypeScript 只能推导出其中一部分的值,因此最好枚举中的每个成员显示赋值。
- 成员的值可以经计算得出,而且不必为所有成员赋值(TypeScript将尽自己所能推导缺少的值)。
- 枚举的值可以为字符串,甚至混用字符串和数字。
- TypeScript 比较灵活,既允许通过值访问枚举,也允许通过键访问,不过这样极易导致问题;为了避免这种不安全的访问操作,可以通过 const enum 指定使用枚举的安全子集。const enum 不允许反向查找,行为与常规的javascript 对象很像,另外默认也不生成任何javascript 代码,而是在用到枚举成员的地方内插对应的值(例如,TypeScript将把 Language.Spanish 替换成对应的值,即1 )。
TSC标志:perserveConstEnums
const enum 内插值的行为在冲某些人编写的 typescript 代码中导入 const enum 时可能导致安全问题:加入原作者在你编译 TypeScript 代码之后更新了 ,那么在运行时你使用的枚举与原作者的枚举指向的值可能不同,而 TypeScript 没有那么只能,无法得知这一变化。
使用const enum时请进来避免内插,而且旨在受自己控制的 Typescript 程序中使用。不要再计划发布到NPM中的程序,或者开放给其他人使用的库中使用
如果想为const enum生成运行时代码。在 tsconfig.json 中把TSC 选项 perserveConstEnums 设为 true;
// 为const enum生成运行时代码
{
"compilerOptions":{
"perserveConstEnums" :true
}
}
关于 const enum
const enum Flippable{
Burger,
Chair,
Cup,
Skateboard,
Table
}
function flip(f:Flippable){
return 'flipped it'
}
flip(Flippable.Chair) // 'flipped it'
flip(Flippable.Cup) // 'flipped it'
flip(12) // 'flipped it' (!!!) 参数为数字12 居然能成功
// 重写:使用字符串枚举值
const enum Flippable{
Burger = 'Burger',
Chair = 'Chair',
Cup = 'Cup',
Skateboard = 'Skateboard',
Table = 'Table'
}
function flip(f:Flippable){
return 'flipped it'
}
flip(Flippable.Chair) // 'flipped it'
flip(Flippable.Cup) // 'flipped it'
flip(12) // Error ts2345 : argument of type '12' is not assignable to parameter of type 'Flippable'
flip('Hat') // Error ts2345 : argument of type 'Hat' is not assignable to parameter of type 'Flippable'
flip(12) 中可以看到,数字也可赋值给枚举。这个行为是 TypeScript 的赋值规则导致的不良后果,为了修正这个问题,我们要额外小心,只在枚举中使用字符串值。
枚举中一个讨厌的数值就能置整个枚举于不安全的境地。
由于使用枚举极易导致安全问题,建议远离枚举。同样的意图在 typescript 中有大量更好的方式表达。
小结
TypeScrjpt内置了大量类型。我们可以让TypeScript根据值推导类型,也可以自己显式注解类型。使用 const 时推导出的类型更具体,而 let 和 var 更一般化。多数类型都分一般和具体两种形式,后者是前者的子类型 。
类型及更具体的子类型
类型 | 子类型 |
---|---|
boolean | Boolean字面量 |
bigint | BigInt字面量 |
number | Number字面量 |
string | String字面量 |
symbol | unique symbol |
object | Object字面量 |
数组 | 元组 |
enum | cosnt enum |
函数
声明和调用函数
function add(a: number, b: number){
return a + b
}
通常,我们会显式注解函数的参数(上例中的a和b)。TypeScript能推导出函数体中的类型,但是多数情况下无法推导出参数的类型,只在少数特殊情况下能根据上下文推导出参数的类型。返回类型能推导出来,不过也可以显式注解。
// typescript 声明函数的五种方式
// 具名函数
function greet(name: string){
return 'hello' + name
}
// 函数表达式
let greet2 = function(name: string){
return 'hello' + name
}
// 箭头函数表达式
let greet3 = (name: string)=>{
return 'hello' + name
}
// 箭头函数表达式简写形式
let greet4 = (name: string)=> 'hello' + name
// 函数构造方法 不建议使用
let greet5 = new Function('name', "return 'hello' + name")
用法说明
- 通常需要注解参数的类型,而返回类型不要求必须注解。
- 在TypeScript中调用函数时,无需提供任何额外的类型信息,直接传人实参即可,TypeScript将检查实参是否与函数形参的类型兼容。
- 如果忘记传人某个参数,或者传人的参数类型有误,TypeScript将指出问题。
可选和默认的参数
与对象和元组类型一样,可以使用?把参数标记为可选的。声明函数的参数时,必要的参数放在前面,随后才是可选的参数。
可以为可选参数提供默认值。这样做在语义上与把参数标记为可选的—样,即在调用时无需传人参数的值(区别是,带默认值
的参数不要求放在参数列表的末尾,而可选参数必须放在末尾)。
// 可选参数函数
function log (message: string, userId?: string){
let time = new Date().toLocaleTimeString()
consoloe.log(time, message, userId || 'Not signed in')
}
// 带默认值的函数
function log (message: string, userId='Not signed in'){
let time = new Date().toLocaleTimeString()
consoloe.log(time, message, userId)
}
用法说明
- TypeScript足够智能,能根据默认值推导出参数的类型,也可以显式注解默认参数的类型,就像没有默认值的参数一样
剩余参数
函数可以接受数量不定的参数,javascript中使用arguments 实现;
JavaScript在运行时自动在函数内定义该对象,并把传给函数的参数列表赋予该对象。arguments 是个类似数组的对象,在调用
之前要把它转换成数组。
function sum():number {
return Array
.from(arguments)
.reduce((total, n) => total + n, 0)
}
sum(1,2,3) // error TS2554: Expected 0 arguments, but got 3.
可是,使用 arguments 有个比较大的问题:根本不安全!
由于声明 sum 函数时没有指定参数,因此在TypeScript看来,该函数不接受任何参数,所以使用时会得到错误。
可使用剩余参数,确保参数安全,一个函数最多只能有一个剩余参数,而且必须位于参数列表的最后。
function sum(...num:number[]):number {
return Array
.from(arguments)
.reduce((total, n) => total + n, 0)
}
call、apply和bind
除了使用圆括号()之外,JavaScript至少还支持两种其他方式。
function add(a: number, b: number){
return a + b
}
add(10,20)
add.apply(null,[10,20])
add.call(null,[10,20])
add.bind(null,10,20)()
appy为函数内部的this绑定一个值,然后展开第二个参数,作为参数传给要调用的函数。call的用法类似,不过是按顺序应用参数的’而不做展开。bind()差不多,也为函数的this和参数绑定值。不过bind并不调用函数,而是返回一个新函数,让你通过()、 .call 或.apply调用,而且可以再传人参数,绑定到尚未绑定值的参数上。
TSC标志:strictBindCallApply
为了安全使用 .call、 .apply、和 .bind,要在 tsconfig.json 中启用 strictBindCallApply 选项(启用strict模式后自动启用该选项)。
this
如果函数使用this,请在函数的第—个参数中声明this的类型(放在其他参数之前),这样每次调用函数时,TypeScript将确保this的确是你预期的类型。this不是常规的参数,而是保留字,是函数签名的一部分:
function fancyDate(this:Date){
return `${this.getDate()}/${this.getMonth()}/${this.getFullYear()}`
}
fancyDate.call(new Date)
fancyDate()
// error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Date'.
TSC标志:noImpolicitThis
如果想强制显示注解函数中的this的类型,在tsconfig.json中启用noImplicitThis 设置。strict 模式包括 noImplicitThis,如果已经启用该模式,就不用设置 noImplicitThis。
注意,noImplicitThis 不强制要求为类或者对象的函数注解 this。
生成器函数
生成器函数(简称生成器)是生成一系列值的便利方式。生成器的使用方可以精确控制生成什么值。生成器是惰性的,只在使用方要求时才计算下—个值。鉴于此,可以利用生成器实现一些其他方式难以实现的操作’例如生成无穷列表。
function* createFibonacciGenerator(){
let a = 0
let b = 1
while(true){
yield a
[ a, b ]=[ b, a+b ]
}
}
let fibonacciGenerator = createFibonacciGenerator() // IterableIterator<number>
用法说明
- TypeScript能通过产出值的类型推导出迭代器的类型 。
- 也可以显式注解生成器’把产出值的类型放在IterableIterator中 。
function* createFibonacciGenerator():IterableIterator<number>{
let a = 0
let b = 1
while(true){
yield a
[ a, b ]=[ b, a+b ]
}
}
调用签名
以上是关于如何注解函数的参数和返回值的类型。那函数自身的完整类型了?
function sum(a:number,b:number): number{
return a + b
}
// sum的类型是什么呢? 没错,由于sum是一个函数,那么它的类型是: Function
我们知道,object能描述所有对象,类似地,Function也可以表示所有函数,但是并不能体现函数的具体类型。
sum 的类型还能怎么表示呢? sum 是—个接受两个 number 参数、返回一个 number 的函数 。 在TypeScript中,可以像下面这样表示该函数的类型:
(a:number,b:number) => number
// a 和 b 这2各参数名称只是一种表意手段,不影响该类型函数的可赋值性。
这是typeScript表示函数类型的句法,也称调用签名(或叫类型签名)。注意,调用签名的句法与箭头函数十分相似,这是有意为之的。如果把函数做为参数传给另一个函数,或者作为其他函数的返回值,就要使用这样的句法注解类型。
函数的调用签名只包含类型层面的代码,即只有类型,没有值。因此,函数的调用签名可以表示参数的类型、this 的类型、返回值的类型、剩余参数的类型和可选参数的类型,但是无法表示默认值(因为默认值是值,不是类型)。调用签名没有函数的定义体,无法推导出返回类型,所以必须显式注解。
类型层面和值层面代码
“类型层面代码阔指只有类型和类型运算符的代码。而其他的都是“值层面代码”。一个简单的判断标准是,如果是有效的
JavaScript代码’就是值层面代码;如果是有效的TypeScript代码,但不是有效的JavaScript代码,那就是类型层面代码。(这个判断标准不适用于枚举和命名空间。枚举既生成类型也生成值,而命名空间只存在于值层面 )
// 简写型调用签名
type Log = (message: string, userId?: string) => void
// 完整型调用签名
type Log ={
(message:string, userId?: string);void
}
let log:Log = (
message,
userId = 'Not signed in'
)=>{
let time = new Date().toISOString()
console.log(time, message, userId)
}
用法说明
- 声明一个函数表达式log,显示注解其类型为Log。
- 不必再次注解参数的类型,因为在定义Log 类型时已经注解了 message 的类型为 string。这里不用再次注解,TypeScript 能从 Log中推导出来。
- 为 userId设置一个默认值。userId的类型可以从 Log 的签名中获取,但是默认值却不得而知,因为Log 是类型,不包含值。
- 无需再次注解返回类型,因为在Log类型中已经声明为 void。
上下文类型推导
前一个示例是我们见过的第一次不用显式注解函数参数类型的情况。由于我们已经把log的类型声明为Log,所以TypeScript能从上下文中推导出message的类型为string。这是TypeScript类型推导的一个强大特性,称为上下文类型推导(contextual typing) °
function times(
f:(n:number)=>void,
n) {
for (let index = 0; index < n; index++) {
f(index)
}
}
// 调用time时,传给time的函数如果是在行内声明的,无需显式注解函数的类型
times(n=> console.log(n),4)
// 注意,如果f不是在行内声明的,TypeScript则无法推导出它的类型
let f = (n) => { // error ts7006: parameter 'n' implicitly has 'any' type.
console.log(n)
}
times(f,4)
函数类型重载
函数的调用签名的两种方式:
// 简写型调用签名
type Log = (message: string, userId?: string) => void
// 完整型调用签名
type Log ={
(message:string, userId?: string);void
}
// 这两种写法完全等效,只是使用的句法不同
简单的情况下,例如 log 函数,首选简写形式,但是较为复杂的函数,使用完整签名有更多
好处。比如重载
重载函数
有多个调用签名的函数。
在多数编程语言中,声明函数时一且指定了特定的参数和返回类型,就只能使用相应的参数调用函数,而且返回值的类型始终如—。但是JavaScript的情况却非如此。因为JavaScript是一门动态语言,势必需要以多种方式调用一个函数的方法。不仅如此,而且有时输出的类型取决于输人的参数类型。
TypeScript也支持动态重载函数声明,而且函数的输出类型取决于输人类型,这一切都得益于TypeScript的静态类型系统。
type Reserve = {
(from:Date, to:Date, destination:string ): Reservation
(from:Date, destination:string): Reservation
}
let reserve : Reserve = (from, to, destination )=>{
// ...
}
// 缺少重载的另一个签名时提示TypeError:
// error TS2322: Type '(from: Date, to: Date, destination: string) => void' is not assignable to type 'Reserve'.
以上的错误是由于 typescript 的调用签名重载机制造成的;
如果为函数 f 声明多个重载的签名,在调用方看来,f 的类型是各签名的并集。但是在实现 f 时,必须一次实现整个类型组合。实现 f 时,我们要自己设法声明组合后的调用签名,TypeScript无法自动推导。
type Reserve = {
(from:Date, to:Date, destination:string ): Reservation
(from:Date, destination:string): Reservation
} // 1
let reserve : Reserve = (
from: Date,
toOrDestination: Date | string,
destination>: string
) => { // 2
// ...
}
用法说明
- 声明两个重载的函数签名。
- 自己动手组合两个签名(即自行计算 Date | string 的结果),实现声明的签名。注意:组合后的签名对调用 reserve 的函数是不可见的。
- 由于 reserve 可以通过两种方式调用,因此实现 reserve 时要向TypeScript证明你检查过了调用方式:
let reserve : Reserve = (
from: Date,
toOrDestination: Date | string,
destination?: string
) => { // 2
if(toOrDestination instanceof Date && ){
// ...
}else if(typeof destination === 'string'){
// ...
}
}
让重载的签名具体一些
一般来说,声明重载的函数类型时,每个重载的签名(例如 Reserve )都必须可以赋值给实现的签名(例如reserve)。但是,声明实现的签名时一般可能会更宽泛一些,保证所有重载的签名都可以赋值给实现的签名。
let reserve : Reserve = (
from: any,
toOrDestination: any,
destination?: any
) => {
// ....
}
重载时,应该尽量让实现的签名具休一些,这样史容易实现函数。因此,在这个示例中,使用 Data 好过 any,使用Data | string 并集好过 any。
因为,如果把参数的类型注解为日any,但是想传入Data类型的值,那么必须向TypeScript证明传入的值的确是日期。
重载函数声明
之前重载的都是函数表达式,也可以重载函数声明,Typescript也支持这么做,句法与函数声明相同。
function reserve(from:Date, destination:string): Reservation
function reserve(from:Date, to:Date, destination:string): Reservation
function reserve(from:Date, toOrDestination: Date | string, destination?:string): Reservation {
// ...
}
描述函数属性
由于JavaScript函数是可调用的对象,因此我们可以为函数赋予属性 。
完整的类型签名并不只限于用来重载调用函数的方式,还可以描述函数的属性。
// 设置函数类型签名,以及属性签名
type WarnUser = {
(waring: string): void
wasCalled: boolean
}
let warnUser:WarnUser = (warning: string)=>{
if(warnUser.waCalled){
retrun
}
warnUser.waCalled = true
alert(warning)
}
// TypeScript足够聪明,它能发现,尽管声明 warnUser 函数时没有给 waCalled 赋值,但是随后就赋值了。
多态
之前的类容都在讲具体类型的用法和用途,以及使用具体类型的函数。
使用具体类型的前提是明确知道需要什么类型,并且想确认传人的确实是那个类型。但是,有时事先并不知道需要什么类型,不想限制函数只能接受某个类型。
type Filter = {
(array:number[], f:(item:number)=>boolean):number[]
(array:string[], f:(item:string)=>boolean):string[]
(array:object[], f:(item:object)=>boolean):object[]
}
let names = [
{ firstName: 'benth' },
{ firstName: 'caitlyn' },
{ firstName: 'xin' },
]
let filter: Filter = (array, f) => {
let result = []
for (let index = 0; index < array.length; index++) {
let item= array[index]
if (f(item)) {
result.push(item)
}
}
return result
}
let result = filter(
names,
_ => _.firstName.startsWith('b') // 只能处理object类型参数
)
以上示例中 filter 函数参数可能有多个类型,且 f 函数的参数受 filter 函数中的参数约束,可以定义重载类型声明,在 f 函数中手动辨别参数类型,但是过于繁琐,可以通过泛型(泛型参数)的方式来实现这种约束和关联。
泛型(泛型参数)
在类型层面施加约束的占位类型,也称多态类型参数。
// 使用泛型参数重写声明
type Filter = {
<T>(array:T[], f:(item:T)=> boolean):T[]
}
“ filter 函数使用一个泛型参数 T,可是我们事先不知道具体是什么类型;TypeScript,调用 filter 函数时,如果你能推导出该参数的类型,那最好。’’ TypeScrjpt从传人的 array 中推导 T 的类型。调用于filter 时,TypeScript推导出 T 的具体类型后,将把 T 出现的每一处替换为推导出的类型。T 就像是—个占位类型,类型检查器将根据上下文填充具体的类型。T 把 Filter 的类型参数化了,因此才称其为泛型参数。
泛型参数使用奇怪的尖括号 <> 声明(你可以把尖括号理解为type关键字,只不过声明的是泛型)。尖括号的位置限定泛型的作用域(只有少数几个地方可以使用尖括号) ’ TypeScript将确保当前作用域中相同的泛型参数最终都绑定同一个具体类型。鉴于这个示例中尖括号的位置,TypeScript将在调用 filter 函数时为泛型 T 绑定具体类型。而为 T 绑定哪一个具体类型,取决于调用filter 函数时传入的参数。在一对尖括号中可以声明任意个以逗号分隔的泛型参数。
// 每次调用函数时都要重新绑定函数的参数。类似地,每次调用 filter 都会重新绑定 T:
type Filter = {
<T>(array:T[], f:(item:T)=> boolean):T[]
}
let filter: Filter = (array, f) => {
// ...
}
// (a) T绑定为 number
filter([1, 2, 3], _ => _ > 2)
// (b) T绑定为 string
filter(['a', 'b', 'c'], _ => _ !== 'b')
// (c) T绑定为 {firstName: string}
let names = [
{ firstName: 'benth' },
{ firstName: 'caitlyn' },
{ firstName: 'xin' },
]
filter(names, _ => _.firstName.startsWith('b'))
用法说明
- 根据 filter 的类型签名,TypeScript知道army中的元素为某种类型T。
- TypeScript知道传人的数组是[1,2, 3],因此 T 必定是array。
- TypeScript遇到T,便把它替换成number。因此,参数 f: (item: T) => boolean 将变成 f: (item: number) => boolean,返回类型 T[] 将变成number[]。
- 检查,TypeScript确认这些类型都满足可赋值性,而且传入的 f 函数可赋值给刚推导出的签名。
泛型让函数的功能更具—般性,比接受具体类型的函数更强大。泛型可以理解为—种约束。
泛型也可在类型别名、类和接口中使用。 只要可能就应该使用泛型,这样写出的代码更具一般性,可重复使用,并且
简单扼要。
什么时候绑定泛型
声明泛型的位置不仅限定泛型的作用域,还决定TypeScrlpt什么时候为泛型绑定具体的类型。
type Filter = {
<T>(array:T[], f:(item:T)=> boolean):T[]
}
let filter: Filter = (array, f) => {
// ...
}
< T > 在调用签名中声明(位于签名的开始圆括号前面),TypeScript 将在调用 Filter 类型的函数时为 T 绑定具体类型。
type Filter <T> = {
(array:T[], f:(item:T)=> boolean):T[]
}
let filter: Filter = (array, f) =>{} // error TS2348: generic type 'Filter' requires 1 type argument(s).
type OtherFilter = Filter // error TS2314: generic type 'Filter' requires 1 type argument(s).
let filter: Filter<number> = (array, f) =>{...}
type StringFilter = Filter<string>
let stringFilter: StringFilter = (array, f) =>{}
一般来说,TypeScrjpt 在使用泛型时为泛型绑定具体类型:对函数来说,在调用函数时;对类来说,在实例化类时;对类型别名和接口来说,在使用类别名和实现接口时。
可以在什么地方声明泛型
type Filter ={ // 1
<T>(array: T[], f:(item: T) => boolean ): T[]
}
let filter: Filter = { ... }
type Filter<T> = { // 2
(array: T[], f:(item: t) => boolean): T[]
}
let filter: Filter<number> ={ ... }
type Filter = <T>(array: T[], f: (item: T)=> boolean) => T[] // 3
let filter: Filter = { ... }
type Filter<T> = (array: T[], f: (item: T)=> boolean) => T[] // 4
let filter: Filter<string> = { ... }
function filter<T>(array: T[], f: (item: T)=> boolean): T[]{ // 5
...
}
- 一个完整的调用签名,T的作用域在单个签名中。鉴于此,TypeScript将在调用 filter 类型的函数时为签名中的 T 绑定具体类型。每次调用 filter 将为 T 绑定独立的类型。
- 一个完整的调用签名,T 的作用域涵盖全部签名。由于 T 是 Filter 类型的一部分(而不属于某个具体的签名) ,因此TypeScript将在声明 Filter 类型的函数时绑定T 。
- 与 1 类似,不过声明的不是完整调用签名,而是简写形式。
- 与 2 类似,不过声明的不是完整调用签名,而是简写形式。
- 一个具名函数调用签名,T 的作用域在签名中。TypeScript 将在调用于 filter 时为 T 绑定具体类型,而且每次调用 filter 将为 T 绑定独立的类型。
泛型推导
多数情况下, TypeScript能自动推导出泛型。
也可以显式注解泛型。显式注解泛型时,要么所有必须的泛型都注解,要么都不注解。
TypeScript将检查推导出来的每个泛型是否可赋值给显式绑定的泛型,如果不可赋值,将报错。
function may <T, U>(array: T[], f: (item;T) => U): U[]{
let result = []
for (let i =0; i < array.length; i++){
result[i] = f(array[i])
}
return result
}
map(
['a','b','c'], // T类型的数组
_ => _==='a' // 返回类型为U的函数
)
// 显示注解泛型
map<string, boolean>(
['a','b','c'], // T类型的数组
_ => _==='a' // 返回类型为U的函数
)
// 显示注解泛型,要注解就需要全部注解
map<string>( // error ts2558: expected 2 type arguments, but got 1.
['a','b','c'],
_ => _==='a'
)
// typescript将检查推导出的泛型是否可赋值给显示注解的泛型。
map<string, boolean | string>( // 没有问题,因为boolean 可赋值给 boolean | string
['a','b','c'],
_ => _==='a'
)
map<string, number>( // error ts2322: type 'boolean' is noet assignable to type 'number'
['a','b','c'],
_ => _==='a'
)
TypeScript根据传入泛型函数的参数推导泛型的具体类型,有时你会遇到这样的情况 :
let promise = new Promise( resolve => {
resolve(45)
} )
promise.then( result =>{
return result * 4
})
// error ts2362: the left-hand side of an arithmetic operation must be of type 'any' ,'number', 'bigint', or an enum type.
怎么回事?为什么TypeScrip t推导出来的 result 的类型为{}? 因为我们没有提供足够的信息,毕竟TypeScript 只通过泛型函数的参数类型推导泛型的类型,所以 T 默认为{}。
这个问题的修正方法是显式注解 Promise 的泛型参数:
let promise = new Promise< number > ( resolve => resolve(45))
promise.then( result => // number
result * 4
)
泛型别名
在类型别名中也可以使用泛型。
// 定义—个 MyEvent 类型,描述DOM事件,例如 click 或 mousedown :
type MyEvent<T> = {
target: T
type: string
}
// 使用 MyEvent 这样的泛型时,必须显式绑定类型参数,TypeScript无法自行推导:
let myEvent: MyEvent< HTMLButtonElement | null> ={
target: document.querySelector('#myButton'),
type: 'click'
}
泛型别名也可以在函数的签名中使用。TypeScript为 T 绑定类型时,还会自动为 MyEvent 绑定:
function triggerEvent<T>(event: MyEvent<T>):void{
// ..
}
triggerEvent({ // T 是 Element | null
target: document.querySelector('#myButton'),
type: 'mouseover'
})
用法说明
- 调用 triggerEvent 时传人一个对象。
- 据函数的签名,TypeScript 认定传入的参数类型必为 MyEvent<T> 。TypeScript还发现定义 MyEvent<T> 时声明的类型为{ target:T ,tγpe:string}。
- TypeScript发现传给该对象target字段的值为 document.querySelector('#myButton') 。这意味着,T 必定为document.querySelector('#myButton')的类型,即 Element | null 。因此,T 现在绑定为 Element | null 。
- TypeScript检查全部代码,把 T 出现的每—处替换为Element | null 。
- TypeScript确认所有类型都满足可赋值性,确保代码是类型安全的。
受限的多态
可以为泛型设置约束,如:类型 U 至少应该为 T,即为U设置一个上限(约束)。
// 定义一个二叉树的三类节点:
// 1. 常规 TreeNode.
// 2. LeafNode, 即没有子节点的 TreeNode.
// 3. InnerNode, 即有子节点的 TreeNode.
type TreeNode = {
value: string
}
type leafNode = TreeNode & {
isLeaf: true
}
type InnerNode = TreeNode & {
children: [TreeNode] | [TreeNode,TreeNode]
}
// 编写mapNode函数, 传入 TreeNode,返回一个新的 TreeNode。
let a: TreeNode = {value: 'a'}
let b: leafNode = {value: 'b', isLeaf: true}
let c: InnerNode = {value: 'c', children:[b]}
let a1 = mapNode(a, _ => _.toUpperCase()) // 返回TreeNode
let b1 = mapNode(b, _ => _.toUpperCase()) // 返回leafNode
let c1 = mapNode(c, _ => _.toUpperCase()) // 返回InnerNode
// mapNode 定义如下
function mapNode <T extends TreeNode>( //1
node: T, //2
f: (value:string) => string
): T{ // 3
return {
...node,
value: f(node,value)
}
}
用法说明
- mapNode 函数定义了一个泛型参数T。T 的上限为 TreeNode,即 T 可以是 TreeNode,也可以是 TreeNode 的子类型。
- mapNode 接受两个参数,第—个是类型为 T 的node。由于在 1 中指明了 node extends TreeNode,如果传入 TreeNode 之外的类型,例如空对象{}、null 或 TreeNode数组,立即就能看到一条红色波浪线。node 要么是 TreeNode 类型,要么是 TreeNode 的子类型。
- mapNode 的返回值类型为 T 。注意,T 要么是 TreeNode 类型,要么是 TreeNode 的子类型。
为什么这样声明T
. 如果只输入 T(没有 extends TreeNode),那么 mapNode 会抛出编译时错误,因为这样不能从 T 类型的 node 中安全读取 node.value(试想传入一个数字的情况)。
. 如果根本不用 T ,把 mapNode 声明为(node: TreeNode,f: (value: string) => string) => TreeNode,那么映射节点后将丢失信息: a1、b1和c1都只是 TreeNode。
. 声明 extends TreeNode,输人节点的类型(TreeNode、LeafNode 或 InnerNode)将得到保留,映射后类型也不变。
有多个约束的受限多态 , 方法是扩展多个约束的交集(&)
type HasSides = { numberOfSides: number}
type SidesHaveLength = {sideLength: number}
function logPerimeter<
Shape extends HasSides & SidesHaveLength
>( s:Shape ): Shape{
console.log( s.numberOfSides * s.sideLength)
return s
}
type Square = HasSides & SidesHaveLength
let square: Square = { numberOfSides:4, sideLength:3 }
logPerimeter(square)
泛型默认类型
函数的参数可以指定默认值,类似地,泛型参数也可以指定默认类型。
// 为 MyEvent 的泛型参数指定一个默认类型
type MyEvent<T = HTMLElement> = {
target: T
type: string
}
// 为 T 设置限制,确保 T 是一个HTML元素
type MyEvent<T extends HTMLElement = HTMLElement> = {
target: T
type: string
}
注意,与函数的可选参数—样,有默认类型的泛型要放在没有默认类型的泛型后面。
// 正确
type MyEvent2<
Type extends string,
Target extends HTMLElement = HTMLElement,
> = {
target: Target
type: Type
}
// 错误
type MyEvent3<
Target extends HTMLElement = HTMLElement,
Type extends string,
> = {
target: Target
type: Type
}
// error ts2706: required type parameters may not follow optional type parameters.
类和接口
类和继承
type Color = 'black' | 'white'
type File = 'a' | 'b'
type Rank = 1 | 2
class Position {
constructor(
private file: File,
private rank:Rank
){}
}
class Piece {
protected position: Position
constructor(
private readonly color: Color,
file: File,
rank:Rank
) {
this.position = new Position(file,rank)
}
}
- 造方法中的 private 访问修饰符自动把参数赋值给 this(this.file等),并把可见性设为私有,这意味着 Piece 实例中的代码可以读取和写入,但是 Piece 实例外部的代码不可以。不同的 Piece 实例访问各自的私有成员;其他类的实例,即便是 Piece 的子类也不可以访问私有成员。
- 实例变量 position 的可见性声明为protected。与private类似,protected也把属性赋值给 this,但是这样的属性对 Piece 的实例和
Piece 子类的实例都可见。声明 position 时没有为其赋值,因此在 Piece 的构造方法中要赋值。如果未在构造方法中赋值,TypeScript将提醒我们变量没有明确赋值。也就是说,我们声明的类型是T,但事实上是 T|undefinded,这是因为我们没有在属性初始化语句或构造方法中为其赋值。如此一来,我们要更新属性的签名,指明其值不—定是—个 Position 实例,还有可能是 undefined。 - new Piece接受三个参数: color、file 和 rank。我们为 colo r指定了两个修饰符:一个是private,把它赋值给this,并确保只能由Piece的实例访问;另—个是readonly,指明在初始赋值后,这个属性只能读取,不能再赋其他值。
TSC标志: strictNullChecks 和 strictPropertyInitialization
如果向检查类的实例变量有没有明确赋值,在 tsconfig.json中启用 strictNullChecks 和 strictPropertyInitialization。如果已经启用strick 标志,那就不用再设置这两个标志了。
TypeScrlpt类中的属性和方法支持三个访问修饰符:
- public:任何地方都可访问,这是默认的访问级别。
- protected:可由当前类及其子类的实例访问。
- private:只可由当前类的实例访问。
如果希望类不被直接实例化,而是在此基础上扩展,使用关键字 abstract 关键字;
abstract class Piece{
// ..
moveTo(positon: Position){
this.positon = positon
}
abstract canMoveTo(positon: Positon):boolean
}
- 告诉子类,子类必须实现—个名为canMoveTo的方法,而且要兼容指定的签名。如果扩展 Piece 的类忘记实现抽象的canMoveTo方法,编译时将报类型错误。注意,实现抽象类时也要实现抽象方法。
- Piece 类为 moveTo 方法提供了默认实现(如果子类愿意,也可以覆盖默认实现)。我们没有为 moveTo 方法设置访问修饰符,因此默认为public,所以其他代码可读也可写。
总结:
- 类使用 class 关键字声明。扩展类时使用 extends 关键字。
- 类可以是具体的,也可以是抽象的( abstract)。抽象类可以有抽象方法和抽象属性。
- 方法的可见性可以是private、protected 或 public(默认)。方法分实例方法和静态方法两种。
- 类可以有实例属性,可见性也可以是private、protected 或 public(默认)。实例属性可在构造方法的参数中声明,也可通过属性初始化语句声明。
- 声明实例属性时可以使用 readonly 把属性标记为只读。
super
super有两种调用方式:
- 方法调用,例如 super.take。
- 构造方法调用。此时使用特殊的形式super(),而且只能在构造方法中调用。如果子类有构造方法,在子类的构造方法中必须调用super()。
注意,使用 super 只能访问父类的方法,不能访问父类的属性。
以 this 为返回类型
this 可以用作值,此外还能用作类型。对类来说,this 类型还可用于注解方法的返回类型。
class Set {
has(..): boolean{}
add(..): Set{}
}
class MutableSet extends Set {
delete(...): boolean {}
add(...): MutableSet{} // 子类需要把返回 this的每个方法的签名覆盖掉,十分麻烦
}
// 利用 this 作为返回类型,把相关工作交给TypeScrlpt
class Set {
has(..): boolean{}
add(..): this{} // 可以把MutableSet中覆盖的add方法删除, 因为在Set中,this指向一个Set实例,而在MutableSet中,this指向—个MutableSet实例。
}
接口
类经常当做接口使用
与类型别名相似,接口是—种命名类型的方式,这样就不用在行内定义了。类型别名和接口算是同一概念的两种句法(就像函数表达式和函数声明之间的关系),不过二者之间还是有一些细微差别。
// 类型别名定义类型
type Sushi = {
calories: number
salty: boolean
tasty:boolean
}
// 接口定义类型
interface Sushi {
calories: number
salty: boolean
tasty:boolean
}
type Cake = {
calories: number
sweet: boolean
tasty:boolean
}
// 类型别名扩展类型
type Food = {
calories: number
tasty:boolean
}
type Sushi = Food & {
tasty:boolean
}
type Cake = Food & {
sweet: boolean
}
// 接口扩展类型
interface Food{
calories: number
tasty:boolean
}
interface Sushi extends Food{
tasty:boolean
}
interface Cake extends Food{
sweet: boolean
}
接口不一定扩展其他接口。其实,接口可以扩展任何结构:对象类型、类或其他接口 。
类型和接口之间区别
- 类型别名更为通用,右边可以是任何类型,包括类型表达式(类型,外加 & 或|等类型运算符),而在接口声明中,右边必须为结构。
- 扩展接口时,TypeScript将检查扩展的接口是否可赋值给被扩展的接口。
- 同一作用域中的多个同名接口将自动合并;同—作用域中的多个同名类型别名将导致编译时错误。这个特性称为声明合并。
关于扩展接口
interface A {
good(x: number): string
bad(x: number): string
}
interface B extends A {
good(x: string | number): string
bad(x: string) : string
}
//error TS2430: Interface 'B' incorrectly extends interface 'A'.
// Types of property 'bad' are incompatible.
// Type '(x: string) => string' is not assignable to type '(x: number) => string'.
// Types of parameters 'x' and 'x' are incompatible.
// Type 'number' is not assignable to type 'string'.
使用交集类型时则不会出现这种问题。如果把前例中的接口换成类型别名,把 extends 换成交集运算符(&),TypeScript将尽自己所能,把扩展和被扩展的类型组合在一起,最终的结果是重载bad的签名,而不会抛出编译时错误。
声明合并
声明合并指的是TypeScrjpt自动把多个同名声明组合在一起。 枚举,命名空间,接口都有这个特性。类型别名不具有此特性,同一作用域,相同的类型别名将报错。
interface User {
name:string
}
interface User {
age:number
}
let a: User = {
name: 'ashley',
age:30
}
// 注意1:合并的接口不能有冲突
interface User {
age:string
}
interface User {
age:number
} // error TS2717: Subsequent property declarations must have the same type. Property 'age' must be of type 'string', but here has type 'number'.
// 注意2:如果接口声明泛型,必须使用相同方式声明泛型
interface User< Age extends number> { // error TS2428: All declarations of 'User' must have identical type parameters.
age:Age
}
interface User< Age extends string> { // error TS2428: All declarations of 'User' must have identical type parameters.
age:Age
}
注意1:两个接口不能有冲突。如果在一个接口中某个属性的类型为T,而在另一个接口中该属性的类型为U,由于 T 和 U 不是同—种类型,TypeScript将报错。
注意2:如果接口中声明了泛型,那么两个接口中要使用完全相同的方式声明泛型(名称一样还不行),这样才能合并接口。
实现
声明类时,可以使用 implements 关键字指明该类满足某个接口。与其他显式类型注解一样,这是为类添加类型层面约束的一种便利方式。这么做能尽量保证类在实现上的正确性,防止错误出现在下游,不知具体原因。
interface Animal {
eat(food: string): void
sleep(hours: number): void
}
class Cat implements Animal{
eat(food: string) {
console.info('eat')
}
sleep(hours: number) {
console.info('sleep')
}
}
// 接口可以声明实例属性,但不能带可见性修饰符,可以使用 readonly
interface Animal {
readonly name: string
eat(food: string): void
sleep(hours: number): void
}
接口可以声明实例属性,但是不能带有可见性修饰符(private、protected 和 public ),也不能使用 static 关键字。另外,像对象类型一样,可以使用 readonly 把实例属性标记为只读。
一个类不限于只能实现一个接口,而是想实现多少都可以。
实现接口还是扩展抽象类
实现接口其实与扩展抽象类差不多。区别是:接口更通用、更轻量,而抽象类的作用更具体、功能更丰富。
接口是对结构建模的方式。在值层面可表示对象、数组、函数类或类的实例。接口不生成JavaScript代码,只存在于编译时。
抽象类只能对类建模,而且生成运行时代码,即JavaScript类。抽象类可以有构造方法,可以提供默认实现,还能为属性和方法设置访问修饰符。这些在接口中都做不到。
具体使用哪个’取决干实际用途。如果多个类共用同一个实现,使用抽象类。如果需要一种轻量的方式表示 "这个类是 T 型" ,使用接口。
类是结构化类型
与TypeScript中的其他类型一样,TypeScript根据结构比较类,与类的名称无关。类与其他类型是否兼容,要看结构;如果常规的对象定义了同样的属性或方法,也与类兼容。从C#、Java、Scale 和其他多数名义类型编程语言转过来的程序员,—定要记住这一点。这意味着,如果一个函数接受 Zebra 实例,而我们传入一个 Poodle 实例,TypeScript可能并不介意。
class Zebra {
trot() {}
}
class Poodle {
trot(){}
}
function ambleAround(animal: Zebra) {
animal.trot()
}
let zebra = new Zebra()
let poodle = new Poodle()
只要 Poodle 可赋值给 Zebra ,TypeScript就不报错,因为在该函数看来,二者是可互用的,毕竟两个类都实现了.trot 方法。如果你使用的是名义类型语言,上述代码将报错,但是TypeScrjpt是彻底的结构化类型语言,因此这段代码完全有效。
然而,如果类中有使用 private 或protected 修饰的字段,情况就不一样了。检查一个结构是否可赋值给—个类时,如果类中有private 或 protected 字段,而且结构不是类或其子类的实例,那么结构就不可赋值给类。
class A {
private x = 1
}
class B extends A { }
function f(a: A) { }
f(new A) // ok
f(new B) // ok
f({x:1}) // error TS2345: Argument of type '{ x: number; }' is not assignable to parameter of type 'A'. Property 'x' is private in type 'A' but not in type '{ x: number; }'.
类既声明值也声明类型
在TypeScript中,多数时候,表达的要么是值要么是类型。
在TypeScript 中,类型和值位于不同的命名空间中。根据场合,TypeScript知道你要使用的是类型还是值。
// 值
let a = 999
function b(){}
// 类型
type a = number
interface b {
():void
}
// 根据上下文推导
if( a + 1 > 3){} // TypeScript 从上下文中推导出你指的是值a
let x: a = 3 // TypeScript 从上下文中推导出你指的是类型a
类和枚举比较特殊,它们既在类型命名空间中生成类型,也在值命名空间中生成值。
class C { }
let c: C // 1
= new C // 2
enum E { F, G }
let e: E // 3
= E.F // 4
// 1这里,C指C类的实例类型
// 2这里,C指值C
// 3这里,E值E 枚举的类型。
// 4这里,E指值E
在上述示例中,C指C类的—个实例。那要怎么表示C类自身的类型? 使用 typeof 关键字(TypeScript提供的类型运算符,作用类似于JavaScript中值层面的typeof,不过操作的是类型)
type State = {
[key: string]: string
}
class StringDatabase{
state: State = {}
get(key: string): string | null {
//...
}
set(key: string, value: string): void{
//...
}
static from(state: State) {
let db = new StringDatabase
for (let key in state) {
db.set(key, state[key])
}
return db
}
}
// StringDatabase类,生成了2个类型:
// 1. 实例类型 StringDatabase:
interface StringDatabase {
state: State
get(key: string): string | null
set(key: string, value: string): void
}
// 2. 构造方法类型,通过 typeof获取: typeof StringDatabase:
interface StringDatabaseConstructor {
new(): StringDatabase
from(state: State): StringDatabase
}
StringDatabaseConstructor 只有一个方法 .from ,使用 new 运算符操作这个构造方法得到—个 StringDatabase 实例。这两个接口组合在一起对类的构造方法和实例进行建模。
new()那一行称为构造方法签名,TypeScript 通过这种方式表示指定的类型可以使用 new 运算符实例化。鉴于TypeScript采用的是结构化类型,这是描述类的最佳方式,即可以通过 new 运算符实例化的是类。
综上,类声明不仅在值层面和类型层面生成相关内容,而且在类型层面生成两部分内容:—部分表示类的实例,另—部分表示类的构造方法(通过类型运算符 typeof 获取) 。
多态
与函数和类型—样,类和接口对泛型参数也有深层支持,包括默认类型和限制。泛型的作用域可以放在整个类或接口中,也可放在特定的方法中。
class MyMap<K, V>{ //1
constructor(initialKey: K, initialValue: V) { //2
//..
}
get(key: K): V{ //3
//..
}
set(key: K, value: V): void{
//...
}
merge<k1, v1>(map: MyMap<k1, v1>: MyMap<K|k1, V|v1>) { //4
//...
}
static of<k, v>(k: k, v: v): MyMap <k, v > {//5
//...
}
}
// 显示为泛型绑定具体类型,也可以让 typescript自动推导
let a = new MyMap<string, number>('k',1) // MyMap<string, number>
let b = new MyMap('k', true) // MyMap<string, boolean>
a.get('ke')
b.set('k', false)
- 声明类时绑定作用域为整个类的泛型。K 和 V在 MyMap 的每个实例方法和实例属性中都可用。
- 注意,在构造方法中不能声明泛型。应该在类声明中声明泛型。
- 在类内部,任何地方都能使用作用域为整个类的泛型。
- 实例方法可以访问类一级的泛型,而且自己也可以声明泛型。merge 方法使用了类一级的泛型 K 和 Y,而且自己还声明了两个泛型: k1和 v1。
- 静态方法不能访问类的泛型,这就像在值层面不能访问类的实例变量—样。of 不能访问 1中声明的 k 和 y,不过该方法自己声明了泛型 k 和 y。
与函数一样,我们可以显式为泛型绑定具体类型,也可以让TypeScript自动推导。
类型进阶
TypeScript 一流的类型系统支持强大的类型层面编程特性; TypeScript的类型系统不仅具有极强的表现力,易于使用,而且可通过简洁明了的方式声明类型约束和关系,并且多数时候能自动为我们推导。
类型之间的关系
子类和超类
子类型
给定两个类型A和B,假设 B 是 A 的子类型,那么在需要 A 的地方都可以放心使用B。
例如:
- Array是Object的子类型。
- Tuple是Array的子类型。
- 所有类型都是 any 的子类型。
- never是所有类型的子类型。
- 如果 Bird 类扩展自 Animal 类,那么 Bird 是 Animal 的子类型。
根据前面给出的子类型定义’这意味着:
- 需要 Object 的地方都可以使用 Array。
- 需要 Array 的地方都可以使用Tuple。
- 需要 any 的地方都可以使用Object。
- never 可在任何地方使用。
- 需要 Animal 的地方都可以使用 Bird。
超类型
给定两个类型 A 和 B,假设 B 是 A 的超类型,那么在需要 B 的地方都可以放心使用A。
型变
在 TypeScript中,A 类型是否可以赋值给 B 类型,主要只判断 A 与 B 类型的父子关系,型变是为保证赋值的判断规则。
多数时候,很容易判断 A 类型是不是 B 类型的子类型。例如,对number、string 等基础类型来说或者自己分析( number 包含在并集类型 number|string 中,那么number必定是它的子类型)。
但是对参数化类型(泛型)和其他较为复杂的类型来说,情况不那么明晰。例如:
- 什么情况下 Array(A)是 Array(B)的子类型?
- 什么情况下结构 A 是结构 B 的子类型?
- 什么情况下函数(a: A)=> B 是函数(c:C)=> D 的子类型?
如果一个类型中包含其他类型(即带有类型参数的类型,如 Array(A);带有字段的结构,如{a: number};或者函数,如(a: A) => B),使用上述规则很难判断谁是子类型。
便于后文阅读理解,以下定义
这套句法不是有效的TypeScript代码,只是为了让后文方便讨论类型。
- A <: B 指 “ A 类型是 B 类型的子类型,或者为同种类型 ”
- A >: B 指 “ A 类型是 B 类型的超类型,或者为同种类型 ”
结构和数组型变
在TypeScript 中,允许我们在预期某类型的超类型的地方使用那个类型。 (B 是 A的超类,在需要B类型的地方,可以使用A类型)。
TypeScript的行为是这样的:对预期的结构,还可以使用属性的类型 <: 预期类型的结构,但是不能传入属性的类型是预期类型的超类型的结构。在类型上,我们说TypeScript对结构(对象和类)的属性类型进行了协变(covariant)。也就是说,如果想保证A 对象可赋值给 B 对象,那么 A 对象的每个属性都必须 <: B对象的对应属性。
型变有四种方式
协变只是型变的方式之一
- 不变 :只能是 T。
- 协变: 可以是 <:T。
- 逆变:可以是 >:T。
- 双变:可以是 <:T 或 >:T
在TypeScript中,每个复杂类型的成员都会进行协变,包括对象、类、数组和函数的返回类型。不过有个例外:函数的参数类型进行逆变。
函数型变
如果函数 A 的参数数量小于或等于函数B的参数数量,而且满足下述条件,那么函数 A 是函数 B 的子类型:
- 函数 A 的 this 类型未指定,或者 >: 函数 B 的 this 类型。
- 函数 A 的各个参数的类型 >: 函数 B 的相应参数。
- 函数 A 的返回类型 <: 函数 B 的返回类型。
总结:如果函数 A 是函数 B 的子类型,那么函数 A 的 this 类型和参数的类型必定 >: 函数B的 this 类型和参数的类型,而函数A 的返回类型必定<:函数 B 的返回类型。
TSC标志:strictFunctionTypes
考虑历史遗留问题,TypeScript中的函数其实默认会对参数和this的类型做协变,如果想更安全一些,不做型变,请在 tsconfig.json中启用{ ”strictFunctionTypes“ : true }标志。
strict 模式包含 strictFunctionTypes,如果已经设置 { ”strick“: true },那就不用在启用 strictFunctionTypes 标志了。
可赋值性
可赋值性指在判断需要 B 类型的地方可否使用 A类型时TypeScript采用的规则。
TypeScript在回答 “ A类型是否可赋值给B类型? ” 这个问题时,将遵循几个简单的规则。对非枚举类型来说,例如数组、布尔值、数字、对象、函数、类、类的实例和字符串,以及字面量类型,在满足下述任—条件时,A类型可赋值给B类型:
- A <: B
- A 是 any
规则 1 就是子类型的定义:如果 A 是 B 的子类型,那么需要B的地方也可以使用A。
规则 2 是规则 1 的例外,是为了方便与JavaScrlpt代码互操作。
对于使用 enum 或 const enum 关键字创建的枚举类型,满足以下任一条件时,A类型可赋值给枚举类型B:
- A是枚举B的成员。
- B至少有一个成员是 number 类型,而且A是数字。
规则 1 与简单类型的规则完全一样(如果 A 是枚举B的成员,那么 A 的类型是B;也就是 A<: B)。
规则 2 是为了方便使用枚举。规则 2 是安全性的一大隐患。无需顾虑,根本不要使用枚举。
类型拓宽
类型拓宽(type widening)是理解TypeScript类型推导机制的关键。一般来说,TypeScrjpt在推导类型时会放宽要求,故意推导出一个更宽泛的类型,而不限定为某个具体的类型。这样做对程序员是有好处的,大大减少了消除类型检查器报错的时间。
// 声明变量时如果允许以后修改变量的值(例如使用 let 或Var声明),变量的类型将拓宽,从字面值放大到包含该字面量的基类型:
let a = 'x' // string
let b = 3 // number
var c = true // boolean
const d = { x: 3 } // {x:number}
enum E { X, Y, Z }
let e =E.X // E
// 然而,声明不可变的变量时,情况则不同:
const a = 'x' // 'x'
const b = 3 // 3
const c = true // true
enum E { X, Y, Z}
const e = E.X // E.X
// 我们可以显式注解类型,防止类型被拓宽:
let a :'x' = 'x' // 'x'
let b :3 = 3 // 3
var c :true = true // true
const d : { x: 3 }= { x: 3 } // {x:3}
// 如果使用 let 或 var 重新为非拓宽类型赋值,TypeScript将自动拓宽。倘若不想让TypeScript拓宽,一开始声明时要显式注解类型:
const a = 'x' // 'x'
let b = a // string
const c: 'x' = 'x' // 'x'
let d = c // 'x'
// 初始化为 null 或 u∩defined 的变量将拓宽为a∩y:
let a = null // any
a = 3 // any
a = 'b' // any
// 但是,当初始化为 nu11 或 undefined 的变量离开声明时所在的作用域后,TypeScript将为其分配一个具体类型:
function x(){
let a = null // any
a = 3 // any
a = 'b' // any
return a
}
x() // string
cosnt类型
TypeScript中有个特殊的 const 类型,我们可以在单个声明中使用它禁止类型拓宽。这个类型用作类型断言 。
let a = { x: 3 } // { x;number }
let b: { x: 3 } // { x: 3 }
let c = { x: 3 } as const // { readonly x:3 }
const 不仅能阻止拓宽类型,还将递归把成员设为readonly,不管数据结构的嵌套层级有多深:
let d = [1, {x:2}] // (number | {x: number})[]
let d = [1, {x:2}] as const // readonly [1, {readonly x : 2}]
如果想让TypeScript推导的类型尽量窄—些,请使用 as const 。
多余属性检查
TypeScript 检查一个对象是否可赋值给另—个对象类型时,也涉及到类型拓宽。
“ 结构和数组型变 ”讲过,对象类型的成员会做协变。但是,如果TypeScript严守这个规则,而不做额外的检查,将导致一个问题。 如下例子:
type Options = {
baseURL: string
cacheSize?: number
tier?:'prod'|'dev'
}
class API {
constructor( private options: Options){}
}
new API({
baseURL: 'https://api.mysite.com',
tier:'prod'
})
// 如果有个拼写错误,TypeScript会报错
new API({
baseURL: 'https://api.mysite.com',
tierr:'prod'
})
// error TS2345: Argument of type '{ baseURL: string; tierr: string; }' is not assignable to parameter of type 'Options'.
// Object literal may only specify known properties, but 'tierr' does not exist in type 'Options'.
// Did you mean to write 'tier'?
既然对象类型的成员会做协变,TypeScript是如何捕获这种问题的呢?
过程是这样的:
- 预期的类型是{ baseURL: string cacheSize?: number tier?:'prod'|'dev' }。
- 传人的类型是{ baseURL:string, tierr: string }。
- 传入的类型是预期类型的子类型,可是不知为何,TypeScript知道要报告错误。
TypeScript 之所以能捕获这样的问题,是因为它会做多余属性检查,具体过程是:尝试把一个新鲜对象字面量类型 T 赋值给另—个类型U时,如果 T 有不在 U 中的属性,TypeScript将报错。
新鲜对象字面量类型指的是 TypeScript 从对象字面量中推导出来的类型。如果对象字面量有类型断言,或者把对象字面量赋值给变量,那么新鲜对象字面量类型将拓宽为常规的对象类型,也就不能称其为新鲜对象字面量类型。
type Options = {
baseURL: string
cacheSize?: number
tier?:'prod'|'dev'
}
class API {
constructor( private options: Options){}
}
new API({ // 1
baseURL: 'https://api.mysite.com',
tier:'prod'
})
new API({ // 2
baseURL: 'https://api.mysite.com',
badTier:'prod' // error TS2345: Argument of type '{ baseURL: string; badTier: string; }' is not assignable to parameter of type 'Options'.
})
new API({ // 3
baseURL: 'https://api.mysite.com',
badTier:'prod'
} as Options)
let badOptions = { // 4
baseURL: 'https://api.mysite.com',
badTier:'prod'
}
new API(badOptions)
let options: Options = { // 5
baseURL: 'https://api.mysite.com',
badTier:'prod' // error TS2322: Type '{ baseURL: string; badTier: string; }' is not assignable to type 'Options'.
}
new API(options)
-
用 baseURL 和两个可选属性中的 tier 实例化API。这一次能按预期运行。
-
这里,把 tier 错误拼写为 badTier。我们传给 new API 的是新鲜的选项对象(因为其类型是推导出来的,没有赋值给变量,也没有对类型下断言),因此 TypeScript 将做多余属性检查,发现 badTier 属性是多余的(选项对象中有,但是 Options 类型中没有)。
-
断言传人的无效选项对象是 Options 类型。TypeScript 不再把它视作新鲜对象,因此不做多余属性检查,所以不报错。
-
选项对象赋值给变量 badOptions 。TypeScript 不再把它视作新鲜对象,因此不做多余属性检查,所以不报错。
-
显式注解 options 的类型为Options,赋值给 options 的是一个新鲜对象,所以TypeScript执行多余属性检查,捕获存在的问题。注意,这种情况下,TypeScript不在把 options 传给 new API 时做多余属性检查,而是在把选项对象赋值给变量 options 时检查。
别担心,你无需记住这些规则。这些规则供TypeScript内部使用,力求以一种务实的方法捕获尽可能多的问题。
细化
TypeScript 采用的是基于流的类型推导,这是一种符号执行,类型检查器在检查代码的过程中利用流程语句(如 if、 ?、|| 和 switch )和类型查询(如 typeof、instaceof 和 in )细化类型,就像程序员阅读代码的流程一样。这是—个极其便利的特性但是很少有语言支持。
注意:细化后的类型只能在当前作用域,不能跨作用域传递(如:函数),需要传递细化后的类型,请使用查阅:用户自定义的类型防护措施。
// 使用宇符串宇面量的并集描述 css 单位的可能取值
type Unit = 'cm' | 'px' | '%'
// 列举单位
let units: Unit[] = ['cm', 'px', '%']
// 检查各个单位,如果役有匹配,返回∩u11
function parseUnit(value: string): Unit | null{
for (let i = 0; i < units.length; i++){
if (value.endsWith(units[i])) {
return units[i]
}
}
return null
}
// 下述代码对类型做了多次细化:
type Width = {
unit: Unit,
value:number
}
function parseWidth(width: number | string | null | undefined): Width | null{
// 如果width是null 或 undefined,尽早返回
if (width == null) { // 1
return null
}
// 如果width是一个数字,默认单位为像素
if (typeof width === 'number') { // 2
return { unit:'px',value:width}
}
// 尝试从width中解析出单位
let unit = parseUnit(width)
if (unit) { // 3
return {unit, value:parseFloat(width)}
}
// 否则,返回null
return null // 4
}
-
TypeScript 足够智能,与 null 做不严格的等值检查便能在遇到 JavaScript 值 null 和 undefined 时返回true。TypeScript 知道,这项检查通过后函数将返回,而未返回则表明检查不通过,width 的类型将变成 number |string(不可能为 null 或 undefined )。这一步把 number |string|null| undefined 类型细化为 number |string 。
-
typeof 运算符在运行时查询值的类型。TypeScript 在编译时也会利用 typeof:检查通过后,在 if 分支中,TypeScript 知道 width 的类型为 number;否则(因为 if 分支返回了) , width 的类型必为 string,毕竟只乘下这一个类型了。
-
由于调用 parseUnit 函数可能返回 null,所以我们要检查结果是否为真值。TypeScript知道,如果 unit 为真值,那么在 if 分支中,其类型必为 Unit;否则,unit 必为假值,因此类型必为 null(细化 Unit|null得来)。
-
最后,返回 nu11。只有用户给 width 传人一个字符串,而且字符串中包含不受支持的单位时才会出现这种情况。
辨别并集类型
// 事件类型结构相对简单
type UserTextEvent = { value: string }
type UserMouseEvent = { value: [number, number] }
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
// ...
return
}
event.value // [number, number]
}
// TypeScript知道, 在 if 块中eve∩t.va1ue肯定是一个字符串(因为使用typeof做了检查)。这意味着,在 if 块后面,event.value肯定是[number,number]形式的元组(因为 if 块中有 retur∩) 。
// 如果事件类型相对复杂,TypeScript细化类型会有如下情况:
type UserTextEvent = { value: string, target:HTMLInputElement }
type UserMouseEvent = { value: [number, number], target:HTMLElement }
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
event.target // HTMLInputElement | HTMLElement
// ...
return
}
event.value // [number, number]
event.target // HTMLInputElement | HTMLElement
}
event.value 的类型可以顺利细化,但是event.target却不可以。为什么呢? handle 函数的参数是 UserEvent类型,但这并不意味着一定传入 UserTextEvent 或 UserMouseEvent 类型的值,还有可能传入 UserTextEvent | UserMouseEvent 类型的值。由于并集类型的成员有可能重复,因此 TypeScript 需要一种更可靠的方式,明确并集类型的具体情况。
为此,要使用一个字面量类型标记并集类型的各种情况。一个好的标记要满足下述条件:
- 在并集类型各组成部分的相同位置上。如果是对象类型的并集,使用相同的字段;如果是元组类型的并集,使用相同的索引。实际使用中,带标记的并集类型通常为对象类型。
- 使用字面量类型(字符串、数字、布尔值等字面量)。可以混用不同的字面量类型,不过最好使用同一种类型。通常,使用字符串字面量类型。
- 不要使用泛型。标记不应该有任何泛型参数。
- 要互斥(即在并集类型中是独一无二的)。
type UserTextEvent = { type: 'TextEvent', value: string, target:HTMLInputElement }
type UserMouseEvent = { type: 'MouseEvent', value: [number, number], target:HTMLElement }
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if ( event.type === 'TextEvent') {
event.value // string
event.target // HTMLInputElement
// ...
return
}
event.value // [number, number]
event.target // HTMLElement
}
现在,根据标记字段( event.type )的值细化 event,TypeScript知道,在 if 分支中 event 必为 UserTextEvent 类型,而在 if 分支后面,event 必为 UserMouseEvent 类型。由于标记在并集类型的各个组成部分中是独—无二的,所以 TypeScript 知道二者是互斥的。
如果函数要处理并集类型的不同情况,应该使用标记。
全面性检查
全面性检查(也称穷尽性检查)是类型检查器所做的—项检查,为的是确保所有情况都被覆盖了。
对象类型进阶
对象类型的类型运算符
TypeScript提供的类型运算符不止 并集(|)和交集(&),下面再介绍几个处理对象结构的类型运算符。
'键入'运算符
type APIResponse = {
user: {
userId: string
friendList: {
count: number
friends: {
firstName: string
lastName:string
}[]
}
}
}
// 当我们想定义friendList 的类型时, 可以单独声明friendList然后,引入到APIResponse中;
// 我们可以 "键入" 类型
type FriendList = APIResponse['user']['friendList']
任何结构(对象、类构造方法或类的实例)和数组都可以 “键入” 。例如:获取单个好友的类型可以这样声明:
type FriendList = FriendList['friends'][number]
注意:number 是 “键入” 数组类型的方式(因为数组的索引是 number )。若是元组,使用o、1或其他数字字面量类型表示想 “键入’’ 的索引。
"键入" 的句法与在JavaScript对象中查找字段的句法类似,这是故意为之的:既然可以在对象中查找值,那么也能在结构中查找类型。但是要注意,通过 “键入” 查找属性的类型时,只能使用方括号表示法,不能使用点号表示法。
keyof 运算符
keyof 运算符获取对象所有键的类型,合并为—个宇符串字面量类型。
type ResponseKeys = keyof APIResponse // 'user'
type UserKeys = keyof APIResponse['user'] // 'userId' | 'friendList'
type FriendListKeys = keyof APIResponse['user']['friendList'] // 'count' | 'friends'
把 '键入' 和 keyof 运算符结合起来,可以实现对类型安全的读值函数,读取对象中指定键的值
function get< // 1
O extends object,
K extends keyof O // 2
>(
o: O,
k:K
): O[K]{ // 3
return o[k]
}
- key函数的参数为一个对象 o 和一个键 k 。
- keyof O 是一个字符串字面量类型并集,表示 o 对象的所有键。K 类型扩展这个并集(是该并集的子类型)。假如 o 的类型为 { a: number, b: string, c: boolean },那么 keyof o 的类型为 'a' | 'b' | 'c',而 K (扩展自 keyof o )可以是类型 'a', 'b','a'|'c',或者 keyof o的其他子类型。
- o[k] 的类型为在 o 中查找 k 得到得具体类型。接着 2 说,如果 K 是 ‘a’,那么在编译时 get 返回一个数字;如果 k 是 ‘b’ | 'c',那么get 返回 string | boolean。
Record 类型
TypeScript 内置的 Record 类型用于描述有映射关系的对象。
Record<K,T>
构造值为类型T,属性为类型K 的类型。在将一个类型的属性映射到另一个类型的属性时,Record
非常方便。
Record 后面的泛型就是对象键和值的类型。
type keys = 'A' | 'B' | 'C'
const result: Record<keys, number> = {
A: 1,
B: 2,
C: 3
}
// 注意键必须全部包含
type keys = 'A' | 'B' | 'C'
const result: Record<keys, number> = {
A: 1,
B: 2,
}
// error TS2741: Property 'C' is missing in type '{ A: number; B: number; }' but required in type 'Record<keys, number>'.
与常规的对象索引签名相比,Record 提供了更多的便利:使用常规的索引签名可以约束对象中值的类型,不过键只能用string、number 或 symbol 类型;使用 Record 还可以约束对象的键为 string 和 number的子类型(具体类型)。
映射类型
下面使用映射类型表示 nextDay 对象中有对应 Weekday 的各个键,而且其值为 Day 类型:
let nextDay: { [K in Weekday]: Day } = {
Mon: 'Tue'
}
与索引签名相同,—个对象最多有—个映射类型 。
从名称可以看出,这是一种在对象的键和值的类型之间建立映射的方式。其实,TypeScript内置的 Record类型也是使用映射类型实现的:
type Record<K extends keyof any, T> = {
[P in K] : T
}
映射类型的功能比 Record 强大,在指定对象的键和值的类型以外,如果结合 “键入” 类型,还能约束特定键的值是什么类型。
type Account = {
id: number
isEmployee: boolean
notes: string[]
}
// 所有字段都是可选的
type OptionalAccount = {
[K in keyof Account]?: Account[K] // 1
}
// 所有字段都可以为 null
type NullableAccount = {
[K in keyof Account]?: Account[K] | null // 2
}
// 所有字段都是只读
type NullableAccount = {
readonly [K in keyof Account]?: Account[K] // 3
}
// 所有字段都是可写的(等同于 Account)
type Account2 = {
-readonly [K in keyof Account]: Account[K] // 4
}
// 所有字段都是必须的(等同于 Account)
type Account3 = {
[K in keyof OptionalAccount]-?: Account[K] // 5
}
- 新建对象类型OptionalAccount,与 Account 建立映射,在此过程中把各个字段标记为可选的。
- 新建对象类型 type NullableAccount ,与 Account 建立映射,在此过程中为每个字段增加可选值 null 。
- 新建对象类型 ReadonlyAccount ,与 Account 建立映射,把各字段标记为只读(即可读不可写)。
- 字段可以标记为可选的(?)或只读的(readonly),也可以把这个约束去掉。使用减号(-)运算符(—个特殊的类型运算符,只对映射类型可用)可以把 ? 和 readonly撤销,分别把字段还原为必须的和可写的。这里新建一个对象类型 Account2,与ReadonlyAccount 建立映射,使用减号(-)运算符把 readonly 修饰符去掉,最终得到的类型等同于Account。
- 新建对象类型 Account3,与 OptionalAccount 建立映射,使用减号(-)运算符把可选(?)运算符去掉,最终得到的类型等同于Account。
减号(-)运算符有个对应的加号(+)运算符。—般不直接使用加号运算符,因为它通常蕴含在其他运算符中。在映射类型中,rreadonly 等效于虱 +readonly,?等效干+?。+的存在只是为了确保整体协调。
内置的映射类型
Record<Keys, Values> // 键的类型为Keys, 值的类型为 Values 的对象
Partial<Object> // 把Object 中的每个字段都标记为可选的。
Readonly<Object> // 把Object 中的每个字段都标记为只读的。
Pick<Object, keys> // 返回 Object 的子类型,只含指定的 Keys。
伴生对象模式
把类型和对象配对在—起,我们也称之为伴生对象模式。
type Currency = {
unit: 'EUR' | 'GBP' | 'JPY' | 'USD'
value: number
}
let Currency = {
DEFAULT: 'USD',
from(value: number, unit = Currency.DEFAULT): Currency{
return {unit,value}
}
}
TypeScript 中的类型和值分别在不同的命名空间中。这意味着,在同一个作用域中,可以有同名(这里的Currency)的类型和值。伴生对象模式在彼此独立的命名空间中两次声明相同的名称,—个是类型,另一个是值。
这种模式有几个不错的性质。首先,可以把语义上归属同一名称的类型和值放在一起。其次,使用方可以一次性导入二者。
import { Currency } from './Currency'
let amountDue: Currency = { // 1
unit: 'JPY',
value: 83733.10
}
let otherAmountDue = Currency.from(330,'EUR') // 2
- 使用的是类型 Currency。
- 使用的是值 Currency。
如果一个类型和一个对象在语义上有关联,就可以使用伴生对象模式,由对象提供操作类型的实用方法。
函数类型进阶
改善元组的类型推导
TypeScript在推导元组的类型时会放宽要求,推导出的结果尽量宽泛,不在乎元组的长度和各位置的类型。
let a = [1,true] // (number | boolean)[]
有时我们希望推导的结果更严格一些,把上例中的a视作固定长度的元组,而不是数组。当然,我们可以使用类型断言把元组转换成元组类型,也可以使用 as const 断言( const 类型)把元组标记为只读的,尽量收窄推导出的元组类型。
如果我们希望元组的推导结果为元组类型,但不想使用类型断言,也不想使用 as const 收窄推导结果,并把元组标记为只读的呢?
function tuple< // 1
T extends unknown[] //2
>(
...ts:T //3
): T { //4
return ts //5
}
let a = tuple(1,true) // [number,boolean]
- 声明 tuple 函数,用于构建元组类型(代替内置的[]句法)。
- 声明一个类型参数T,它是 unknow[] 的子类型(表明 T 是任意类型的数组)。
- tuple 函数接受不定数量的参数ts。由于 T 描述的是剩余参数, 因此 typescript 推导出的是一个元组类型。
- tuple 函数的返回类型与 ts 的推导结果相同。
- 这个函数返回传人的参数。神奇之处全在类型中。
用户定义的类型防护措施
function isString(a: unknow): boolean{
return typeof a === 'string'
}
isString('a') // 结果:true
isString([7]) // 结果:false
function parseInput(input: string | number) {
let formattedInput: string
if (isString(input)) {
formattedInput = input.toUpperCase() // Error ts2339: pooerty 'toUpperCase' does not exist 'number'
}
}
细化类型时可以使用的 typeof 运算符,但是类型细化的能力有限,只能细化当前作用域中变量的类型,一旦离开这个作用域,类型细化能力不会随之转移到新作用域中。在 isString 的实现中,使用 typeof 把输人参数的类型细化为 string,可是由于类型细化不转移到新作用域中,细化能力将消失,TypeScript 只知道 isString 函数返回一个布尔值。
isString 函数是返回一个布尔值,但是我们要让类型检查器知道,当返回的布尔值是true时,表明我们传给 isString 函数的是一个字符串。为此,我们要使用用户定义的类型防护措施:
function isString(a: unknow): a is string{
return typeof a === 'string'
}
类型防护措施是TypeScript内置的特性,是 typeof 和 instanceof 细化类型的背后机制。可是,有时我们需要自定义类型防护措施的能力,is 运算符就起这个作用。如果函数细化了参数的类型,而且返回一个布尔值,我们可以使用用户定义的类型防护措施确保类型的细化能在作用域之间转移,在使用该函数的任何地方都起作用。
用户定义的类型防护措施只限于一个参数,但是不限于简单的类型。
type LegacyDialog = //...
type Dialog = //...
function isLegacyDialog(
dialog: LegacyDialog | Dialog
): dialog is LegacyDialog{
//...
}
用户定义的类型防护措施不太常用,不过使用这个特性可以简化代码,提升代码的可重用性。如果没有这个特性,要在行内使用typeof 和 instanceof 类型防护措施,而构建 isLegacyDialog 和 isString 之类的函数做同样的检查可实现更好的封装,提升代码的可读性。
条件类型
type IsString<T> = T extends string //1
? true //2
: false //3
type A = IsString<string> // true
type B = IsString<number> // false
- 声明一个条件类型 IsString,它有个泛型参数 T 。这个条件类型中的 “条件” T extends string,即“ T 是不是 string 的子类型? ”
- 如果 T 是 string 的子类型,得到的类型为true。
- 否则,得到的类型为 false。
这里使用的句法与值层面的三元表达式差不多,只是现在位于类型层面。与常规三元表达式相似的另一点是,条件类型句法可以嵌套。
条件类型不限于只能在类型别名中使用,可以使用类型的地方几乎都能使用条件类型,包括类型别名、接口、类、参数的类型,以及函数和方法的泛型默认类型。
条件分配
TypeScript可以通过多种不同的方式表达简单的条件,例如条件类型、重载的函数签名和映射类型。不过,条件类型的作用更强大,原因在于条件类型遵守分配律。
这个类型 | 等价于 |
---|---|
string extends T ? A : B | string extends T ? A : B |
(string | number )extends T ? A : B | (string extends T ? A : B ) | ( number extends T ? A : B ) |
(string | number | boolean )extends T ? A : B | (string extends T ? A : B ) | ( number extends T ? A : B ) | ( boolean extends T ? A : B ) |
使用条件类型时,TypeScript将把并集类型分配到各个条件分支中。这就好像把条件类型映射(分配)到并集类型的各个组成部分上。 这样可以安全的表达一些常见的操作。
// 构建 Without<T,U>, 计算在 T 中而不在U中的类型。
type Without<T, U> = T extends U ? never : T
type A = Without<
boolean | number | string,
boolean
> // number | string
// 步骤为:
// 1. 先分析输入的类型:
type A = Without<boolean | number |string, boolean
>
// 2. 把条件分配到并集中:
type A = Without<boolean, boolean>
| Without<number, boolean>
| Without<string, boolean>
// 3. 代入Without的定义,替换 T 和 U:
type A = (boolean extends boolean ? never : boolean)
| (number extends boolean ? never : boolean)
| (string extends boolean ? never : boolean)
// 4. 计算条件
type A = never | number | string
// 5. 化简:
type A = number | string
infer 关键字
条件类型的最后一个特性是可以在条件中声明泛型。回顾一下,目前我们只见过—种声明泛型参数的方式,即使用尖括号
(<T> )。在条件类型中声明泛型不使用这个句法,而使用 infer 关键字。
// 下面声明一个条件类型ElementType,获取数组中元素的类型:
type ElementType<T> = T extends unknown[] ? T[number] : T
type A = ElementType<number[]> // number
// 注意: type A = number[][number]
// 利用 infer 关键字,重写:
type ElementType2<T> = T extends (infer U)[] ? U : T
type B = ElementType<number[]> // number
注意:number[][number]当中: [number]表示 ”键入“ number[] 中所有数字索引的值的类型;所以 type A的类型为number
[typescript T[number]的出处]: https://blog.csdn.net/qq_34629352/article/details/130034202
这里,ElementType 和 ElementType2 是等价的。注意,infer 子句声明了一个新的类型变量U,TypeScript 将根据传给ElementType2 的 T 推导 U 的类型。
另外注意,U 是在行内声明的,而没有和 T —起在前面声明。
内置的条件类型
TypeScript自带了一些全局可用的条件类型
Exclude<T, U> // 与前面的 Without 类型相似,计算在T中而不在U中的类型:
type A = number | string
type B = string
type C = Exclude<A, B> // number
Extract<T, U> // 计算 T 中可赋值给U的类型:
type A = number | string
type B = string
type C = Extract<A, B> // string
NonNullable<T> //从 T中排除 null 和undefined:
type A = {a?:number | null}
type B = NonNullable<A['a']> // number
ReturnType<F> // 计算函数的返回类型(注意,不只用于泛型和重载的函数)
type F = (a: number) => string
type R = ReturnType<F> // string
InstanceType<C> // 计算类构造方法的实例类型
type A = {new():B}
type B = {b:number}
type C = InstanceType<A> // {b:number}
解决办法
有时,我们没有足够的时间把所有类型都规划好,这时我们希望 TypeScrlpt 能相信我们,即便如此也是安全的。
类型断言
给定类型B,如果 A <: B <: C,那么我们可以向类型检查器断定,B其实是 A 或 C。注意,我们只能断定一个类型是自身的超类型或子类型,不能断定 number 是 string,因为这两个类型毫无关系。
function formatInput(input: string) {
//...
}
function getUserInput(): string | number{
//...
}
let input = getUserInput()
// 断定input是字符串
formatInput(input as string) // 1
// 等效于
formatInput(<string>input) // 2
- 用类型断言(as)告诉 TypeScript,input 是字符串,而不是 string | number 类型。如果想先测试 formatInput 函数,而且肯定getUserInput 函数返回一个字符串,就可以这样做。
- 型断言的旧句法使用尖括号。这两种句法的作用是相同的。
注意:类型断言的 as 句法和尖括号(<>)句法,优先使用前者。as 句法意思明确,而尖括号句法可能与TSX句法冲突。
有时,两个类型之间关系不明,无法断定具体类型。此时,直接断定为 any( any可赋值给任何类型)。
function addToList(list:string[], item;string){
//...
}
addToList('this is redlly,' as any, 'really unsafe')
显然,类型断言不安全,应尽量避免使用。
非空断言
可为空的类型,即 T | null 或 T | null | undefined 类型,比较特殊,TypeScript 为此提供了专门的句法,用于断定类型为T,而不是 null 或 undefined 。这种情况并不少见。
// 假如我们编写了一个框架,用于显示和隐藏Web应用中的对话框。每个对话框都有唯一的ID,通过ID可以获取对话框的DOM节点的引用。对话框从DOM中移除后,把对应的ID删除,表明该对话框已经不在DOM中。
type Dialog = {
id?:string
}
function closeDialog(dialog:Dialog) {
if (!dialog.id) { // 1
return
}
setTimeout(() => { // 2
removeFromDOM(
dialog,
document.getElementById(dialog.id) // 3
// error ts2345: arument of type 'string | undefined' is not assignable to parameter of type 'string'
)
})
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode.removeChild(element) // 4
// error ts2531: object is possibly 'null'
delete dialog.id
}
-
如果对话框已经被删除(因此没有id),那就尽早返回。
-
在下—次事件轮询时,把对话框从DOM中删除,给依赖 dialog 的其他代码留出时间运行完毕。
-
身处—个箭头函数中,开始一个新作用域。TypeScript 不知道 1 和 3 之间的代码修改了dialog ,因此 1 中所做的类型细化不起作用.鉴于此。虽然 dialog .id 存在绝对可以确定DOM中有该ID对应的元素 ,但是在TypeScript看来,调用 document.getElementById(dialog.id) 返回的类型将是 HTMLElement| null。我们知道结果是不为空的 HTMLElement,但是TypeScript不知道,TypeScript只知道我们指定的类型。
-
类似地尽管我们知道对话框一定在DOM中,而且绝对有父级DOM节点,然而 TypeScript 只知道 element.parentNode 的类型是 Node | null。
这个问题的—种修正方法是大量使用 if ( _ === null )做检查。在不确定是否为 null 时,确实要这么做,但是如果确定不可能是null | undefined,可以使用TypeScript提供的一种特殊句法:
type Dialog = {
id?:string
}
function closeDialog(dialog:Dialog) {
if (!dialog.id) {
return
}
setTimeout(() => {
removeFromDOM(
dialog,
document.getElementById(dialog.id!)!
)
})
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode.removeChild(element)
delete dialog.id
}
注意深藏其中的非空断言运算符( ! ) 。这个运算符告诉TypeScrjpt,我们确定 dialog.id 、document.getElementById 调用和 element.parentNode 得到的结果已定义。在可能为 null 或 undefined 类型的值后面加上非空断言运算符,TypeScript将假定该类型已定义: T | null| undefined 变成 T,number|string|null|变成 number | string等。
如果频繁使用非空断言,这就表明你的代码需要重构。
// 更新 closeDialog 函数, 改用并集类型:
type VisibleDialog = { id: string }
type DestroyedDialog = {}
type Dialog = VisibleDialog | DestroyedDialog
function closeDialog(dialog:Dialog) {
if (!('id' in dialog)) {
return
}
setTimeout(() => {
removeFromDOM(
dialog,
document.getElementById(dialog.id)!
)
})
}
function removeFromDOM(dialog: VisibleDialog, element: Element) {
element.parentNode!.removeChild(element)
delete dialog.id
}
确认 dialog 有id属性之后(表明是 VisibleDialog 类型),即使在箭头函数中TypeScript也知道 dialog 引用没有变化:箭头函数内的 dialog 与外面的 dialog 相同,因此细化结果随之转移,不会像前例那样不起作用。
明确赋值断言
TypeScript为非空断言提供了专门的句法,用于检查有没有明确赋值(提醒一下,TypeScript 通过明确赋值检查确保使用变量时已经为其赋值)。
let userId: string
userId.toUpperCase() // error ts2454: variable 'userId' is used before assigned
显然,TypeScript十分周到,为我们捕获了这个错误。我们声明了变量 userId,但是还未赋值就想把它转换成大写形式。倘若TypeScript没有注意到这个问题,将在运行时抛出错误。
let userId: string
fetchUser()
userId.toUpperCase() // error ts2454: variable 'userId' is used before assigned
function fetchUser() {
userId = globalCache.get('userId')
}
调用 fetchUser 之后,userId 肯定已经有值了。但是,TypeScript无法通过静态方式知晓这一点,所以依然抛出相同的错误。我们可以使用明确赋值断言告诉TypeScript,在读取 userId 时,肯定已经为它赋值了(留意感叹号)
let userId!: string
fetchUser()
userId.toUpperCase()
function fetchUser() {
userId = globalCache.get('userId')
}
与类型断言和非空断言一样,如果经常使用明确赋值断言,可能表明你的代码有问题。
模拟名义类型
虽然TypeScript原生不支持名义类型,但是我们可以使用类型烙印( type branding )技术模拟实现。
使用类型烙印技术之前要稍微设置一下,而且在TypeScript中使用这个技术不像在原生支持名义类型别名的语言中那么平顺。可是,带烙印的类型可以极大地提升程序的安全性。
type CompanyID = string
type OrderID = string
type UserID = string
type ID = CompanyID | OrderID | UserID
// queryForUser只想接受 UserID类型的ID
function queryForUser(id: UserID) {
//...
}
// typescript原生不支持名义类型,只检查是否具有相同的结构类型,造成代码隐形错误。
function queryForUser(id: CompanyID) {
//...
}
使用烙印技术重写如下:
type CompanyID = string & {readonly barnd: unique symbol}
type OrderID = string & {readonly barnd: unique symbol}
type UserID = string & {readonly barnd: unique symbol}
type ID = CompanyID | OrderID | UserID
function CompanyID(id: string) {
return id as CompanyID
}
function OrderID(id: string) {
return id as OrderID
}
function UserID(id: string) {
return id as UserID
}
function queryForUser(id: UserID) {
// ...
}
let companyId = CompanyID('CompanyID')
let userId = UserID('UserID')
let orderID = OrderID('OrderID')
queryForUser(userId) // ok
queryForUser(companyId) // error ts2345: argument of type 'CompanyID' is not assignable to parameter of type 'userId'
使用 string 和 {readonly barnd: unique symbol} 的交集显得有点乱,但是只能这么做,如果想创建这个类型的值,别无他法。只能使用断言。这就是带烙印的类型的一个重要性质:不太可能意外使用错误的类型。这里选择的 “烙印” 是 unique symbol,因为这是TypeScript中两个真正意义上是名义类型的类型之—(另一个是 enum)。之所以取这个烙印与 string 的交集,是为了通过断言指明给定的字符串属于这个带烙印的类型。
安全的扩展原型
虽然过去认为扩展原型不安全,但是有了TypeScript提供的静态类型系统,现在可以放心扩展原型了。
// 让 TypeScript知道 .zip 方法的存在
interface Array<T>{ // 1
zip<U>(list:U[]):[T,U][]
}
// 实现 .zip 方法
Array.prototype.zip = function <T, U>(
this: T[], //2
list: U[]
): [T, U][]{
return this.map((v,k) => {
tuple(v.list[k]) // 3
})
}
// 在declare global全局扩展中声明类型
declare global{
interface Array<T>{
zip<U>(list:U[]):[T,U][]
}
}
-
首先让 TypeScript 知道我们要为 Array添加 zip方法。我们利用接口合并特性增强全局接口Array<T>,为这个全局定义的接口添加zip方法。这个文件没有显式导人或导出(意味着在脚本模式中),因此可以直接增强全局接口Array。我们声明一个接口,与现有的 Array<T> 同名,TypeScript将负责合并二者。如果文件在模块模式中(如果实现 zip 需要导人其他代码,便是这种情况),就要把全局扩展放在 declear global 类型声明中;global 是一个特殊的命名空间,包含所有全局定义的值(在模块模式中无需导人就能使用任何值),可以增强模块模式文件中全局作用域内的名称。
-
然后在 Array 的原型上实现 zip 方法。这里使用 this 类型,以便让TypeScript正确推导出调用 .zip方法的数组的类型T 。
-
由于 TypeScript 推导出的映射函数的返回类型是( T | U)[](TypeScrjpt 没那么智能, 意识不到这个元组的0索引始终是T、1 索引始终是U),所以我们使用 tuple 函数创建一个元组类型,而不使用类型断言。
注意,我们声明的 interface Array<T> 是对全局命名空间 Array 的增强,影响整个TypeScript项目,即便没有导人 zip.ts 文件,在TypeScript看来,[].zip 方法也可用。但是,为了增强 Array.prototype,我们要确保用到 zip 方法的文件都已经加载了zip.ts文件,这样才能让 Array.prototype上的 zip 方法生效。那么,如何才能保证使用 zip 方法的文件已经加载了 zip 文件?
编辑 tsconfig.json 文件,把 zip.ts 排除在项目之外,这样使用方就必须先使用 import 语句将其导入:
{
"exclude":[
'./zip.ts'
]
}
注意:
exclude用于指定当解析
include
选项时,需要忽略的文件列表。首先要注意,
exclude
的默认值是["node_modules", "bower_components", "jspm_packages"]
加上outDir
选项指定的值。其次要注意的是,
exclude
只会对include
的解析结果有影响。而且,include
的默认值为["**/*"]
,即全部文件。再有:即使在
exclude
中指定的被忽略文件,还是可以通过import
操作符、types
操作符、///<reference
操作符以及在files
选项中添加配置的方式对这些被忽略的代码文件进行引用的。
参考:TSConfig 之 include、exclude 和 files 选项
处理错误
TypeScript竭尽所能,把运行时异常转移到编译时。
不管使用什么语言,总是有一些异常会在运行时暴露出来。TypeScript虽能尽职尽责,但有些问题是无法避免的,例如网络和文件系统异常、解析用户输人时出现的错误、堆栈溢出及内存不足。不过,TypeScript的类型系统足够强大,提供了很多处理运行时错误的方式,不会眼看程序崩溃。
TypeScript为表示和处理错误而提供的一些最为常用的模式:
- 返回null
- 抛出异常
- 返回异常
- Option类型
具体使用哪种机制由你自己决定’当然也要看应用的需求。
返回null
// 将用户输入的生日解析为Data对象
function ask() {
return prompt('你的生日是什么时候')
}
function parse(birthday: string): Date | null{
let date = new Date(birthday)
if (!isValid(date)) {
return null
}
return date
}
// 检验指定日期是否有效
function isValid(date:Date) {
return Object.prototype,toString.call(date) === '[object Date]' && !Number.isNaN(date.getTime())
}
let date = parse(ask())
if (date) {
console.info('日期是',date.toISOString())
} else {
console.error('error parsing date for som reason')
}
考虑到类型安全,返回 null 是处理错误最为轻量的方式。有效的用户输人得到—个Date对象,无效的用户输人得到一个null,类型系统会自动检查我们有没有涵盖这两种情况。
然而,这么做丢失了一些信息,parse 函数没有指出操作到底为什么失败,负责调试的工程师要梳理日志才能找出原因。
返回 null 也不利于程序的编写,每次操作都检查结果是否为 null 太烦琐,不利于嵌套和串联操作。
抛出异常
把返回 null 改成抛出异常方便处理具体的问题,这样能获取关于异常的元数据,便于调试。
function parse(birthday: string): Date | null{
let date = new Date(birthday)
if (!isValid(date)) {
throw new RangeError('输入日期格式需为 yyy/mm/dd')
}
return date
}
// 捕获异常
try{
let date = parse(ask())
console.info('日期是',date.toISOString())
}catch(e){
if(e instanceof RangeError){
console.error(e.message)
}else{
throw e
}
}
直接抛出异常的问题是:工程师可能不会(忘记)把代码放在try/catchjie结构中,根本不检查异常,而且类型系统不会指出缺少了什么情况要做处理。如本例,如不处理异常,将导致程序崩溃。
如果想告诉使用方,成功和出差两种情况都要处理,我们还可以返回异常(不直接抛出异常)。
返回异常
TypeScript与Java不同,不支持throws子句(throws子句的作用是指出一个方法可能抛出什么运行时异常,让使用发知道该处理哪些异常)。不过我们可以使用并集类型近似实现这个特性:
// 自定义错误类型
class InvalidDateFormatError extens RangeError {}
class DateInTheFutureError extens RangeError {}
function parse(birthday: string): Date | InvalidDateFormatError | DateInTheFutureError{
let date = new Date(birthday)
if (!isValid(date)) {
return new InvalidaDateFormatError('输入日期格式需为 yyy/mm/dd')
}
if (date.getTime() > Date.now()) {
return new DateIsInFutureError('不能输入超过当前事件')
}
return date
}
//...
let result = parse(ask()) // 返回一个日期或错误
if (result instanceof InvalidDateFormatError ) {
console.error(result.message)
} else if( result instanceof DateInTheFutureError){
console.info(result.message)
} else {
console.info('日期是',result.toISOString())
}
这里,我们利用TypeScript的类型系统实现了以下三点:
- 在 parse 函数的签名中加人可能出现的异常。
- 告诉使用发可能抛出哪些异常。
- 迫使使用发处理(或再次抛出)每一个异常。
这种方式的缺点是串联和嵌套可能出错的操作时容易让人觉得烦琐。如果—个函数返回 T|Error1,那么使用该函数的函数有两个选择:
- 显示处理Error1。
- 处理T(成功的情况),把 Error1传给使用方处理。然而这样传来传去,使用发要处理的错误将越来越多
function x(): T | Error1{
//...
}
function y(): U | Error1 | Error2{
let a = x()
if (a instanceof Error) {
return a
}
// 对a执行一定的操作
}
function z(): U | Error1 | Error2 | Error3{
let a = y()
if (a instanceof Error) {
return a
}
// 对a执行一定的操作
}
这种方式确实烦琐,但却极好地保证了安全。
Option类型
还可以使用专门的数据类型描述异常。这种方式与返回值和错误的并集相比是有缺点的(尤其是与不使用这些数据类型的代码互操作时) ,但是却便于串联可能出错的操作。在这方面,常用的三个选项是 try、Option 和 Either类型。这里只介绍Opiton类型,其他两个类型本质上基本相同。
Option 类型源自Haskell、OCaml、Scala和Rust等语言,隐含的意思是,不返回一个值,而是返回一个容器,该容器中可能有—个值,也可能没有。这个容器有—些方法,即使没有值也能串联操作。容器几平可以是任何数据结构,只要能在里面存放值。
Option 真正发挥作用是在一次执行多个操作,而每个操作都有可能出错。
// 将用户输入的生日解析为Data对象
function ask() {
let result = prompt('你的生日是什么时候')
if (result === null) { // prompt也有可能出错 返回统一类型
return []
}
return [result]
}
function parse(birthday: string): Date[]{
let date = new Date(birthday)
if (!isValid(date)) {
return []
}
return [date]
}
function isValid(date:Date) {
return Object.prototype,toString.call(date) === '[object Date]' && !Number.isNaN(date.getTime())
}
interface Option<T>{
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value:T):T
}
class Some<T> implements Option<T> {
constructor(private value: T) { }
flatMap<U>(f: (value: T) => None): None
flatMap<U>(f: (value: T) => Some<U>): Some<U>
flatMap<U>(f: (value: T) => Option<U>): Option<U>{ // 1
return f(this.value)
}
getOrElse(): T{ // 2
return this.value
}
}
class None implements Option<never>{
flatMap(): None{ // 3
return this
}
getOrElse<U>(value:U):U{ // 4
return value
}
}
function Option<T>(value:null|undefined):None // 1
function Option<T>(value:T):Some<T> //2
function Option<T>(value:T):Option<T> {
if (value == null) {
return new None
}
return new Some(value)
}
Option(
ask()
)
.flatMap(parse)
.flatMap(date => new Some(date.toISOString())) // Opting<string>
.flatMap(date => new Some('Date is' + date)) // Opting<string>
.getOrElse('Error parsing date for some reason') // Opting<string>
对一系列可能成功也可能失败的操作,Option是一种强有力的执行方式,不仅保证了类型安全性,还通过类型系统向使用方指出了某个操作可能失败。
然而,Option也不是没有缺点。Option 通过一个 None 值表示失败,没有关于失败的详细信息,也不知道失败的原因。另外,与不使用 Option 的代码无法互操作(要自己动手包装这些API,让它们返回Option)。
小结
本章介绍了在TypeScript中表示和处理错误的几种不同方式,包括返回 null、抛出异常、返回异常和 Option 类型。现在,遇到失败的操作时我们有很多方案可选,而且都兼顾安全。具体选择哪种方案取决于你自己,但是要考虑以下事项:
- 你只想表示有操作失败了(null 、Option ) ,还是想进—步指出失败的原因(抛出异常和返回异常)。
- 你想强制要求使用方显式处理每一个可能出现的异常(返回异常),还是尽量少编写处理错误的样板代码(抛出异常)。
- 你想深人分析错误(Option) ,还是在遇到错误时做简单的处理(null,异常)。
命名空间和模块
模块
模块(module)是一个重要的概念,我们要知道编译器(TSC)是如何解析模块的,要知道构建系统(Webpack、Gulp等)是如何解析模块的,还要知道模块在运行时是如何加载到应用中的(使用<script/>标签、SystemJS等)。对JavaScript来说,这些操作往往都由单独的程序执行,有点儿混乱。CommonJS和ES2015模块标准简化了这三个程序之间的互操作,Webpack等强大的打包程序进一步抽象了这三个解析过程背后涉及的操作。
现在在JavaScript和TypeScrjpt中使用的模块标准是ESM(ES Module)。
在使用其他模块及从模块中导出代码上,TypeScript提供了好几种方式:可以使用全局声明,可以使用ES2015中标准的 import 和 export,也可以使用CommonJS模块那种向后兼容的 Import。除此之外,TSC的构建系统还支持针对不同的目标环境编译模块,包括:全局、ES20l5、CommonJS、AMD、SystemJS和UMD(CommonJS、AMD和全局的混合体,就看使用方的环境中有哪个标准)。
由于我们编写的是TypeScript,而不是JavaScrjpt代码,理所当然,除了值以外,还可以导出类型和接口。又因为类型和值位于不同的命名空间中,所以完全可以导出两个同名的内容,一个在值层面,另一个在类型层面。与其他代码一样,TypeScript将推导出你指的是类型还是值。
// g.ts
export let X = 3
export type X = { y: string }
// h.ts
import { X } from './g'
let a = X + 1 // X指代值X
let b:X = {y:'z'} // X指代类型X
动态导入(惰性加载)
let locale = await import('locale_zh')
- import可以作为—个语句使用,作用是静态获取代码。
- 另外,也可以作为一个函数调用,此时返回结果是一个promise。
尽管传给 import 的参数可以是任何求值结果为字符串的表达式,不过这样做丧失了类型安全性。出于安全考虑,动态导人应该使用下面两种做法中的其中一种:
- 直接把字符串宇面量传给import,不要事先赋值给变量。
- 把表达式传给 import,但要手动注解模块的签名。
如果选择第二种方式,常见的做法是静态导人模块,不过只做为类型使用,TypeScript在编译时将把静态导入忽略 。
import { locale } from 'xxx'
async function main() {
let userLocale = await getUserLocale()
let path = `./locales/locale-${userLocale}`
let localeUS: typeof locale = await import(path)
}
我们从 xxx 中导入locale,不过仅用作类型,通过 typeof locale 获取。之所以要这么做,是因为TypeScript通过静态方式无法获知 import(path) 的类型,而这其中的原因在于path是需要通过计算才能得到结果的变量,而不是静态的字符串。在这段代码中,我们不把 locale 当作值使用,而是只通过它获得它的类型,TypeScript在编译时将把这个静态导人语句忽略(即TypeScript不生成任何顶层导人代码)。这样写出的代码不仅具有十足的类型安全性,而且还能动态计算导人哪个包。
模块模式与脚本模式
TypeScript采用两种模式编译TypeScript文件:模块模式和脚本模式。具体编译为哪个模式,通过一项检测确定:文件中有没有import 或 export语句?如果有,使用模块模式:否则,使用脚本模式。
多数时候,我们使用的都是这个模式。在模块模式下。我们使用 import 和 import() 从其他文件中引入代码,使用export把代码开放给其他文件使用。如果想使用第三方UMD模块(注意,UMD模块尝试使用CommonJS、RequireJS或测览器全局模块,具体取决于环境支持哪个),必须先使用 import 将其导人,而且不能直接使用全局导出的代码。
在脚本模式下,声明的顶层变量在项目中的任何文件中都可以使用,无需导入;而且,可以放心使用第三方UMD模块中的全局导出,不用事先导入。下述情况使用脚本模式:
- 快速验证不打算编译成任何模块系统的浏览器代码(在 tsconfig.json 中设置{"module": "none"}),在HTML文件中直接使用<script/>标签引人。
- 创建类型声明
命名空间
TypeScript还提供了另—种封装代码的方式: namespace 关键字。
命名空间所做的抽象掘除了文件在文件系统中的目录结构,我们无须知道 .mine 函数保存在 shcemes/scams/bitcoin/apps 文件夹中,若想使用这个函数,使用简洁的命名空间 shcemes.scams.bitcoin.apps.mine 即可。
命名空间必须有名称。命名空间可以导出函数、变量、类型、接口或其他命名空间。namespace 块中没有显式导出的代码为所在块的私有代码。由于命名空间可以导出命名空间,因此命名空间可以嵌套。
冲突
导出相同名称的代码会导致冲突
这条规则有个例外:改进函数类型时对外参函数声明的重载不导致冲突。
编译输出
与导入和导出不一样,命名空间不遵守 tsconfig.json中的 module 设置,始终编译为全局变量。
声明合并
目前,我们接触到了TypeScript所做的三种合并:
- 合并值和类型,根据使用情况区分同一个名称引用的是值还是类型。
- 把多个命名空间合并成一个。
- 把多个接口合并成一个
TypeScript支持合并很多类型的名称,这让—些难以表达的模式变为可能。
声明可以合并吗?
到 | 到 | 到 | 到 | 到 | 到 | 到 | 到 | 到 | |
---|---|---|---|---|---|---|---|---|---|
从 | 值 | 类 | 枚举 | 函数 | 类型别名 | 接口 | 命名空间 | 模块 | |
从 | 值 | 否 | 否 | 否 | 否 | 是 | 是 | 否 | - |
从 | 类 | - | 否 | 否 | 否 | 否 | 是 | 是 | - |
从 | 枚举 | - | - | 是 | 否 | 否 | 否 | 是 | - |
从 | 函数 | - | - | - | 否 | 是 | 是 | 是 | - |
从 | 类型别名 | - | - | - | - | 否 | 否 | 是 | - |
从 | 接口 | - | - | - | - | - | 是 | 是 | - |
从 | 命名空间 | - | - | - | - | - | - | 是 | - |
从 | 模块 | - | - | - | - | - | - | - | 是 |
关于 moduleResolution 标志
tsconfig.json 中有个 moduleResolution 标志。TypeScript 使用这个标志指定的算法解析应用中模块的名称。这个标
志支持两种模式:
-
node:应该始终使用这个模式。在这个模式下,TypeScript使用NodeJS所用的算法解析模块。以 。、/ 或~ 开头的模块名称(例如./my/file)相对于本地文件系统解析,要么相对于当前丈件,要么使用绝对略径(相对/目录或 tsconfig.json 中 baseUrl 设定约目录),具休要看以什么开头。如果模块略径不包含前述几个前缀,与NodeJS一样, TypeScrlpt从 node_modules 文件夹中加载模块。在NodeJS的解析策咯之上,TypeScript又增加了两个步骤:
-
NodeJS 在 package.json 中 main 字段指定的目录中寻找默认可导入的丈件,除此之外,TypeScript还会在 types 属性指定的包中搜寻类型声明。
-
如果导入的丈件没有扩展名,TypeScrjpt首先寻找扩展名为 .ts 的同名文件,随后依次寻找扩展名为 .tsx、.d.ts 和 .js的文件。
-
-
clssic:不应该使用这个模式。在这个模式下,相对路径的解析方式与 node 模式一样,但是如果路径没有前缀,TypeScript将在当前文件夫中寻找指定名称的文件,倘若找不到,沿着目录树向上一层,直至找到为止。对于从 NodeJS 或 JavaScript 转过来的人说,这个行为十分怪异,而且与其他构建工具也无法很好地配合。
与 javascript 互操作
你可能把代码从无类型的语言向TypeScript迁移,也有可能要使用第三方JavaScript库,或者为了紧急修补—个漏洞而牺牲—点类型安全性。本章专门探讨如何与JavaScript互操作。
类型声明文件
类型声明文件的扩展名为 .d.ts。类型声明配合JSDoc注解,是为无类型的JavaScript代码附加TypeScript类型的一种方式 。
类型声明的句法与常规的 TypeScript 代码类似,不过也有几点区别:
-
类型声明只能包含类型,不能有值。这意味着,类型声明不能实现函数、类、对象或变量,参数也不能有默认值。
-
类型声明虽然不能定义值,但是可以声明JavaScript代码中定义了某个值。此时,使用特殊的declare关键字。
-
类型声明只声明使用方可见的类型。如果某些代码不导出,或者是函数体内的局部变量,则不为其声明类型。
使用TSC编译,加上 declarations 标志( 如:tsc -d Observable.ts),得到的类型声明文件 Observable.d.ts 。
这个类型声明对于本身项目中使用 Observable.ts 的其他文件而言没有什么用,因为其他文件可以直接访问Observable.ts ,而该文件自身就是TypeScript源文件。然而,如果你在自己的其他项目中使用该文件(第三方引入,且为.js文件),类型声明就有用了。
可以想见,如果项目开发人员想打包信息,发布到NPM中供TypeScript用户使用( 项目在TypeScript和JavaScrjpt应用中都可以使用 ),有两种选择:第一,打包TypeScript源文件(供TypeScript用户使用)和编译得到的JavaScript文件(供JavaScript用户使用);第二,提供编译得到的JavaScript文件和供TypeScript用户使用的类型声明。后—种方式所占的文件体积较小,而且十分明确该导入什么。另外,后—种方式还能减少编译应用所用的时间因为编译应用时,TSC不用重新编译该项目。
类型声明文件有以下几个作用:
- 其他人在他们的TypeScript应用中使用你提供的编译好的TypeScript代码时,TSC会寻找与生成的JavaScript文件对应的 .d.ts 文件,让TypeScrjpt知道项目中涉及哪些类型。
- 支持TypeScript的代码编辑器(例如VSCode)会读取 .d.ts 文件,在输人代码的过程中显示有用的类型提示(即使用户不使用TypeScript)。
- 由于无须重新编译TypeScrjpt代码,能极大地减少编译时间。
类型声明的作用是告诉TypeScrjpt,“ JavaScrjpt文件中定义了这个,我来告诉你它的信息。’’ 类型声明描述的是外参环境,与包含值的常规声明要区分开。例如,外参变量声明使用 declare 关键字声明JavaScript文件中定义了某个变量,而常规的变量声明使用 let 或 const 关键字声明变量。
借助类型声明可以做到以下几件事:
- 告诉TypeScript,JavaScrlpt文件中定义了某个全局变量。假如你在测览器坏境中通过腻子脚本全局定义了 Promise 或 process.env,或许应该使用外参变量声明让TypeScript提前知道这一点。
- 定义在项目中任何地方都可以使用的全局类型,无须导入就能使用(这叫外参类型声明)。
- 描述通过NPM安装的第三方模块(外参模块声明)。
类型声明,不管作何用途,始终放在脚本模式下的 .ts 或 .d.ts 文件中。按约定,如果有对应的.js文件,类型声明文件使用 .d.ts扩展名;否则,使用 .ts 扩展名。类型声明文件的名称没有具体要求,例如,一般使用存储在顶层目录中的 types.ts,除非内容多到不可控,而且,—个类型声明文件中可以有任意多个类型声明。
最后要注意—点:在类型声明文件中,顶层值要使用declare关键字( declare let、declare function、declare class等),而顶层类型和接口则不需要(因为 类型和接口是typescript独有的,javascript没有)。
外参变量声明
外参变量声明让TypeScript知道全局变量的存在,无须显式导人即可在项目中的任何 .ts 或 .d.ts 文件中使用。
//假设你在测览器中运行—个NodeJS程序,某个时刻,该程序会检查process.env.NODE_ENV的(为 'development' 或 'production')。运行这个程序,你会看到如下的运行时错误:
uncaught referenceError: process is not defined.
// 让该程序正常运行最简单的方法是自己定义process.env.NODE_ENV。为此,你新建一个文件,名为 polyfills.ts 在该文件中定义一个全局对象process.env:
process = {
env: {
NODE_ENV:'production'
}
}
// TypeScript会告诉你不应该增强全局对象Wi∩dow:
Error TS2304: cannot find name 'process'
// 利用外参变量声明
declare let process: {
env: {
NODE_ENV:'production' | 'development'
}
}
process = {
env: {
NODE_ENV:'production'
}
}
你向TypeScrlpt声明,有个全局对象名为 process ,而且该对象有个属性,名为env,该属性名下也有一个属性,名为NODE_ENV。告诉TypeScript这些信息之后,提示报错就会消失,现在便可以安全地定义全局对象 process 了。
TSC 设置:lib
TypeScript 自带一些类型声明,用于描述JavaScript标准库,包括JavaScript内置的类型,例如 Array 和 Promise;内置类型的方法,例如 ''.toUpperCase;以及—些全局对象,例如 window 和 document(浏览器环境)及 onmessage(Web职程环境) 。
如果需要,可以通过 tsconfig.json 文件中的 lib 宇段引入这些内置的类型声明。
外参类型声明
外参类型声明的规则与外参变量声明—样:外参类型声明保存在脚本模式下的 .ts 或 .d.ts 文件中,无须显式导入即可在项目中的其他文件里全局使用。
//举个例子,声明一个全局可用的 ToArray < T > 类型,把T变为数组(如果还不是数组的话)。这个类型可以在项目中任何一个使用脚本模式的文件里定义, 比如说在项目顶层目录中的 type.ts 文件里定义:
type ToArray<T> = T extends unknow[]? T :T[]
// 现在,在项目中的任何一个文件里都可以使用该类型,无须显式导人:
function toArray<T>(a:T): ToArray<T>{
//..
}
外参模块声明
使用JavaScript模块时,为了安全,你可能想随手声明一些类型。如果先给 JavaScript 模块在GitHub中的仓库或DefinitelyTyped提交类型声明,显然要麻烦一些。这时,便可以使用外参模块声明。
外参模块声明就是把常规的类型声明放在特殊的句法 declare module中 :
declare module 'module-name' {
export type MyType = number
export type MyDefaultType = { a: string }
export let myExport: MyType
let MyDefaultExport: MyDefaultType
export default MyDefaultExport
}
// 模块名称(本例中的 module-name )是 import导人的路径。导人这个路径后,TypeScript便获知了外参模块声明提供的信息:
import ModuleName from 'module-name'
// 如果是嵌套模块,声明时要使用完整的导人路径:
declare module '@most/core' {
// Type declaration
}
// 如果只想告诉TypeScript。“我要导人这个模块,具体类型稍后确定,现在先假设为a∩y” ,那么只保留头部,省赂声明即可:
// 声明一个可被导入的模块,但是导入的类型为any
declare module 'unsafe-module-name'
// 模块声明支持通配符导人,借此可以为匹配指定模式的任何导人路径声明类型。导人路径使用通配符(*)匹配:
// 为webpack的 json-loader导入的JSON文件声明类型
declare module 'json!*' {
let value: object
export default value
}
// 为webpack的 style-loader导入的css文件声明类型
declare module '*.css' {
let css: CSSRuleList
export default css
}
// 加载JSON和CSS文件:
import a from 'json!myFile'
a // object
import b from './widget.css'
b // CSSRuleList
逐步从 JavaScript 迁移到 TypeScript
逐步从 JavaScript 迁移到 TypeScript 的大概步骤为:
- 在项目中添加TSC。
- 对现有的 JavaScript 代码做类型检查。
- 将 javascript 代码改写成 typescript,一次改一个文件
- 为依赖安装类型声明,没用到类型的依赖就算了,否则要自己手动为没有类型信息的依赖编写类型声明,然后提交给 DefinitelyTyped。
- 为代码基开启 strict 模式。
第一步:添加TSC
如果代码中既有TypeScript也有JavaScript,那就设置一下,也让TSC编译JavaScript文件。
// tsconfig.json
{
"compilerOptions": {
"allowJs":true
}
}
这样设置之后,TSC就能编译JavaScript文件了。把TSC添加到构建过程中,可以使用TSC分别编译现有的各个JavaScript文件,也可以继续使用原有构建过程运行JavaScript文件,同时使用TSC编译新编写的TypeScrlpt文件。
把 allowJs 设为true,TypeScript不会对现有的JavaScrjpt代码做类型检查,但是会使用指定的模块系统(参照 tsconfig.json 中的 module 字段)转译( transpile )JavaScript文件(转义为ES3、ES5等,由 tsconfig.json 中的 target 字段设定)。
第二步(上):对 javascript 代码做类型检查(可选)
既然让TSC处理JavaScrjpt代码了,为什么不更进一步,对代码做类型检查呢?尽管JavaScrjpt代码中没有显式类型注解,但是别忘了,TypeScript能自动推导类型,就像在 typescript 代码中一样。
// tsconfig.json
{
"compilerOptions":{
"allowJs": true,
"checkJs":true
}
}
如果代码基较大,启用 checkJs 后一次可能报告一大堆类型错误。这时,请禁用该功能,分别在各个JavaScript文件中加人 // @ts-chcek 代指令( 一个普通的注释,放在文件顶部),一次只检查—个JavaScript文件。或者,如果一个文件的内容较多’抛出的错误很多,而我们暂时不想修正,可以保留 checkJs 设置,在这样的文件中加入 //@ts-nocheck指令。
注意
我们知道,TypeScript不能推导出全部类型信息(例如函数的参数类型就推导不出来) ,因此JavaScript代码中将出现很多 any类型。如果在 tsconfig.json 中启用了 strick 模式(应该启用),在迁移的过程中可以临时允许隐式 any。
// tsconfig.json
{
"compilerOptions":{
"allowJs": true,
"checkJs":true,
"noImplicitAny":false
}
}
大部分代码都迁移到TypeScript之后,别忘了启用 noImplicitAny,以免错过某些真正的错误。
TypeScript处理JavaScript代码时,使用的推导算法比TypeScrjpt代码宽容很多。具体规则如下:
- 所有函数的参数都是可选的。
- 函数和类的属性根据使用场景推导类型(无须事先声明)。
- 声明对象、类或函数之后,可以再附加额外的属性。在背后,TypeScript 为各个类和函数声明生成一个命名空间,为每个对象字面量自动添加一个索引签名。
第二步(下):添加JSDoc注解(可选)
有时,你可能很匆忙,只想在现有的JavaScript文件中为一个新定义的函数添加类型注解。在腾出时间把JavaScript文件改写成TypeScrjpt之前,你可以使用JSDoc为新增的函数添加类型注解。
或许你以前见过JSDoc,就是那些看着有点奇怪的注释,放在JavaScript和TypeScript代码上面,以@开头,例如@param,@ returns 等。TypeScrjpt能读懂JSDoc,会把JSDoc当成类型检查器的输入,功效与显式注解TypeScript代码的类型一样。
第三步:把文件重命名为 .ts
把TSC 添加到构建过程中以后,你可能开始做类型检查及注解 javascript 代码了。在这之后,该切换成 typescript了。
一次—个文件,把文件的扩展名由.js(或者.coffee、es6等)改成 .ts。在代码编辑器中重命名之后,你会看到熟悉的红色波浪线,指出类型错误大小写错误、遗忘的 null 检查,以及拼错的变量名。这些错误有两种修正策赂:
-
根治怯:花点时间为结构、字段和函数添加正确的类型信息,捕获使用方可能出现的错误。如果启用了 checkJs,在 tsconfig.json中启用 noImplicitAny,把 any 暴露出来,逐个修正,然后再禁用,以免对剩下的JavaScript文件做类型检查时输出太多错误。
-
快速法:把JavaScrjpt文件批量重命名为.ts扩展名,但是让 tsconfig.json中的设置保持宽松一些(即把 strict 设为 false) ,尽量少抛出一些类型错误。为了让类型检查器喘口气,把复杂的类型声明为any。修正余下的类型错误,然后提交。接下来,逐个开启严格模式相关的标志(noImplicitAny,noImplicitThis,strictNulLChecks等),修正出现的错误。
第四步:严格要求
把大多数JavaScrlpt代码迁移到TypeScript之后,为了保障代码的安全性,应该逐个启用更为严格的TSC标志。
最后,可以禁用与JavaScrjpt互操作的TSC标志,强制要求所有代码都使用严格类型的IypeScript编写。
// tsconfig.json
{
"compilerOptions":{
"allowJs": false,
"checkJs": false
}
}
这样修改之后,最后一批与类型相关的错误将浮出水面。
在TypeSc∏pt中使用JavaScript有好几种方式,各种方式概述见表
方式 | tsconfig.json 标志 | 类型安全性 |
---|---|---|
导入无类型的 javascript | 较差 | |
导入并检查 javascript | 尚可 | |
导入并检查有 JSDoc注解的 javascript | 极好 | |
导入带类型声明的 javascript | 极好 | |
导入 TypeScript | 极好 |
寻找 javascript 代码的类型信息
在TypeScript文件中导入本地JavaScript文件,TypeScript按照下述算法查找JavaScript代码的类型声明:
-
在同一级目录中寻找与 .js 文件同名的 .d.ts文件。如果存在这样的文件,把该文件用作.js文件的类型声明。
-
如果不存在这样的文件,而且 allowJs 和 checkJs的值为 true,推导.js文件的类型信息( 或由js文件中的JSDoc注解得出 ) 。
-
如果无法推导,把整个模块视作 any。
然而,导人第三方JavaScript模块(即安装到 node_modules中的NPM包)时,TypeScript使用的算法稍有不同:
-
在本地寻找模块的类型声明,找到就用。
-
如果在本地找不到,再分析模块的pαckαge.jso。如果该文件中定义了名为 types 或 typings 的字段,使用字段设置的 .d.ts 文件做为模块的类型声明源。
-
如果没有上述字段,沿着目录结构逐级向上,寻找 node_modules/@types文件夹,看看其中有没有模块的类型声明。
-
如果依然找不到类型声明,按照前面针对本地算法的 1~3步查找。
TSC设置:types 和 typeRoots
默认情况下,TypeScript在项目根目录下的,node_modules/@types文件夹及上级目录下的相应文件夹(../node_modules/@types等)中寻找第三方类型声明。多数时候,这就是我们需要的行为。
若想修改寻找全局类型声明的默认行为,在 tsconfig.json 中配置 typeRoots,把值设为一个文件夹路径组成的数组,让TypeScript在这些文件夹中寻找类型声明。例如,可以让 TypeScript 在 typings 和 node_modules/@types 文件夹中寻找类型声明:
// tsconfig.json
{
"compilerOptions":{
"typeRoots":["./typings","./node_modules/@types"]
}
}
如果想更为细致地控制,在 tsconfig.json 中使用 types 选项指定希望TypeScrjpt寻找哪个包的类型。例如,下述配置忽略所有第三方类型声明,唯有React除外:
// tsconfig.json
{
"compilerOptions":{
"types ":["react"]
}
}
使用第三方 javascript
使用NPM在项目中安装的第三方 javascript 大致可以分为三种情况:
- 安装的代码自带类型声明。
- 装的代码没有自带类型声明,不过 DefinitelyTypes 中有相应的类型声明。
- 安装的代码没有自带类型声明,而且 DefinitelyTypes 中也没有相应的类型声明。
自带类型声明的 javascript
如果一个包自带类型声明,而且项目中设置了{ "noImplicitAny" : true },那么导人时TypeScript不会显示红色的波浪线。
如果你安装的代码是由TypeScrlpt编译而来的,或者作者很负责任,在NPM包中放人了类型声明,那就比较幸运,安装后可以直接使用。
如果你安装的代码不是由 typescript 编译而来的,肯定有自带的类型声明与声明所描述的代码不匹配的的风险,类型声明与源码打包在一起的话,这个风险比较低(尤其是流行的包),但也不是彻底无风险。
DefinitelyTypes 中有类型声明的 javascript
就算你导入的第三方代码没有自带类型声,DefinitelyTypes ( https://github.com/DefinitelyTypes/DefinitelyTypes )中也可能有相应的类型声明。DefinitelyTypes 是由社区维护的中心化仓库,为众多开源项目提供外参模块声明。
若想检查你安装的包在 DefinitelyTypes 中有没有类型声明,在TypeSearch( https://microsoft.github.io/TypeSearch)中搜索,或者直接尝试安装类型声明。DefinitelyTypes 中的所有类型声明都已发布到NPM中,放在@types作用域下。因此,可以直接从该作用域下安装:
npm install lodash --save # 安装Lodash
npm install @types/lodash --save-dev #安装Lodash的类型声明
DefinitelyTypes 中没有类型声明的 javascript
这是三种情况中最少见的。遇到这种情况,有好几种选择,从用时最少、最不安全的方案到用时最多、最安全的方案都有:
-
在导人的文件中加上 //@ts-ignore指令,把该文件加人白名单。TypeScript允许使用无类型信息的模块,但这样的模块及其中的全部内容都是 any 类型:
// @ts-ignore import Unsafe from 'untyped-module' Unsafe // any
-
创建一个空的类型声明文件,假装已为模块提供类型信息,把对该模块的使用加人白名单。假如你安装了少有人使用的 nearby-ferret-alerter 包,那就可以新建一个类型声明( 例如 types.d.ts ),写入下述外参类型声明:
// types.d.ts
declare module 'nearby-ferret-alerter'
这么做的目的是告诉TypeScrjpt,有这样—个可导入的模块( import alert from 'nearby-ferret-alerter') ,但是TypeScript对该模块中的类型一无所知。这种方案比第一种稍好—些,毕竟在 types.d.ts 文件中集中列举了应用中无类型的模块。可是这样做依然不安全,因为 nearby-ferret-alerter 及其导出的全部代码仍是 any 类型。
-
自己编写外参模块声明,与前—种方案—样,创建一个名为 types.d.ts 的文件,写入空声明(declare module 'nearby-ferret-alerter' )。不过这一次还要填充类型声明。
-
自己编写类型声明,并且发布到NPM中。如果你选择了第三种方案,本地已经有类型声明了,那么建议你把自己的劳动成果发布到NPM中,让使用 nearby-ferret-alerter 包的其他人受益。为此,你可以向 nearby-ferret-alerter 包的Git仓库提交一个拉取请求,直接贡献自己编写的类型声明;如果仓库的维护者不愿意投人精力维护TypeSQrjpt类型声明,你还可以把自己写的类型声明贡献给DefinitelyTyped。
构建和运行 typescript
本部分讨论如何构建TypeScrjpt应用并部署到生产环境。
构建 typescript 项目
项目结构
建议把TypeScript源码放在顶级文件夹 src/ 中,把编译结果放在顶级文件夹 dist/ 中。这种文件夹结构是—种约定,比较流行。把源码和生成的代码放在两个顶级文件夹中,在需要与其他工具集成时,你会从中受益的。另外,我们还能轻易地把生成的构建产物排除在版本控制之外。
构建产物
把TypeScript程序编译成JavaScrjpt的过程中,TSC会生成一些构建产物。
文件种类 | 扩展名 | tsconfig.json标志 | 默认生成? |
---|---|---|---|
javascript | .js | 是 | |
源码映射 | .js.map | 否 | |
类型声明 | .d.ts | 否 | |
声明映射 | .d.ts.map | 否 |
- 第一种构建产物,即JavaScript文件,你应该不陌生。TSC把TypeScript代码编译成JavaScript代码,以便在JavaScript平台(NodeJS或Chrome)中运行。执行 ts yourfile.ts 命令时,TSC先对 yourfile.ts 做类型检查,然后把它编译成JavaScript。
- 第二种构建产物,即源码映射,是一种特殊的文件,它把生成的JavaScript文件中的每一部分链接回到对应 TypeScrjpt 文件中的具体行和列。这种文件对调试代码有所帮助( ChromeDevTools 会显示TypeScript代码,而不是生成的JavaScript代码 ),还能把JavaScript异常的堆栈跟踪映射到TypeScript源码中的某行某列。
- 第三种构建产物,即类型声明,把生成的类型提供给其他TypeScript项目使用。
- 最后一种构建产物,即声明映射,能提升编译TypeScript项目的速度。
设置编译目标
由于 javascript 规范标准多,宿主运行环境多,这就导致一系列问题:
- 在受我们自己控制的服务器中运行JavaScript后端程序,具体使用哪个 JavaScript 版本由我们自己决定。
- 可是,如果开源自己编写的 JavaScript 后端程序,使用方的平台中是哪个 JavaScript 版本就无从得知了。如果是NodeJS环境,我们还可以声明支持哪些NodeJS版本,但是测览器环境就没那么幸运了。
- 如果在浏览器中运行JavaScript,我们不可能知道用户使用的是什么浏览器,最新的Chrome、Firefox和Edge支持最新的JavaScript特性,这三个测览器较旧的版本不支持最前沿的功能,陈旧的测览器(例如InternetExplorer8)或嵌人式测览器(例如PlayStation4中运行的测览器)更是拖后腿。我们只能精简功能,为其他功能提供腻子脚本,确保应用基本能用,并且尝试检测用户使用的测览器是否过于陈旧,导致应用无法正常运行,如果是,显示一个消息,提醒用户升级。
- 如果发布同构的 javascript库(例如既可以在浏览器中也可以在服务器中运行的日志库),要指定最低支持 NodeJS哪一版,以及支持哪些浏览器 javascript 版本。
TSC原生支持把代码转译成旧版JavaScript,不过无法自动添加腻子脚本。一定要记住这—点:TSC能把多数JavaScript特性转译成旧环境支持的版本,但是没有为缺少的特性提供实现。
为了指明想接入的目标平台,TSC提供了三个设置:
- target 设定把代码转译成哪个JavaScript版本: es5、es2015等。
- module 设定想使用的模块系统:es2015模块、commonjs模块、systemjs模块等。
- lib 告诉TypeScript,目标环境支持哪些JavaScript特性:es5特性、es2015特性,dom 等等。TypeScript没有为这些特性提供具体的实现,这是腻子脚本的功用。lib 设置只是告诉TypeScript,指定的特性可用(原生提供或通过腻子脚本实现)。
你要根据想运行应用的环境设置 target 和 lib,指明把代码转译成哪个JavaScript版本。如不确定,这两个设置都使用默认es5,通常是没有问题的。 把 module 设定为什么,取决于目标环境是NodeJS还是测览器,如果是后者,还取决于你想使用什么模块加载器。
target
TSC内置的转译器支持把多数JavaScrlpt特性转换成较旧的JavaScript版本,这意味着,使用最新的 TypeScript 版本编写的代码可以转译成想支持的任何JavaScript版本。TypeScrjpt支持最新的JavaScript特性(例如 asynck/await,但是截至目前,不是所有主流JavaScript平台都支持),为了把代码转换成NodeJS和浏览器当前能理解的版本,几乎离不开内置的转译器。
过去,每隔几年会发布新一版JavaScript语言,版本号逐渐递增(ES1、ES3、ES5、ES6)。不过从20l5年开始,每一年都会发布新版本JavaScript,而且版本号也变成了所在的年份(ES2015、ES2016等)。然而,有些JavaScript特性先由TypeScrjpt支持,而后才在某个JavaScript版本中定案,我们把这样的特性称为 “ESNext” (即下一个版本)。
TSC 支持转译的特性
版本 | 特性 |
---|---|
ES2015 | const、let、for...if循环、数组/对象展开(…)、带标签的模板字符串、 类生成器、箭头函数、函数默认参数、函数剩余参数析构声明/赋值/参数 |
ES2016 | 幂运算符(**) |
ES2017 | async函数,await promise |
ES2018 | async迭达器 |
ES2019 | catch自居的可选参数 |
ESNext | 数字分隔符(123_456) |
TSC不支持转译的特性
版本 | 特性 |
---|---|
ES5 | 对象读值方法和设置方法 |
ES2015 | 正则表达式的 y和 u标志 |
ES2018 | 正则表达式的s标志 |
ESNext | BigInt(123n) |
设定转译目标的方法是,打开 tsconfig.json 把 target 字段设为:
- es3: ECMAScript3。
- es5: ECMAScript5(如不确定该使用哪个版本,使用这个默认值) 。
- es6 或 es2015: ECMAScript20l5 。
- es2016: ECMAScript2016。
- es2017: ECMAScript2017。
- es2018: ECMAScript2018。
- esnext:最新的ECMAScript版本。
lib
前文说过,把代码转译成旧版JavaScrjpt有—个问题:虽然多数语言特性可以安全转译( let转译为var,class 转译为 function),但是如果目标环境不支持较新的标准库特性,还要借助腻子脚本提供具体实现。比如说,Promise 和 Reflect,以及 Map、Set 和 Symbol 等数据结构。如果目标坏境是最新版Chrome、Flrefox或Edge,通常无须使用腻子脚本,但是,如果目标环境是早前几版浏览器,或者多数NodeJS环境,就要借助腻子脚本提供缺少的特性。
幸好,我们无须自己动手编写腻子脚本。我们可以从流行的腻子脚本库中安装,例如core-js (https://www.npmjs.com/package/core-js),或者使用Babel运行通过类型检查的 TypeScript代码,让 @bable/ployfill (https://babeljs.io/docs/en/babel-plyyfill) 自动添加腻子脚本。
如果打算在浏览器中运行应用,为了减小JavaScript代码的体积,不要打包全部腻子脚本,要判断运行应用的测览器到底是否需要某个腻子脚本,有可能目标平台已经支持某些通过腻子脚本实现的特性。建议使用Polyfill.io( https://polyfill.io/v2/docs/ )这样的服务,只加载用户的测览器需要的腻子脚本。
把腻子脚本添加到代码中之后,要告诉TSC,目标环境—定支持相应的特性。具体方法是在 tsconfig.json 文件里的 lib 字段中设置。假如我们为所有ES2015特性和ES20l6的 Array.prototype.includes 添加了腻子脚本,那么可以使用下述配置:
{
"compilerOptions":{
"lib":["es2015","es2016.array.includes"]
}
}
如果想在浏览器中运行代码,还要加上 window,document,以及在浏览器中运行代码所需的其他API的DOM类型声明:
{
"compilerOptions":{
"lib":["es2015","es2016.array.includes","dom"]
}
}
若想查看所有支持的库,执行 tc-help命令。
生成源码映射
源码映射把转译得到的代码与对应的源码链接起来。多数开发者工具(如ChromeDevTools)、错误报告库日志框架和构建工具都支持源码映射。由于构建流程产出的代码通常与最初编写的代码有很大的差异(比如说,在构建流程中可能会把TypeScript编译成ES5 JavaScript,再使用Rollup筛除无用的代码,接着使用Prepack预先求值,最后使用Uglify精简代码),因此在构建流程中使用源码映射对调试得到的JavaScript代码有很大的帮助。
一般来说,建议在开发环境中使用源码映射,也建议在浏览器和服务器等生产环境中提供源码映射。不过有个例外:如果你想通过混淆提高代码在放浏览器中运行的安全性,那就不要在生产环境中提供源码映射。
项目引用
随着应用的增长,TSC对代码做类型检查和编译所用的时间将越来越长。花费在类型检查和编译上的时间几乎与代码基的体量呈线性关系。
为了解决这个问题,TSC内置了—个称为 “项目引用” (projectreferences) 的功能,能显著减少编译耗时。如果项目中有上百个文件,那就一定要使用项目引用。
项目引用的用法如下:
- 把一个TypeScrjpt项目分成多个子项目。一个子项目放在一个文件夹中,该文件夹中有—个 tsconfig.json 文件和—些TypeScript文件。拆分项目时,应该把可能一起更新的代码放在同一个文件夹中。
- 在各个子项目的文件夹中创建一个 tsconfig.json 文件,至少写入下述内容:
{
"compilerOptions":{
"composite": true,
"declaration": true,
"declarationMap": true,
"rootDir": "."
},
"include": [
"./**/*.ts"
],
"references": [
{
"path": "../myReferencedProject",
"prepend": true
}
],
}
这里关键的设置是:
- composite:告诉TSC,这个文件夹是一个大型 TypeScript 项目的子项目。
- declaration:告诉TSC,为这个子项目生成 .d.ts声明文件。在项目引用中,子项目可以访问各子项目的声明文件和生成JavaScript,但是不能访问TypeScript源文件。这就划定了一条界限,TSC不会再重新检查或编译代码:如果更新了子项目A中的一行代码,TSC不会对子项目B重新做类型检查和编译;TSC所要做的只是检查B的类型声明中有没有类型错误。这个行为是项目引用在重新构建大型项目时用时较少的关键。
- declarationMap:告诉TSC,为生成的类型声明构建源码映射。
- references:在一个数组中列出该子项目依赖的其他子项目。每个引用的 path 字段要指向—个内有 tsconfig.json 文件的文件夹,或者直接指向—个TSC配置文件(如果配置文件的名称不是 tsconfig.json )。prepend 字段指明把引用的子项目生成的JavaScript和源码映射与当前子项目生成的JavaScript和源码映射拼接在一起。注意,prepend 只在使用 outFile 时有用;如未使用 outFile 可以不设置prepend。
- rootDir:明确指明该子项目应该相对根项目(.)编译。另一种做法是设置 outDir,指向根项目的outDir中的—个子文件夹。
- 在根文件夹中创建—个 tsconfig.json 文件,引用没有被其他子项目引用的子项目:
{
"files":[],
"references": [
{
"path": "../myProject",
},
{
"path": "../mySecondProject",
}
]
}
- 现在,使用TSC编译项目时,通过build标志让TSC把项目引用考虑进来:
tsc --build # 或者简写为 tsc -b
使用 extends 减少 tsconfig.json 中的样板代码量
有时,所有子项目的编译器选项是一样的。这种情况下,最好在根目录中创建一个基本的 tsconfig.json文件,让子项目的 tsconfig.json 文件在此基础上扩展:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap" : true,
"lib": ["es2015", "es2016.array.include"],
"rootDir": '.',
"sourceMap": true,
"strict": true,
"target": "es5",
}
}
然后,更新于项目的′sco帧gjso″丈件,使用exte∩d5选项扩展:
{
"extend": "../tsconfig.base",
"include": [
"./**/*.ts"
],
"references":[
{
"path": "../myReferencedProject",
"prepend": true
}
]
}
监控错误
TypeScript在编译时会报告错误,不过我们还是需要在运行时监控用户可能遇到的异常,然后设法在编译时避免(至少也要修正导致运行时错误的缺陷)。若想报告和整理运行时异常,可以使用Sentry( https://sentry.io )和Bugsnag( https://bugsnag.com )等错误监控工具。
在服务器中运行 typescript
若想在NodeJS环境中运行 TypeScript 代码,把代码编译成 ES20l5 JavaScript(如果目标环境使用旧版 NodeJS,编译为ES5),并在 tsconfig.js 文件中把 module 字段设为 commonjs:
{
"compilerOptions":{
"target": "es2015",
"module": "commonjs"
}
}
这样设置之后,ES2015中的 import 和export 调用将分别被编译成 require 和 module.exports,无须进一步打包就能在NodeJS中运行。
如果想使用源码映射(应该使用),要想办法把源码映射提供给NodeJS进程。为此,从NPM中安装 source-map-support 包 ,然后按照说明设置即可。多数进程监控日志和错误报告工具,例如PM2、Winston 和 Sentry 都内置对源码映射的支持。
在浏览器中运行 typescript
若想在测览器中运行TypeScript,编译过程比在服务器中运行要复杂一些。
首先,要为编译选择一个目标模块系统。经验表明,如果是发布—个库,供他人使用(例如发布到NPM上),应该始终使用umd,以便最大程度上兼容各人在自己的项目中使用的不同的模块打包工具。
如果代码只供自己使用,不发布到NPM上,那么编译为哪种格式取决于你使用的模块打包工具。具体应该使用什么模块系统请参阅打包工具的文档, 例如 Webpack 和 Rollup对ES2015 模块支持最好,而Browserify要求使用CommonJS模块。下面给出—些参考:
-
如果使用 SystemJS 模块加载器,把 module 设为 es2015或更高的版本。
-
如果使用兼容 ES2015 的模块打包工具,例如Webpack 或 Rollup ,把 module 设为 es2015 或更高的版本。
-
如果使用兼容 ES2015 的模块打包工具,而且代码中用到了动态导人,把 module 设为 esnext。
-
如果构建供其他项目使用的库,而且经过 tsc 处理之后没有再通过额外的构建步骤处理,为了最大程度上兼容各种加载器,把module 设为 umd 。
-
如果使用CommonJS打包工具打包模块,例如Browserify,把 module 设为 commonjs。
-
如果打算使用RequireJS 或其他 AMD模块加载器加载代码,把 module 设为 amd。
-
如果希望导出的顶级代码可在 windows 对象上全局使用(拥有特权的人就可以这样做),把 module 设为none。注意,如果代码在模块模式下,为了减少其他软件工程师的痛苦,TSC会强制把代码编译成 commonjs 模块。
接下来,配置构建流程,把所有TypeScript代码编译成—个JavaScript文件或一系列JavaScript文件。虽然设置 outFile标志后,TSC会把小型项目编译为一个JavaScript文件,但是只能打包成SystemJS和AMD模块。另外,与专门的构建工具(如Webpack)不同,TSC不支持构建插件和智能代码分拆,在某个阶段,你会发现需要使用功能更强大的打包工具。
把 typescript 代码发布到 NPM 中
编译TypeScript代码供其他TypeScript和JavaScript项目使用其实很简单。如果是编译成供他人使用的JavaScr1pt,有几点最佳实践要牢记于心:
- 生成源码映射,以便调试自己的代码。
- 编译为ES5,让其他人能轻易构建并运行你的代码。
- 审慎选择编译为哪种模块格式(UMD、CommonJS、ES20l5等)。
- 生成类型声明,让其他TypeScript用户知晓你的代码中有哪些类型。
首先,使用 tsc 把 TypeScript 编译成JavaScript,并且生成相应的类型声明。切记要配置 tsconfig.json,最大程度上兼容各种流行的JavaScript环境和构建系统。
{
"compilerOptions": {
"declaration": true,
"module" : "umd",
"sourceMap": true,
"target": "es5",
}
}
接下来,在 .npmignore 中列出不想发布到NPM中的TypeScript源码,尽量减少包的体积;在 .gitignore中列出生成的构建产物,不纳人Git仓库。
最后,在项目的 package.json 文件中添加 "types"字段,指明该项目自带类型声明(注意,这一步不是必须的,不过加上 ”types“ 字段后使用TypeScript的用户将从中受益)。再添加一个脚本,在发布前构建包,确保包中的JavaScript、类型声明和源码映射与编译源TypeScript是同步的。
{
"name":'xxx',
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"script":{
"perpublishOnly": "tsc -d"
}
}
tsconfig.json 部分选项说明
选项 | 说明 |
---|---|
include | TSC在哪个文件夹中寻找ts文件 |
lib | TSC假定运行代码的环境中有哪些API? 包括ES5的Function.prototype.bind、ES2015的object.assign和DOM的document.querySelector |
module | TSC把代码编译成哪个模块系统(CommonJS、SyemJS、ES2015等) |
outDir | TSC生成的JavaScript代码放在哪个文件夹中 |
strict | 类型检查器检查等级;检查无效代码时尽量严格,该选项强制所有代码都正确声明了类型。 |
target | TSC把代码编译成哪个JavaScript版本(ES3、ES5、ES2015、ES2016等) |
这些选项很少改动,偶尔需要改动的是,切换模块打包工具时修改module和target设置,编写在测览器中运行的TypeScript时在lib中添加'dom',或者把现有javascript代码迁移到ts时调整严格(strict)等级。