microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
99.89k stars 12.36k forks source link

the typeclass model offers superior extensibility #10844

Closed shelby3 closed 7 years ago

shelby3 commented 7 years ago

Subclassing inheritance is an anti-pattern (see also this and this and this).

For those who aren't familiar with nominal typeclasses in for example Rust and Haskell, they conceptually (without getting into details and caveats yet) are a way of adding implementation to existing nominal types without modifying the source for those existing nominal types. For example, if you have an instance of existing nominal type A and your want to invoke a function that expects a nominal type B input, typeclasses conceptually enable you to declare the implementation of type B for type A and invoke the said function with the said instance of type A as input. The compiler is smart enough to automatically provide (the properties dictionary data structure or in ECMAScript the prototype chain) to the function the type B properties on the type A instance.

The has efficiency, SPOT, and DNRY advantages over the alternative of manually wrapping the instance of A in a new instance which has B type and delegate to the properties of A. Scala has implicit conversion to automate, but this doesn't eliminate all the boilerplate and solve the efficiency (and tangentially note Scala also can implement a typeclass design pattern employing implicits). This disadvantage of wrapping with new instances compared to typeclasses is especially significant when the instance is a collection (or even collection of collections) of instances (potentially heterogeneous with a union type), all of which have to be individually wrapped. Whereas, with typeclasses only the implementations for each necessary pair of (target, instance) types need to be declared, regardless of how many instances of the instance type(s) are impacted.

And afaics this typeclass model is what the prototype chain in EMCAScript provides.

When we construct a new instance, the A.prototype of the constructor function A is assigned to the instance's protoype, thus providing the initial implementation for the instance (of type A) of the properties of type A. If we want to add an implementation of constructor function B (i.e. of type B) for all instances of type A, then we can set A.prototype.prototype = B.prototype. Obviously we'd like to type check that A is already implemented for B, so we don't attempt to implement B for A thus setting B.prototype.prototype = A.prototype creating a cycle in the prototype chain.

That example was a bit stingy thus contrived, because actually we'd want a different implementation of type B for each type we apply it to. And afaics this is exactly what typeclasses model.

I am very sleepy at the moment. When I awake, I will go into more detail on this proposal and try to justify its importance, relevance to TypeScript's goals, and important problems it solves.

Note I had recently discovered in my discussions on the Rust forum in May what I believe to be a complete solution to Wadler's Expression Problem of extensibility (the O in SOLID), which requires typeclasses and first class unions (disjunctions) and intersections (conjunctions).

There are 500+ detailed comments of mine (and @keean) over there (~335 of which are private) I need to reread and condense into what I want to say in this issue proposal. And it was to some extent and unfinished analysis that I had put on the back burner. I have elevated this priority seeing that TypeScript has the first-class unions and intersections and seeing that upcoming 2.1 is supposed to look into the nominal typing issue for #202.

I have mentioned typeclasses in a few of my recent comments on TypeScript issues.

spion commented 7 years ago

In the above example you cannot just pass a single dictionary "$T1" into the function 'fn' because each of A, B and Bd need a different dictionary, and you have no idea which types are in the Array because they could be any type that implements T1, even a type not declared yet, declared in another module, etc. Note: this is not a problem if each object in the array provides the required dictionary.

In my above hypothetical example, the dictionary $T1 is a dictionary of dictionaries, i.e. $T1[objecttype][methodname] 😀 Which assumes that this type information is available at runtime (a pretty big assumption for TS/JS already, as its hard to get a unique type tag in JS - its either too unique (not cross-realm) or not unique enough (class name wont do).

keean commented 7 years ago

You cannot know all the types that could potentially be in the array. Your method would require whole program compilation, and effectively gather all implementations from everywhere in the code into a single dictionary of dictionaries, which would break modular compilation. But It would work :-)

SimonMeskens commented 7 years ago

@keean If it relies on emits and doesn't interop with JS well, I'd say it's rather ill-suited for Javascript.

keean commented 7 years ago

I am not sure what you mean by 'emits'? Do you mean templated code generation? That is the fundamental operation of all compilers, pass the input into an AST, perform AST to AST tranformations and then emit the final code.

If so you are missing the point. A type-class is a constraint on the type, it has nothing to do with the value level code. Monomorphisation of templates is only one way to do this, you can just use normal JavaScript object constructors. All the type-class does is check the object provides the correct methods. As I said, its formalised duck-typing.

spion commented 7 years ago

@keean Hrm. The module that takes the dictionary can be compiled separately. It doesn't need to know all the types.

However at use site, the type checker can check if the dictionary includes all types that can potentially be in the array. (Already breaks a lot of assumptions that TS/JS currently makes, but perhaps doable in a new language)

e.g.

Array<T>.push(u:U): Array<T|U>

(or alternatively control flow analysis)

keean commented 7 years ago

True but something has to build the dictionary of dictionaries, I suppose this could be done at runtime on program startup, or during the 'linking' step with a custom linker.

What you really want is to statically check they types when they are inserted, as you will know they types statically at that point, which gives a much more efficient implementation with static rather than dynamic checking. Really the goal should be to never have to dynamically check any types (and you can do this with type-classes, its one of the most attractive properties).

SimonMeskens commented 7 years ago

I'll leave this here for a second, it explains why multi-methods and extension methods are part of the proposal in a way. As demonstrated, I think we can get away without extension methods. I think the proposal requires multi-methods. I have more to say on this later.

class A { a; }
class B { b; }
class Bd extends B { bd; }
interface T1 { x(data: number): void }

// With mutlti-methods:

function x(this: A, data: number) { log(`Received ${data} in T1 for A.x`); } 
function x(this: B, data: number) { log(`Received ${data} in T1 for B.x`); } 
function x(this: Bd, data: number) { log(`Received ${data} in T1 for Bd.x`); }

// without multi-methods:

function xa(this: A, data: number) { log(`Received ${data} in T1 for A.x`); } 
function xb(this: B, data: number) { log(`Received ${data} in T1 for B.x`); } 
function xbd(this: Bd, data: number) { log(`Received ${data} in T1 for Bd.x`); }

function x(this: A|B|Bd, data: number) {
    // with extension methods
    if (this instanceof A)
        return x::xa(data);

    // without extension methods:
    if (this instanceof B)
        return xb.call(this, data);

    if (this instanceof Bd)
        return xbd.call(this, data);
}

// Code of interest follows:
// Note that you need to state that you are consuming T1 explicitly
function fn (arg: Array<trait T1>) {
  console.log(`Received ${arguments.length} arguments`);
  arg.forEach(el => el.x(123));
}
let a: Array<trait T1> = [new A(), new B(), new Bd()];
fn(a);

@keean I mean templated code generation, yes, this is directly against the stated goals of the project.

keean commented 7 years ago

@SimonMeskens okay, templated code generation is a distraction, I only mentioned it because you can get very fast/efficient code that way by getting rid of virtual function calls. Monomorphisation is a desirable thing for performance as modern super-scalar out-of-order processors take a big performance hit from indirect calls.

As I said we don't care how the methods get onto the object, you can read the type-class as simply a validation check, does the object provide these methods.

keean commented 7 years ago

The following would be fine for example:

trait Incremenetable {
    function inc() : void
}  

function doit(x : trait Incrementable) : void {
    x.inc()
}  

Using plain JS

a = {x : 1};

a.inc = function() {
    this.x = this.x + 1;
}

doit(a);

I think the following would be the de-sugared version of the typed version at the bottom.

function A(y) {
    this.x = y;
}

function Incrementable_A(a) {
    a.inc = function() {
        this.x = this.x + 1;
    }
}

a = new A(1);
Incrementable_A(a);
doit(a); // note calling from plain JS the function doit has the same definition, 
         // we just don't do any type checking.

However this is strictly for JS interop, when writing type safe code we would prefer to do:

class A {
    x : int
    constructor(y : int) {
        this.x = y;
    }
}

implement Incrementable for a : A {
    a.inc = function() : void {
        this.x = this.x + 1;
    }
}

a = new A(1); 
Incrementable(a);
doit(a); // here the compiler statically checks 'a' implements 'Incrementable'
         // we know this because we know 'a' has type 'A', and we can find an
         // implementation of 'Incrementable' for 'A' in scope (whether the
         // implementation is local or imported doesn't matter). 

Edit: I have been playing with the syntax to try and make the plain JS code as close to a simple type desugared version of the typed code. As we are not trying to monomorphise, we can get away with declaring a to be Incrementable separately, but really it should be part of the 'new' operation.

keean commented 7 years ago

A further thought is that whist the implementation is structural, the compiler can provide static compile time checks that are nominal. So in the above 'doit' is duck typed if called from plain JavaScipt, but if both the definition of the class and the definition of the type-class constrained function are typed, the compiler can statically check the nominal types to provide a guarantee that it is sound and cannot go wrong at runtime.

