proposal-first-class-protocols
proposal-first-class-protocols copied to clipboard
enforce constraints on implemented functions; start by at least checking their signatures
Barring static types, it would be nice to have checks ensuring that if a protocol intends a field to be a method that an implementation at least defines is as a function (preferably of the same length).
protocol A {
a; // maybe a function
b(); // definitely a function
c(d, e); // definitely a function of at least length two
}
class B {
[A.c](d, e){}
}
B.prototype[A.a] = 'a';
B.prototype[A.b] = 'b';
B implements A; // Error: Symbol(A.b) must be a function
class C {
[A.b](){}
[A.c](){}
}
C.prototype[A.a] = 42;
C implements A; // Error: Symbol(A.c) must be a function of length >= 2
Very nice idea! I would love to explore even richer constraints, but this seems like a nice first step.
For now, I will leave this out of the proposal proper until I get a feeling for its support as-is.
Personally I would love some manner of more powerful constraints, although after writing an experimental pattern matching library (here) I found that trying to pattern match functions themselves to be pretty futile and gave up leaving just a generic func pattern which does nothing but check call-ability.
The problem with functions specifically is that there's many valid implementations with different argument lengths thanks to arguments/...args:
function add(a, b) {
return a + b
}
function add(a, ...[b]) {
return a + b
}
function add(...[a, b]) {
return a + b
}
function add() {
// And we can't forget the old arguments object
return arguments[0] + arguments[1]
}
So there's no way at class creation time to validate that sort've thing, however you could definitely make it so that it's runtime checked e.g.:
// Supposing some arbitrary fictional syntax
interface Monad {
bind(Function) -> Monad
}
// Checking on the prototype might be too eager as an instance might still be correct
class Fizz implements Monad {
constructor() {
this[Monad.bind] = someInvalidBind
}
}
class Foo implements Monad {
[Mond.bind]() {
return 3
}
}
class Bar implements Monad {
[Monad.bind](func) {
return 'Not a Monad'
}
}
new Fizz() // InterfaceError, object definitely doesn't satisfy the Monad interface at this point
new Foo().bind(3) // InterfaceError, incorrect arguments for method bind under the Monad interface
new Bar().bind(x => x**2) // InterfaceError returned type doesn't satisfy interface Monad
How feasible this is I'm not certain but it's probably worth looking into, it definitely doesn't enable arbitrary type-checking, but it might allow things to become incrementally "type" checkable.
@Jamesernator I was thinking a simple Function.prototype.length check when implements is invoked. At the very least, it ensures that the number of "required" parameters matches.
Function length is a really really unreliable way of checking things, it breaks with even just a simple decorator for example:
function cached(prop) {
...
// This function has length zero
return func(...args) {
...
}
}
protocol Foo {
a(b, c)
}
class Bar implements Foo {
// @cached a(b, c) has length 0 and so would fail the interface
// which seems counter-useful
@cached
a(b, c) {
...
}
}
That's why I came up with some other syntax so that'd be more towards the goals of what you actually want to validate which is generally protocols themselves.
For example: You care that Monad.bind returns another Monad but you shouldn't really ever care that's its implementation uses ...args or not.
class Future implements Monad {
[Monad.bind](func) {
}
}
class Future implements Monad {
// Length 0 but it doesn't matter, it's still a valid implementation
[Monad.bind](...args) {
if (args.length === 0 || typeof args[0] !== 'function') {
throw new Error(`Expected a function`)
}
return ...
}
}
It's very similar to the anti-pattern of checking if a function is an async function, while occasionally useful for introspection it tends to be more common for people to mistakenly use this as a way to try and check if something is synchronous or not, but any function can return a Promise not just async function. Effectively it's the same thing here, the details you care about (whether or not it actually takes two arguments) is separate from the implementation detail of real positional arguments.
I made a small couple functions for experimenting with type-based protocols and it seems to work just fine.
The decorators proposal is Stage 2 AFAIK so I'm not too concerned with interop, but your point is taken @Jamesernator.
I very much want the ability (totally fine to not do it by default, as in the current proposal) to enforce constraints.
Some use cases include "arraylike", but also define a protocol that describes data records - a "data property descriptor" or "accessor property descriptor" protocol, say.
One suggestion I'd have is augmenting the syntax inside protocol blocks (and a corresponding option name in the dynamic API) that allows an additional "has errors" predicate function to be supplied - the function would return something falsy if there were no errors, and a truthy string if there were any errors - and that string would be used as part of the error message thrown by Protocol.implement et al. That way, if i return nothing, it's valid, but if i return a string, something's wrong.