TypeScript 中的泛型类型保护
在 TypeScript 中编写通用类型保护器,以及我从中学到的东西
在 TypeScript 中编写通用类型保护器,以及我从中学到的东西
介绍
我最近在工作中遇到了一个问题,这个问题源于一个函数假定它的输入是一种类型,而实际上它有时可能是另一种类型。
我最初尝试解决这个问题的方法是确定输入可能的类型,然后修改函数声明,使输入类型为所有可能类型的并集,最后在函数内部使用类型守卫。例如,对于以下函数:
export function myFunc(a: TypeA[]): void {
// ...
}
并将其重构为:
export function myFunc(a: TypeA[] | TypeB[]): void {
if (a.every(e => e instanceof TypeA)) {
// ...
} else {
// ...
}
}
这让我想要编写一个通用的类型守卫。这样,在数组中使用它就非常简单:a instanceof Array && a.every(typeGuard<T>)。
但是这是什么呢typeGuard<T>?嗯,我已经为上面的示例中的一些元素编写了类型守卫TypeA,所以通用的类型守卫可以简单地包装对的调用instanceof。我们稍后会看到一个更复杂的实现。现在,我们有:
export function typeGuard<T>(o: any): o is T {
return o instanceof T;
}
然而,这样做会报错:'T' only refers to a type, but is being used as a value here.
问题在于类型T并非总能在运行时获取,因为它可能是一个接口——底层 JavaScript 无法访问这种结构。这意味着编写通用的类型守卫来区分接口是行不通的——尽管可以为特定接口编写非通用的类型守卫。但这种方法对类是有效的:
class myClass {}
function classTypeGuard(object: any): boolean {
return object instanceof myClass;
}
即使我们不试图对 进行泛化T,也会得到同样的错误——上面的代码片段e instanceof TypeA会给出同样的错误,即TypeA只能引用一个类型。
那么,我们如何将要检查的类型传递给函数object呢?对于像上面这样的类myClass,我们希望将myClass其自身传递给函数,如下所示:
function typeGuard(o, className) {
return o instanceof className;
}
const myClassObject = new myClass();
typeGuard(myClassObject, myClass); // returns true
引入构造函数类型签名
上述方法可行,但我们没有对className变量指定任何类型限制。类似这样的代码typeGuard(myClassObject, 5)不会引发错误,但会导致运行时异常TypeError: Right-hand side of 'instanceof' is not an object。我们需要对className`a` 的类型添加限制,使其只能用于 `a` 右侧的对象instanceof。此限制源于 JavaScript 中 `a` 的定义,其中对象必须是某种类型的构造函数。我们可以通过如下方式instanceof指定 `a` 的类型来实现:className
type Constructor<T> = { new (...args: any[]): T };
function typeGuard<T>(o, className: Constructor<T>): o is T {
return o instanceof className;
}
const myClassObject = new myClass();
typeGuard(myClassObject, myClass); // returns true
typeGuard(myClassObject, 5); // Argument of type '5' is not assignable to parameter of type 'Constructor<{}>'
让我们来解读一下这里的内容:我们声明了一个新类型——Constructor<T>它有一个方法new,该方法接受任意数量的参数(包括零个参数),并返回一个类型的实例T。这正是我们需要的限制,以便能够className与一起使用instanceof。
扩展类型守卫以使其适用于基本类型
目前为止,我们所做的只是instanceof用另一个函数进行包装,尽管类型看起来很漂亮。我们也希望能够实现类似这样的功能:
typeGuard(5, 'number'); // true
typeGuard('abc', 'number'); // false
这里我们需要做的是扩展我们正在使用的参数类型myClass,使其类似于这样:type PrimitiveOrConstructor<T> = Constructor<T> | 'string' | 'number' | 'boolean'。
我们来尝试使用这种新类型:
type PrimitiveOrConstructor<T> =
| Constructor<T>
| 'string'
| 'number'
| 'boolean';
function typeGuard<T>(o, className: PrimitiveOrConstructor<T>): o is T {
if (typeof className === 'string') {
return typeof o === className;
}
return o instanceof className;
}
class A {
a: string = 'a';
}
class B extends A {
b: number = 3;
}
console.log(typeGuard(5, 'number'), 'is true');
console.log(typeGuard(5, 'string'), 'is false');
console.log(typeGuard(new A(), A), 'is true');
console.log(typeGuard(new A(), B), 'is false');
console.log(typeGuard(new B(), A), 'is true');
console.log(typeGuard(new B(), B), 'is true');
console.log(typeGuard(new B(), 'string'), 'is false');
让我们来看一下 typeGuard 的新实现:className现在,它要么是一个Constructor<T>函数,要么是一个字符串,其值被限制为'string'`null`、'number'`undefined` 或 `undefined`之一'boolean'。如果它是一个字符串(严格来说,如果它的类型是 `null` 'string' | 'number' | 'boolean'),那么typeof className === 'string'`true` 将为真,此时类型守卫将基于`null`typeof而不是`undefined` instanceof。请注意, `typeGuard`if检查的className是 `undefined` 的类型(如果是函数则为 `'function'` Constructor<T>,其他情况下为 `'string'`),而类型守卫本身是将我们要保护的对象的类型与 `undefined`的实际值className进行比较。
typeGuard然而,问题依然存在。当我们检查对象是否为原始类型时,`f`的返回类型是错误的。请注意, typeGuard`f` 的返回类型是 ` None` o is T。这T是因为Constructor<T>如果className`f` 的类型是 `None`,但如果不是,则 `f`T会被解析为 `None` {},这意味着对于原始类型,我们的类型守卫是错误的:
function typeDependent(o: any) {
if (typeGuard(o, 'number')) {
console.log(o + 5); // Error: Operator '+' cannot be applied to types '{}' and '5'
}
}
我们可以通过手动告知编译器该内容来纠正这个问题T,如下所示:
function typeDependent(o: any) {
if (typeGuard<number>(o, 'number')) {
console.log(o + 5); // o is number, no error
}
}
但我们希望typeGuard`T` 的返回类型能够从 `T` 的值推断出来className。我们需要使用类型 `T`PrimitiveOrConstructor<T>来保护 ` T | string | number | booleanT`。首先,只有当我们要保护的类型不是原始类型时,才应该推断类型 `T`。我们将创建一个新的非PrimitiveOrConstructor泛型 ` T` ,然后使用该类型来推断它所保护的类型。
type PrimitiveOrConstructor =
| { new (...args: any[]): any }
| 'string'
| 'number'
| 'boolean';
PrimitiveOrConstructor在非原始类型的情况下,创建对象的类型没有明确指定,因为可以通过解析对象所保护的类型来推断:
type GuardedType<T extends PrimitiveOrConstructor> = T extends { new(...args: any[]): infer U; } ? U : T;
现在,如果我们想要添加类型守卫的类型是 `T` aClass,那么GuardedType<aClass>`T` 解析为 `T` aClass。否则,如果我们设置`T`T为`T`,那么 `T`就只是`T`,而不是类型 ` T` 。我们仍然需要能够将类似 `T` 的字符串值映射到相应的类型,为此,我们将引入`T`、`T` 和索引类型。首先,我们将使用类型映射创建一个从字符串到类型的映射:'string'GuardedType<'string'>'string'string'string'keyof
interface typeMap { // can also be a type
string: string;
number: number;
boolean: boolean;
}
现在,我们可以用keyof typeMap它来引入'string' | 'number' | 'boolean'我们的PrimitiveOrConstructor,并索引到以获取原始情况下的typeMap适当类型:GuardedType
type PrimitiveOrConstructor =
| { new (...args: any[]): any }
| keyof typeMap;
type GuardedType<T extends PrimitiveOrConstructor> = T extends { new(...args: any[]): infer U; } ? U : T extends keyof typeMap ? typeMap[T] : never;
这里有几点需要注意:
keyof是一个关键字,它接受一个类型并返回该类型所有属性名称的联合。在我们的例子中,它keyof typeMap正是我们需要的:'string' | 'number' | 'boolean'。这就是为什么 `T` 的属性名称typeMap与其类型相同(例如,字符串属性的类型为 `T`string,`T` 和 `numberT`也是如此boolean)。GuardedType<T>现在使用嵌套三元运算if符:我们首先检查我们正在保护的类型是否有构造函数(T是提供构造函数的类型,U是该构造函数实际创建的类型 - 它们可能相同),然后我们检查是否T是原始类型之一,在这种情况下,我们使用它来索引我们的类型typeMap,并从'string'到string。- 如果这两个条件都不满足,
never则在最后一个分支中使用该类型,因为我们永远不会到达那里。 - 其实完全可以避免第二种方法
if,直接这样做:
type GuardedType<T extends PrimitiveOrConstructor> = T extends { new(...args: any[]): infer U; } ? U : typeMap[T];
但我们遇到了这个错误:Type 'T' cannot be used to index type 'typeMap'.当T`T` 不是构造函数类型时,编译器仍然无法将其范围缩小T到 `T` keyof typeMap,因此告诉我们不能安全地使用 `T`T作为 `T` 的索引typeMap。我们稍后还会遇到这个问题,这是一个值得一提的未解决的问题。我将在附录中对此进行详细说明。
既然我们已经GuardedType为给定的正确定义了T extends PrimitiveOrConstructor,我们可以回到 的实现typeGuard:
function typeGuard<T extends PrimitiveOrConstructor>(o, className: T):
o is GuardedType<T> {
if (typeof className === 'string') {
return typeof o === className;
}
return o instanceof className;
}
我们的className参数现在的类型是 `T` T extends PrimitiveOrConstructor,因此GuardedType<T>可以解析为我们想要保护的实际类型——类或基本类型。但是,我们还没有完成,因为最后一行代码报错了:
return o instanceof className; // The right-hand side of an 'instanceof' expression must be of type 'any' or of a type assignable to the 'Function' interface type.
这里的问题与定义时发生的情况类似GuardedType。这里,函数体中始终className是 `T` 类型T extends PrimitiveOrConstructor,即使我们希望它在`WHERE` 子句'string' | 'number' | 'boolean'内部缩小为if`T`,在 ` WHERE` 子句new (...args: any[]) => any之后缩小为 `T`。正确的做法是,将 `T` 赋值className给一个类型为 `T` 的局部变量PrimitiveOrConstructor,并使用该变量,因为它的类型会被编译器缩小:
function typeGuard<T extends PrimitiveOrConstructor>(o, className: T):
o is GuardedType<T> {
// to allow for type narrowing, and therefore type guarding:
const localPrimitiveOrConstructor: PrimitiveOrConstructor = className;
if (typeof localPrimitiveOrConstructor === 'string') {
return typeof o === localPrimitiveOrConstructor;
}
return o instanceof localPrimitiveOrConstructor;
}
把所有东西整合起来
呼,感觉信息量有点大。让我们把所有内容整合起来,以便更好地理解整体情况:
interface typeMap { // for mapping from strings to types
string: string;
number: number;
boolean: boolean;
}
type PrimitiveOrConstructor = // 'string' | 'number' | 'boolean' | constructor
| { new (...args: any[]): any }
| keyof typeMap;
// infer the guarded type from a specific case of PrimitiveOrConstructor
type GuardedType<T extends PrimitiveOrConstructor> = T extends { new(...args: any[]): infer U; } ? U : T extends keyof typeMap ? typeMap[T] : never;
// finally, guard ALL the types!
function typeGuard<T extends PrimitiveOrConstructor>(o, className: T):
o is GuardedType<T> {
const localPrimitiveOrConstructor: PrimitiveOrConstructor = className;
if (typeof localPrimitiveOrConstructor === 'string') {
return typeof o === localPrimitiveOrConstructor;
}
return o instanceof localPrimitiveOrConstructor;
}
为了测试一下,我们使用和之前一样的例子,只不过现在类型保护将真正生效,并根据情况给出 `T` string、number`C`A或B`D` 等值:
class A {
a: string = 'a';
}
class B extends A {
b: number = 5;
}
console.log(typeGuard(5, 'number'), 'true'); // typeGuard<"number">(o: any, className: "number"): o is number
console.log(typeGuard(5, 'string'), 'false'); // typeGuard<"string">(o: any, className: "string"): o is string
console.log(typeGuard(new A(), A), 'true'); // typeGuard<typeof A>(o: any, className: typeof A): o is A
console.log(typeGuard(new B(), A), 'true');
console.log(typeGuard(new A(), B), 'false'); // typeGuard<typeof B>(o: any, className: typeof B): o is B
console.log(typeGuard(new B(), B), 'true');
console.log(typeGuard(new B(), 'string'), 'false');
总之
综上所述,我意识到,对于特定情况,使用 `*` 进行测试几乎总是更简单;instanceof对于具有用户定义类型守卫的接口,使用 `*` 进行测试几乎总是更简单;对于基本类型,使用 `*` 进行测试几乎总是更简单typeof。
我从自己尝试解决这个问题的过程中学到了很多,尤其是从 StackOverflow 上一位用户的回答中受益匪浅。本文主要分析了他的回答并解释了其中的各个部分。要理解这个实现步骤,需要掌握 TypeScript 的类型系统、泛型、类型守卫、诸如`and`和 `or`jcalz之类的实用关键字、联合类型和索引类型。keyofinfer
来源
StackOverflow 上关于尝试对泛型类型调用 instanceof 的回答
附录
当我们T extends PrimitiveOrConstructor在两者GuardedType中都使用时typeGuard,我们发现关于 's 类型的条件T(例如扩展构造函数与扩展keyof typeMap)并不能帮助编译器缩小T's 的类型范围,即使我们将 定义PrimitiveOrConstructor为构造函数类型或 的有效属性名称typeMap。
在定义检查构造函数类型的GuardedType分支时,尽管 `<T>` 是唯一其他选项,但else我们无法对其进行索引。在函数实现中,我们尝试反向操作——我们检查了 ` <T>`,它涵盖了 `<T>` 的情况,但在该子句之外,它并没有被缩小到构造函数类型。typeMapTtypeGuardtypeof className === 'string'T extends keyof typeMapT
为了定义GuardedType,我们需要显式地编写第二个三元运算符,if以便编译器知道,T extends keyof typeMap从而可以将类型解析为typeMap[T]。为了实现typeGuard,我们需要将className(类型为T extends PrimitiveOrConstructor)赋值给一个类型为的局部变量PrimitiveOrConstructor。该变量的类型会根据需要在子句'string' | 'number' | 'boolean'内部缩小为if,在子句new (...args: any[]) => any之后缩小为。
这两个问题都在于,它T是一个扩展了联合类型的泛型类型PrimitiveOrConstructor。截至目前(2019年4月7日),这仍然是一个未解决的问题。幸运的是,StackOverflow 上的一个回答也提到了这一点jcalz。