In theory a language with type-classes like this can provide all the expressive power without the complexities of subtyping and with only invariant calls (no more covariance/contravariance problems) You can have a small language with all the expressive power, however this might be too divergent from typescript... maybe it needs to be a new language project?

SimonMeskens commented 7 years ago

@keean Actually, I've been playing around and I think I can write a proper implementation that has good interop qualities and doesn't require complex emit (using more of a syntax sugar style than a heavy emit style a la generators). Thanks for the concise code examples. As I mentioned above, I feel like this also closely relates to and facilitates extension methods, though I think I can keep that part orthogonal to the proposal. I'm going to dive into my editor and write up a few examples of what I'm thinking of.

In any case, I'd love to have type classes and multimethods in TypeScript, not a new language project, because I think, as you noted, that it could allow a new expressiveness, currently lacking.

Btw, in this example:

trait Incremenetable {
    function inc() : void
}  

function doit(x : trait Incrementable) : void {
    x.inc()
}  

Couldn't we just write this:

interface Incremenetable {
    function inc() : void
}  

function doit(x : trait Incrementable) : void {
    x.inc()
}  

I'm not quite sure why we need the trait declarative keyword, I think we only need it at call-site.

keean commented 7 years ago

You probably could use the interface keyword, however I have left out several details for the sake of simplifying the explanation that need to be considered (Rust has already got the solutions to this in its type system, but it adds some complexity none the less). You need a way of dealing with multi-parameter type-classes (so you can write functions that are generic in collections of some type), there needs to be a way of dealing with the 'self' type for objects, and you need a syntax for polymorphic functions, and you need a way of typing anonymous function arguments.

The first thing is type-classes are constraints on types, so you need to start with type variables. Consider:

function id<A>(x : A) : A {
    return x
}

This is the identity function, you pass it any type you like, and it returns the same type (and in the case of identity the same value). To make type classes work properly you need parametric types (aka generics) and type variables. Then you need a way (syntax) to say that 'A' is constrained by some type class (I will use where):

function id<A>(x : A) : A where A : Incrementable {
    return x
}

So here we have an identity function that is only valid on Incrementable objects, and we expect a compile time error if you try and use it on a value that cannot be statically proved to implement the methods necessary to be incremenetable.

SimonMeskens commented 7 years ago

@keean Yep, I just ran into that, I'm exploring it right now and I think if we add composable multimethods, you basically get type classes and pattern matching for free (sugar only). I'm currently writing up a proposal, I'll present it in a little bit.

I'm not sure if you are familiar with this paper that explains a few more benefits of multiple dispatch and prototypes, it's a very expressive system by itself. I aim to show it would enable us to cleanly write type classes and pattern matching on top of it.

http://sauerbraten.org/lee/ecoop.pdf

SimonMeskens commented 7 years ago

Btw, TypeScript already has constraints. It has all the things you mention, except polymorphic functions (aka multimethods or multiple dispatch). Hence why I think this is where the key lies.

function id<A extends Incrementable>(x : A) : A {
    return x
}
keean commented 7 years ago

Well that wouldn't be a type-class then. A type class contrains the polymorphic type 'A', A does not extend incrementable. A could be a type like 'Int for example, and if there exists an implementation of Incrementable for 'int' then you could pass it to id. Are you comfortable saying a primitive type like 'int' extends implementable?

SimonMeskens commented 7 years ago

Yes, very comfortable, as that is exactly how the language works today. I assume you aren't very familiar with TypeScript right now?

keean commented 7 years ago

Well it sounds a lot like Java, which I think is a bad thing. A really doesn't extend Incrementable in that sense, and thinking about it that way is not good in my opinion. But its a long road from that way of thinking to generic programming. I would recommend reading "Elements of Programming" by Alexander Stepanov.

SimonMeskens commented 7 years ago

Prototypal languages are strictly more powerful than classic OO languages, like Java. They are even more powerful than Haskell, as Haskell wouldn't be able to typecheck a lot of common JavaScript constructs. JavaScript is modeled after Lisp (Scheme in particular) and Smalltalk, both very expressive. The stated goal of TypeScript is simply to typecheck this very expressive language, not to add features to it. The only times TypeScript generates templated code, is when you target an older version of JavaScript.

