microsoft / TypeScript

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

Suggestion: Extension methods #9

Closed RyanCavanaugh closed 8 years ago

RyanCavanaugh commented 10 years ago

Allow a declarative way of adding members to an existing type's prototype

Example:


class Shape {
 // ...
}

/* ...elsewhere...*/
extension class Shape { // Syntax??
    getArea() { return /* ... */; }
}

var x = new Shape();
console.log(x.getArea()); // OK
knazeri commented 10 years ago

Highly suggested :+1: This would add methods to existing types without creating a new derived type or modifying the original type and it would definitely make sense in linq to typescript query operators

The original issue at codeplex had pretty much lots of votes: https://typescript.codeplex.com/workitem/100

basarat commented 10 years ago

:+1:

mpawelski commented 10 years ago

:+1:

basarat commented 10 years ago

Perhaps this can be used in an ambient context as well :

declare module foo{
    class A{}
    extension class A{} 
}
saschanaz commented 10 years ago

extending A { } for short? I think we don't need class there.

knazeri commented 10 years ago

A couple of questions: What would be the compiled js like? And how would be the runtime interoperability with the existing class? Extension methods in C# are static methods with syntactic sugar for instance method call syntax with the first parameter specifying the type the method operates on. Which brings some benefits beside the instance call syntax:

Having considered extension class syntax, is extension class actually a class or a module at runtime? How do you translate this in a body of an extension method at runtime? (Function.apply, Function.call maybe?) And how would extension methods be like on an interface which I believe is the most commonly use case for extension methods?

zlumer commented 10 years ago

While the naive implementation of extension methods is probably like this:

extension class Shape { // Syntax??
    getArea() { return /* ... */; }
}
console.log(x.getArea()); // OK

Compiling to:

// js
var _Shape = {
    getArea:function() { return /* ... */; }
}
console.log(_Shape.getArea.call(x));

I think due to first-class functions in JS there is a problem with this approach:

extension class Shape { // Syntax??
    getArea() { return /* ... */; }
}
var x = new Shape();
var y = new Shape();
console.log(x.getArea); // Function or undefined?
x.getArea.foo = "bar";
console.log(y.getArea.foo); // "bar" or undefined?

If extension methods are to be implemented, there would probably have to be restirictions on accessing/changing their properties.

Considering "class" syntax

extension class Shape { // Syntax??
    getArea() { return /* ... */; }
}

In my opinion, one of the common uses of extension methods is to make them work on interfaces, not just classes, so you could do something along the lines of

interface IPoint {
    x:number;
    y:number;
}
class Point implements IPoint {
    constructor(public x,public y) {
        // ...
    }
}
var p1:Point = new Point(0, 50);
var p2:IPoint = { x:32, y:32 };

console.log(p1.getLength()); // extension method
console.log(p2.getLength()); // same extension method

If possible, I think C#-like extension methods would work best:

extension function getLength(this p:IPoint):number {
    return Math.sqrt(p.x*p.x + p.y*p.y);
}
// another extension method with the same name but for different class
extension function getLength(this s:string):number {
    return s.length;
}

console.log(getLength); // compiler error
console.log(p1.getLength); // Function or undefined or compiler error?
console.log(p1.getLength.foo); // compiler error
p1.getLength.foo = "bar"; // compiler error

Compiling to:

// js
function _IPoint$getLength() {
    return Math.sqrt(this.x*this.x + this.y*this.y);
}
function _string$getLength() {
    return this.length;
}
console.log(_IPoint$getLength.call(p1)); // OK
console.log(_IPoint$getLength.call(p2)); // OK
saschanaz commented 10 years ago

Hmm, I thought that this proposal was just to extend prototypes.

class A {
    foo() {

    }
}
extending A {
    bar() {

    }
}

Compiling to:

var A = (function () {
    function A() {
    }
    A.prototype.foo = function () {
    };
    return A;
})();
A.prototype.bar = function () {
};
knazeri commented 10 years ago

