类型系统特性
强大的类型系统特性简介。
文章中使用了许多特性的两个示例,用于在类型系统中递增数字
目录
特点
引言
我听说过开发者将 TypeScript 称为“带类型的 JavaScript”,一开始我也这么认为。虽然从广义上讲这在某种程度上是正确的,但你获得的类型系统比表面看起来要丰富得多。它提供了基本的逻辑构造、对象转换以及许多可能非预期的技巧,让你能够比大多数人习惯的流行的语言更加富有表现力。
基本类型
乍一看,TypeScript 类型系统在基本类型方面看起来像下面这样
这看起来可能很复杂,但如果你仔细看一会儿,就会发现它很有意义。关系可以读作“undefined
扩展 void
”或者等同地说“undefined
可赋值给 void
”。快速总结
any
是通用类型。它既是顶类型,也是底类型,并且包含其间的所有类型。unknown
是顶类型。所有类型都可赋值给unknown
。never
是底类型。never
可赋值给所有类型。- 字面量扩展其非字面量对应项(例如,
true
扩展boolean
,3
扩展number
)。 - 复杂的基类型扩展
object
(例如,function
、tuple
、enum
)
不同的仅线箭头用于存在一些显著异常的情况,例如数组和元组之间的可赋值性。请记住,虽然这些可能看起来像继承关系,但这不一定如此,因为 TypeScript 使用结构化类型系统。
class A { A: string; } class B { A: string; }
var a: A extends B ? true : false; //true
a
的类型是 true
,尽管 A
和 B
在层级上是不相关的。这是因为它们具有相同的结构。我认为从集合的角度思考要容易得多
class A { A: string; }
class B { A: string; }
class C { A: number; }
class D { A: number; B: string; }
//or equivalently
class D extends C { B: string; }
从集合的角度来看,类型可以被认为是所有可能属性的通用集合 U
的命名分组,其中属性是与类型关联的名称。因此 A: number
和 A: string
是不同的属性,A: string
和 B: string
也是。如果一个类型是另一个类型的超集,那么它就扩展另一个类型。所以上面的例子中,D
扩展 C
。如果超集关系不是严格的超集,那么两种类型互相扩展——A
扩展 B
,B
扩展 A
。这是因为它们实际上是同一类型的不同名称,因为它们具有完全相同的属性集。
无论如何,这都是有意义的,重要的是它有意义,因为其余的特性需要理解类型之间是如何相互关联的。
特点
A extends B ? True-branch : False-branch
这等同于一个三元 if
,条件是 A
到 B
的赋值兼容性。此外,在 true-branch 中,A
的类型具有 B
的附加类型约束,类似于 类型守卫 的工作方式。如果 A
是一个裸类型参数(只是一个单独的类型变量)并且是一个联合类型,那么该联合类型会分布到 extends
上,并将结果联合起来。例如
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
type example<T> = T extends B ? true : false;
var a: example<A|C>; //a has the type boolean (i.e. true | false)
上面发生的原因是,分布后我们得到 (A extends B ? true : false)|(C extends B ? true : false)
,它简化为 false | true
。请注意,在以下情况中不会发生这种情况:
var b: A|C extends B ? true : false; //b has the type false.
由于我们不再有类型参数,因此不会发生分布。也请考虑以下情况:
type example2<T> = [T] extends [B] ? true : false;
var c: example2<A|C>; //c has the type false.
var d: example2<C>; //d has the type true.
由于我们在元组类型([T]
)中使用类型参数,所以类型参数不再是裸露的,因此不会发生分布。
推断的类型变量是临时的类型变量,类似于类型参数,但它们绑定到 true-branch 的作用域,并由上下文自动赋值。它们的类型尽可能窄/具体(安全地推断),否则所有推断都会轻易地解析为 any
或 unknown
。
type example<T> = T extends infer U ? U : never;
var a: example<boolean>; //boolean
一个更复杂的例子
type example<T extends [...any]> = T extends [...infer _, infer A] ? A : T;
var a: example<[boolean, null, undefined, number]>; //number
var b: example<[void]>; //void
var c: example<[]>; //unknown
这使用了推断的类型变量来返回元组中的最后一个类型。请注意,我们实际上在这里说的是“我们能否从 T
中成功推断出 _
和 A
的类型,使其与给定的签名相匹配?”推断语句的签名可能对结果变量的类型产生重要影响
type example<T extends [...any]> = T extends (infer A)[] ? A : never;
var a: example<[string, number]>; //string|number
type example2<T extends [...any]> = T extends [...infer A] ? A : never;
var b: example2<[string, number]>; //[string, number]
这些之间的主要区别在于 (infer A)[]
是一个可变长度数组签名,而 [...infer A]
是一个固定长度数组签名(即元组)。这表明第一个示例中的结果类型信息被泛化为联合类型,因为它是从可变长度数组签名推断出来的,尽管输入是更窄的元组类型。这种位置类型信息的丢失是因为可变长度数组签名没有位置的概念——只有哪些类型对它的所有元素(而不是每个元素)是有效的。
extends
的一个限制是它不允许清晰的 switch 风格的分支。我们可以通过利用这样一个事实来解决这个问题:如果你索引一个对象定义,你得到的是关联属性的类型,这有效地将对象定义变成了一个 switch 语句
type PickOne<Choice extends 0 | 1 | 2 | 3> =
{
0: 'Uhhh...',
1: 'Odd',
2: 'Even',
3: 'Odd'
} [Choice];
记住,字面量是一种类型!如果我们只使用 extends
,它看起来会像这样
type PickOne<Choice extends 0 | 1 | 2 | 3> =
Choice extends 0 ?
'Uhhh...'
: Choice extends 1 ?
'Odd'
: Choice extends 2 ?
'Even'
: Choice extends 3 ?
'Odd'
: never;
有点乱,对吧?对象索引器还支持任何产生有效索引的类型表达式——不仅仅是简单的类型参数
type Nullable<T> =
{
0: T | null,
1: T
} [T extends string | object ? 0 : 1];
一个有点傻的例子,但它展示了这一点。另一个很酷的功能是支持递归
type digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type DigitsAfter<X extends digit, Current = never> =
{
0: DigitsAfter<1, 1>,
1: DigitsAfter<2, Current | 2>,
2: DigitsAfter<3, Current | 3>,
3: DigitsAfter<4, Current | 4>,
4: DigitsAfter<5, Current | 5>,
5: DigitsAfter<6, Current | 6>,
6: DigitsAfter<7, Current | 7>,
7: DigitsAfter<8, Current | 8>,
8: Current | 9,
9: never
} [X];
递归只要不能无限递归就会起作用。如果类型使用不当,或者递归属性有问题,则会出现错误,如果可能发生无限递归。有时这可能对类型系统接受的内容有些挑剔,但如果您有一个清晰的基线和浅层递归,它通常效果最好。
方差本身在 TypeScript 中并没有什么不同。预期的标准内容都在这里
class A { A: string; } class B extends A { B: string; } class C extends B { C: string; } let f1: ()=>A = () => new B(); //Covariant with respect to return types let f2: (_: B)=>void = (_: A) => {}; //Contravariant with respect to parameter types let f3: (_: B)=>B = (_: A) => new C(); //Mixed variance let f4: (_:B)=>void = (_:C) => {}; //Wait... what? Bivariant parameter type?
如果设置了 "strictFunctionTypes":false
tsconfig 选项,则标准内容有一个大的补充——函数参数类型是双变的。在我看来,除了在双变函数上下文中显式过滤内容外,它不太有用,但如果出现需要,了解它仍然很好。
//Note: requires '"strictFunctionTypes": false' to be set in tsconfig
class A { A: string; }
class B extends A { B: string; }
class C extends B { C: string; }
class D { D: string; }
class E extends D { E: string; }
type GetTypesRelatedTo<T, options> =
T extends any ? //distribute T if it's a union to handle each type separately
options extends any ? //distribute options
//compare each option to T in a bivariant context
((_:options)=>void) extends ((_:T)=>void) ?
options
: never
: never
: never;
let test: GetTypesRelatedTo<B, A | B | C | D | E>; //returns B | A | C
联合和交叉的行为与基类和派生类非常相似。与继承一样,可赋值性和功能属性彼此相反。“向上”朝向更基本的类型,可赋值性更大,但功能性较低;而“向下”朝向更派生的类型,可赋值性较低,但功能性更高。
从这个角度来看,联合是相对于其组成类型的基类型,而交叉是相对于其组成类型的派生类型。我们可以验证这个类比是否成立
class A { A: string; }
class B { B: string; }
class AB { A: string; B: string; }
let x: A | B;
//Testing assignability
x = new A();
x = new B();
x = new AB();
//Testing functionality
x.A; //Error, property A does not exist on type A | B
x.B; //Error, property B does not exist on type A | B
let y: A & B;
//Testing assignability
y = new A(); //Error, type A is not assignable to type A & B
y = new B(); //Error, type B is not assignable to type A & B
y = new AB();
//Testing functionality
y.A;
y.B;
这个类比对于讨论函数的联合和交叉特别方便。例如,尝试在将以下内容强制转换为 T=>U
形式时填写“???”
(a: ???)=>??? = ((a: string)=>string) | ((a: number)=>number)
(a: ???)=>??? = ((a: string)=>string) & ((a: number)=>number)
要解决这个问题,首先让我们确定返回值,因为这是一个简单的起点
- 函数联合自然会有一个返回类型,该类型是函数返回类型的联合。
- 函数交叉自然会有一个返回类型,该类型是函数返回类型的交叉。
这导致了一个有些令人惊讶的结论,即考虑赋值时;强制转换为 T=>U
形式的结果并不严格等同。相反,强制转换函数联合会创建一个更基本的类型,而强制转换函数交叉会创建一个更派生的类型。
//If this was reversed, (string | number) wouldn't be assignable to string due to possibility
//of getting a number, and it wouldn't be assignable to number due to the possibility of getting
//a string.
(a: ???)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)
//If this was reversed, string wouldn't be assignable to (string & number) due to not having
//the properties of number, and number wouldn't be assignable to it either due to not having
//the properties of string.
((a: string)=>string) & ((a: number)=>number) = (a: ???)=>(string & number)
旁注:尽管只讨论了类型之间的可赋值性,但记住类型的目的是描述具体的表示。在确定什么应该兼容时,必须考虑这个事实。虽然联合示例相对直观,但我们甚至在交叉示例中考虑单个赋值可能令人惊讶。这是因为在实际操作中,函数交叉代表了具体层面的函数重载,因此具体结果仅来自一个函数。这就是为什么 string=>string & number=>number
不可赋值给 ???=>(string & number)
。其中只有一个值将被返回,其返回值将不满足更派生的 (string & number)
。以下是这些属性的一个示例
class Overload {
static test(a: string): string;
static test(a: number): number;
static test(a: string | number) { return a; }
}
//Behavior of the function overload
let overloadTest1 = Overload.test("a"); //variable type is string
let overloadTest2 = Overload.test(5); //variable type is number
//Behavior of the function intersection (concretely backed by the function overload)
let overloadFunc: ((a:string)=>string) & ((a:number)=>number) = Overload.test;
let overloadTest3 = overloadFunc("a"); //same result as overloadTest1
let overloadTest4 = overloadFunc(5); //same result as overloadTest2
现在我们需要确定原始示例的参数类型。对于联合,我们想要一个更派生的(参数的逆变),所以我们将选择 string & number
,因为它可赋值给 string
或 number
。对于交叉,我们想要一个不那么派生的,因为我们现在在赋值的另一侧工作,所以我们将选择 string | number
,因为 string
和 number
都可赋值给它。
(a: string & number)=>(string | number) = ((a: string)=>string) | ((a: number)=>number)
((a: string)=>string) & ((a: number)=>number) = (a: string | number)=>(string & number)
总之,将函数交叉强制转换为 T=>U
形式会创建一个更派生的类型,而对函数联合执行相同操作会创建一个更基本的类型。以下演示了我们刚才得出的所有结论
class W { w: string; }
class U { u: string; }
declare const Inter: ((a: W)=>W) & ((a:U)=>U);
declare const TU_Inter: (a: W | U)=>(W & U);
let ex1: typeof Inter = TU_Inter;
let ex2: typeof TU_Inter = Inter; //Error
declare const Union: ((a: W)=>W) | ((a:U)=>U);
declare const TU_Union: (a: W & U)=>(W | U);
let ex3: typeof Union = TU_Union; //Error
let ex4: typeof TU_Union = Union;
在下面的示例中,我们将逐步分析代码,解释如何利用函数联合和交叉的属性来提取联合中的最后一个元素(请参阅本节末尾的注释)——这在表面上似乎是不可能的,除非已经知道要区分联合的元素类型。
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
//Function union coerced into the form V=>void.
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
V
: never
: never;
//Function intersection coerced into the form ()=>U.
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
让我们从头开始分解。
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
我们在这里所做的是在 T 是联合类型时将其分布,然后返回一个函数联合,其中 T 的每个元素都位于返回类型位置。我们将此结果推断为 U
,以便有一个简单的类型变量供以后引用。这也意味着下一行代码将正确地分布我们刚刚创建的联合。
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
这里我们正在分布函数联合 U
,然后返回另一个函数联合,其中 U 的每个元素都位于参数类型位置。例如,如果我们将类型 A | D
作为 T
开始,则 U
将是 ()=>A | ()=>D
,现在签名将是 (_:()=>A)=>void | (_:()=>D)=>void
。看起来很荒谬,但这都是为下一步做准备。
所以当我们到达 extends (_: infer V) => void
时,乍一看我们可能会认为我们会得到原始的 U
,因为我们在将 U 的元素移入的同一位置推断一个类型变量。然而,类型的分布仅发生在表达式是简单类型变量的情况下。extends
前面的复杂表达式不是一个简单的类型变量。那么,如果复杂表达式是 (_:()=>A)=>void | (_:()=>D)=>void
,V
会被推断为什么呢?
//A reminder of the current state of the expression we're exploring
((_:()=>A)=>void | (_:()=>D)=>void) extends (_: infer V) => void
如果你还记得本节早些时候的联合讨论,将函数联合强制转换为 T=>U
形式会导致参数类型被交叉,因此 V
被推断为 ()=>A & ()=>D
。
现在让我们看一下最后一行,看看我们为什么费了这么大的劲才将联合转换为函数交叉。
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
这里我们正在接受函数交叉并将类型变量推断为其返回类型。()=>A & ()=>D
的返回类型是什么?同样,如果你回忆起之前的讨论,将函数交叉强制转换为 T=>U
形式会导致返回类型被交叉,因此 U
被推断为 A & D
。但请记住,使用 extends
时,可赋值性是从左到右检查的。我们正在反向进行!我们解决了 T=>U
形式更派生的普遍情况,而在这里它需要更不派生。
()=>U = (()=>A) & (()=>D)
由于没有参数,我们只需要考虑标准协变角度的答案。但这会遇到一个小问题,因为交叉是可赋值给其组成类型的——A
和 D
都适用于 U
。这里没有逻辑上最好的解决方案。只能选择一个,所以 TypeScript 决定总是返回交叉中的最后一个返回类型。因此,U
被推断为 D
。这也带来了副作用,即交叉在 TypeScript 中不是严格可交换的(尽管在许多上下文中它们看起来是)。
declare const F1: ((a:string)=>string) & ((a:number)=>number);
declare const F2: ((a:number)=>number) & ((a:string)=>string);
//Non-commutative
type F1Last = typeof F1 extends (_:any)=>(infer L) ? L : never; //number
type F2Last = typeof F2 extends (_:any)=>(infer L) ? L : never; //string
//Another example of how intersections represent overloading.
F1('a'); //in my editor, this shows as "(a:string)=>string (+1 overload)".
F1(1); //this shows as "(a:number)=>number (+1 overload)".
//Same results as above if you use F2
这总结了所有的黑魔法。我们构建了一个看起来很荒谬的函数签名,以便通过利用函数联合的参数类型推断以及函数交叉的返回类型推断的独特属性来将其解开,从而从联合中获取最后一个类型。
重要提示:实际中联合通常是良好排序的,但 TypeScript 规范不要求如此。因此,只能假定 Last<T>
将从联合中给出某个类型,而不是最后一个类型,即使在实践中后者大多数时候都是正确的。这是因为用于检索最后一个类型的交叉是基于联合的,因此联合排序的变化会改变结果交叉的排序。
分解是将元组分解为其各个部分的过程。实现此目的的最简单方法是使用推断,并使用一个元组签名,该签名使用剩余参数来处理未显式处理的元素。
type FirstElementOf<Tuple> = Tuple extends [infer E, ...infer _] ? E : never;
type LastElementOf<Tuple> = Tuple extends [...infer _, infer E] ? E : never;
您也可以在此使用函数上下文
type FirstElementOf<Tuple> =
(Tuple extends any[] ? (...a:Tuple)=>void : never) extends (a:infer E, ...b:infer _)=>void ?
E
: never;
函数不支持参数列表开头的剩余参数,因此无法通过这种方式实现 LastElementOf<Tuple>
。此方法不比更清晰、更容易的第一种方法有任何优势;它只是一个有趣的脚注。
正如我们在前面的章节中看到的,extends
子句在创建使用现有类型的新类型时非常有用。例如,A extends any ? ()=>A : never
或 A extends any ? { a: A } : never
。TypeScript 还提供了一种机制,可以通过使用现有类型的属性而不是整个类型来创建新类型——映射类型。
//Basic structure
{
[ <propertyName> in <properties> as <propertyRemap> ]: <propertyType>
}
propertyName
是一个变量,它将保存从集合 properties
返回的每个元素。它的功能与 JavaScript 的 for...of
循环相同。propertyRemap
是一个可选表达式,您可以在其中将 propertyName
转换为其他有效值,用作属性索引。propertyType
是新属性的类型。让我们看一些基本示例,了解所有这些有什么用。
以下示例在映射类型中使用内置的 Lowercase
实用类型来创建一个新类型,该类型是旧类型,但所有属性名称都已转换为小写。
type LowercaseProperties<T> =
{
[Key in keyof T as Lowercase<Key>]: T[Key]
};
type TestType = { ABC: number; XYZ: string };
type TestTypeLower = LowercaseProperties<TestType>; //{ abc: number; xyz: string }
但这只是它能力的一小部分。您可以从原始信息创建新类型
type Create<PropertyNames extends string> =
{
[Key in PropertyNames as
(Key extends 'id' ? Uppercase<Key> : Capitalize<Lowercase<Key>>)
]: Key extends 'id' ? number : string
};
type IdCardType = Create<'id'|'firstName'|'lastName'>;
/*
{
ID: number;
Firstname: string;
Lastname: string;
}
*/
您还可以使用 + 或 - 在修饰符前加上来更改类型的 readonly
和 optional
修饰符。以下代码创建了一个所有属性都必需的只读类型。
type MakeReadonly<T> =
{
+readonly [Key in keyof T]-?: T[Key]
};
type ReadonlyID = MakeReadonly<IdCardType>;
这还表明,如果省略可选的 as
子句,则迭代器变量将用于属性索引。
字面意思。要小心,因为尽管它们在需要时非常方便,但插值的位置会交叉相乘,因此很快就会失控,变成一个编译器崩溃的、可怕的联合。例如,下面的模板创建了从 0 到 1999 的所有数字。再增加几位数字,然后看看 TypeScript 是否希望它从未被创建过。
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type UhOh = `${0|1}${Digit}${Digit}${Digit}`;
当映射类型时,模板通常用于从旧属性创建新属性索引。
//Look ma! I'm a real Java bean now!
type MakeJava<T> =
{
-readonly [K in keyof T]-?: T[K]
} &
{
-readonly [K in keyof T as `get${Capitalize<Lowercase<K>>}`]: ()=>T[K]
} &
{
//Skip mutator for any property named 'ID'
-readonly [K in Exclude<keyof T, 'ID'> as `set${Capitalize<Lowercase<K>>}`]
: (set:T[K])=>void
};
//Uses the ReadonlyID type from the previous section.
type JavaID = MakeJava<ReadonlyID>;
/* Resulting object
{
ID: number;
Firstname: string;
Lastname: string;
getID: ()=>number;
getFirstname: ()=>string;
getLastname: ()=>string;
setFirstname: (set: string)=>void;
setLastname: (set: string)=>void;
}
*/
这些确实就是它们的内容。
我甚至不会假装完全理解某些类型解析行为是如何发生的,或者为什么会发生。但是,无论出于何种原因,确实存在一种类型解析总是被延迟的情况,这对于处理 TypeScript 的递归深度限制导致的“类型实例化过深”错误非常有用。当一个类型别名是对象定义的属性的类型,并且该对象定义是从一个别名返回的,那么在返回之前,该类型别名似乎不需要解析。如果我们考虑到在确定要返回的对象类型时不需要解析属性类型,这似乎是有道理的。
这可能是最容易展示的
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type FiftyNumbers = `${0|1|2|3|4}${Digit}`;
type UnionToFunctionIntersection<T> =
(T extends any ? () => T : never) extends infer U ?
(U extends any ? (_: U) => void : never) extends (_: infer V) => void ?
V
: never
: never;
type Last<T> = UnionToFunctionIntersection<T> extends () => (infer U) ? U : never;
type Push<Tuple extends unknown[], Element> = [...Tuple, Element];
type GenerateTuple<
Union,
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
[]
: Last<Union> extends infer T ?
GenerateTuple<Exclude<Union, T>> extends [...infer U] ?
Push<U, T>
: never
: never;
//Error - Type instantiation is excessively deep and possibly infinite. ts(2589)
type tuple = GenerateTuple<FiftyNumbers>;
上面所有的代码只是使用给定联合的元素创建一个元组。我们确切地知道类型实例化不是无限的,因为只有 50 个元素,所以为了避免错误,我们只需要避免递归深度限制。为了做到这一点,我们将重写 GenerateTuple
以使用延迟类型解析。
type PushFront<Tuple extends unknown[], Element> = [Element, ...Tuple];
type _GenerateTuple<
Union,
Tuple extends unknown[] = [],
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
{ _wrap: Tuple }
: Last<Union> extends infer T ?
{ _wrap: _GenerateTuple<Exclude<Union, T>, PushFront<Tuple, T>> }
: never;
原始的 GenerateTuple
从末尾剥离联合元素,然后在递归展开时从这些元素创建元组。由于新的 _GenerateTuple
使用递归来创建一个深度嵌套的对象定义,我们将使用一种略有不同的方法来创建元组——在展开时而不是展开后。因此,我们需要将元素推到元组的前面以保持顺序,并且需要一个新的类型参数将元组传递给下一个调用,因为我们不再依赖堆栈返回。生成的对象将看起来像这样
//abridged for my own sanity
{ _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04'] } } } } }
所以我们解决了递归深度限制问题,但是我们似乎为此付出了代价——如何以深度无关的方式解开它?幸运的是,这不像一开始看起来那么麻烦。
type Unwrap<T> = T extends { _wrap: unknown } ? Unwrap<_Unwrap<T>> : T;
type _Unwrap<T> =
T extends { _wrap: { _wrap: infer R } } ?
{ _wrap: _Unwrap<R> }
: T extends { _wrap: infer R } ?
R
: T;
type GenerateTuple<Union> = Unwrap<_GenerateTuple<Union>>;
让我们先处理核心别名——_Unwrap
。前两行所做的是将两个 _wrap
对象合并为一个 _wrap
对象,并对剩余的任何内容递归调用 _Unwrap
。我们在这里再次使用延迟类型解析来确保我们在解开过程中不会达到深度限制。一旦没有两个 _wrap
对象可供合并,就一定只有一个,所以我们只需解开它。如果 _Unwrap
被调用到一个未包装的值上,我们只需返回该值(T
)。
所以,当完全解析时(包括其自身的递归调用),一个“外部”调用 _Unwrap
的结果是整体结构中的 _wrap
对象数量减半。为了完全解开值,我们因此需要调用 _Unwrap
直到我们完全解开内部值。这就是 Unwrap
所做的。它调用 _Unwrap
,然后设置一个递归调用来处理结果。只有当该结果不再匹配 _wrap
对象的结构时(即结果已完全解开),它才会返回一个结果,该结果将是内部值。
type example = { _wrap: { _wrap: { _wrap: { _wrap: { _wrap: ['00', '01', '02', '03', '04']}}}}};
type unwrappedType = Unwrap<example>; //resulting type is ['00', '01', '02', '03', '04']
看!通过使用延迟类型解析,我们成功地避免了深度限制错误并执行了操作。
同样,这只是我对所谓的延迟类型解析在幕后实际发生的事情的教育性猜测,但我的猜测是,实际上发生的是,由于不需要属性类型来完成对象类型,因此对象类型被标记为已完成以供返回,别名展开,然后在对象返回后立即,系统意识到属性类型尚未完全解析,这反过来会触发对象内的别名(们)被调用。这意味着通过使用这种技术,我们实际上只使用了比我们当前所在的栈深度多一个的栈深度来处理递归别名。这也意味着不幸的是,我们无法使包装“更整洁”,因为用于包装的别名会在返回时触发内部属性类型的解析,将我们带回到深度限制错误。
type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type FiftyNumbers = `${0|1|2|3|4}${Digit}`;
//The "clean" way to wrap objects, abstracting out the property name details.
type Wrap<T> = { _wrap: T };
type GenerateTuple2<Union> = Unwrap<_GenerateTuple2<Union>>;
//All we need to do to break the solution is replace the raw object wrapping
//that is recursive with the Wrap<> call.
type _GenerateTuple2<
Union,
Tuple extends unknown[] = [],
IsEmpty extends boolean = [Union] extends [never] ? true : false
> =
true extends IsEmpty ?
{ _wrap: Tuple } //Replacing this with Wrap<Tuple> would work because it only
//executes once since it's the base case for our recursion.
: Last<Union> extends infer T ?
//This blows up though.
Wrap<_GenerateTuple2<Exclude<Union, T>, PushFront<Tuple, T>>>
: never;
//Uh oh...
type tuple2 = GenerateTuple2<FiftyNumbers>;
感谢阅读!
参考文献
GitHub: TypeScript Recursive Conditional Types PR#40002
GitHub: TypeScript Function Intersection Issue#42204
Susisu: How to Create Deep Recursive Types
历史
21/9/9:初始发布。