One of the unfortunate things we are stuck with is Java syntax, which is for historical reasons. Don't ever think that this Java syntax means a Java mentality in any way, JavaScript is far more powerful.

keean commented 7 years ago

Exactly, you are 'abusing' Java's classical object inheritance syntax, and making the keywords mean things they they don't actually mean. This is no longer type extension.

I would not object to:

function id<A : Incrementable>(x : a) : A {...}

Edit: well I can live with 'extends' event though it should be 'is constrained to implement' or something like that, but I don't like it :-)

SimonMeskens commented 7 years ago

What you would or would not object to is irrelevant to this proposal. TypeScript is the largest typechecker for JavaScript, which is the most used language in the world. TypeScript's syntax is largely based on proposals from the TC39 committee and concensus between Facebook, Microsoft and Google.

Even if you can convince this massive momentum that the syntax is not correct, no one would care, as they are not going to rewrite a couple hundreds of thousands of lines of code collectively because it looks prettier.

keean commented 7 years ago

Its not about prettyness, its about actually understanding what the concepts mean and not propagating incorrect concepts. I am sure the way typescript currently uses extends was in-line with the concepts of Java like languages, but if you start including type-class like mechanisms its clearly the wrong concept. Sure I can't stop you doing something that's not quite right, but as an engineer and a professional I have an obligation to point these things out.

SimonMeskens commented 7 years ago

Fair enough, I don't like many things about JavaScript's syntax and the way TypeScript is forced to inherit that. Problem is that currently, 7 people are reading this thread, out of a couple tens of thousands of people using the language, I doubt it matters much.

SimonMeskens commented 7 years ago

Also, the "limited way" TypeScript uses extends is one of the most expressive type systems in history. I doubt you realize what the language can currently do. It can "extend" a complex union of types, like this:

function fn<A extends B & (C | { () => string })>(a: A) {}
keean commented 7 years ago

Whilst that is nice, I am not sure you realise what you can do with type classes :-), for example A can implement multiple type classes, and be in multiple type-classes. so the '&' above is already handled by type classes. As for the '|' or, I am not sure that makes any sense as { () => string } can be declared as an instance of 'C'. So you can achieve the same level of expressive power with type classes... so I dont think your claim of 'most expressive' type system is true...

Edit: now add to that multi-parameter type classes which can express relations between types.

SimonMeskens commented 7 years ago

I'm saying it is "one of" the most expressive type systems. It can currently do things no other language that I know of can, but it can't do type classes, pattern matching, polymorphic functions or extension methods. I would love to add these things to the language, hence why I'm participating in this thread.

The language can actually already type most common cases where you'd use a type class, but in a lot of cases, it becomes verbose and finicky. Adding proper type classes would be a step up. Just remember that types in TypeScript are never nominal, unless they are tagged with a provably unique property and types can morph structure, as types in prototypal inheritance are mutable. That's right, the type declarations themselves are mutable. Just like in Lisp, I can change the constructor of a type at runtime and we have to model such behavior.

RyanCavanaugh commented 7 years ago

I feel the need to restate some things before we get too deep in the weeds here:

There is a hard short-circuit in place here: If you're going to propose new syntax, but can't show how to transpile that code in a way that is agnostic to the input types, it is a complete non-starter. We've looked at over 1,000 suggestions here and none of those taken have type-directed emit.

It's basically "here be dragons" territory because an expression changing into type any, or getting aliased through some different type, suddenly stops working in a way that is undebuggable. This is the firmest line we have other than not outright changing JavaScript runtime behavior.

keean commented 7 years ago

How about:

function fn<A, B, C>(a : A, c : C) where A : TypeRelation<B>, B : TypeRelation<C>

I guess it really depends on what you consider the most common cases of type-class use, but I find a lot of use for multi-parameter type classes. Then we should also talk about type-classes with associated types.

@RyanCavanaugh I don't see any need for a type directed emit with type-classes. It is of course one way to implement them, but as I pointed out above, you can see them as a type constraint, which constrains duck typing. Even with nominal type-classes, all you do is constrain which programs pass the type-checker, it does not need to add type-dependent emits.

Edit: also there should not be an 'any' type in my opinion, and type-classes don't work like that.

SimonMeskens commented 7 years ago

@keean what you describe as type classes seems to be far more simple than what @shelby3 proposes. Can you give us a few samples of behavior that TypeScript currently doesn't check that type classes would allow?

Just so we're on the same page here.

SimonMeskens commented 7 years ago