I don't think extension methods should be implemented by adding a function to the prototype! For one thing extending the prototype of built in types, is considered dangerous, 3rd party libraries may also add a method with the same name to the prototype, causing the behavior to depend on which JS file was loaded last, or worse you might overwrite the existing built-in methods! And for another, there's no way to have different method overloads for the extension methods defined in different modules! More importantly, prototype extending works only for concrete classes, in a sense, no interface or declared class extension methods!

basarat commented 10 years ago

I still agree with @SaschaNaz . There are issues surely but for :

3rd party libraries may also add a method with the same name to the prototype, causing the behavior to depend on which JS file was loaded last, or worse you might overwrite the existing built-in methods

this would be caught by name collision in the lib's .d.ts file and your custom.d.ts file.

More importantly, prototype extending works only for concrete classes, in a sense, no interface or declared class extension methods

I don't expect it there to be any codegen for ambient declarations. PS: If you ambiently say something is a class TypeScript already assumes it has a prototype. e.g.

declare class Foo{}
console.log(Foo.prototype); // Compiles fine

So adding to the ambient prototype is consistent with what already exists.

Perhaps examples of your concerns (if still there) would help.

knazeri commented 10 years ago

this would be caught by name collision in the lib's .d.ts file and your custom.d.ts file.

This is only true if there is .d.ts metadata for every 3rd party library (which is not) and only when .ts files are being compiled together (ie. in the same project). Besides there are lots of .js libraries as part of server-side environment which are being injected into dynamic pages at runtime (take asp.net server controls for example)

If you ambiently say something is a class TypeScript already assumes it has a prototype

