高级类型(Higher-kinded Types)——并非完整的提案
高级类型(HKTs)在语言的大部分存在时间里都是被广泛讨论和期望的功能,多年来也提出了许多用例,但到目前为止,我所知的还没有具体的提案。
其中一个困难之处在于,高级类型通常在具有截然不同的(即名义上的)类型系统的编程语言中定义。因此,将它们简单地移植到该语言中实际上没有意义——相反,它们需要重新构想并重新表述以适应该语言的其他部分。
以下是一个这样的重新构想。
这个提案引入了两个新的类型系统构造。
- 容器类型:这些是高级类型。它们不可分配(没有示例)。它们将类型声明转换为TypeScript可以推理的结构。
- Kinds: HKT中的K。Kinds是高阶类型,其示例都是其他类型。它们用于量化一系列容器类型的家族。使用
type type
声明一个kind别名。
第一部分:容器类型
一个容器类型是一个包含类型成员和无值成员的类型。下面是一个容器类型的示例:
// A container type that has two type members.
type Container = {
type A = number
type B = string
}
容器类型不是有人居住的——它们没有示例,而像let a: Container
这样的类型注解等同于let a: never
,尽管在实践中这实际上是非法的。我们也可以说一个容器类型是非分配的。
容器类型确实有一个类型级别的结构。这种结构可以产生可分配的类型。
下面是一个这样的别名从一个容器类型产生可分配的类型的示例:
// You've seen this before, haven't you?
type Example = Container.A
定义了它们之后,我们实际上可以在语言中找到容器类型。让我们看一下常见的命名空间。
// Plot twist!
// Namespaces have been the key to HKTs all along.
namespace Container {
type Example1 = number
type Example2 = string
export const value = 5 as const;
}
上面的声明创建了几个不同的实体:
- 值绑定
Container
,其中有值成员如Container.value
。 - 类型
typeof Container
,它等同于{value: 5}
。
但是类型定义Example1
和Example2
实际上是在第三个实体中,一个隐藏的容器类型也被称为Container
。
容器类型的成员
容器类型可以有泛型成员以及常规成员。这与对象类型可以有值成员和函数成员类似。此外,我们知道这一点,因为命名空间也可以拥有这些东西。
将泛型作为带有泛型调用签名的容器类型的泛型类型
普通类型可以定义一个调用签名。类似地,容器类型可以定义一个泛型调用签名:
type Example = {
type <T> = number
}
事实上,每个泛型类型实际上都是一个带有此类签名的容器类型。
容器类型还可以嵌入其他容器类型,创建嵌套的类型结构。
type Nest = {
type Parent = {
type Leaf = string
}
}
容器类型的子类型关系
TypeScript通过结构性方式确定子类型关系,对于容器类型也是如此。然而,容器类型的结构是其他类型的嵌套方式,这会影响基于它可以产生的可分配类型的整个程序。
在我们可以抽象出容器类型之前,我们需要为它们定义一个子类型关系⊆
。子类型关系决定了两个容器类型A
、B
在结构上是等价的—— A ≡ B
。这种关系的定义将改变这些类型的“结构”在上下文中的含义。
我们将定义一个非常严格的子类型关系,比普通类型的赋值关系严格得多。也就是说:
对于一个容器类型Sub
要成为另一个容器类型的子类型—— Super
、 Sub ⊆ Super
、 Sub
必须当受到各种操作时产生与Super
相同的类型。
让我们形式化一下。为了做到这一点,我们需要定义一种称为*type formula的东西。
type formula: 一个元语言构造。一个type formula是一个涉及替换令牌φ的表达式。type formula由以下组成:
- φ
- 访问成员类型,
φ.Member
- 用
φ<T₁, …, Tₙ>
示例化一个类型构造器
我们将编写这样一个type formula:
F{φ} ⇒ φ.Something<number>.A
这里还有一些更多的例子。
F{φ} ⇒ φ.One.Two.Three<number>
F{φ} ⇒ φ<string>
现在来定义我们的子类型关系:
对于两个容器类型Sub
、Super
、如果对于所有使F{φ}
可分配的type formula F{Super}
,那么对于所有type formula F{Sub} ≡ F{Super}
也是如此。
注意我们将最终测试委托给现有的赋值关系,以确保没有无限递归。
我们定义的type formula告诉我们一个容器类型的结构包括:
- 包含的类型的名称。
- 对于泛型类型:
- 泛型类型的参数数量。
- 对泛型类型的参数施加的约束必须兼容,即如果
Super<X₁, …, Xₙ>
是有效的,那么 x30n30 也必须是有效的。
让我们来看一些具体的示例。
可分配类型是不变的
{ type A = "a" } ⊈ { type A = string }
可分配类型必须是不变的。
部分结构意味着超类型
{ type A = "a", type B = "b" } ⊆ { type A = "a" }
泛型签名很重要
{ type F<T> = number } ⊈ { type F = number }
{ type F<S> = number } ⊆ { type F<T> = number }
类型参数约束必须兼容
{ type F<T> = T } ⊆ { type F<T extends number> = T }
第二部分:种类(Kinds)
种类是类型的种类,所有普通类型都是其示例。所有非容器类型都有种类 *
,而容器类型则有描述其复杂类型结构的种类。为种类定义别名使用 type type
,其定义使用双大括号 {{}}
。以下是一个示例:
// These things are complicated. Take it from me
type type SomeKind = {{
type Scalar:*
type KindBased:OtherKind
type Constrained extends string
type Fixed = number
}}
种类将类型变量定义为其成员。每个类型变量都与一个约束一起定义,该约束可以引用其他任何变量。可能有几种约束:
- 一种种类注解,如在
type KindBased: OtherKind
中,类似于类型注解。这限制了类型变量只能是给定种类的示例。 - 一种带有种类
*
的种类注解,如在type Scalar:*
中,它将类型变量限制为非容器(常规)类型。 - 一种子类型约束,如
type Constrained extends string
。 - 一个等式约束。这使类型变量固定。
一个种类的示例是一个满足其成员上所有约束的容器类型。没有特殊的类型信息在别名本身中传递——那部分只是将 kind表达式 绑定到名称。无论您使用对别名的引用还是表达式,对于类型检查的目的都无关紧要。它可能会影响推断,但这是另一回事。
与其他语言构造一样,您可以嵌套种类表达式。
种类还可以定义泛型类型成员。这些被转换为使用种类注解的非泛型容器类型。例如,以下是等价的:
type type A = {{
type F<T> extends number
}}
type type B = {{
type F: {{
type <T> extends number
}}
}}
种类基本上是具有个体示例的超级对象类型,个体示例都是容器类型。一个种类可以有许多可能的示例,可以在需要时轻松构建它们。想象一下一个充满类型的物体。
在对象类型中,每个键都用类型注解约束。在种类中,每个键都可以是更高级的类型(即容器类型)或常规类型(如 string
)。我们只需要相应地约束每个成员即可。
子类型约束允许使用常规类型(如 string
)约束,而种类约束用于包含泛型类型的容器成员,包括泛型类型。 =
约束主要适用于具有种类 *
的类型。该约束的一个用途是创建辅助类型变量。您甚至可以将其前缀为 _
并假装它是私有的。
type type WeirdStuff = {{
type _Something = Blah | string
type Blah = number
}}
种类不能有自己的类型参数本身。事情已经够糟糕了,不必再处理那个。
示例-of vs subtype-of
重要的是不要把定义在容器类型上的两个关系混淆——容器类型的子类型关系 A extends B
和种类注解 A: K
。
这是因为容器类型既是自身又是其他类型的示例(即种类)。两者之间存在关系——具体来说,如果 B: K
且 A ⊆ B
则 A: K
。
平凡的种类
我们事先知道的一些种类被称为平凡种类。
- 我们已经提到过的
*
- 又出现了一次 xm55n1x 。作为没有示例的类型,它就像一个种类一样自在。毕竟只有一个空集之后还有什么呢?
- 这个种类描述了一个空容器类型。它没有用处。
不适宜居住的种类
你可以很容易地定义自相矛盾的种类。
这个种类 不适宜居住 –它没有示例。像 xm57n1x 这样的存在等同于 xm58n1x 。然而,一般问题 xm5e0f1x 是不可行的,所以语言无法检测到这样的东西。
你也可以创建描述包含自身的类型的种类。
语法位置
一个种类注解看起来就像一个类型注解,除了操作数之外。这些总是一个 类型 和一个 种类 。种类注解应用于以下情况:
- 类型参数声明。
- 其他种类的其他类型的类型成员。
- 对于非泛型容器类型的非泛型容器类型的类型别名声明。在这种情况下,注解确保正在定义的类型符合给定的种类。
类型变量也可以在它们的约束中引用自身。这里它被用来实现一个在不可变集合中常见的模式:
type type Imm = {{
type Seq<T> extends {
push(x: T): Seq<T>
pop(): Seq<T>
readonly length: number
}
}}
作为一个附注,这种引用可能在定义泛型调用签名时是不可能的:
type type Seq = {{
type <T> extends {
push(x: T): ?
}
}}
有几种解决这种情况的方法。第一种方法就是直接禁止它。如果你想写这样的类型,你必须这样做:
type type Seq = {{
type Instance<T> extends {
push(x: T): Instance<T>
}
type <T> = Instance<T>
}}
虽然这样很别扭。另一种选择是使用类似 <T>
或 This<T>
的东西:
type type Seq = {{
type <T> extends {
push(x: T): <T>
}
}}
我最喜欢的方法是允许使用名称 Seq
。当它出现在类型的位置而不是种类的位置时,它将被视为与 this
类似的自我引用。
type type Seq = {
type <T> extends {
push(x: T): Seq<T>
}
}
我认为这将使通用种类成为定局——如果允许这种语法,它们将会太令人困惑了。
在其他情况下,我们有一个明确标注的符号可以作为自我引用使用,比如在这个函数中:
declare function example<Seq: {{ type <T> extends { push(x: T): Seq<T> }}}>()
类型检查
假设我们有一个带有种类约束的类型参数的函数:
function test<T: K>(x: T.SomeType<string>): true
并且我们有一个调用,如下所示:
test<{
type SomeType<T> = boolean
}>(thing)
确定这个调用是否合法主要涉及以下几个阶段:
- 首先,确定指定的容器类型是否是种类
K
的示例。 - 计算所有类型表达式,用容器类型替换
T
。 - 对(2)计算出的
thing
和x
类型的标准可分配性检查。
所有这些都是可行的,即使第1步对于复杂的种类来说可能会变得计算量很大。
类型推断
假设我们没有显式地指定上面的 test
函数的类型参数,而是直接调用它:
test(thing)
编译器必须尝试构造一个满足 K
并允许函数被调用的容器类型。这基本上相当于解决两个困难的问题:
- 找到种类为
K
的示例。 - 在这些示例中,找到一个使得
thing
可以分配给x
的示例。
这两个问题对于非平凡的种类来说都是无法解决的,尤其是因为在 TypeScript 中,可分配性已经是一个困难的问题。然而,仍然有一些策略可以解决这个问题。 - 在调用本身或其上下文中收集现有的类型信息。
- 在最简单的情况中,已知
thing
的类型可能已经被编译器用容器类型表示出来,用户可以通过确保参数具有精心选择的类型来利用这一点。
另一个解决方案涉及到尝试在调用本身的上下文中找到K
的示例,使用各种隐式系统。
如果有任何值输入被注解为在K
(即type Something = Sibling<number>
)中固定的类型,我们可以使用它们来解析其他类型。
另一种方法是:以部分容器类型的形式提供直接的类型提示。
假设我们有以下内容:
type type Example = {{
type Num extends number
type Array = Num[]
}}
function test<T: Example>(arr: T.Array): T.Num
虽然当我们调用该函数时可以坚持使用完整的容器类型,但实际上只需提供 Example.Num
类型的类型值就足够了,因为编译器可以弄清楚其余的部分。
x
9条答案
按热度按时间798qvoo81#
FWIW it’s been my observation over the years that, when people ask for higher-kinded type support in TypeScript, for the most part they ultimately just want to be able to do this:
i.e. to be able to pass generic type aliases as type arguments and instantiate them generically. Introducing what basically amounts to full-on type classes, as in e.g. Haskell, feels like overengineering and actually counter to the goal, which is to have effortless first-class generics, just like JS has first-class functions.
lnxxn5zx2#
我认为你可能低估了有多少人想要实际的类型类。围绕类型类有 multiple 、 working 和 encodings 种 HKTs,而类型类往往一直是关于 HKTs 讨论的核心问题。
在 TypeScript 中实现 HKTs 的一个核心问题是,就子类型关系而言,类型声明不存在,泛型类型也不存在。这与其他语言不同,其他语言中的类型是名义上的,围绕声明展开。类型可以简单地声明为泛型,当示例化泛型类型时,你可以恢复参数,因为它们是类型固有的一部分。
TypeScript 非常不同。因为一切都是纯粹的结构性的,任何 HKT 提案都需要发明新的结构并定义一个新的子类型关系来操作该结构。虽然我认为其他解决方案肯定是可行的,但我不认为它们会与我所做的有什么太大的不同。也许如果他们转向名义类型。
另一方面,我并不一定反对限制、削弱或简化提案中的任何具体部分。
brc7rcf03#
我认为我应该废弃容器类型,或者换个说法。子类型关系应该是基于示例的,类似于一个子集关系。这在实际编程语言中并不起作用,但拥有一个没有示例但有其他东西的类型的概念可能没有意义。
我认为可以以不同的方式解释。功能元素不需要改变——关系仍然存在,种类看起来和以前一样,但我们只是将其描述为不同的东西。
容器类型不再是类型,而是被称为类型对象的高阶实体。如果你愿意的话,它们就是上升的对象。它们既有值成员也有方法成员,就像我们之前讨论过的那样。
你可以这样定义一个:
如果类型对象获得了一个不是
*
的 kind 注解,那么它们就可以作为类型参数传递。在这种情况下,我们可以称之为类型对象参数。子类型关系变成了一个 子对象 关系,这是一个更好的描述。它仍然具有这样的性质:如果
A
是B
和B: Kind
的子对象,那么A: Kind
也成立。我觉得这很有道理。常规的 HKTs 在 Haskell 中被定义为函数。TypeScript 对函数类型不像对对象类型那样关注,所以TypeScript的 HKT 系统的基石应该是一个对象是很合理的。
也许
kind
不是type type
的合适词汇。重新命名它们的一个方法是type class
,这与type object
相符,但会让人感到困惑,因为type type
不是真正的 typeclasses,而且class
已经意味着其他东西了。所以我开始对
&
和|
如何工作有了感觉。你可以使用&
在类型对象上组合它们的结构,但是|
不起作用,因为结果不能是一个类型对象,而是某种混乱的状态。|
可以应用于种类,但如果你有一个像T: A | B
这样受到限制的类型对象参数,那么就像在 TypeScript 中一样,你必须弄清楚你在持有什么。它是A
还是B
?你只能使用测试来做到这一点。你实际上可以重现运行时 TypeScript 代码中常见的模式:
我认为在这里用处不大。你应该能够直接做
Either extends Option1
,因为我们现在完全处于 TypeScript 的虚拟类型奇妙之地。我现在不会立即更改关于类型对象的原始帖子。我认为容器类型比一开始就拿出一个“高阶对象”更容易理解。
j8ag8udp4#
因为一切都是纯粹的结构化的。
它不是。具有私有成员的类被当作名义上的处理。
368yc8dk5#
因为一切都是纯粹的结构性的。
不是这样的。具有私有成员的类会被名义上处理。
你是对的!我不知道这个。我有点认为私有成员仍然兼容。
类足够不同,你可能能够提出一个严格的基于类的HKT提案。这将更多地转向名义类型,但它可能会起作用。
bogh5gae6#
你也可以使用符号。它们被设计成“名义值”。
但是请注意,类和符号仅在其声明中是唯一的。一个每次调用都会创建一个新的私有类的函数或一个每次调用都会创建一个新的符号的函数,不会为每次调用创建一个新的类型。
我不太明白这个提案的目的是什么。这个提案试图实现什么功能,而现有的HKT实现无法实现?
mbyulnm07#
嗯,正如第二段所说:
困难之一是HKT通常在具有截然不同的(即名义)类型系统的语种中被定义。因此,简单地将它们移植到该语言中并不真正有意义——相反,它们需要重新构想和重新表述以适应该语言的其余部分。
我不太明白这个提案的目的是什么。这个提案试图做什么,而现有的HKT实现不能做到这一点?
本提案的目的是为TypeScript服务。现有的HKT实现是为其他语言服务的。
没有其他提案甚至试图回答上述任何一个问题。它们只是指向一些其他语言,并给出一些模糊的可能语法示例。
实际上,由HKTs带来的许多问题在每种语言中都被单独解决。事实上,大多数语言并没有通过没有它们来解决这些问题。认为你可以四处寻找现有的实现并仅仅......复制粘贴到编译器中是荒谬的。
qni6mghb8#
正如您所说:
有 multiple 个 HKTs 以类型类为中心,而类型类通常也是关于 HKTs 的讨论的核心。
您提到的这些库是否无法捕捉到您想要表达的内容?
在现有的 TypeScript 语言中,有哪些内容是您希望表达但无法实现的?
现有的语言构造如何无法满足您的需求?例如下面的模式:
6yoyoihd9#
正如您所说:
有 multiple 个 HKTs 以类型类为中心,而类型类通常也是关于 HKTs 讨论的核心。
您提到的这些库是否无法捕捉到您想要的内容?
在现有的 TypeScript 语言中,有什么表达不出来的东西吗?
现有的语言构造如何无法满足您的需求?例如下面的模式:
我真的不知道该如何回答这个问题,所以我想提供一些背景信息。
HKTs 可以被描述为本身是泛型的类型参数。一个基本的使用场景是表示一个接受泛型集合(如
Set<T>
)并返回相同类型但元素类型不同的集合的单一map
函数的签名。换句话说,如果给定一个
Set<number>
和一个x => "" + x
函数,它应该返回Set<string>
。如果输入的集合是一个number[]
,它应该返回string[]
。以下是在 Scala 中的一个示例:
**TypeScript 没有这个功能。**然而,它已经被多次请求,最早的请求可以追溯到 #1213 。我当时参与了讨论,从那以后我一直在思考这个问题。最终我意识到这个特性实际上无法直接应用于 TypeScript,因为它的结构化类型系统是独一无二的。
这个提案的目的是提出一个具体的系统,使 HKTs 成为可能,并描述它们如何与语言的其他功能相互作用。
我希望您能看到一个库确实无法提供这种功能。