@RyanCavanaugh I think this meets all of your requirements:

Okay, let's analyze what we want to do. Here's the starting point:

class Car { revision: string; }
class Animal { species: string; }
class Dog extends Animal { breed: string; }
interface Identifier { (): string }
interface Identifyable { identify: Identifier }

function identifyCar(this: Car) { return this.revision; }
function identifyAnimal(this: Animal) { return this.species; }
function identifyDog(this: Dog) { return this.breed; }

function logIdentity (arg: Array<Identifyable>) {
    arg.forEach(element => {
        console.log(element.identify());
    });
}

let all = [new Car(), new Dog(), new Animal()];

The question is, how do we call logIdentity() with array all as a parameter?

In contemporary JavaScript, we would add the specific identify functions to the prototype with the name identify. What if we had multimethods? Well, the function definitions would change to:

function identify(this: Car) { return this.revision; }
function identify(this: Animal) { return this.species; }
function identify(this: Dog) { return this.breed; }

function logIdentity (arg: Array<Car|Animal|Dog>) {
    arg.forEach(element => {
        console.log(identify.call(element));
    });
}

The problem is, doing multimethods like this is pretty much impossible and an emit/interop nightmare. Part of the problem we ran into earlier is this emit. Imports become even worse to handle.

We can solve this by introducing a new syntax for composable multiple dispatch functions:

interface Dispatch<T, U extends Function> {
    guard(obj: T): obj is T;
    dispatch: U;
}

let identifyCar: Dispatch<Car, Identifier> = {
    guard(obj: Car): obj is Car { return obj instanceof Car },
    dispatch(this: Car) { return this.revision; }
}
let identifyAnimal: Dispatch<Animal, Identifier> = {
    guard(obj: Animal): obj is Animal { return obj instanceof Animal },
    dispatch(this: Animal) { return this.species; }
}
let identifyDog: Dispatch<Dog, Identifier> = {
    guard(obj: Dog): obj is Dog { return obj instanceof Dog },
    dispatch(this: Dog) { return this.breed; }
}

//notice that order matters
function identify(): string from identifyCar, identifyDog, identifyAnimal;

The multiple dispatch syntax lets you compose dispatchers that have the Dispatch structure. I bet we can further sugar the dispatch functions to look prettier, but note that in TypeScript, you are forced to write the type guard, since it structurally typed. With further sugar, you might be able to write something like this, that relies on instanceof:

let identifyCar = function identify(): string for Car {
    return this.revision;
}

With ES2016, you can allow this dispatch syntax for any object with Symbol.species, which is how instanceof works from now on. We can write type guards not just for classes this way, if I'm not mistaken.

The multiple dispatch function itself would emit this code:

function identify(): string {
    if (identifyCar.guard(this))
        return identifyCar.dispatch.call(this);
    else if (identifyDog.guard(this))
        return identifyDog.dispatch.call(this);
    else if (identifyAnimal.guard(this))
        return identifyAnimal.dispatch.call(this);

    throw "Useful error message";
}

The final step is make the code fully dynamic:

function logIdentity(arg: Array<typeof identify>) {
    arg.forEach(element => {
        console.log(identify.call(element));
    });
}

let all: Array<typeof identify> = [new Car(), new Dog(), new Animal()];
logIdentity(all);

I used the typeof keyword here to signify that all types guarded by the multiple dispatch method are legal. The completed code sample will look like this:

class Car { revision: string; }
class Animal { species: string; }
class Dog extends Animal { breed: string; }
interface Identifyable { identify(): string }

let identifyCar = function identify(): string for Car {
    return this.revision;
}

let identifyAnimal = function identify(): string for Animal {
    return this.species;
}

let identifyCar = function identify(): string for Car {
    return this.breed;
}

//notice that order matters
function identify(): string from identifyCar, identifyDog, identifyAnimal;

function logIdentity(arg: Array<typeof identify>) {
    arg.forEach(element => {
        console.log(identify.call(element));
    });
}

let all: Array<typeof identify> = [new Car(), new Dog(), new Animal()];
logIdentity(all);

I'm not sure if this system is exactly type classes, but it's just as expressive and concise and it actually opens the doors to use the Dispatch interface for pattern matching syntax. Thoughts?

keean commented 7 years ago

Well lets extend the above example:

type Meters = int
type Yards = int
type Centimenters = int

interface ConvertTo<A> {
    convert(a : Self) : A
}

