As of ES2015, new ECMAScript standard library APIs have used a protocol-based design, enabled by the introduction of Symbols. Symbols are ECMAScript values which have identity and may be used as object property keys. The goal of this proposal is to provide a convenient syntactic facility for protocol-based design.
Stage: 1
Champions:
- Michael Ficarra (@michaelficarra)
- Lea Verou (@leaverou)
The syntax for declaring a protocol looks like this:
protocol Foldable {
requires foldr;
// provided members
toArray() {
return this[Foldable.foldr]((m, a) => [a].concat(m), []);
}
get length() {
return this[Foldable.foldr](m => m + 1, 0);
}
}Important
An alternative to the requires keyword is abstract. See issue #50.
Required members are defined by the requires keyword.
Any other member is provided.
Despite the syntactic similarity to class elements, the names of protocol members are actually symbols, which ensures uniqueness and prevents name collisions.
E.g. in this example, the required member is not a "foldr" property, but a Foldable.foldr symbol,
and the two methods provided will not be added to classes as "toArray" or "length" properties, but as Foldable.toArray and Foldable.length symbols.
Once a protocol is declared, it can be implemented on any class that satisfies the protocol's requirements.
Important
Currently the only constraint is around property presence. See issue #4 for discussion on additional constraint types.
One possible syntax is via the implements keyword:
class C implements Foldable {
[Foldable.foldr](f, memo) {
// implementation elided
}
}By implementing Foldable, class C now gained a C.prototype[Foldable.toArray] method and a C.prototype[Foldable.length] accessor, which it can choose to expose to the outside world like so:
class C implements Foldable {
[Foldable.foldr](f, memo) {
// implementation elided
}
get toArray() {
return this[Foldable.toArray];
}
get length() {
return this[Foldable.length];
}
}Conveniences can be provided for implementing protocols without repeating protocol names through a new ClassElement for declaring protocol implementation:
class NEList {
constructor(head, tail) {
this.head = head;
this.tail = tail;
}
implements protocol Foldable {
// Sugar for defining [Foldable.foldr]
foldr (f, memo) {
// implementation elided
}
}
}Important
Is this useful? Should it be generalized beyond protocols? See #56.
While typically classes are protocol consumers, protocols can also define implementations for existing classes, including built-in classes:
protocol Foldable {
// ...
implemented by Array {
foldr (f, memo) {
// implementation elided
}
}
}Important
Is this MVP, given Protocol.implement() can also do this? See issue #63.
Protocol implementation can also be entirely decoupled from both protocol and class definition, via the Protocol.implement() function:
protocol Functor { map; }
class NEList {
constructor(head, tail) {
this.head = head;
this.tail = tail;
}
}
NEList.prototype[Functor.map] = function (f) {
// implementation elided
};
Protocol.implement(NEList, Functor);Like classes, protocols can use inheritance to compose progressively more complex protocols from simpler ones:
protocol A { requires a; }
protocol B extends A { requires b; }
class C {
implements protocol B {
a() {}
b() {}
}
}
// or
class C {
implements protocol A {
a() {}
}
implements protocol B {
b() {}
}
}Protocols can also be constructed imperatively, via the Protocol() constructor.
All options are optional.
const Foldable = new Protocol({
name: 'Foldable',
extends: [ ... ],
requires: {
foldr: Symbol('Foldable.foldr'),
},
staticRequires: { ... },
provides:
Object.getOwnPropertyDescriptors({
toArray() { ... },
get length() { ... },
contains(eq, e) { ... },
}),
staticProvides: // ...,
});An implements operator can be used to query protocol membership, by checking whether a class satisfies a protocol's requirements and includes its provided members.
if (C implements P) {
// reached iff C has all fields
// required by P and all fields
// provided by P
}By default, both provided and required member names actually define symbols on the protocol object, which is a key part of how protocols avoid conflicts. It is possible to provide an explicit member name that will be used verbatim, by using ComputedPropertyName syntax:
protocol P {
requires ["a"];
b(){ print('b'); }
}
class C implements P {
a() {}
}
C implements P; // true
(new C)[P.b](); // prints 'b'This makes it possible to describe protocols already in the language which is necessary per committee feedback. This includes protocols whose required members are strings, such as thenables, as well as protocols whose required members are existing symbols, such as the iteration protocol:
protocol Iterable {
requires [Symbol.iterator];
forEach(f) {
for (let entry of this) {
f.call(this, entry);
}
}
// ...
}An outdated prototype using sweet.js is available at https://github.com/disnet/sweet-interfaces. It needs to be updated to use the latest syntax. A polyfill for the runtime components is available at https://github.com/michaelficarra/proposal-first-class-protocols-polyfill.
The most well-known protocol in ECMAScript is the iteration protocol. APIs such
as Array.from, the Map and Set constructors, destructuring syntax, and
for-of syntax are all built around this protocol. But there are many others.
For example, the protocol defined by Symbol.toStringTag could have been
expressed using protocols as
protocol ToString {
requires tag;
toString() {
return `[object ${this[ToString.tag]}]`;
}
}
Object.prototype[ToString.tag] = 'Object';
Protocol.implement(Object, ToString);The auto-flattening behaviour of Promise.prototype.then was a very controversial decision.
Valid arguments exist for both the auto-flattening and the monadic versions to be the default.
Protocols eliminate this issue in two ways:
- Symbols are unique and unambiguous. There is no fear of naming collisions, and it is clear what function you are using.
- Protocols may be applied to existing classes, so there is nothing preventing consumers with different goals from using their own methods.
protocol Functor {
requires map;
}
class Identity {
constructor(val) { this.val = val; }
unwrap() { return this.val; }
}
Promise.prototype[Functor.map] = function (f) {
return this.then(function(x) {
if (x instanceof Identity) {
x = x.unwrap();
}
let result = f.call(this, x);
if (result instanceof Promise) {
result = new Identity(result);
}
return result;
});
};
Protocol.implement(Promise, Functor);Finally, one of the biggest benefits of protocols is that they eliminate the fear of mutating built-in prototypes. One of the beautiful aspects of ECMAScript is its ability to extend its built-in prototypes. But with the limited string namespace, this is untenable in large codebases and impossible when integrating with third parties. Because protocols are based on symbols, this is no longer an anti-pattern.
class Ordering {
static LT = new Ordering;
static EQ = new Ordering;
static GT = new Ordering;
}
protocol Ordered {
requires compare;
lessThan(other) {
return this[Ordered.compare](other) === Ordering.LT;
}
}
String.prototype[Ordered.compare] = function() { /* elided */ };
Protocol.implement(String, Ordered);This proposal was strongly inspired by Haskell's type classes. The conceptual model is identical aside from the fact that in Haskell the type class instance (essentially an implicit record) is resolved automatically by the type checker. For a more Haskell-like calling pattern, one can define functions like
function fmap(fn) {
return function (functor) {
return functor[Functor.fmap](fn);
};
}Similar to how each type in Haskell may only have a single implementation of each type class (newtypes are used as a workaround), each class in JavaScript may only have a single implementation of each protocol. Haskell programmers get around this limitation through the use of newtypes. Users of this proposal will extend the protocol they wish to implement with each possible alternative and allow the consumer to choose the implementation with the symbol they use.
Haskell type classes exist only at the type level and not the term level, so they cannot be passed around as first class values, and any abstraction over them must be done through type-level programming mechanisms. The protocols in this proposal are themselves values which may be passed around as first class citizens.
Rust traits are very similar to Haskell type classes. Rust traits have
restrictions on implementations for built-in data structures; no such
restriction exists with this proposal. The implements operator in this
proposal would be useful in manually guarding a function in a way that Rust's
trait bounds do. Default methods in Rust traits are equivalent to what we've
called methods in this proposal.
Java interfaces, as of Java 8, have many of the same features as this proposal. The biggest difference is that Java interfaces are not ad-hoc, meaning existing classes cannot be declared to implement interfaces after they've already been defined. Additionally, Java interfaces share the member name namespace with classes and other interfaces, so they may overlap, shadow, or otherwise be incompatible, with no way for a user to disambiguate.
Ruby mixins are similar to this proposal in that they allow adding functionality to existing classes, but different in a number of ways. The biggest difference is the overlapping/conflicting method names due to everything existing in one shared namespace. Another difference that is unique to Ruby mixins, though, is that they have no check that the methods they rely on are implemented by the implementing class.
class A extends mixin(SuperClass, FeatureA, FeatureB) {}This mixin pattern usually ends up creating one or more intermediate prototype objects which sit between the class and its superclass on the prototype chain. In contrast, this proposal works by copying the provided protocol methods into the class or its prototype. This proposal is also built entirely off of Symbol-named properties, but doing so using existing mechanisms would be tedious and difficult to do properly. For an example of the complexity involved in doing it properly, see the output of the sweet.js implementation.