让你的 TypeScript 更加安全!
本文会介绍 TypeScript 带来的好处,后面主要会介绍 TS 的类型系统,一些高级语法等,主要包括 条件类型、as const、逆变协变等
TypeScript 的好处
- 静态类型检查出更多错误,避免 JavaScript 的人肉类型推导器
- 记录参数会让代码可读性更高 jsdoc 也有类型
- TS 提供额外的 doc 提示
- 重构更加的安全,深有感触
- 提高生产力:代码智能补全、提示,代码跳转、展示引用、自动 import、自动 rename等
首先最开始的是介绍类型兼容性,这是 TypeScript 的关键问题,类型兼容性是基于结构子类型的。
父子类型
子类型是指在类型系统中,一个类型可以被看作是另一个类型的特殊情况。
换句话说,如果类型 A 是类型 B 的子类型,那么类型 A 的值可以被安全地赋值给类型 B。
例如下面的例子中,Dog 类型可以安全的赋值给 Animal 类型,因为 Dog 类型是 Animal 的子类型
interface Animal {
type: number
}
interface Dog extends Animal {
wang(): void
}
let animal: Animal
let dog: Dog
animal = dog // ✅ok
dog = animal // ❌error! animal 实例上缺少属性 'wang'
子类型的属性比父类型的更多,更具体。
也可以说,子类型是父类型的超集,而父类型是子类型的子集,这个很绕,直觉上容易混淆。
例如下面的例子就很容易被混淆:
type Parent = "a" | "b" | "c";
type Child = "a" | "b";
let parent: Parent;
let child: Child;
// 兼容
parent = child
// 不兼容,因为 parent 可能为 c,而 c 无法 assign 给 "a" | "b"
child = parent
'a' | 'b' | 'c' 乍一看比 'a' | 'b' 的属性更多,那么 'a' | 'b' | 'c' 是 'a' | 'b' 的子类型吗?其实正相反,
'a' | 'b' | 'c' 是 'a' | 'b' 的父类型,因为前者包含的范围更广,而后者则更具体。
关键在于:子类型比父类型更加具体。
鸭子类型
前面我们说了父子类型,他们都是基于类型上的父子关系,在 TypeScript 中我们称为鸭子类型。
这也是 TypeScript 类型系统中最大的特性,仅关注对象身上的属性和方法,而不关注继承关系。
简而言之,满足了类型上的所有属性和方法就是它的子类型,不需要关注继承关系,也就是定义上的相同。
例如下面的例子中,Cat 是 Dog 的子类型
Class Cat{ miao, animal }
Class Dog{ animal }
T extends {}
在写类型挑战时,大部分题目都会用到 extends 关键字,但是确实没有认真研究过它的作用。以及标题中这段代码在 T 为 object时始终返回 true 的原因也值得深究。
例如下面这些
实现 Exclude
type MyExclude<T, U> = T extends U ? never : T;
实现 If
type If<C extends boolean, T, F> = C extends true ? T : F;
实现 OmitByType
type OmitByType<T, U> = {
[P in keyof T as T[P] extends U ? never : P]: T[P]
}
我们都使用了 extends 做条件类型的判断
例如 Exclude 的例子
如果 T 是 U 的子类型,那么结果是 never 否则是 T
联合类型也叫分布式条件类型,也就是说 当 T 是 'A' | ‘B' 时,会被拆分成 'A' extends U ? never : T | 'B' extends U ? never : T
再回到我们标题中的例子,如何解释 T extends {} 当 T 是对象时始终为 true
因为 {} 类型表示一个空对象类型,它允许包含任意属性。因此, {} 类型相当于一个宽泛的类型,可以说是所有对象类型的基类型。
as const
as const 把类型缩紧成字面量类型。
const 断言会告诉编译器,为表达式推断出最窄最特定的类型。
当我们定义对象的时候,如果我们希望对象是完全不被改变的常量,就可以使用 as const,把对象的所有键变为 readonly,缩紧成字面量类型,这样对象就完全不可更改了。
const a = {
pc: '移动端',
h5: 'H5'
} as const;
映射类型
[K in T] 称为映射类型,K 表示 T 中的字面量被一个个取出,类似于 for in
type Keys = 'a' | 'b'
type O = {
[K in Keys]: string
}
条件类型
条件类型的语法类似于我们平时常用的三元表达式,它的基本语法如下(伪代码):
A === B ? 1 : 2;
A extends b ? 1 : 2;
但需要注意的是,条件类型中使用 extends 判断类型的兼容性,而非判断类型的全等性。这是因为在类型层面中,对于能够进行赋值操作的两个变量,我们并不需要它们的类型完全相等,只需要具有兼容性,而两个完全相同的类型,其 extends 自然也是成立的。
如果想要判断类型的全等性,可以实现一个 IsEqual 方法
type IsEqual<A, B> = ((<T>() => T extends A ? true : false) extends
(<T>() => T extends B ? true : false)
? true
: false
)
通过 IsEqual 方法再来判断是否 extends true or false
IsEqual<T, A> extends true ? true : false
条件类型绝大部分场景下会和泛型一起使用,我们知道,泛型参数的实际类型会在实际调用时才被填充,而条件类型在这一基础上,可以基于填充后的泛型参数做进一步的类型操作,比如这个例子:
type Test<T> = T extends string ? "string" : "other";
分布式条件类型
在前面我们也提到了分布式条件类型,听起来很高级,其实是条件类型的分布式特性,当条件类型满足一定情况下会执行的逻辑而已。
例如下面的例子
type Condition<T> = T extends 1 | 2 ? T : never;
// 1 | 2
type Res = Condition<1 | 2 | 3 >;
// never
type Res2 = 1 | 2 | 3 extends 1 | 2 ? 1 | 2 : never
这个例子可能会让你有疑惑,看起来他们返回的结果应该是一样的,但是 Res 返回的是联合类型,而 Res2 返回的 never
这是因为触发了分布式特性,将联合类型拆开来,每个分支分别进行一次条件类型判断。
官方的解释是:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。
自动分发也就是下面这样,将联合类型拆开来
type Test<T> = T extends boolean ? 'A' : 'B'
// 'A' extends U ? never : T | 'B' extends U ? never : T
type A = Test<'A' | 'B'>
而这里的裸类型参数,其实指的就是泛型参数是否完全裸露,简而言之裸类型就是未经过任何其他类型修饰或包装的类型。
而我们可以通过下面这些方式让参数不是裸类型参数
type NoDistribute<T> = T & {};
// Promise [] enum
因为我们并不只需要利用裸类型参数来确保分布式特性能够触发,有时候我们也需要禁用掉分布式特性。
例如前面联合类型的场景,我们不希望进行分布式判断,想要直接判断两个联合类型的兼容性
type CompareUnion<T, U> = [T] extends [U] ? true : false;
type CompareRes = CompareUnion<1 | 2 | 3, 1 | 2 >; // false
通过讲参数和条件包裹的方式,我们对联合类型的比较就变成了数组成员类型的比较,在此时就会严格遵守类型层级一文中联合类型的类型判断了
在类型体操中,我们经常会遇到判断一个类型是否为 never 的问题,第一反应可能是
type IsNever<T> = T extends never ? true : fasle
但是这样是不可以的,因为 never 属于 bottom type,它是所有类型的基类型,当泛型参数为 never 时,会直接返回 never,跳过判断,因此上面的方式是不正确的。
我们需要用包裹的手段来处理
type IsNever<T> = [T] extends [never] ? true : false;
类型收窄
类型断言
// 类型断言 —— 不飘红,但执行时可能错误
(value as Number).toFixed(2)
类型守卫
// 2、类型守卫 —— 不飘红,且确保正常执行
if (typeof value === 'number') {
// 推断出类型: number
value.toFixed(2);
}
typeof、instanceof、in、=== 都可以做类型守卫
never
never 表示的是空类型,永不存在的类型。
有两种可能,一种是 throw error 一种是死循环,程序一直不会运行到返回的时候
// 异常
function err(msg: string): never {
throw new Error(msg)
}
// 死循环
function loop (): never {
while(true) {}
}
bottom type
never 可以表示任何类型的子类型,所以可以赋值给任何类型
let neverValue: never
let num = 4;
num = neverValue
unknown 可以理解为 全集,never 是空集,任何类型都包含了 never
null、undefined
null 和 undefined 也可以表示任何类型的子类型。但是和 never 不同,never 除了本身以外,没有任何类型是它的子类型,不能把值赋给 never 类型的除了 never 可以。
never 的使用
never 相关的讨论:www.zhihu.com/question/35…
- 可以用来做类型保护,只有 never 才能赋值给 never,那么如果有一天值变成非 never 了,就会 ts 提示。
- Unreachable code 检查:标记不可达代码,获得编译提示。
- 类型运算:作为类型运算中的最小因子。
- Exhaustive Check:为复合类型创造编译提示。
最小因子
T | never => T
T & never => never
可以用来简化类型运算
type NullOrUndefined = null | undefined
type NonNullable<T> = T extends NullOrUndefined ? never : T
// 运算过程
type NonNullable<string | null>
// 联合类型被分解成多个分支单独运算
=> (string extends NullOrUndefined ? never : string) |
(null extends NullOrUndefined ? never : null)
// 多个分支得到结果,再次联合
=> string | never
// never 在联合类型运算中被消解
=> string
React.FC
React.FC 在实际代码中没什么作用,但还会有很多缺点,具体可以看 github.com/facebook/cr…
逆变和协变
在 Typescript 中,类型系统提供了逆变和协变两种类型变换方式,以实现更加灵活的类型推导和组合。
逆变和协变都是术语,在其他的编程语言中也有类似的概念。
在网上官方的解释
协变是指:能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型
逆变是指:能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型
简单来说:协变允许子类型转换为父类型,逆变允许父类型转换为子类型
逆变
逆变是指子类型关系随着类型参数的变化而相反的情况。换句话说,如果类型 A 是类型 B 的子类型,那么在逆变情况下,对于某个泛型类型 F,F<B>
是 F<A>
的子类型。逆变通常出现在函数参数类型中。可以看下面的例子
type Callback<T> = (value: T) => void;
function contravariantFunction(callback: Callback<number>): void {
callback(42);
}
const callback: Callback<object> = (value: object) => console.log(value);
contravariantFunction(callback); // 输出:42
在这个例子中,我们定义了一个泛型回调类型 Callback<T>
,并创建了一个接受 Callback<number>
类型参数的函数 contravariantFunction。当我们将一个 Callback<object>
类型的回调传递给这个函数时,程序可以正常运行,并输出 42。
这是因为在逆变情况下,函数参数类型的子类型关系是相反的。在这个例子中,number 类型是 object 类型的子类型,因此 Callback<number>
类型是 Callback<object>
类型的子类型。
这意味着我们可以将 Callback<object>
类型的回调函数安全地传递给 contravariantFunction 函数。
这个要如何理解呢?我们用一个例子来说
假如有如下三种类型:Greyhound < Dog < Animal
问题:以下哪种类型是 Dog → Dog 的子类型呢?「Dog => Dog」 表示「参数为 Dog,返回值为 Dog 的函数」
- Greyhound → Greyhound
- Greyhound → Animal
- Animal → Animal
- Animal → Greyhound
我们从参数传入和返回两部分来看
对于参数传入部分:只能保证传入 Dog 类型参数,所以当我们定义 Animal 类型时,是可以保证的
对于返回部分:保证只使用 Dog 的方法或属性,那么类型 Greyhound 的方法 Dog 都有。
因此我们可以得到 (Animal -> Greyhound) ≼ (Dog -> Dog)
返回值类型很容易理解:Greyhound 是 Dog 的子类型。但参数类型则是相反的:Animal 是 Dog 的父类!
也称 参数是逆变的,返回值是协变的。
协变
协变是指子类型关系随着类型参数的变化而保持一致的情况。也就是说,如果类型 A 是类型 B 的子类型,那么对于某个泛型类型 F,F<A>
是 F<B>
的子类型。协变通常出现在函数返回值类型和只读属性类型中。
type Producer<T> = () => T;
function covariantFunction(producer: Producer<object>): void {
const value: object = producer();
console.log(value);
}
const producer: Producer<number> = () => 42;
covariantFunction(producer); // 输出:42
在 TS 中协变逆变的概念很少见,因为 TypeScript 中只有函数的参数才是逆变的,仅有一处。
未完待续….
学习资料
转载自:https://juejin.cn/post/7254792161072660540