你不知道的 TypeScript 高级类型(上)https://developer.aliyun.com/article/1411324
5. 条件类型
(1)基本概念
条件类型根据条件来选择两种可能的类型之一,就像 JavaScript 中的三元运算符一样。其语法如下所示:
T extends U ? X : Y
上述类型就意味着当 T
可分配给(或继承自)U
时,类型为 X
,否则类型为 Y
。
看一个简单的例子,一个值可以是用户的出生日期或年龄。如果是出生日期,那么这个值的类型就是 number;如果是年龄,那这个值的类型就是 string。下面定义了三个类型:
type Dob = string; type Age = number; type UserAgeInformation = T extends number ? number : string;
其中 T
是 UserAgeInformation
的泛型参数,可以在这里传递任何类型。 如果 T
扩展了 number
,那么类型就是 number
,否则就是 string
。 如果希望 UserAgeInformation
是 number
,就可以将 Age
传递给 T
,如果希望是一个 string
,就可以将 Dob
传递给 T
:
type Dob = string; type Age = number; type UserAgeInformation<T> = T extends number ? number : string; let userAge:UserAgeInformation<Age> = 100; let userDob:UserAgeInformation<Dob> = '25/04/1998';
(2)创建自定义条件类型
单独使用条件类型可能用处不是很大,但是结合泛型使用时就非常有用。一个常见的用例就是使用带有 never
类型的条件类型来修剪类型中的值。
type NullableString = string | null; let itemName: NullableString; itemName = null; itemName = "Milk"; console.log
其中 NullableString
可以是 string
或 null
类型,它用于 itemName
变量。定义一个名为 NoNull
的类型别名:
type NoNull
我们想从类型中剔除 null
,需要通过条件来检查类型是否包含 null
:
type NoNull = T extends null;
当这个条件为true
时,不想使用该类型,返回never
类型:
type NoNull = T extends null ? never
当这个条件为 false
时,说明类型中不包含 null
,可以直接返回 T
:
type NoNull = T extends null ? never : T;
将 itemName
变量的类型更改为 NoNull
:
let itemName: NoNull<NullableString>;
TypeScript 有一个类似的实用程序类型,称为 NonNullable
,其实现如下:
type NonNullable = T extends null | undefined ? never : T;
NonNullable
和 NoNull
之间的区别在于 NonNullable
将从类型中删除 undefined
以及 null
。
(3)条件类型的类型推断
条件类型提供了一个infer
关键字用来推断类型。下面来定义一个条件类型,如果传入的类型是一个数组,则返回数组元素的类型;如果是一个普通类型,则直接返回这个类型。如果不使用 infer
可以这样写:
type Type = T extends any[] ? T[number] : T; type test = Type<string[]>; // string type test2 = Type<string>; // string
如果传入 Type
的是一个数组类型,那么返回的类型为T[number]
,即该数组的元素类型,如果不是数组,则直接返回这个类型。这里通过索引访问类型T[number]
来获取类型,如果使用 infer
关键字则无需手动获取:
type Type = T extends Array ? U : T; type test = Type<string[]>; // string type test2 = Type<string>; // string
这里 infer
能够推断出 U
的类型,并且供后面使用,可以理解为这里定义了一个变量 U
来接收数组元素的类型。
6. 类型推断
(1)基础类型
在变量的定义中如果没有明确指定类型,编译器会自动推断出其类型:
let name = "zhangsan"; name = 123; // error 不能将类型“123”分配给类型“string”
在定义变量 name
时没有指定其类型,而是直接给它赋一个字符串。当再次给 name
赋一个数值时,就会报错。这里,TypeScript 根据赋给 name
的值的类型,推断出 name
是 string 类型,当给 string
类型的 name
变量赋其他类型值的时候就会报错。这是最基本的类型推论,根据右侧的值推断左侧变量的类型。
(2)多类型联合
当定义一个数组或元组这种包含多个元素的值时,多个元素可以有不同的类型,这时 TypeScript 会将多个类型合并起来,组成一个联合类型:
let arr = [1, "a"]; arr = ["b", 2, false]; // error 不能将类型“false”分配给类型“string | number”
可以看到,此时的 arr
中的元素被推断为string | number
,也就是元素可以是 string
类型也可以是 number
类型,除此之外的类型是不可以的。
再来看一个例子:
let value = Math.random() * 10 > 5 ? 'abc' : 123 value = false // error 不能将类型“false”分配给类型“string | number”
这里给value
赋值为一个三元表达式的结果,Math.random() * 10
的值为0-10的随机数。如果这个随机值大于5,则赋给 value
的值为字符串abc
,否则为数值123
。所以最后编译器推断出的类型为联合类型string | number
,当给它再赋值false
时就会报错。
(3)上下文类型
上面的例子都是根据=
右侧值的类型,推断左侧值的类型。而上下文类型则相反,它是根据左侧的类型推断右侧的类型:
window.onmousedown = function(mouseEvent) { console.log(mouseEvent.a); // error 类型“MouseEvent”上不存在属性“a” };
可以看到,表达式左侧是 window.onmousedown
(鼠标按下时触发),因此 TypeScript 会推断赋值表达式右侧函数的参数是事件对象,因为左侧是 mousedown
事件,所以 TypeScript 推断 mouseEvent
的类型是 MouseEvent
。在回调函数中使用 mouseEvent
时,可以访问鼠标事件对象的所有属性和方法,当访问不存在属性时,就会报错。
7. 类型保护
类型保护实际上是一种错误提示机制,类型保护是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内。类型保护的主要思想是尝试检测属性、方法或原型,以确定如何处理值。
(1)instanceof 类型保护
instanceof
是一个内置的类型保护,可用于检查一个值是否是给定构造函数或类的实例。通过这种类型保护,可以测试一个对象或值是否是从一个类派生的,这对于确定实例的类型很有用。
instanceof
类型保护的基本语法如下:
objectVariable instanceof ClassName ;
来看一个例子:
class CreateByClass1 { public age = 18; constructor() {} } class CreateByClass2 { public name = "TypeScript"; constructor() {} } function getRandomItem() { return Math.random() < 0.5 ? new CreateByClass1() : new CreateByClass2(); // 如果随机数小于0.5就返回CreateByClass1的实例,否则返回CreateByClass2的实例 } const item = getRandomItem(); // 判断item是否是CreateByClass1的实例 if (item instanceof CreateByClass1) { console.log(item.age); } else { console.log(item.name); }
这里 if
的判断逻辑中使用 instanceof
操作符判断 item
。如果是 CreateByClass1
创建的,那它就有 age
属性;如果不是,那它就有 name
属性。
(2)typeof 类型保护
typeof
类型保护用于确定变量的类型,它只能识别以下类型:
- boolean
- string
- bigint
- symbol
- undefined
- function
- number
对于这个列表之外的任何内容,typeof
类型保护只会返回 object
。typeof
类型保护可以写成以下两种方式:
typeof v !== "typename" typeof v === "typename"
typename
只能是number
、string
、boolean
和symbol
四种类型,在 TS 中,只会把这四种类型的 typeof
比较识别为类型保护。
在下面的例子中,StudentId
函数有一个 string | number
联合类型的参数 x
。如果变量 x
是字符串,则会打印 Student
;如果是数字,则会打印 Id
。 typeof
类型保护可以从 x
中提取类型:
function StudentId(x: string | number) { if (typeof x == 'string') { console.log('Student'); } if (typeof x === 'number') { console.log('Id'); } } StudentId(`446`); // Student StudentId(446); // Id
(3)in 类型保护
in
类型保护可以检查对象是否具有特定属性。它通常返回一个布尔值,指示该属性是否存在于对象中。
in
类型保护的基本语法如下:
propertyName in objectName
来看一个例子:
interface Person { firstName: string; surname: string; } interface Organisation { name: string; } type Contact = Person | Organisation; function sayHello(contact: Contact) { if ("firstName" in contact) { console.log(contact.firstName); } }
in
类型保护检查参数 contact
对象中是否存在 firstName
属性。 如果存在,就进入if
判断,打印contact.firstName
的值。
(4)自定义类型保护
来看一个例子:
const valueList = [123, "abc"]; const getRandomValue = () => { const number = Math.random() * 10; // 这里取一个[0, 10)范围内的随机值 if (number < 5) { return valueList[0]; // 如果随机数小于5则返回valueList里的第一个值,也就是123 }else { return valueList[1]; // 否则返回"abc" } }; const item = getRandomValue(); if (item.length) { console.log(item.length); // error 类型“number”上不存在属性“length” } else { console.log(item.toFixed()); // error 类型“string”上不存在属性“toFixed” }
这里,getRandomValue
函数返回的元素是不固定的,有时返回 number
类型,有时返回 string
类型。使用这个函数生成一个值 item
,然后通过是否有 length
属性来判断是 string
类型,如果没有 length
属性则为 number
类型。在 JavaScript 中,这段逻辑是没问题的。但是在 TypeScript 中,因为 TS 在编译阶段是无法识别 item
的类型的,所以当在 if
判断逻辑中访问 item
的 length
属性时就会报错,因为如果 item
为 number
类型的话是没有 length
属性的。
这个问题可以通过类型断言来解决,修改判断逻辑即可:
if ((<string>item).length) { console.log((<string>item).length); } else { console.log((<number>item).toFixed()); }
这里通过使用类型断言告诉 TS 编译器,if
中的 item
是 string
类型,而 else
中的是 number
类型。这样做虽然可以,但是需要在使用 item
的地方都使用类型断言来说明,显然有些繁琐。
可以使用自定义类型保护来解决上述问题:
const valueList = [123, "abc"];
const getRandomValue = () => { const number = Math.random() * 10; // 这里取一个[0, 10)范围内的随机值 if (number < 5) return valueList[0]; // 如果随机数小于5则返回valueList里的第一个值,也就是123 else return valueList[1]; // 否则返回"abc" }; function isString(value: number | string): value is string { const number = Math.random() * 10 return number < 5; } const item = getRandomValue(); if (isString(item)) { console.log(item.length); // 此时item是string类型 } else { console.log(item.toFixed()); // 此时item是number类型 }
首先定义一个函数,函数的参数 value
就是要判断的值。这里 value
的类型可以为 number
或 string
,函数的返回值类型是一个结构为 value is type
的类型谓语,value
的命名无所谓,但是谓语中的 value
名必须和参数名一致。而函数里的逻辑则用来返回一个布尔值,如果返回为 true
,则表示传入的值类型为is
后面的 type
。
使用类型保护后,if
的判断逻辑和代码块都无需再对类型做指定工作,不仅如此,既然 item
是 string
类型,则 else
的逻辑中,item
一定是联合类型中的另外一个,也就是 number
类型。
8. 类型断言
(1)基本使用
TypeScrip的类型系统很强大,但有时它是不如我们更了解一个值的类型。这时,我们更希望 TypeScript 不要进行类型检查,而是让我们自己来判断,这时就用到了类型断言。
使用类型断言可以手动指定一个值的类型。类型断言像是一种类型转换,它把某个值强行指定为特定类型,下面来看一个例子:
const getLength = target => { if (target.length) { return target.length; } else { return target.toString().length; } };
这个函数接收一个参数,并返回它的长度。这里传入的参数可以是字符串、数组或是数值等类型的值,如果有 length 属性,说明参数是数组或字符串类型,如果是数值类型是没有 length 属性的,所以需要把数值类型转为字符串然后再获取 length 值。现在限定传入的值只能是字符串或数值类型的值:
const getLength = (target: string | number): number => { if (target.length) { // error 类型"string | number"上不存在属性"length" return target.length; // error 类型"number"上不存在属性"length" } else { return target.toString().length; } };
当 TypeScript 不确定一个联合类型的变量到底是哪个类型时,就只能访问此联合类型的所有类型里共有的属性或方法,所以现在加了对参数target
和返回值的类型定义之后就会报错。
这时就可以使用类型断言,将target
的类型断言成string
类型。它有两种写法:value
和 value as type
:
// 这种形式是没有任何问题的,建议使用这种形式 const getStrLength = (target: string | number): number => { if ((target as string).length) { return (target as string).length; } else { return target.toString().length; } }; // 这种形式在JSX代码中不可以使用,而且也是TSLint不建议的写法 const getStrLength = (target: string | number): number => { if ((<string>target).length) { return (<string>target).length; } else { return target.toString().length; } };
类型断言不是类型转换,断言成一个联合类型中不存在的类型是不允许的。
注意: 不要滥用类型断言,在万不得已的情况下使用要谨慎,因为强制把某类型断言会造成 TypeScript 丧失代码提示的能力。
(2)双重断言
虽然类型断言是强制性的,但并不是万能的,在一些情况下会失效:
interface Person { name: string; age: number; } const person = 'ts' as Person; // Error
这时就会报错,很显然不能把 string
强制断言为一个接口 Person
,但是并非没有办法,此时可以使用双重断言:
interface Person { name: string; age: number; } const person = 'ts' as any as Person;
先把类型断言为 any
,再接着断言为想断言的类型就能实现双重断言,当然上面的例子肯定说不通的,双重断言我们也更不建议滥用,但是在一些少见的场景下也有用武之地。
(3)显式赋值断言
先来看两个关于null
和undefined
的知识点。
① 严格模式下 null 和 undefined 赋值给其它类型值
当在 tsconfig.json
中将 strictNullChecks
设为 true
后,就不能再将 undefined
和 null
赋值给除它们自身和void
之外的任意类型值了,但有时确实需要给一个其它类型的值设置初始值为空,然后再进行赋值,这时可以自己使用联合类型来实现 null
或 undefined
赋值给其它类型:
let str = "ts"; str = null; // error 不能将类型“null”分配给类型“string” let strNull: string | null = "ts"; // 这里你可以简单理解为,string | null即表示既可以是string类型也可以是null类型 strNull = null; // right strNull = undefined; // error 不能将类型“undefined”分配给类型“string | null”
注意,TS 会将 undefined
和 null
区别对待,这和 JavaScript 的本意也是一致的,所以在 TS 中,string|undefined
、string|null
和string|undefined|null
是三种不同的类型。
② 可选参数和可选属性
如果开启了 strictNullChecks
,可选参数会被自动加上 |undefined
:
const sum = (x: number, y?: number) => { return x + (y || 0); }; sum(1, 2); // 3 sum(1); // 1 sum(1, undefined); // 1 sum(1, null); // error Argument of type 'null' is not assignable to parameter of type 'number | undefined'
根据错误信息看出,这里的参数 y
作为可选参数,它的类型就不仅是 number
类型了,它可以是 undefined
,所以它的类型是联合类型 number | undefined
。
TypeScript 对可选属性和对可选参数的处理一样,可选属性的类型也会被自动加上 |undefined
。
interface PositionInterface { x: number; b?: number; } const position: PositionInterface = { x: 12 }; position.b = "abc"; // error position.b = undefined; // right position.b = null; // error
看完这两个知识点,再来看看显式赋值断言。当开启 strictNullChecks
时,有些情况下编译器是无法在声明一些变量前知道一个值是否是 null
的,所以需要使用类型断言手动指明该值不为 null
。下面来看一个编译器无法推断出一个值是否是null
的例子:
function getSplicedStr(num: number | null): string { function getRes(prefix: string) { // 这里在函数getSplicedStr里定义一个函数getRes,我们最后调用getSplicedStr返回的值实际是getRes运行后的返回值 return prefix + num.toFixed().toString(); // 这里使用参数num,num的类型为number或null,在运行前编译器是无法知道在运行时num参数的实际类型的,所以这里会报错,因为num参数可能为null } num = num || 0.1; // 这里进行了赋值,如果num为null则会将0.1赋给num,所以实际调用getRes的时候,getRes里的num拿到的始终不为null return getRes("lison"); }
因为有嵌套函数,而编译器无法去除嵌套函数的 null
(除非是立即调用的函数表达式),所以需要使用显式赋值断言,写法就是在不为 null 的值后面加个!
。上面的例子可以这样改:
function getSplicedStr(num: number | null): string { function getLength(prefix: string) { return prefix + num!.toFixed().toString(); } num = num || 0.1; return getLength("lison"); }
这样编译器就知道 num
不为 null
,即便 getSplicedStr
函数在调用的时候传进来的参数是 null
,在 getLength
函数中的 num
也不会是 null
。
(4)const 断言
const
断言是 TypeScript 3.4 中引入的一个实用功能。在 TypeScript 中使用 as const
时,可以将对象的属性或数组的元素设置为只读,向语言表明表达式中的类型不会被扩大(例如从 42 到 number)。
function sum(a: number, b: number) { return a + b; } // 相当于 const arr: readonly [3, 4] const arr = [3, 4] as const; console.log(sum(...arr)); // 7
这里创建了一个 sum 函数,它以 2 个数字作为参数并返回其总和。const 断言使我们能够告诉 TypeScript 数组的类型不会被扩展,例如从 [3, 4]
到 number[]
。通过 as const
,使得数组成为只读元组,因此其内容是无法更改的,可以在调用 sum 函数时安全地使用这两个数字。
如果试图改变数组的内容,会得到一个错误:
function sum(a: number, b: number) { return a + b; } // 相当于 const arr: readonly [3, 4] const arr = [3, 4] as const; // 类型“readonly [3, 4]”上不存在属性“push”。 arr.push(5);
因为使用了 const
断言,因此数组现在是一个只读元组,其内容无法更改,并且尝试这样做会在开发过程中导致错误。
如果尝试在不使用 const
断言的情况下调用 sum
函数,就会得到一个错误:
function sum(a: number, b: number) { return a + b; } // 相当于 const arr: readonly [3, 4] const arr = [3, 4]; // 扩张参数必须具有元组类型或传递给 rest 参数。 console.log(sum(...arr)); // 👉️ 7
TypeScript 警告我们,没有办法知道 arr
变量的内容在其声明和调用 sum()
函数之间没有变化。
如果不喜欢使用 TypeScript 中的枚举,也可以使用 const 断言作为枚举的替代品:
// 相当于 const Pages: {readonly home: '/'; readonly about: '/about'...} export const Pages = { home: '/', about: '/about', contacts: '/contacts', } as const;
如果尝试更改对象的任何属性或添加新属性,就会收到错误消息:
// 相当于 const Pages: {readonly home: '/'; readonly about: '/about'...} export const Pages = { home: '/', about: '/about', contacts: '/contacts', } as const; // 无法分配到 "about" ,因为它是只读属性。 Pages.about = 'hello'; // 类型“{ readonly home: "/"; readonly about: "/about"; readonly contacts: "/contacts"; }”上不存在属性“test”。 Pages.test = 'hello';
需要注意,const
上下文不会将表达式转换为完全不可变的。来看例子:
const arr = ['/about', '/contacts']; // 相当于 const Pages: {readonly home: '/', menu: string[]} export const Pages = { home: '/', menu: arr, } as const; Pages.menu.push('/test'); // ✅
这里,menu
属性引用了一个外部数组,我们可以更改其内容。如果在对象上就地定义了数组,我们将无法更改其内容。
// 相当于 const Pages: {readonly home: '/', readonly menu: string[]} export const Pages = { home: '/', menu: ['/about'], } as const; // 类型“readonly ["/about"]”上不存在属性“push”。 Pages.menu.push('/test');
(5)非空断言
在 TypeScript 中感叹号 ( ! ) 运算符可以使编译器忽略一些错误,下面就来看看它有哪些实际的用途的以及何时使用。
① 非空断言运算符
感叹号运算符称为非空断言运算符,添加此运算符会使编译器忽略undefined
和null
类型。来看例子:
const parseValue = (value: string) => { // ... }; const prepareValue = (value?: string) => { // ... parseValue(value); };
对于 prepareValue
方法的 value
参数,TypeScript就会报出以下错误:
类型“string | undefined”的参数不能赋给类型“string”的参数。 不能将类型“undefined”分配给类型“string”。
因为我们希望 prepareValue
函数中的 value
是 undefined
或 string
,但是我们将它传递给了 parseValue
函数,它的参数只能是 string
。所以就报了这个错误。
但是,在某些情况下,我们可以确定 value
不会是 undefined
,而这就是需要非空断言运算符的情况:
const parseValue = (value: string) => { // ... }; const prepareValue = (value?: string) => { // ... parseValue(value!); };
这样就不会报错了。但是,在使用它时应该非常小心,因为如果 value
的值是undefined
,它可能会导致意外的错误。
② 使用示例
既然知道了非空断言运算符,下面就来看几个真实的例子。
在列表中搜索是否存在某个项目:
interface Config { id: number; path: string; } const configs: Config[] = [ { id: 1, path: "path/to/config/1", }, { id: 2, path: "path/to/config/2", }, ]; const getConfig = (id: number) => { return configs.find((config) => config.id === id); }; const config = getConfig(1);
由于搜索的内容不一定存在于列表中,所以 config 变量的类型是 Config | undefined
,我们就可以使用可以使用费控断言运算符告诉 TypeScript,config
应该是存在的,因此不必假设它是 undefined
。
const getConfig = (id: number) => { return configs.find((config) => config.id === id)!; }; const config = getConfig(1);
这时,config
变量的类型就是 Config。这时再从 config
中获取任何属性时,就不需要再检查它是否存在了。
再来看一个例子,React 中的 Refs 提供了一种访问 DOM 节点或 React 元素的方法:
const App = () => { const ref = useRef<HTMLDivElement>(null); const handleClick = () => { if(ref.current) { console.log(ref.current.getBoundingClientRect()); } }; return ( <div className="App" ref={ref}> <button onClick={handleClick}>Click</button> </div> ); };
这里创建了一个简单的组件,它可以访问 class 为 App 的 DOM 节点。组件中有一个按钮,当点击该按钮时,会显示元素的大小以及其在视口中的位置。我们可以确定被访问的元素是在点击按钮后挂载的,所以可以在 TypeScript 中添加非空断言运算符表示这个元素是一定存在的:
const App = () => { const handleClick = () => { console.log(ref.current!.getBoundingClientRect()); }; };
当使用非空断言运算符时,就表示告诉TypeScript,我比你更了解这个代码逻辑,会为此负责,所以我们需要充分了解自己的代码之后再确定是否要使用这个运算符。否则,如果由于某种原因断言不正确,则会发生运行时错误。