implement ConvertTo<Meters> for Yards {
    convert(a : Yards) : Meters {
        return a / 1.0935
    }
}

implement ConvertTo<Centimeters> for Meters {
    convert(a : Meters) : Centimeters {
        return a / 100
    }
}

function print_centimeters(x : Centimeters) {
    log(x)
}

function fn<A, B, C>(a : A) : C where A : ConvertTo<B>, B : ConvertTo<C> {
    return convert(convert(a))
}

var (x : Yards) = 1

print_centimeters(fn(x))
// compilation will fail if the type of 'x' is not convertable
// to centimeters in exactly two steps.
spion commented 7 years ago

Typeclasses allow you to define and implement new ones after a type has already been declared.

For example, you could define a trait:

trait Eq<T> {
  eq(t1:T, t2:T):boolean;
}

then implement it for built-in types

instance Eq<String> {
  eq(s1, s2) { return s1 == s2; }
}

You can also conditionally implement traits for existing generic types:

instance Eq<Array<T>> if Eq<T> {
  eq(a1, a2): { 
    if (a1.length !== a2.length) return false;
    for (var k = 0; k < a1.length; ++k) if (!eq(a1[k],a2[k])) return false;
  }
}

The two above already give you working eq([["hello", "world"], ["a"]], [["hello", "world"], ["a"]])

Finally type-based dispatch lets you define traits which return the desired type (instead of being restricted to an argument)

trait Wrappable<T<U>> {
  wrap(u:U):T<U>
}

And of course implement it for built in types 😀

instance Wrappable<Promise<T>> {
  wrap(t) { return Promise.resolve(t); }
}

Now lets see what it takes to do this in typescript:

interface Eq<T> { 
  eq(other:T)
}

We cannot implement it for string; we could with extension methods (but extending built in prototypes is frowned upon).

extend string {
  eq(other) { ... }
}

I'm not sure if we can conditionally extend Array at all. If we are adding to the prototype, its going to be there for all arrays. I suppose you could:

extend Array<T extends Eq> {
  eq(other) { ... }
}

And finally, we obviously can't do dispatch based on return type. And they can't be modular (i.e. scope based) - they have to be global, like in Haskell.

SimonMeskens commented 7 years ago

Ah, good, my proposal covers all those cases. All it's missing is extension methods, which is a separate issue and would allow for better call-site syntax.

shelby3 commented 7 years ago

@keean great to see you arrived. Guys I will be out-of-commission for hours up to a day or so, due to having a flare-up of my autoimmune illness temporarily making it very difficult for me to read and think clearly. I will catch up asap when I'm recovered. Se also the parallel discussion ongoing at the N4JS language Issue..

keean commented 7 years ago

@SimonMeskens of course I don't think we can implement the transitive closure of 'conversions' without emitting code, so this will not have the full power of type-classes due to the goals of the language.

SimonMeskens commented 7 years ago

@keean Not familiar with the term "conversions", can you give me a code sample that demonstrates behavior?

keean commented 7 years ago

See the example I posted above, 5 posts above yours. 'conversions' is just the type-class example (ConvertTo) that I used. We can type a fixed number of conversions, but typescript (due to restrictions on emit) cannot calculate the transitive closure of the conversions and emit the correct conversion code to convert between any two types that are convertable through an arbitrary number of intermediate types. We can do this in Haskell and Rust.

shelby3 commented 7 years ago

@keean wrote:

@RyanCavanaugh I don't see any need for a type directed emit with type-classes. It is of course one way to implement them, but as I pointed out above, you can see them as a type constraint, which constrains duck typing. Even with nominal type-classes, all you do is constrain which programs pass the type-checker, it does not need to add type-dependent emits.

.

@SimonMeskens of course I don't think we can implement the transitive closure of 'conversions' without emitting code, so this will not have the full power of type-classes due to the goals of the language.

Do you mean the programmer would manually supply the implementation of the trait and other boiler-plate? So the compiler is only checking that the types match, but not inferring the type of the implementation and not emitting any code structure.

I find that less attractive, but if that is the only way to get it into TypeScript, I guess it is better than not having the feature.

(apology I am not following your discussion in great thought and detail yet, due to temporary brain fog)

keean commented 7 years ago

So consider:

interface TransConvert<A> {
    trans(a : Self) : A
}

implement TransConvert<B> for A where A : ConvertTo<B> {
    trans(a : A) : B {
        return convert(a)
    }
}

implement TransConvert<B> for A where A : TransConvert<C>, C : TransConvert<B> {
    trans(a : A) : B {
        return trans(trans(a))
    }
}

a call to trans will result in a backtracking search of conversions such that the compiler finds the transitive closure of available conversions for the types, and emits the correct conversion function.

keean commented 7 years ago

@shelby3 It seems that emitting code (generating JavaScript in the compilation phase) is against the core design principals of typescript. So something like type-classes does seem a good idea for typescript even though it will not have the full power of type-classes available. I agree it is less attractive and it would appear that there are still no languages providing the feature set you and I discussed, and none really going for that point in the design space.

SimonMeskens commented 7 years ago

I'd love to further talk about the design space you want to see filled, but the problem with what you're proposing is that large parts of it are specific to a set of languages that TypeScript simply doesn't belong to. I think there's probably way more elegant ways to approach the same expressive power, but you are looking at it with the wrong glasses on. I can show you how prototypal languages work completely different and how to adapt ideas from one to the other, but as it stands, most of your code samples just don't make sense.

It looks like you are trying to get better type extensions through type classes. Extension methods in TypeScript are currently a very hard problem to solve, but at the same time, extending a type in a prototypal language is just a question of embedding new features on the prototype. Unlike what you said earlier, that's perfectly acceptable, common and the use case that modern JavaScript engines optimize for.

You want to add a new method onto an Array? Just subclass Array and add an extension to it. You want to call that function on a regular array? Use call/apply. Want that to look prettier? Solve extension methods, though that really doesn't add any expressive power, it just looks pretty.

The only really new feature in this discussion is polymorphic functions, which I provided an implementation for above. In Self, you could do even more, because it's more tailored towards swapping out entire delegation objects. Unfortunately, Javascript only just started allowing that and engines haven't optimized for it at all. We also have no way properly type that yet in TypeScript, and type classes might help there.

We can use the above implementation of multiple dispatch to make type classes a purely compile time feature.

RyanCavanaugh commented 7 years ago

This is some nearly-equivalent code that works in TypeScript 2.0

// Boilerplate, once
interface Dispatch<T, U extends Function> {
    guard(obj: T): obj is T;
    dispatch: U;
}
type Dispatcher<InType, FuncType> = (arg: InType) => FuncType;

function dispatchFactory<InType, TFunc extends Function>(...args: Dispatch<InType, TFunc>[]): Dispatcher<InType, TFunc> {
    return function(this: any, ...funcArgs: any[]): TFunc {
        for(const c of args) {
            if (c.guard(this)) {
                return c.dispatch.apply(this, funcArgs);
            }
        }
        throw new Error('oops');
    }
}

// Convenience method for instanceof-ing
function guard<T, U extends Function>(ctor: new(...args: any[]) => T, invoke: U): Dispatch<T, U> {
    return {
        guard: (a: any): a is T => a instanceof ctor,
        dispatch: invoke
    };
}

// User code time
class Car { revision: string = 'caddy'; }
class Animal { species: string = 'mammal'; }
class Dog extends Animal { breed: string = 'pomeranian'; }

let identifyCar = function(this: Car) { return this.revision; };
let identifyAnimal = function(this: Animal) { return this.species; };
let identifyDog = function(this: Dog) { return this.breed; };

type Identifiable = Car | Animal | Dog;
const printIdentity = dispatchFactory<Identifiable, (this: Identifiable) => string>(
    guard(Dog, identifyDog),
    guard(Animal, identifyAnimal),
    guard(Car, identifyCar));

function logIdentity(arg: Identifiable[]) {
    arg.forEach(element => {
        // Wait for ES7 bind operator and write element::printIdentity(); instead, which would be typesafe
        console.log(printIdentity.call(element));
    });
}

let all = [new Car(), new Dog(), new Animal()];
logIdentity(all);
shelby3 commented 7 years ago

@SimonMeskens wrote:

Extension methods in TypeScript are currently a very hard problem to solve

Because they bind the extended interfaces within the instance that is extended, which a reduction in degrees-of-freedom and mucks up many things and also because the type of instances are first-class (per @RyanCavanaugh's upthread link to problems with implementing them in TypeScript). I already explained that typeclasses don't have this problem of interacting with first-class aspects and they don't bind to instances (only refer to the type of instances but not bound within those types). Typeclasses keep all aspects orthogonal. @keean has explained that unless we are referring to virtualized traits (aka trait objects in Rust), the typeclass paradigm is monomorphic and not first-class. It is something similar to an intelligent macro (AST transformation) at the function use site.

We can entirely write typeclasses in JavaScript manually and doing all the boilerplate and "transitive closure of available conversions" manually. It just wouldn't be as convenient and fool-proof as having the compiler emit the boilerplate. JavaScript code can interopt with the boilerplate code such as my first example. Consider typeclasses to be a (boilerplate heavy) design pattern that JavaScript programmers can employ in JavaScript.

You want to add a new method onto an Array? Just subclass Array and add an extension to it.

That binds the the new method to one class, which means functions which consume that new interface only can get instances which bind that new interface to themselves via inheritance. And all preexisting instances of Array can't avail of that new method, at least not type checked.

You want to call that function on a regular array? Use call/apply.

That won't work if the implementation of the new method for the subclassed Array relies on new member properties in the subclassed Array. The entire point of typeclasses is that each type can be implemented a different way for the typeclass interface. You are simply entirely missing the point of Wadler's famous Expression Problem.

keean commented 7 years ago

@RyanCavanaugh That has a huge amount of boilerplate. Maybe its the best that can be done with the restriction of emitting code, but I would want to write something like:

class Car { revision: string = 'caddy'; }
class Animal { species: string = 'mammal'; }
class Dog extends Animal { breed: string = 'pomeranian'; }

interface Identify {
    as_string() : string
}

implement Identify for Car {
    as_string() : string {
        return this.revision
    }
}

implement Identify for Animal {
    as_string() : string {
        return this.species
    }
}

implement Identify for Dog {
    as_string() : string {
        return this.breed
    }
}

function logIdentify<A : Identify>(x[] : List<A>) : void {
    x.forEach(element => {
        console.log(element.as_string())
    }
}

let all = [new Car(), new Dog(), new Animal()];
logIdentity(all);
RyanCavanaugh commented 7 years ago

I don't understand. This is the only non-"write once" portion:

let identifyCar = function(this: Car) { return this.revision; };
let identifyAnimal = function(this: Animal) { return this.species; };
let identifyDog = function(this: Dog) { return this.breed; };

type Identifiable = Car | Animal | Dog;
const printIdentity = dispatchFactory<Identifiable, (this: Identifiable) => string>(
    guard(Dog, identifyDog),
    guard(Animal, identifyAnimal),
    guard(Car, identifyCar));

which is is smaller than what you've posted right there. Everything else in my sample is code that you'd only need in one file, ever, throughout your project.

SimonMeskens commented 7 years ago

Yup, that's the only part, all my proposal does is add syntactic sugar to codify that behavior. The advantage to mine is that codifying dispatch guards like that would make it easier to write both polymorphic functions and pattern matching.

keean commented 7 years ago

@RyanCavanaugh I think you need to include the definitions of "identifyX" in there too.

The problem with your code is that 'printIdentity' needs to be rewritten (and recompiled) every time you insert a new type into the 'all' list.

RyanCavanaugh commented 7 years ago

The thing is, there are so many different dispatch policies. I'm sure some people would want multi-dispatch for objects satisfying multiple conditions. Some would want an exception when an object failed all tests, some wouldn't. Some people will want to pass the object through this and some will want it in the parameter list.

I'm not sure the 35 pages of discussion so far are merited for a slight change in sugar for the sake of a static dispatching template. And since none of it depends on the type system for correct execution, the ES committee is usually the right place to start for pushing this into the JS ecosystem.

@keean that's also true of the proposed sugar, no?

shelby3 commented 7 years ago

@keean wrote:

The problem with your code is that 'printIdentity' needs to be rewritten (and recompiled) every time you insert a new type into the 'all' list.

I hope @SimonMeskens will start to appreciate what I wrote 6 days ago and reiterated yesterday about typeclasses solve Wadler's famous Expression Problem. Extension methods apparently don't.

keean commented 7 years ago

I can extend my version like this:

class MyCat {
    name : string = "Widget"
}

implement Identify for MyCat {
    as_string() : string {
        return this.name
    }
}

all.push(new MyCat());
logIdentity(all);

Here none of the original code above needs to change. logIdentity can be in a different module, already compiled, as can the interface definitions.

keean commented 7 years ago

@shelby3 whilst the discussion is interesting, I think its too much of a direction change for typescript. My expectation would be an attempt to gain some partial type-class like features by expanding on the existing concepts, although this will lead to a more complex and inconsistent language.