-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Higher-kinded Types... And other stuff #55280
Comments
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: type Instantiate<F^, T> = F<T>; 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. |
@fatcerberus A core problem of implementing HKTs in TypeScript is that as far as the subtype relation is concerned, type declarations don't exist, and neither do generic types. This is unlike in other languages, where types are nominal and revolve around declarations. Types can just be declared generic, and when a generic type is instantiated you can recover the arguments because they're an inherent part of the type. TypeScript is very different. Because everything is purely structural, any HKT proposal will need to invent new structure and define a new subtype relation to operate on that structure. While I think other solutions are definitely possible, I don't think they'd be all that different from what I've done. Maybe if they turned towards nominal typing. On the other hand, I'm not necessarily against restricting, weakening, or simplifying any concrete part of the proposal. |
I think I should scrap container types. Or rather call them something else. The subtype relation is supposed to be based on instances, similar to a subset relation. That's not how it works in programming languages in practice, but the idea of having a type with no instances but something else instead may not make sense. I think it can be explained differently. The functional elements don't have to change -- the relation is still there, kinds look just like did previously, but we just describe it as a different thing. The container types, instead of being types, are higher-order entities called type objects. Ascended objects if you will. And they have both value members and method members, as we talked about previously. You define one like this, using the same system as before: type object Whatever = {
// I'm less sure how to explain the use of the 'type' keyword here
// It's important because otherwise people might confuse it for some sort of runtime thing.
type A = number
type B<T> = {
type C = string
type D = object
}
} And type objects can pass for type parameters if they get a kind annotation that’s not The subtype relation becomes a sub-object relation, which is a better description. It still has the quality that if I feel like this makes a lot of sense. Regular HKTs are defined in Haskell as being functions. TypeScript isn't as focused about function types as it is on object types, so it makes sense that the cornerstone of TypeScript's HKT system would be an object. Maybe So I'm starting to get a feel for how
You could actually reproduce a pattern that's common in runtime TypeScript code: type object Option1 = {
type Type = "Option1"
type OneThing = number
}
type object Option2 = {
type Type = "Option2"
type An = {
type Other= string
}
}
declare function example<Either: Option1 | Option2>():
Either.Type extends "Option1" ? Either.OneThing : Either.An.Other I don't think it's that useful here. You ought to be able to just do I don't think I'm going to change the original post right now, regarding the type objects. I think the container types are easier to understand than just pulling a "higher-order object" right out the gate. |
It's not. Classes with private members are treated nominally. |
You're right! Didn't know that. I kind of thought that private member would still be compatible. Classes are different enough you might be able to make a strictly class-based HKT proposal. This would be turning more towards nominal typing, but it might work. |
You can also use symbols. They're kinda designed to be "nominal values"
But note that both classes and symbols are only unique to their declaration. A function that creates a new private class for each call or a new symbol for each call will not create a new type for each call. I don't quite understand what the purpose of this proposal is. What does this seek to do that can't be done with existing HKT implementations? |
Well, as the second paragaraph put it:
The purpose of this proposal is to be for TypeScript. Existing HKT implementations are for other languages.There is no other proposal that even tries to answer any of the above questions. They all just point at some other languages and give a few vague examples of possible syntax. In reality, the many problems posed by HKTs are solved in every language separately. In fact, most don't solve them at all by not having them. It's absurd to think that you can shop around for an existing implementation and just... copy-paste it into the compiler. |
As you said:
Do these libraries you mention fail to capture what you want? What would you like to express that you can't do in the existing TypeScript language? How would existing language constructs fail to meet your needs? E.g. the below pattern: // this is merely a generic type that captures type variables
// the properties exist only to enforce variance constraints
type MappingTypeClass<X,Y> = {
inX: (x:X)=>unknown,
outY: ()=>Y
}
// here's a definition of your associated types
namespace MappingTypeClass{
export type InElement<C> = C extends MappingTypeClass<infer X, any> ? X : never
export type OutElement<C> = C extends MappingTypeClass<any, infer Y> ? Y : never
export type InCollection<C> = ArrayLike<InElement<C>>
export type OutCollection<C> = ArrayLike<OutElement<C>>
export type Mapping<C> = (x:InElement<C>) => OutElement<C>
} |
Well, I'm really not sure how to answer that, so I'd like to provide some background. HKTs can be described as type parameters that are themselves generic. A basic use-case is expressing the signature of a single In other words, if the function is given a Here is an example of this in Scala: // ↓-- Higher-kinded type parameter
trait Bind[F[_]] {
def map[A, B](fa: F[A], f: A => B): F[B]
def flatMap[A, B](fa: F[A], f: A => F[B]): F[B]
} TypeScript does not have this feature. It has, however, been requested multiple times, with the earliest request being #1213. I participated in the discussion back then and I've been thinking about the problem ever since. I eventually came to realize the feature can't really be applied to TypeScript as-is due to its unique structural type system. The point of this proposal was to present a concrete system that makes HKTs possible and to describe how they interact with the language's other features. I hope you see that a library can't really provide this functionality. |
Is this superseded by #55521? |
Higher-kinded Types – Not quite a proposal
HKTs have been a much-discussed and desired feature for most of the language’s existence, with many usecases proposed over the years, but so far there have been no concrete proposals I'm aware of.
One of the difficulties is that HKTs are usually defined in languages with radically different (i.e. nominal) type systems. As such simply porting them into the language doesn’t really make sense – instead, they need to be reimagined and rephrased to fit with the rest of the language.
What follows is one such reimagining.
This proposal introduces two new type system constructs.
type type
.Part 1: Container types
An container type is a type that contains type members and no value members. Here is an example of an container type:
Container types are not inhabited – they have no instances, and a type annotation such as
let a: Container
is equivalent tolet a: never
, though in practice it would just be illegal. We can also say that an container type is non-assignable.What container types do have is a type-level structure. This structure can produce assignable types.
Here is an example of such an alias producing the assignable type
number
from an container type:Having defined them, we can actually find container types in the language already. Let’s take a look at the common namespace.
The declaration above creates several different entities:
Container
, which has value members such asContainer.value
.typeof Container
which is equivalent to{value: 5}
.But the type definitions
Example1
andExample2
are actually in a third entity, a hidden container type also calledContainer
.Members of container types
Container types can have generic members as well as regular members. This is analogous to how object types can have value members and function members. Also, we know this because namespaces can have those things too.
Generic types as container types with generic call signatures
Normal types can define a call signature. Analogously, container types can define a generic call signature:
In fact, every generic type is actually an container type with such a signature.
Container types can also embed other container types, creating nested structures of types.
Subtype relation for container types
TypeScript determines subtype relations structurally, and the same is true for container types. However, the structure of an container type is the way in which other types are embedded inside it, and it affects the rest of the program based on the assignable types it can produce.
Before we can abstract over container types, we need to define a subtype relation
⊆
for them. The subtype relation determines what it means for two container typesA
,B
to be structurally equivalent –A ≡ B
. The way this relation is defined will change what structure means in the context of these types.We’re going to define a very strict subtype relation, much stricter than the assignability relation used by normal types. Namely:
Let’s formalize it. To do that we need to define something called a type formula.
Now to define our subtype relation:
Notice that we delegate the final test to the existing assignability relation, which makes sure there is no infinite recursion.
The way we defined the type formula tells us that the structure of a container type involves:
Super<X₁, …, Xₙ>
is valid, then so mustSub<X₁, …, Xₙ>
be.Super.A
is an container type thenSub.A
must also be container andSub.A ⊆ Super.A
.Super.B
is assignable, thenSuper.B ≡ Sub.B
.Let’s take a look at some specific examples.
Assignable types are invariant
Assignable types must be invariant.
Partial structure means supertype
The generic signature is important
Type parameter constraints must be compatible
Part 2: Kinds
Kinds are the types of types, of which all normal types are instances.
All non-container types have the kind
*
, while container types have kinds that describe their complex type structures.Aliases for kinds are defined using
type type
and their definitions use double braces{{}}
. Here is an example:Kinds define type variables as members. Every type variable is defined together with a constraint, which may reference any of the other variables.
Several constraints are possible:
type KindBased: OtherKind
, which resembles a type annotation. This restricts that type variable to be an instance of the given kind.*
, such as intype Scalar:*
, which restricts it to a non-container (regular) type.type Constrained extends string
.An instance of a kind is a container type that fulfills all constraints on its members.
No special type information is conveyed in the alias itself – that part simply binds a kind expression of the form
{{ … }}
to a name. Whether you use a reference to an alias or the expression doesn't matter for type checking purposes. It may affect inference but that's a different story.As with other language constructs, you can nest kind expressions.
Kinds can also define generic type members. These are translated to non-generic container types using a kind annotation. For example, the following are equivalent:
Kinds are basically superpowered object types, with individual instances that are container types. A kind can have many possible instances, which can be easily constructed when needed. Just imagine an object full of types.
In object types every key is constrained with a type annotation. In kinds, every key can be a higher-kinded type (i.e. a container type) or a regular type (like
string
). We just need to constrain every member accordingly.Subtype constraints allow constraining with regular types like
string
, while kind constraints are used for container type members, which includes generic types. The=
constraint is mainly useful for types with the kind*
. One use case of that constraint is creating helper type variables. You can even prefix one with_
and pretend it's private.Kinds can’t have type parameters themselves. Things are already bad enough without having to deal with that.
Instance-of vs subtype-of
It’s important not to confuse two relations defined on container types – the subtype relation container types
A extends B
and the kind annotationA: K
.This comes up because container types are both types themselves but also the instances of other types (that is, kinds). There is a relationship between the two however – specifically, if
B: K
andA ⊆ B
thenA: K
.Trivial kinds
There are a few kinds we know about in advance and so call them trivial.
*
which we’ve mentionednever
makes a reappearance. As a type with no instances, it’s just at home as a kind. There is only one empty set after all.{{}}
describes an empty container type. It has no purpose.Uninhabited kinds
You can easily define paradoxical kinds.
This kind is uninhabited – it has no instances. Kinds like
Paradox
are equivalent tonever
. However, the general problem of whether a type is equivalent tonever
is unfeasible, so the language can’t detect things like this.You can also create kinds that describe types that contain themselves.
Syntax positions
A kind annotation looks just like a type annotation, except for its operands. These are always a type and a kind. Kind annotations apply to the following:
Mutually recursive references
The type members of kinds can reference each other in their constraints.
Here is an instance of
NumberExample
:One can define constraints like the folowing.
While this may seem like infinite recursion, it’s really not. It doesn't construct a infinitely large type, just place an odd constraint.
It is true, however, that the only instances of this kind are types of the form:
The compiler will be able to recognize the fact that this is an instance of
Crazy
, but I'm not sure it will be capable of constructing an instance on its own (that is, for inference purposes).Regular recursive reference
Type variables can also reference themselves in their constraints. Here it's used to implement a common pattern seen in immutable collections:
As a sidenote, this kind of reference might not be possible when defining a generic call signature:
There are several ways out of this situation. The first is to just disallow it. If you want to write a type like that, you'd have to do:
That's pretty awkward though. Another option is to use something like
<T>
orThis<T>
:My favorite approach is allowing the use of the name
Seq
. When it appears in the position of a type rather than a kind, it will be treated as a self-reference that's similar tothis
.I think this would seal the deal on having generic kinds - they would just be too confusing if this syntax was allowed.
In other situations we have an unambiguous symbol being annotated that can be used as a self reference, such as in this function:
Type checking
Let’s say we have a function with kind-constrained type parameters:
And we have an invocation such as:
Determining if this invocation is legal boils down to the following stages:
K
.T
.thing
versus the type ofx
computed at (2).All of these are feasible, even if 1 will probably get computationally expensive for complicated kinds.
Type inference
Let’s say we invoke the
test
function above without specifying type parameters explicitly:The compiler must try to construct a container type that satisfies
K
and allows the function to be called. This essentially amounts to solving two hard problems:K
thing
being assignable tox
.Both of these problems are untractable for non-trivial kinds, especially since assignability in TypeScript is already a difficult problem. However, there are still strategies for tackling it.
thing
might already be expressed with a container type that the compiler can just use. Users could use this fact by making sure arguments are asserted to have carefully chosen types.K
in the context of the call itself, using various systems of implicits.K
(i.e.type Something = Sibling<number>
), we can use them to resolve the other types.Another way is: Direct type hints in the form of partial container types.
Let’s say that we have the following:
While we could stick the complete container type when calling the function, in fact simply providing the type value of
Example.Num
might be enough, as the compiler could be able to figure it out the rest.Let’s phrase this in the language of the type system.
When calling a generic function that expects a kind-annotated type parameter, you can supply a supertype instead. If the supertype isn’t an instance of the kind, the compiler will try to generate a subtype of the supplied type that is an instance of the kind.
Large container types
Let’s say we define a kind for an immutable sequential collection:
And let’s say we want to write a
map
function that will conserve the collection type. Well, one way to do it is as follows.However, there is another way to do it – we can put all the type parameters inside the container type.
Is this better? It has advantages. Is it worse? It has drawbacks. You decide!
Fin
This is not quite a proposal and it doesn’t really cover everything. Here is a partial list of things I haven’t addressed:
&
and|
infer
ed?Have I missed something ? Is there something wrong with the syntax? Is there a better way to explain or represent something? I'd love to hear your feedback!
The text was updated successfully, but these errors were encountered: