tc39 / proposal-first-class-protocols

a proposal to bring protocol-based interfaces to ECMAScript users

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`class`-less protocol implementation

azz opened this issue · comments

I've just been experimenting with the proposal a bit, when implementing Applicative and Maybe's instance of it, I ended up with:

// JS
protocol Applicative extends Functor {
  static pure;
  ap;
}

class Maybe implements Applicative {
  static pure(value) {
    return Maybe.just(value)
  }
  ap(mb) {
    if (this._value === nothing) return this;
    return mb[Functor.fmap](this._value);
  }
}

Note that I gravitated to putting state inside this, i.e. the Maybe, when the state actually belongs inside the Just, as it should be an algebraic data-type.

It would be great if we could also do this without classes, because they restrict the ability to do currying, don't work so great with pattern matching, and (in my opinion) encourage putting state in all the wrong places.

Here is an alternative:

protocol Applicative extends Functor {
  pure;
  ap;
}

const Just = value => ({ type: "Just", value });
const Nothing = { type: "Nothing" };

// NB: intentional use of object without `prototype` property
const Maybe = Protocol.implement({
  [Applicative.pure]: Just,

  [Applicative.ap]: ma => mb =>
    ma.type === "Just" ? Maybe[Functor.fmap](ma.value)(mb)
                       : Nothing
}, Applicative);

Which aligns much more closely to Haskell's instance:

instance Applicative Maybe where
    pure = Just

    Just f  <*> m       = fmap f m
    Nothing <*> _m      = Nothing

We could even pattern match:

const Maybe = Protocol.implement({
  [Applicative.pure]: Just,

  [Applicative.ap]: ma => mb => match (ma) {
    { type: "Just", value }: Maybe[Functor.fmap](value)(mb)
    { type: "Nothing" }:     Nothing
  }
}, Applicative);

And as a possible syntax extension:

const Maybe = implements Applicative {
  [Applicative.pure]: Just,

  [Applicative.ap]: ma => mb => match (ma) {
    { type: "Just", value }: Maybe[Functor.fmap](value)(mb)
    { type: "Nothing" }:     Nothing
  }
};

Which, to me, looks 🏆

How about?

// JS
protocol Applicative extends Functor {
  static pure
  ap
}

class Just extends Maybe implements Applicative {
  [Applicative.ap](mb) { return mb[Functor.fmap](this._value) }
}
class Nothing extends Maybe implements Applicative {
  [Applicative.ap](mb) { return this }
}
class Maybe implements Applicative {
  static [Applicative.pure](value) { return new Just(value) }
}

That would be the OO way of doing it, yes. But it means you need more classes and more methods. It also seems a bit strange that Just extends Maybe, while it fits the purpose here it seems like the wrong relationship. Additionally Maybe is now somewhat abstract and could throw if you try to construct it. In which case why have a class in the first place?

Can you clarify

Note that I gravitated to putting state inside this, i.e. the Maybe, when the state actually belongs inside the Just, as it should be an algebraic data-type.

Just is a tagged form of a Maybe. In other languages like Rust both None and Some are Options. You cannot unbox the Just/Some to be on its own.

@azz Thanks for the suggestion, but I think the current design fits better with the rest of JavaScript as a language. The idiomatic way to do this would be as @keithamus suggests. There's actually an example of a Maybe type implemented in this way already in the examples.

@michaelficarra I don't see why protocols should be implementable by classes only. It seems completely reasonable to want to implement a protocol with an object.

@aluanhaddad this is something that can be built on top of Protocol.implement

const implementForObject = (o, p) => new Protocol.implement(function () {
  this.constructor.prototype = o;
}, p);

The usage is the same as in your example @azz

const Maybe = implementForObject({
  [Applicative.pure]: Just,

  [Applicative.ap]: ma => mb =>
    ma.type === "Just" ? Maybe[Functor.fmap](ma.value)(mb)
                       : Nothing
}, Applicative);

@gabejohnson Nice idea. This is how I would do it:

const implementForObject = (o, ...protocols) => {
  class C{}
  C.prototype = Object.create(o);
  return new Protocol.implement(C, ...protocols);
};

Forgive me if this is not totally related to this issue (else I'll open a new one)
But what I'd hope this proposal can also solve, is to simply enforce a plain object structure.

For example, imagine I'm dealing with simple {x: 1.2, y: 0.9} position objects, I'd like to be able to do:

protocol Coords {
  x;
  y;
}

// the current 'unsafe' way, e.g. if I do a typo on a prop `{x: 1.2, u: 0.9}`
// const coords = {x: 1.2, y: 0.9}; 

// this should work with this proposal? but it's verbose
const coords = Protocol.implement({x: 1.2, y: 0.9}, Coords); 

// const coords implements Coords = {x: 1.2, y: 0.9}; // would be cooler
// const coords : Coords = {x: 1.2, y: 0.9}; // would be ideal
// const coords : Coords = {x: 1.2, u: 0.9}; // would throw, since `u` doesn't respect the protocol 

@caub While you could use this feature to do what you want, it would probably be more appropriate to use the proposed pattern matching feature instead.

With protocols:

protocol Coords {
  static "x";
  static "y";
}

const coords = {x: 1.2, y: 0.9};

Protocol.implement(coords, Coords); 
console.log(coords implements Coords);

With pattern matching:

function looksLikeCoords(coords) {
  return match (coords) {
    {x, y} => true,
    _ => false,
  };
}

const coords = {x: 1.2, y: 0.9};

if (!looksLikeCoords(coords)) throw new Error("message");
console.log(looksLikeCoords(coords));