Ambient class declaration does not have implementation, which is not the case for extension methods, unless extension methods for ambient class declarations are being erased in the compiled .js (which I believe is counterproductive to it's purpose), the program might break at runtime!

And how about interfaces? To extend an interface new methods has to be added to the interface, but changing the interface by adding new methods would break compatibility with existing consumers! Take IEnumerable<T> in C# for example, I cannot imagine how many classes are already implementing this interface! Without having extension methods on interfaces, there could be no LINQ as we know it!

I believe the real advantage of extension methods is being loosely coupled with the existing type, e.g.

Array.prototype.join = function (inner, outerKeySelector, innerKeySelector, resultSelector) {
};

The above example is supposed to add linq-like join operator for arrays, however having altered the prototype, we have lost the built-in join method. Having extension methods separated in a concrete different method actually benefits better method overloading.

There's also a big benefit with generic extension methods which is reducing the need for a common base class. The following is an example of using generic extension method in C#:

public static bool In<T>(this T obj, params T[] source)
{
    return new HashSet<T>(source).Contains(obj);
}

Which you can simply use:

var o = 1;
o.In(1, 2, 3, 4, 5); /// true
RyanCavanaugh commented 10 years ago

Great discussion so far.

There's a key decision point here on this feature, which is how the emitted code would work. I see two options based on what's been discussed so far

Example code I'll use

class Square { /* ... */ }
extension function getArea(this: Square) { // First param must be 'this'
    return this.width * this.height;
}
var x = new Square(10, 10), y: any = x;
console.log(x.getArea());
console.log(y.getArea());

Add to .prototype

/* Regular emit for 'Square' here */
Square.prototype.getArea = function() {
    return this.width * this.height;
}
var x = new Square(10, 10), y = x;
console.log(x.getArea());
console.log(y.getArea()); // Succeeds

Pros

Cons

Rewrite call sites

/* Regular emit for 'Square' here */
__Square_getArea = function() {
    return this.width * this.height;
}
var x = new Square(10, 10), y = x;
console.log(__Square_getArea.call(x));
console.log(y.getArea()); // Fails

Pros

Cons

knazeri commented 10 years ago

@RyanCavanaugh I would alter the example code, to use the type parameter for the extension method instead of dynamic binding of this :

class Square { /* ... */ }
extension function getArea(this square: Square) { // First param must be 'this'
    return square.width * square.height;
}
var x = new Square(10, 10);
console.log(x.getArea());

Which would translate to this:

function getArea(square) {
    return square.width * square.height;
}
var x = new Square(10, 10);
console.log(getArea(x));

So that the function behave like a static helper method and it's valid to call it non-instance way. However still fails when strong type information is not present. You can call the extension function explicitly:

var x = new Square(10, 10), y:any = x;
console.log(x.getArea()); // Succeeds
console.log(getArea(y));  // Succeeds
ivogabe commented 10 years ago

I like the suggestion of @KamyarNazeri, this can be used on an enum (or an union in the feature) too. This code is also faster than a prototype call or a .call().

Would the extension keyword be necessary? Since you already add this to the first argument.

knazeri commented 10 years ago

@ivogabe +1 for mentioning enum, I would like to also mention that using static functions, you can call methods on objects that are null, e.g. isNullOrEmpty for string type. small things yes, but darn useful

saschanaz commented 10 years ago

How about this?

class Square { /* ... */ }
function getArea() joins Square {
    return this.width * this.height;
}
var x = new Square(10, 10);
console.log(x.getArea());

Translates to:

/* Regular emit for 'Square' here */
__Square_getArea = function(_this) {
    return _this.width * _this.height;
}
var x = new Square(10, 10);
console.log(__Square_getArea(x));

PS: Or without renaming, as @KamyarNazeri did. I like the non-instance way.

function getArea(_this) {
    return _this.width * _this.height;
}
var x = new Square(10, 10);
console.log(getArea(x));
RyanCavanaugh commented 10 years ago

I want to emphasize the danger of the "rewrite call sites" approach:

class Square { /*... */ }
/* Extension method getArea, syntax irrelevant */
function process(callback: (n: Square) => void) {
    /* invoke callback on a variety of Squares */
}

process((n) => {
    n.getArea(); // Succeeds
});
process(((n) => {
    n.getArea(); // No compile error, fails at runtime
}));

var x = new Square(), y: any;
var arr1 = [x];
arr1[0].getArea(); // Succeeds
var arr2 = [x, y];
arr2[0].getArea(); // No compile error, fails at runtime

There are very strong reasons TypeScript avoids rewriting code based on type information (see https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals). No one wants to get in a situation where adding a non-observed element to an array or adding extra parentheses around a function could causes program to start to fail at runtime.

knazeri commented 10 years ago

@RyanCavanaugh Seriously I don't understand why by adding extra parenthesis around a lambda function, the runtime infers variable n as any? It's like to evaluate variable b's type argument in the following code as any (which is not)

var a = function (n: number): void { }
var b = (a);  // here b accepts number!

As for the latter example, I believe no-one expects an extension method to work when boxing variable to another type (here any due to the type mismatch in the array). At least this is how C# / VB work, with boxing to another type, there's no extension method! Only they don't compile in such situations.

danquirk commented 10 years ago

We explicitly decided parenthesizing an expression should be the way to opt out of contextual typing or else you were always at the mercy of the inference algorithm even if it was wrong/incomplete.

saschanaz commented 10 years ago

Maybe we can choose separate syntax to call those functions.

class Square {
  foo() {
  }
}
function getArea() joins Square {
  return this.width * this.height;
}
var x = new Square(10, 10);
var y: any = x;
console.log(x calls getArea()); // Succeeds
console.log(y calls getArea()); // Succeeds both in compiling and in runtime.
console.log(x.getArea()); // Fails, this is only for functions in prototype
console.log(x calls foo()); // Fails, this is only for extension functions

PS: Fixed to allow y calls getArea().

knazeri commented 10 years ago

This turned to be more complicated that it looked like! Having rewrite call sites comes handy when coding, however I agree that the code might break at runtime! I've seen lots of javascript developers debugging their code in the browser developing tools which also fails using rewrite call sites. But the ability to extend interfaces is so good that you can't simply ignore it, so probably we are addressing two different problems here:

Maybe we could consider Java 8 default methods (or as they are often called defender methods) to extend an existing interface. Of course default methods in an interface could translate back to prototype in the emitted js code:

interface ISquare {
    width: number;
    height: number;
    default getArea(): number {
        return this.width * this.height;
    }
}

class Square implements ISquare {
    constructor(public width: number, public height: number) {
    }
    // no need to implement getArea here
}

var x = new Square(10, 10), y: any = x;
console.log(x.getArea());  // Succeeds
console.log(y.getArea());  // Succeeds

which would compile to:

var Square = (function () {
    function Square(width, height) {
        this.width = width;
        this.height = height;
    }
    Square.prototype.getArea = function () {
        return this.width * this.height;
    };
    return Square;
})();
basarat commented 10 years ago

default methods in an interface could translate back to prototype in the emitted js code

This interface looks more like an abstract class. #6

knazeri commented 10 years ago

This interface looks more like an abstract class.

Only a class might already inherit another base class, I think the real benefit with interface extension comes with multiple inheritance simulation.

ivogabe commented 10 years ago

Maybe we could consider Java 8 default methods

The difference is that interfaces in TypeScript can be used without classes. How would the following code compile?

interface ISquare {
    width: number;
    height: number;
    default getArea(): number {
        return this.width * this.height;
    }
}
var square: ISquare = {
    width: 100,
    height: 50
};
console.log(square.getArea);

Maybe we can choose separate syntax to call those functions.

Maybe call the function like this: (since this syntax already exists)

getArea(square);
// instead of
square calls getArea();
saschanaz commented 10 years ago

Maybe call the function like this: (since this syntax already exists)

Chaining would become uncomfortable in that case. Example:

var num = new Number(0);
function plus(input: number) joins Number {
  return this.valueOf() + input;
}
num.plus(3).plus(3).plus(3);
num calls plus(3) calls plus(3) calls plus(3); // Well... not very good to see. Hmm

versus

plus(plus(plus(num, 3), 3), 3);

...while the rewritten JavaScript will be anyway plus(plus(plus ... form.

Edited:

num->plus(3); // Syntax that looks like the C++ one but for extensions?

or

num<-plus(3); // function 'plus' joins to 'num'.
knazeri commented 10 years ago

Yea well, object literals are not the only problem:

interface A {
    default void foo() {
        console.log("A");
    }
}

interface B {
    default void foo() {
        console.log("B");
    }
}

class Bar implements A, B {
    default void foo() {    // required for conflict resolution
        console.log("Bar"); 
    }
}

var o = new Bar();
A o1 = o;
B o2 = o;
o.foo(); // Bar
o1.foo; // probably want A
o2.foo; // probably want B

From the client code perspective, default methods are just ordinary methods, so in case of the simple example with one class that implements an interface with a default method, prototype seems to be fine, however most of the time client code that invokes the default method will have to invoke the method at the call site. same old story. :zzz:

zlumer commented 10 years ago

For the record, here's an example of runtime exceptions in C#: http://stackoverflow.com/questions/5311465/extension-method-and-dynamic-object-in-c-sharp While TypeScript is certainly far more likely to have untyped variables than C# (I myself have about 60% variables untyped in my TS code and only about 10% untyped in C# code), this can be considered a known limitation. Maybe extension methods should only work with a "--noImplicitAny" compiler flag to decrease the possibility of a runtime error.

Personally I'm strongly against any silent prototype modifications - changes to any existing prototype (everything except initial class-defining code generated by TS compiler) should always be explicit, having programmer manually type "class.prototype" to prevent at least some part of the errors. Having said that, there's absolutely no reason to introduce new syntax for prototype extending: you can always do Square.prototype.getArea = /*...*/ and get all the syntactical benefits of such approach. If "rewriting call sites" is considered inappropriate, I think it's best to not introduce extension methods at all, than confuse programmers with a very dangerous silent prototype-changing feature.

Extension methods are nothing more than syntax sugar for method calls (at least that's how I see it and how they are implemented in C#). While certainly useful, they are not necessary.

saschanaz commented 10 years ago

Hmm... this is my current suggestion:

class Square {
  width: number;
  height: number;
}

// I think this is explicit by itself, saying "This will extend Square.prototype".
extending Square {
  get area() {
    return this.width * this.height;
  }
}

// Let's make these two extending methods coexist.
function getArea() joins Square {
  // Extension functions can only access public properties, as normal functions works so.
  return this.width * this.height;
}

var square: Square;
var x: any = square;
square.area;
square<-getArea(); // New syntax to call extension functions
getArea(square); // Still allows normal function call
x<-getArea(); // Even works without any type information

becomes

var Square = (function () {
    function Square() {
    }
    return Square;
})();

(function () {
    Object.defineProperty(Square.prototype, "area", {
        get: function () {
            return this.width * this.height;
        },
        enumerable: true,
        configurable: true
    });
});

function getArea(_this) {
  return _this.width * _this.height
}

var square;
var x = square;
square.area;
getArea(square);
getArea(square);
getArea(x);

Edited: Added getArea(square);

saschanaz commented 10 years ago

BTW, wouldn't 'conditional extension' help us? I think some extensions are to polyfill new standard functions for old browsers.

extending Window {
  // We don't want to overwrite functions that potentially already exist.
  createImageBitmap() { /* */ }
}

extending Window {
  // We cannot do this because this is already declared in lib.d.ts.
  setImmediate() { /* */ }
}

We would have to keep using Window.prototype.x = something, or window.x = something here, to add functions conditionally.

electricessence commented 10 years ago

So is this simply a syntax discussion? Since this can already be done this way:

module A {
  class B {
    c():void {}
  }

 // somewhere else.
 module B {
 // Extend.
  d():void {}
 }

}
ivogabe commented 10 years ago

@electricessence That way you can add static members, not instance members (to the prototype).

@SaschaNaz I don't think we need a special syntax for that, because you don't need to add type-info for those functions. You can just write:

if (!window.setImmediate) {
    window.setImmediate = (cb: () => void) => window.setTimeout(cb, 1);
}

window.setImmediate(() => console.log(''));
saschanaz commented 10 years ago

@ivogabe, That's right, but I would be happier if I can do that also with extension syntax, in the future when this is implemented anyway. Similar thing, similar syntax ;)

danfma commented 10 years ago

Why not just use something like the kotlin syntax?

// some class A
class Hello {
    language: string;
}

extension Hello.printHello(name: string) {
    return "Hello, " + name + " in " + this.language;
}
saschanaz commented 10 years ago

@danfma, :+1:

danquirk commented 10 years ago

If all you want to do is add an instance method why not just allow class declarations to merge and avoid the need for new syntax?

knazeri commented 10 years ago

@danquirk I guess the primary use case of extension methods is to make them work on interfaces

jbondc commented 10 years ago

Is this similar to a 'trait'?

class Shape {
 // ...
}

trait dimensions {
      getArea() { return /* ... */; }
}
Shape use dimensions;
Shape use {
      getArea() { return /* ... */; }
}

Or for compiled code:

class Shape2 extends Shape {
  use dimensions;
}

I don't know about 'use' as keyword, 'with' could be re-used:

Shape with dimensions;
Shape with {
      getArea() { return /* ... */; }
}

class Shape2 extends Shape {
  with dimensions;
}
jbondc commented 10 years ago

Would be nice if compiled time 'traits' and runtime 'extensions' can be baked into same concept: http://wiki.ecmascript.org/doku.php?id=strawman:trait_composition_for_classes

electricessence commented 10 years ago

To start you make it like C# and worry about syntax sugar later.

module A {
  class B {
    c():void {}
  }

 // somewhere else.
 module D {
 // Extend.
  static d(this target:B):void {}
 }

}
zlumer commented 10 years ago

@electricessence @danfma the problem lies mostly with compiler output, not syntax. You can see @RyanCavanaugh's great summary of two different approaches and their pros and cons.

jbondc commented 10 years ago

Personally prefer using static functions and not touching the prototype, e.g.

trait count {
    static countCalls = 0;
     count() {
        countCalls++;
        return this.length;
   }
}

Compiles to:

trait_count = {};
trait_count.countCalls = 0;
trait_count.count = function(type)
{
   return type.length;
}
class foo {
   with count;
}

Compiles to:

var foo = ...
/* begin trait_count */ 
foo.countCalls = 0;
foo.prototype.count = function() {
   foo.countCalls++;
   return trait_count.count(this);
}
/* end trait_count */ 
jbondc commented 10 years ago

Actually with static modifiers, compiled version would look more like:

trait_count.count = function(static, type, ...arguments)
{
   static.countCalls++;
   return type.length;
}

var foo = ...
/* begin trait_count */ 
foo.countCalls = 0;
foo.prototype.count = function() {
   return trait_count.count(foo, this);
}
/* end trait_count */ 

A bit uglier, only if you used 'static'. In most cases, you'd extend the prototype and proxy to a static method. I use traits again since an extension really looks like an anonymous trait.

WanderWang commented 10 years ago

Highly suggested extension keyword

RyanCavanaugh commented 10 years ago

With the advent of union types, I think the "rewrite call sites" approach is effectively ruled out, as we would no way to emit a call to a method that came in via a union type where one was an extension method and one wasn't (or if they came from two different places).

I think this is thematic with #182 and other places where we want to have something declared in lib.d.ts that you can use as a base class (in an extends clause), but can also add members to via module or interface merging.

One thing we could do is to allow you to add methods to the prototype of a class. Something like this could work:

class Dog { 
 ...
}

extends class Dog {
  woof() { /* ... */ }
}
var x = new Dog();
x.woof();

Would emit:

// Begin regular class emit
var Dog = (function () {
    function Dog() { }
    return Dog;
})();
// End regular class emit

// Begin extends class emit
Dog.prototype.woof = function () {
};
// End extends class emit
var x = new Dog();
x.woof(); // Not altered

A declare extends class would work similarly.

The only restrictions here are that an extends class could not declare initialized instance members as those initializations are emitted into the top of the constructor body. Similarly, you would not be able to add constructors or construct signatures to a class. Since there's no runtime analogue to either of these, the restrictions shouldn't be too bothersome.

knazeri commented 10 years ago

Although class extension is great asset to the language, I'm a bit disappointed not to be able to extend an interface in TypeScript while languages tend to evolve to streamline methods of adding new functionality to the interfaces!

NoelAbrahams commented 10 years ago
extends class Dog {
  woof() { /* ... */ }
}

:+1:

RyanCavanaugh commented 10 years ago

I'm a bit disappointed not to be able to extend an interface in TypeScript

Can you clarify? You can extend (subclass) an interface with extends or add members to it (augment) through interface merging.

knazeri commented 10 years ago

@RyanCavanaugh My understanding of Extension Methods is how C# does it via static methods or Java with default methods. Lets be honest, the real benefit of having extension methods is with interfaces not concrete classes. Sure you can use interface merging, but then you have to rewrite the implementations of those interfaces you have implemented throughout all your libraries.

IEnumerable<T> in C# is a great example of extension methods, I guess the whole LINQ depends on it. I was thinking with ECMA6 generators, we could have a general Iterable interface containing @@iterator and extend it linq style, could be used on String, Array, Map, Set and Generator.

Now instead, in order to extend an existing interface, we have to merge it with another interface with optional methods (not to break existing code) and extend it to all the classes implementing that interface with an ugly mixin.

saschanaz commented 10 years ago

Maybe we should find more JS-like way for those "extension methods", for example, bind() or call()...

class Foo { /* */ }
function fooExtensionMethod(this: Foo) { /* */ }

var foo: Foo;
fooExtensionMethod.call(foo);
// This is not so good... "object, method" order, please do not be backward
interface Object {
  callFn(func: Function, ...args: any[]); // from an external library?
}

foo.callFn(fooExtensionMethod);
// This is not really foo.fooExtensionMethod, but isn't it just sufficient?

(Burrowing concepts from #229, maybe we also need to solve #212)

sccolbert commented 9 years ago

I'm :-1: on extending the object prototype. An extension doesn't own the class, it shouldn't be able to mess with it's prototype. The issues @SaschaNaz mentions are real, and IMHO not worth the effort to attempt to surmount, because the number of corner cases in the case of dynamically loaded script is huge.

I would be :+1: on rewriting the call site (just hypothetically speaking), but that would open the door to rewriting call sites in other places (like overloaded functions) which, as far as I understand, is a ship that's already sailed, and I would be :-1: on semantics special-cased to extension methods.

So, while I like the idea of extension classes, I'm not sure there's an effective way to implement them, given the larger constraints already imposed on the language.