Skip to content
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

Yet another Higher Kinded Types proposal in Typescript #44875

Open
5 tasks done
Wikiemol opened this issue Jul 2, 2021 · 16 comments
Open
5 tasks done

Yet another Higher Kinded Types proposal in Typescript #44875

Wikiemol opened this issue Jul 2, 2021 · 16 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@Wikiemol
Copy link

Wikiemol commented Jul 2, 2021

Suggestion

First, let me say that this proposal is different than #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. #34516

We first note that currently, the following is valid typescript

interface Functor<Me extends Functor<Me, unknown>, Z> {
  fmap<Y>(f : (x : Z) => Y, w : Functor<Me, Z>) : Functor<Me, Y>;
}

class MyList<X> implements Functor<MyList<X>, X> {
  readonly xs: X[];
  constructor(xs : X[]) {
    this.xs = xs;
  }
  
  fmap<Y>(f: (x: X) => Y,w: MyList<X>): MyList<Y> {
    return new MyList(w.xs.map(f));
  }

}

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,

interface^2 MyHigherOrderInterface {
     myHigherOrderMethod() : void;
}

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 :.

class MyLowerOrderClass : MyHigherOrderClass {
    static myHigherOrderMethod() { // Compiler error if this method isn't here and isn't static.
        console.log("Implemented");
    }
}

From an implementation standpoint, we are using almost (exactly in the case when there are no generics) the same code to type check that

class MyLowerOrderClass implements MyHigherOrderClass {
    myHigherOrderMethod() { 
        console.log("Implemented");
    }
}

does, but it is run on typeof MyLowerOrderClass instead of MyLowerOrderClass

Like extends, : can be used on generics, e.g.

interface^2 Functor<Me : Functor<Me, unknown>, Z> {
  fmap<Y, Input : Functor<Me, Z>, Result: Functor<Me, Y>>(f : (x : Z) => Y, w : Input) : Result;
}

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 example

interface^2 Functor<Me : Functor<Me, unknown>, Z> {
  fmap<Y, Input : Functor<Me, Z>, Result: Functor<Me, Y>>(f : (x : Z) => Y, w : Input) : Result;
}

/**
 * By our rules for inhabitants of higher order types, 
 * we have no other choice but to have the first argument be MyList<X> 
 * and the second argument be X,
 * furthermore, Z must be specialized with MyList<W> thus fmap _must_ specialize in a form we expect
 * and anything that implements functor must indeed be a functor (modulo the functor laws).
 */
class MyList<X> : Functor<MyList<X>, X> {
  readonly xs: X[];
  constructor(xs : X[]) {
    this.xs = xs;
  }
  

/**
 * Here, generics of the inhabitant class are added to the generics of the method, since it is static.
 * This will be inferred from the input in this case and probably most cases, 
 * but in other cases it is a lot like having the input to the function X => MyList<X> be 'curried'
 */
  static fmap<X, Y>(f: (x: X) => Y,w: MyList<X>): MyList<Y> {
    return new MyList(w.xs.map(f));
  }

}

We could try to trick it,

/**
 * Here we are attempting to subvert the restriction that Y must be a paramater of X
 */
class MyTrick<W, X extends MyList<W>, Y> : Functor<X, Y> {...}  

But it won't work when we try to implement fmap, we note that the following is a compiler error in existing typescript

class MyTrick<X, Z extends MyList<X>, Y> implements Functor<Z, Y> {

  // Compiler error on fmap
  fmap<Y>(f: (x: Y) => Y,w: Functor<Z,Y>): Functor<Z,Y> {
    throw new Error("Method not implemented.");
  }
}

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

/** 
 * The constraint is satisfied in current typescript, but thats okay, because map will 
 * get _statically_ specialized as a functor implementation for MyList, 
 * which is useful to have multiple functor implementations.
 */
class MyTrick<X, Z extends MyList<X>, Y> implements Functor<Z, X> {
  fmap<Y>(f: (x: X) => Y,w: MyList<X>): MyList<Y> {
  	throw new Error("Method not implemented.");
  }
}

Here is a Playground Link to these examples (which we can do because this is just existing behavior!)

Other miscellaneous things/concerns:

  • Interfaces/classes can only implement/extend classes of the same order.
interface^2 Functor<Me : Functor<Me, unknown>, Z> {
  fmap<Y, Input : Functor<Me, Z>, Result: Functor<Me, Y>>(f : (x : Z) => Y, w : Input) : Result;
}

class MyList<X> implements Functor<MyList<X>, 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<Y>(f: (x: X) => Y,w: MyList<X>): MyList<Y> {
    return new MyList(w.xs.map(f));
  }

}
  • The base types of Union/Intersection/Conditional/Product types must all be of the same order. I think there may be some fancy ways to lift this restriction in the future. For example, it may be fine to infer the order of a composite type like this to be the highest order among its leaf types, however, I am uneasy about the soundness of this.

  • Ideally, typeof X would always have an order which is one level higher than the order of X. But I am not sure this would be compatible with how typescript currently works.

  • My suggestion would be to first only allow higher order interfaces. But I could see something like static super being somewhat easily added to instantiate a higher order class, for example

