Open Wikiemol opened 3 years ago
Hey everyone, sorry! I accidentally posted this before it was ready, so there was a time when there was no description. But reopening now that I am done with the full proposal. (@MartinJohns You were right to thumbs down, but I implore you to reconsider now that I have updated with more info)
I would also like to add that I have successfully implemented this with no generics on abstract classes without concrete methods. But I would like to start over now that I have a better idea of how the code base works, and also start with interfaces instead of abstract classes to avoid implementing the static super
syntax initially.
I really wanted to put up a forked branch to show people with this proposal, and was just sort of using this as a way to get my thoughts down but then I accidentally posted the issue before I was done Dx.
I have some questions after reading this.
It seems like every instance of
interface^2 A { foo; }
class B : A { static foo; }
is isomorphic in behavior to a hypothetical #33892:
interface A { foo; }
class B static implements A { static foo; }
Some clarification on what else is going on would be good.
The :
syntax in generic constraints isn't clear to me - generic constraints and class implements
checks are in reality quite different and I don't understand how I'm supposed to reason about them. There's no equivalent "static side" to a type parameter the way there is for a class. In general any proposal that claims to provide HKT should stand alone without class
-based examples; this proposal seems very class
-specific in a way that makes me skeptical it's actually doing HKT rather than static implements
.
This proposal would be much simpler to understand if it didn't jump straight into the deep end with functors and fmap
. What's the simplest version of this one could show to a person who wasn't a Haskell expert? Why do ^2
interfaces need to exist at all, in fact -- shouldn't a class be free to :
-implement a type of any order?
@RyanCavanaugh These are good questions. Generic constraints work exactly the same way that they do in other languages which have higher order types. The idea is that we are guaranteeing that we can call static functions of a certain kind on the type T.
Let me use the example of parsing to make it a bit less "abstract nonsense". The goal of this example is to avoid silent bugs when using JSON.parse() on some incoming data whose structure may have changed.
For example, lets say I am writing an app to search car models, and I am using some external open source API which provides me with car models, but the contract of the API changes regularly. I would like to take some action when I notice that the type returned from the API varies from the data structure I am parsing it into.
Moreover, I have a lot of different objects which I am getting from this API, and I want it to be as painless as possible to add this validation, and I don't want to have to remember to use this, I want the compiler/linter to remind me. So I create a higher order class like this.
abstract class^2 Parseable<T : Parseable<T>> {
readonly dirnamePath : string; //Set to __dirname by the user
readonly validate : (str : string) => void
constructor(dirnamePath: string, t : typeof T) {
this.dirnamePath = dirnamePath;
this.validate = // lambda that gets auto generated JSON schema guaranteed unique combination of t.name and __dirname
}
public parse(str: string) : T {
this.validate(str);
return JSON.parse(str) as T;
}
}
And a parsing function like this
function parse<T : Parseable<T>>(json: string, x : Parseable<T>) : T {
return x.parse(json);
}
The key thing here is that the dirnamePath and validation function should not change instance to instance, it should always be the same.
Now, if my schema generator is set up, then I can write the following to easily have validation when parsing JSON to this class
class Car : Parseable<Car> {
// Will get compiler error below if second argument is not Car, or if this constructor isn't here
// Since Parseable can only be instantiated as a type (i.e. implemented by a class)
// We know that if our schema generator is working properly, then either we will get an error
// at startup if the first argument is wrong or a compiler error if the second argument is wrong.
static construct(__dirname, Car);
}
Now when I want to parse the api response I can type
const car: Car = parse(json, Car)
And it will ensure that the validation and the type matches up. Moreover, if I ban JSON.parse
with my linter then now I can effectively ensure that this validation will always take place on all incoming requests.
In languages like Haskell, we don't have to pass in the instance of Parseable
// Haskell works like this, without having to pass in Car. It just knows to use parse from car since it only allows one static implementation
const car: Car = parse(json)
this is just the "typescript" way of accomplishing the same thing without inference, i.e. without having to add fancy inference logic to the type checker/transformer.
So the answer to your question about how to reason about :
is essentially that x : Y
means typeof x extends Y
function parse<typeof T extends Parseable<T>>(json: string, x : Parseable<T>) : T {
return x.parse(json);
}
The above is not currently valid typescript syntax. It seems to me that, while these kinds of things are to some degree expressible, you often have to jump through hoops to enforce these kinds of constraints in typescript, and you never really feel totally secure that you got it right. Essentially you often have contracts you want enforced the same way on an entire class of objects, you don't want someone to be able to extend that class, and enforce the contract a different way. But then you also want to be able to use this "generically" in the same way that you use extends in generics. You can't use some "non override-able" keyword like final
to accomplish this, because then you can't write the contract in the first place, since it wouldn't be overrideable.
To answer your question about the syntax only applying to classes, I would say that It applies to anything whose type has static fields. This is "classes" to some degree, but consider that everything in Javascript has fields.
If typeof T
doesn't have the static fields your contract is requiring, then it simply won't conform to the constraint. If we wanted to add the ability to add higher order types to arbitrary types though, this could also be done with a simple extension of the syntax (this might be a bit harder to implement though).
type MyParseableType : Parseable<MyParseableType> = ..... {
... Implement static methods here
}
This would, in addition to creating a type on the compiler side, also be saying "I want to create an instance of Parseable corresponding to this type called MyParseableType", and the transpiler would just do that in exactly the same way you create a class with static methods.
Now for the last question, which is,
Why do ^2 interfaces need to exist at all, in fact -- shouldn't a class be free to :-implement a type of any order?
The original intention here is communication. Having used languages with higher order types, it is extremely natural to have a strict separation of kinds, and I think people coming from other languages with higher order kinds would expect it to be this way. I.e. Using precedent from other languages, typeclasses (e.g. functors) in haskell aren't types, they are something different, they can't be used in the same way that types can be used, and there is a strict hierarchy of them enforced in the compiler (although you can't go higher than order 2 as far as I know). More practically, Parseable, in the example above, simply does not make sense when being extended (or in general implemented) by the things which need to be parsed. Extending parseable would be a mistake, even though it might be many people's first instinct. static implements
gives people the ability to do this, but nothing about the Parseable class above would communicate that they are supposed to do this instead of just implementing it, which simply would not be right. When you get to higher levels of abstraction than these simple examples, I could see this being very confusing.
An example of this is a comparable interface, and a sort function. You can easily express that you want the input to the sort function to be a list of comparables, but you can't easily express that you need them all to implement compare
in the same way. Which a sort function needs to enforce. If you just have static implements without adding constraints to generics, you still do not have the ability to easily enforce this.
This I think, is just scratching the surface. I am not aware of languages that have successfully implemented higher kinded types without this strict seperation between types and higher kinded types, so its a bit hard to provide explicit examples, but there is a reason they are called higher kinded types, its because they are of a completely different sort, and have to be handled with care when mingling with the other types, otherwise logical paradoxes start to arise from a type theoretical perspective. Mathematically, when you have "types of types" you end up with a lot of logical paradoxes if you aren't very careful. The easiest way of being "very careful" is to enforce a strict hierarchy. There are other ways, but this is the one that has had the most success.
But I think this is at the heart of why people are somewhat uncomfortable with the existing ways of implementing static contracts, its that they aren't really static contracts, they are just contracts, and the user of the contract chooses to implement them statically, regardless of whether or not it was written to be this way, and you can't straightforwardly abstract over this, you can't straightforwardly express with the type system that "this function works for all types implementing this contract statically, but not necessarily types implementing this contract non statically".
@Wikiemol actually your proposal is nothing else than another syntax for defining static properties on interfaces.
The following example you provided:
interface^2 MyHigherOrderInterface {
myHigherOrderMethod() : void;
}
could in fact be represented by the following syntax quite more understandable:
interface MyHigherOrderInterface {
static myHigherOrderMethod() : void;
}
But the issues with this type of interface have already been discussed in detail which is why imho it does not make sense to continue such exotic syntax and try to argue for static properties on interfaces instead (which I also thought at first would be nice but after reading https://github.com/microsoft/TypeScript/issues/33892#issuecomment-542440546 I quickly noticed why this would not be a great idea).
Also, your proposal never even makes use of interface^3
which shows again why it would not make sense to go for such a syntax.
@olee
This
interface MyHigherOrderInterface {
static myHigherOrderMethod() : void;
}
and this
interface^2 MyHigherOrderInterface {
myHigherOrderMethod() : void;
}
are not the same thing.
The idea is that an interface of order 2 still cannot have static fields. The fields are not static. Rather, it can only be implemented statically by types of order 1. I did not give explicit examples of types of order 3, but the same idea applies. Interfaces of order 3 can only be implemented statically by types of order 2 etc.
This does not have the same downsides outlined in the linked comment. It can still be used "like" a type, instances of it can be created and passed into methods etc. It simply cannot be implemented or extended by types of order 1. It cannot be intersected with types of order 1, etc.
This is exactly like typeclass
in Haskell and trait
in Scala. To a lesser extent, instances of a higher order interface are very much like Class<X>
in java. I.e. as interfaces, they specify the structure of a type like a higher kinded type, and their instances would likely be passed into methods for type coercion, for reflection, etc.
As instances, they have the power to turn generic types into runtime objects at the programmer's discretion (again very much like Class<X>
in Java, but more flexible, since you can define Class
itself).
FWIW, it's unlikely we would ever make forward progress here if the answer to all questions is "Do what Haskell / Scala do with it" with no elaboration. TS isn't those languages and we need answers that are concrete in our own type system. Without actual examples of code showing these things in action, it's impossible to meaningfully discuss what behavior is being proposed.
@RyanCavanaugh Sorry, I tried to provide code examples above, implementing a parseable interface. Was that not what you were asking? That example wasn't really a haskell/scala thing at all, but something that I face as a working backend programmer and one of the usability reasons I wouldn't use typescript for my backend over , say Java. I need to validate incoming data in complicated and uniform ways, and in typescript it's really hard to do that uniformly and enforce this over an entire codebase (which is necessary for security reasons).
I think there may have been a confusion. The second comment was replying to Olee, not you. See my previous comment for a code example.
@RyanCavanaugh Furthermore, I mention Haskell/Scala only to do what the guidelines say, which is
If relevant, precedent in other languages can be useful for establishing context and expected behavior
If this is a criticism, then I think this should be removed from the guidelines. I am really not a dogmatic person about this functional stuff, I just think functional programming languages are really the only ones doing both abstract static function implementations and higher kinds, so its really the only place I can go to set precedent .
I would also like to say that this proposal was inspired by @ShuiRuTian's work specifically the comment here
https://github.com/microsoft/TypeScript/pull/40368#issuecomment-803744625
(I would very very much like this user's input, the motivation for this ticket was honestly just to help out with the implementation of this by providing a way to reason about the edge cases run up against in that pull request, while also addressing the static interfaces/abstract classes problem),
I really should have mentioned this in the original post (but I accidentally prematurely posted it and I am still regretting that now!). It is meant as a way to provide a complete specification for the issues mentioned in that comment.
With this proposal, situations like ((*->*)->*)->*
becomes more reasonable to wrap your head around.
A higher kinded type of this form is a type of order 3
interface^3 MyOrderThreeType<T : MyOrderThreeType<T, unknown>, X> {
....
}
This is saying that T is an order 2 type (i.e. it has 2 levels of parenthesis, (*->*)->*)
) with one type paramater X. Now an implementation of this order three type has the form:
interface^2 MyOrderTwoType<T : MyOrderTwoType<T, unknown>, X> : MyOrderThreeType<MyOrderTwoType<T, unknown>, X> {
....
}
Explicitly, the situation mentioned in the comment linked above here
interface MyFunctor<G<T extends string> extends MyGeneric1<T>>{}
becomes expressible as
interface^2 MyFunctor<G extends MyGeneric1<T> : MyFunctor<G, T>, T extends string> {
}
without much hassle or changing language/type inference substantially. In fact, we aren't changing it at all except by replacing extends
with :
. The difference between :
and extends
is just that with :
, the programmer knows that G
must have type paramater T
because of the rules for how a class extends :
(i.e. it must extend with itself or type parameters and they must be unique). Thus what needs to be changed about the type-checker becomes a lot easier to reason about, since :
is just a drop in replacement for extends
but because of the extra constraints on how it is implemented, it is functionally equivalent to MyFunctor<G<T>>
Then, syntactic sugar can be added to transform
interface MyFunctor<G<T extends string> extends MyGeneric1<T>>{}
to the above syntax. I.e. the nesting level of angle brackets determines uniquely the order of the type, and thus generic type constraints become way easier to implement.
IMO, this seems easier to reason about when you are dealing with these complicated edge cases.
Afaik you can just express intentions such as
interface MyFunctor<G<T extends string> extends MyGeneric1<T>>{}
simply with this
interface MyFunctor<G extends MyGeneric1<T>, T extends string>{}
if I'm not wrong
@olee This isn't the same thing. I would look at this ticket https://github.com/microsoft/TypeScript/issues/1213 for more info. This ticket was already approved by Ryan a few years ago, so I don't think I should have to defend the need for that particular part of this feature.
@RyanCavanaugh I have another use case for you, in addition to my Parseable comment and my comment here https://github.com/microsoft/TypeScript/issues/44875#issuecomment-879453425. Forget about the specific syntax :
for a moment, I don't really care about that. It could be static implements
/static extends
, I think its a bit verbose if we add it to generics as I am suggesting, but its the functionality I care about more. Barring the specific keyword, I am arguing for the need to have the following features in addition to just a static implements
/static extends
:
static construct
syntax mentioned in the OP)Here is another use case, which perhaps better showcases the need for all 4 of these: the ability to more easily/automatically discriminate union types.
I find myself doing the following a lot in typescript:
const MY_TYPE = Symbol("MyType Discriminator")
export function isMyType(t: unknown): t is MyType {
return (t as MyType)?.type === MY_TYPE;
}
export class MyType {
public readonly type = MY_TYPE;
}
so that I can easily discriminate MyType
in cases such as MyType | MyOtherType
.
The goal here is to make this a bit less boiler platy, and also avoid situations where strange things happen when someone extends MyType and potentially forgets (or doesn't forget) to override type. With requirement 2 (the ability to specify static generic constraints), in addition to the static implements
proposal, we could do the following
interface Discriminateable<T static extends Discriminateable<T>> {
public readonly type : symbol;
}
export function is<T static extends Discriminateable<T>>(t: unknown, type: Discriminateable<T>): t is T {
return (t.constructor as any)?.type === type.type;
}
const MY_TYPE = Symbol("MyType Discriminator")
export class MyType static implements Discriminateable<MyType> {
public static readonly type = MY_TYPE;
}
const MY_OTHER_TYPE = Symbol("MyOtherType Discriminator")
export class MyOtherType static implements Discriminateable<MyOtherType> {
public static readonly type = MY_OTHER_TYPE;
}
Now we have the ability to do the following to discriminate between them
declare const x : MyType | MyOtherType | string
if (is(x, MyType)) {
//... x is typed as MyType inside the if statement
} else if (is(x, MyOtherType)) {
//... x is typed as MyOtherType
}
With 4 (the ability to statically construct a type) we can make this even easier
class Discriminateable<T static extends Discriminateable<T>> {
public readonly type : symbol;
constructor() {
this.type = Symbol("Unique Symbol")
}
}
//same as before
export function is<T static extends Discriminateable<T>>(t: unknown, type: Discriminateable<T>): t is T {
return (t as T)?.type === type.type;
}
export class MyType static extends Discriminateable<MyType> {
static super() // the type field is automatically created
}
export class MyOtherType static extends Discriminateable<MyOtherType> {
static super() // the type field is automatically created
}
Now consider the situation where we have both Discriminateable and Parseable in our code base. And a few months ago another developer added Discriminateable to Parseable, but you didn't know about that. You are writing some "reflection" style code that needs to be serialized, and you decide you need to add Parseable to Discriminateable. But now you have the two types circularly depending on eachother! In order to get around this you will have to create another type which is identical to either Parseable or Discriminateable to keep these from depending on eachother in this way.
Avoiding this situation is what proposal 1 (a heirarchy of kinds) is for. This situation comes up a lot when working with higher order types, and is a chance for typescript to actually be better than Haskell and other functional languages in this regard.
Trying to avoid debates about the syntax here and focus on the functionality, so Instead of just the ^2
syntax, consider a 'static' keyword which is an alias for it, so our Parseable and Discriminateable classes are written like so
static class Discriminateable<T static extends Discriminateable<T>> {
public readonly type : symbol;
constructor() {
this.type = Symbol("Unique Symbol")
}
}
static class Parseable<T static extends Parseable<T>> {
//... same as before
}
now note that by this proposal, we are allowed to (at least try) having Parseable extend (i.e. non statically) Discriminateable without worrying, since they have the same order (they are both order 2). The compiler now knows if you are trying to do something which is nonsense.
static interface Discriminateable<T static extends Discriminateable<T>> {
public readonly type : symbol;
}
//This is okay if Discriminateable remains an interface as in our first example, and is probably what the original developer _meant_ to do
static class Parseable<T static extends Parseable<T> & Discriminateable<T>> extends Discriminateable<T> {
//... same as before
}
//This is not okay, since the orders don't match up
static class Parseable<T static extends Parseable<T> & Discriminateable<T>> static extends Discriminateable<T> {
//... same as before
}
In the future, to allow statically extending circularly, a utility type can more easily added to "raise the order" of a type, so you don't have to manually do it.
// This is okay, since the order has been raised
static class Parseable<T static extends Parseable<T> & Discriminateable<T>> static extends RaiseOrder<Discriminateable<T>> {
//... same as before
}
But the point is that, without this, circular dependencies become far more likely when dealing with higher order types than with normal extensions, because usually when you are using higher order types, you mean to write interfaces that apply to a wider variety of things than normal classes.
This covers reasons for 1, 2, and 4, of the proposed features. This leaves 3, the desire for a way to express "this generic type takes some amount of type paramaters". This feature, albeit in a different form, has already been approved. But the addition of "orders" to types makes it a lot easier to implement, and to reason about, because now kinds like (((* -> *) -> *) -> *)
become expressible, again see https://github.com/microsoft/TypeScript/issues/44875#issuecomment-879453425 for the reasoning. This has come up as an issue in an already approved feature, and this simply provides a fix for it. This is because "static implements" and "higher types" are intimately related on a theoretical level. But to make it more explicit, lets go back to our Discriminateable example.
The goal here is, if MyGenericType
takes a type parameter, we would like to discriminate cases such as MyGenericType<MyType> | MyGenericType<MyOtherType>
Lets add another class for discriminating union types with a type parameter
static class Discriminateable1<T static extends Discriminateable1<T>, V static extends Discriminateable<V>> extends Discriminateable<T> {
public readonly genericType: symbol;
constructor(v: Discriminateable<V>) {
super();
this.genericType = v.type
}
}
Remember that under this proposal, V
must be a type parameter of T
.
Now we have the ability to go even further with automatic discrimination, basically now the programmer has the option of declaring classes that carry around type information at runtime if they so desire, without having to change the compiler to do that for you. Lets see this in action
const MyGenericType = <T static extends Discriminateable<T>>(typeParamater: T) => class<X extends T> static extends Discriminateable1<MyGenericType, X> {
static super(typeParameter)
//...
}
(I may have gotten this slightly wrong, but hopefully you get the point)
Now we can write something along the lines of
export function is1<V extends Discriminateable<V>, T static extends Discriminateable1<T, V>>(t: unknown, type: Discriminateable1<T, V>, genericType: Discriminateable<V>): t is T {
return (t.constructor as any)?.type === type.type && (t.constructor as any)?.genericType === genericType;
}
and we can discriminate like this
declare const x : MyGenericType<MyType> | MyGenericType<MyOtherType> | string
if (is1(x, MyGenericType, MyType)) {
//... x is typed as MyGenericType<MyType> inside the if statement
} else if (is1(x, MyGenericType, MyOtherType)) {
//... x is typed as MyGenericType<MyOtherType>
}
So hopefully this gives you a better idea of the type (pun perhaps intended) of thing I am talking about. There a lot of requests in typescript that essentially boil down to "I want to carry around type information at runtime, can you add this to compiler?" (I want a lot of these things) And this proposal is meant as a mechanism of allowing this level of expression, but only at the programmer's discretion, so as not to go against typescript's philosophy.
I don't know how well it covers the use cases you are interested in, but the fp-ts library provides a Higher Kinded Type implementation within existing TypeScript.
EDIT: The library's documentation specifically calls out Functor & Monad support.
@GusBuonv Interesting! Their approach to this is very clever! I hadn't seen this before. The approach is actually fundamentally the same to what I am proposing here, but I didn't think it was possible without actual language support. However, its extremely verbose and boilerplaty, and requires you to explicitly spell out the name of the interface you want to 'higher kinded-ize' four times in total.
It would be nice if this design pattern was just built right into the language. Perhaps the fact that it already exists as a popular and battle tested library -- and in existing typescript at that -- is more evidence that this basic idea is
Allow me to also elaborate a little bit on how this could potentially work for arbitrary types. The idea is that for any implementation of higher kinded types, an implementation of the higher kinded interface must be stored somewhere, so when compiled to javascript, some sort of class or structure will need to be created.
The idea then would be to use this to essentially create a class which 'delegates' for arbitrary type aliases.
So, for example, I am currently working on something where I have a type
type AbstractGenerator<T> = Generator<T, void> | AsyncGenerator<T, void>
And I need to distinguish between the case when AbstractGenerator
However, doing this is very tricky without making my type something like
type AbstractGenerator<T> = {type: "sync", value: Generator<T, void>} | {type: "async", value: AsyncGenerator<T, void>}
Which just ends up adding a lot of unnecessary mess to my code.
Instead, what I would really like to have is some sort of "Discriminateable" interface like the above.
type GeneratorSync<T> static extends Discriminateable<GeneratorSync<T>>= Generator<T, void> {
static construct()
}
type GeneratorAsync<T> static extends Discriminateable<GeneratorAsync<T>> = AsyncGenerator<T, void> {
static construct()
}
This would get compiled equivalently to the following
class GeneratorSync<T> static extends Discriminateable<GeneratorSync<T>> {
static construct();
private readonly value: Generator<T, void>;
construct(value: Generator<T, void>) {
this.value = value;
}
public next() {
return value.next()
}
//... all inferable delegates for the type
}
(same for GeneratorAsync)
Now I can essentially write my type as before
type AbstractGenerator<T> = GeneratorSync<T> | GeneratorAsync<T>
GeneratorSync and GeneratorAsync have exactly the same interface as the original types so nothing about my existing code needs to change except in the places where I am constructing these types, but I can now easily distinguish between the two using is
like discussed above.
I've seen a number of these discussions over time and even in other languages, but they often get framed in terms of these other languages. It's also dense because people are most interested in achieving the ability to create TypeClasses, myself included. I'd like to make a slightly different case for HKTs as it relates to Typeclasses and Category Theory which are often the goals of utilizing HKTs.
As I see it the desire for HKTs is often the desire for writing TypeClasses defined by things from Category Theory, for which Haskell's interpretation is a small subset of it. My own understanding is definitely more towards TypeClasses/CT than that of HKT implementations as "Type Functions"
As I've been learning about those systems myself, it's dawned on me that Category Theory (CT), as it applies to programming, is the mathematical study of composition of algebras. If we can roughly agree that all systems built are taking data from one place, transforming/filtering/accumulating/etc that data, making choices, and moving some data to another place, then CT is the mathematical representation of how to break our problems down into smaller solutions and compose them back into larger solutions. If we can further agree that testing is a good thing, then writing code that adheres to algebras is great, because algebras are intended to be solved.
The algebras can also be used to implement runtime optimizations. For example, https://github.com/mostjs/core/ is the fastest push-based Stream library in JS for a really long time, and it's partially because it follows the laws of Associativity and Commutativity to perform optimizations to the Stream graph (it also has a low-overhead architecture).
Oftentimes these discussions also get lost in the specifics TypeClasses such as Functor/Applicative/Monad, but ultimately these are just reusable interfaces that describe common patterns that different data structures or "effects" might be capable of implementing like mapping over collections, combining values together, performing and operation using the result of a previous operation, filtering values out, etc. These qualities are incredibly valuable on their own, but this still leaves a lot on the table in terms of re-use.
If you looked in the fp-ts codebase, Scala's CATS or ZIO, Haskell's Prelude, or many other libraries with TypeClasses, you'll also find a vast array of combinators that can be derived from the implementation of those TypeClasses, that can be derived when a data structure implements 2 or more of those TypeClasses, or are able to compose 2 or more data structures into other data structures that have lawful implementations of those TypeClasses where the circle goes around again for more reuse.
The pedantic amount of reuse is actually quite powerful for front-end programming and has benefited me greatly in terms of being able to define reusable logic that can be shared across multiple data structures (I usually use fp-ts) which improves my load times, parse times, and the other metrics impacted by JS payload size.
I'm not positive what it'd take to further this discussion but I'd be extremely happy to work with anyone willing to sort out questions as they apply specifically to TypeScript. I've got a good handle on the theory aspects, but much less on the TS internals. I understand that Haskell strictly has no subtyping and thus no problems to do with variance as TypeScript (and Scala) would, but could someone help me understand what the outstanding issues/questions are regarding this topic?
Suggestion
First, let me say that this proposal is different than https://github.com/microsoft/TypeScript/issues/1213, although it achieves a similar goal. I am not proposing that type parameters be added to generic types, but rather, that classes be allowed to be instantiated like the objects that they are. I am proposing that Higher order types be added in the truest since, that is, there is a strict hierarchy of type orders, and that when one creates a class, one can assert the type of this class.
This proposal is meant to provide a somewhat easy mechanism for both implementing and understanding the expected behavior of this highly requested feature in the near future, which takes advantage of the typescript compiler's existing infrastructure, and also Javascript's prototype inheritance.
In addition, it covers the almost as old issue (if you include all of the tickets leading up to it) that abstract static methods be added to abstract classes and interfaces. https://github.com/microsoft/TypeScript/issues/34516
We first note that currently, the following is valid typescript
We have effectively created a Functor interface, but on the wrong "level". We would like fmap to be static, but it is not. Moreover, in our Functor interface decleration, we cannot guaruntee that Z is a type paramater of Me.
The idea is to utilize this "almost" functor interface to make a real functor interface by only slightly changing the syntax.
We allow interfaces to be instantiated with some order. So for example,
This now represents a type of order 2. A normal class is automatically a type of order 1, and a value is a type of order 0. A class is asserted to be an inhabitant of MyHigherOrderClass in a similar way to the way all values are asserted to be an inhabitant of any type, with a
:
.From an implementation standpoint, we are using almost (exactly in the case when there are no generics) the same code to type check that
does, but it is run on
typeof MyLowerOrderClass
instead ofMyLowerOrderClass
Like extends,
:
can be used on generics, e.g.Unlike extensions, specializing generics in a
:
heritage clause has restrictions. They must either be specialized with generics of the inhabitant class or the inhabitant class itself, and they must all be unique. With these restrictions we have a guarantee that generics will be specialized with the type parameters of its inhabitant class. For exampleWe could try to trick it,
But it won't work when we try to implement fmap, we note that the following is a compiler error in existing typescript
so it would be a compiler error with
:
instead of implements as well.We can however use a version of this trick to implement multiple versions of a functor
Here is a Playground Link to these examples (which we can do because this is just existing behavior!)
Other miscellaneous things/concerns:
class MyList implements Functor<MyList, X> { // Compiler error, A Type of order 1 can only implement or extend another type of order 1
readonly xs: X[];
constructor(xs : X[]) {
this.xs = xs;
}
fmap(f: (x: X) => Y,w: MyList): MyList {
return new MyList(w.xs.map(f));
}
}
Now I will list the pros and cons of this approach:
Pros
Cons
T<~>
syntax proposed by others. However, I would argue that it is better to have something that is easy to implement to increase the likelihood of it being added to the language. Having a strong foundation makes it easier to add syntactic sugar later.typeof
, it would need to be handled carefully. Ideally, typeof would always return a type of a higher order, but I am not certain this would break existing typescript code.Functor<Me extends Functor<Me, unknown>, X>
in the example above is essentially equivalent to something likeFunctor me
in a language like haskell is not immediately obvious. But again, hopefully syntactic sugar can help with this in the future.π Search Terms
Higher Order Types Higher Kinded Types Abstract static methods static methods in interfaces
β Viability Checklist
My suggestion meets these guidelines:
β Suggestion
π Motivating Example
For me, the motivation for this was having static contracts for classes, inspired by this ticket https://github.com/microsoft/TypeScript/issues/34516#issue-507967613, but then I realized that it could be used to implement Functors/Monads etc. See my comment https://github.com/microsoft/TypeScript/issues/34516#issuecomment-869072237 for an example of a Parseable higher order type
π» Use Cases
What do you want to use this for?
I would love to see actual type classes and higher order types like functors and monads be in a more mainstream language. If pure state monads became mainstream instead of "state managers" for the front end, that would be sweet :).
Moreover, there are many situations where static contracts are useful even for those who do not like functional style programming, for example, comparable classes when overrides are possible becomes an issue the way typescript handles assignability, because you can't gauruntee that they are all using the same comparison function without asserting that they all are exactly T (i.e. no subclasses).
Having a static contract on a class and having methods that take the class itself based on the kind ensures that you are sorting by the same comparison method, but also allows you to have multiple comparable implementations and to mix subclasses with their superclasses. I.e. instead of
You can write
As I mentioned above, another usecase is type parsing. Parsing with JSON.parse incorrectly introduces no errors, having a class be "parseable" in such a way that ensures an exception is thrown if it does not conform to the type would be very useful.
What shortcomings exist with current approaches?
The shortcomings of the existing attempts to provide a mechanism for static contracts are elucidated very clearly by Ryan Cavanaugh here https://github.com/microsoft/TypeScript/issues/34516#issue-507967613. The shortcomings of the existing attempts to provide a mechanism for "higher kinded types" is that they do not actually introduce true "kinds" or "orders" into the type system, and are thus very difficult to reason about. In order for any higher kinded type system to be sound, there needs to be an explicit heirarchy. The existing approaches also involve entirely new concepts.
This proposal only adds one new concept, which is that of orders. The other concepts are existing ones, since in Javascript you are already instantiating a new object when you create a class. We are just imposing the same type system as the one that already exists on these objects. This adheres to the design goal of