abstract class^2 MyHigherOrderClass {
     abstract myHigherOrderMethod() : void;
     
     constructor(x : string) {
         console.log(x);
     }
}

class MyLowerOrderClass : MyHigherOrderClass {
    static super("hello world") // Compiler error if this isn't called
    static myHigherOrderMethod() { 
        console.log("Implemented");
    }
}

Now I will list the pros and cons of this approach:

Pros

  • Adds two highly requested features at once
  • Allows us to use existing typescript to understand the expected behavior of higher order types and their relationsionship with lower order types
    rather than having to reinvent the wheel.
  • Seemingly pretty easy to implement . The key thing is that parsing ^n and adding the order to a node is very easy, parsing another heritage clause is very easy. Asserting that a typeof C implements the higher order class is essentially the same asserting that a class C implements an interface with some minor tweaks (adding class level generics to the static scope), just on typeof C instead of C itself.
  • Easy to reason about its soundness from a type theoretical perspective. (However, I would like feedback from someone more familiar with all of the intricacies of the language)
  • Makes the problem of constraints much less challenging, since we are just implementing a "higher order" version of stuff that already exists.
  • Is actually more flexible than type classes in haskell in some ways, since you can decide which paramater should be 'the' paramater
    (in haskell, Something like Either<X, Y> could only be a functor on Y, not X, but here, you can provide both implementations), and higher orders we get for free

Cons

  • Very Ugly. This is not as nice as the 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.
  • I am not sure about 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.
  • Perhaps a bit tricky for the user to understand. The fact that Functor<Me extends Functor<Me, unknown>, X> in the example above is essentially equivalent to something like Functor 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:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

📃 Motivating Example

For me, the motivation for this was having static contracts for classes, inspired by this ticket #34516 (comment),
but then I realized that it could be used to implement Functors/Monads etc. See my comment #34516 (comment) 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

interface Comparable<X extends Comparable<X>> {
    ...
}

sort<T extends Comparable<T>>(ts : T[]);

You can write

interface Comparable<X : Comparable<X>> {
    ...
}

// We can have multiple implementations of Comparable<T>, but they aren't tied to a particular subclass
// In the future it would be cool to infer the second argument if there is only one implementation like in Haskell, 
// or specify a default one.
sort<T : Comparable<T>>(ts : T[], comparisonFunction : Comparable<T>) 

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 #34516 (comment).
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

... [using] the behavior of JavaScript and the intentions of program authors as a guide for what makes the most sense in the language.

@Wikiemol Wikiemol closed this as completed Jul 2, 2021
@Wikiemol Wikiemol changed the title Higher Kinded Types in Typescript Yet another Higher Kinded Types proposal in Typescript Jul 3, 2021
@Wikiemol
Copy link
Author

Wikiemol commented Jul 3, 2021

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)

@Wikiemol Wikiemol reopened this Jul 3, 2021
@Wikiemol
Copy link
Author

Wikiemol commented Jul 3, 2021

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.

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Jul 7, 2021
@RyanCavanaugh
Copy link
Member

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?

@Wikiemol
Copy link
Author

Wikiemol commented Jul 8, 2021

@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, it just infers it if there is only one possible implementation of parse

// 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".

@olee
Copy link

olee commented Jul 12, 2021

@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 #33892 (comment) 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.

@Wikiemol
Copy link
Author

@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).

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jul 13, 2021

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.

@Wikiemol
Copy link
Author

Wikiemol commented Jul 13, 2021

@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.

@Wikiemol
Copy link
Author

Wikiemol commented Jul 13, 2021

@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 .

@Wikiemol
Copy link
Author

Wikiemol commented Jul 13, 2021

I would also like to say that this proposal was inspired by @ShuiRuTian's work specifically the comment here

#40368 (comment)

(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.

@olee
Copy link

olee commented Jul 13, 2021

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

@Wikiemol
Copy link
Author

Wikiemol commented Jul 21, 2021

@olee This isn't the same thing. I would look at this ticket #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 #44875 (comment). 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:

  1. A specific keyword that specifies that an interface needs to be implemented statically and can't be extended normally, and a hierarchy of these sorts of interfaces.
  2. The ability to add static implements/static extends as a generic constraint
  3. The ability to have type parameters as part of the static contract (i.e. like the T<~> syntax)
  4. The ability to statically "construct" a type (i.e. the 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 #44875 (comment) 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.

@Cosmic-Ryver
Copy link

Cosmic-Ryver commented Jul 28, 2021

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.

@Wikiemol
Copy link
Author

@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

  1. a good solution to the existing requests for both static interfaces and higher kinds.
  2. has a community of people who want these features (otherwise a library would not have been made for it)
  3. The approach actually works and doesn't conflict/cause paradoxes with other typescript features
  4. Since it can be implemented in existing typescript, this approach really is the 'typescript' way of doing this and doesn't go against the typescript philosophy

@Wikiemol
Copy link
Author

Wikiemol commented Jul 30, 2021

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 is a Generator and when it is an AsyncGenerator.

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.

@TylorS
Copy link

TylorS commented Sep 26, 2022

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).

Screen Shot 2022-09-26 at 7 57 19 PM

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants