Multimethods
A multimethod is a kind of generic function that behaves differently depending on which types of arguments are handed to it. You can think of it like a switch statement; however, unlike a switch statement, a multimethod can be extended by adding methods to it.
In JSOS, methods are not owned by classes or objects, they are owned by multimethods.
Basic Usage
In this section we will take a look at the most fundamental features of JSOS's multimethods. These features will likely be enough for a majority of developers and projects.
However, if you need to customize your multimethods further, take a look at the Advanced Usage section further down the page.
Defining Multimethods
You can create a multimethod by using the Generic proxy:
import { Generic } from "@fed1/jsos";
const { multi } = Generic; // Creates a multimethod
multi(); // Multimethods are just regular functions and can be called
This creates a function that is internally named multi
and is also written
into a variable called multi
. This multimethod does nothing but throw an
exception because we haven't added a method to it yet.
The
Generic
object is a proxy object that uses any property key accessed on it as an internal name for the to-be-created multimethod.JSOS often uses this pattern to make naming things more explicit than using strings as arguments.
Defining Methods
Methods can be added to a multimethod by calling the def function:
import { Generic, def, JSNumber, JSArray, TAny } from "@fed1/jsos";
const { add } = Generic;
def(add, [JSNumber, JSNumber], (a, b) => a + b);
def(add, [JSArray, TAny], (a, b) => a.push(b));
def(add, [JSArray, JSArray], (a, b) => [...a, ...b]);
The first argument to def
is the multimethod for which we want to define
a new method.
The second argument is the method signature. It's an array containing so-called comparators. Comparators are types or values that determine when it's appropriate for a multimethod to delegate its work to this method.
The third argument is the implementation. It's a JS function that receives as its arguments values of the types determined by its signature.
The imports starting with
JS
orT
are type objects representing a set of permissible values. Types are the main way JSOS uses to dispatch from a multimethod to an appropriate method.
It's also possible to use an actual value in a method signature. By default,
values are just compared by identity (===
).
For example, you could define a method that treats 0
as a special case that
gets its own method:
const { divide } = Generic;
def(divide, [JSNumber, JSNumber], (a, b) => a / b);
def(divide, [JSNumber, 0], a => {
throw new RangeError("Cannot divide by zero.");
});
Variable-length Signatures
Usually method signatures must have exactly as many parameters as there are arguments provided to the multimethod. Sometimes though you need to define a method with a signature where you don't know how many arguments will actually be used.
For this use case, JSOS provides the catch-all rest
symbol. You can add rest
to the end of your method signature:
import { Generic, def, JSString, JSNumber, rest } from "@fed1/jsos";
const { multi } = Generic;
def(multi, [JSString, JSNumber, rest], (a, b, ...args) => {
/* ... */
});
Multimethod Contracts
Multimethods are a very powerful way to provide extensibility for your software. Sometimes you might want to contain all this power just a little bit to prevent unintended ways of using your software.
That's where multimethod contracts come in to save the day. A contract is a way to safeguard both the inputs and the outputs of methods that can be added to your multimethod.
On the input side, you can specify a list of allowed method signatures. If
someone tries to add a method that does not fit any of the permissible
signatures, a ContractInputError
is thrown, effectively preventing the
extension of your multimethod.
On the output side, you can specify a return type. If a method gets called and
returns a value that does not conform to the expected return type, a
ContractOutputError
gets thrown, preventing your software from continuing
with garbage data.
Due to the way JavaScript works, it's currently impossible to find out what return type a method has before calling it.
That's why JSOS can only safeguard the return values, not prevent a method with wrong return type to be added to the multimethod in the first place.
Defining A Contract
You can define a contract for a multimethod by supplying two options during the creation of the multimethod.
To safeguard a multimethod's inputs, you can specify the enforce
option.
It must be an array of arrays where each of the inner arrays corresponds to an
allowed method signature.
To safeguard a multimethod's outputs, you can specify the expect
option.
It must be a TType
. You can only specify one type here, but it can be a
union type.
Here's an example of how you can define a contract:
const { multi } = Generic({
enforce: [[JSString, JSNumber], [JSNumber]],
expect: JSNumber
});
Here's what the error message of a ContractInputError
looks like:
ContractInputError: Method signature '[JSString]' does not match any of the
expected signatures of multimethod 'multi'.
Expected signatures:
[JSString, JSNumber]
[JSNumber]
Actual signatue: [JSString]
Contract-violating method: file:///.../some-extension/src/extension.js:35:13
Multimethod location: file:///.../my-project/src/multi.js:3:15
Location: file:///.../my-project/src/multi.js:3:15
And the message of a ContractOutputError
looks like this:
ContractOutputError: Multimethod invocation 'multi(JSString)' returned an
unexpected result of type 'JSString'.
Expected return type: JSNumber
Actual return type: JSString
Contract-violating method: file:///.../some-extension/src/extension.js:35:13
Multimethod location: file:///.../my-project/src/multi.js:3:15
Location: file:///.../my-project/index.js:5:12
Qualified Methods
So far, the methods we have looked at have all been what is called primary methods. And for good reason: they are the "meat and potatoes" of multimethods.
However, as the name primary already suggests, there are also other kinds of methods. Specifically, these other kinds of methods are called qualified methods.
Qualified methods are auxiliary methods. They serve a similar purpose as decorators found in some other programming languages such as Python.
There are three kinds of qualified methods:
Around methods wrap around the execution of the primary method(s). They can manipulate both the inputs and the output of the multimethod. They are so powerful, indeed, that they can even decide to not call the primary method at all! That means they can completely bypass pre-existing functionality without touching any of the existing code.
Before methods are called before any primary method(s). They cannot change the inputs of the multimethod and they don't have access to its result. But they are very useful to add transparent functionality like logging to your (or other people's) code. Think of them like event listeners in event-driven software.
After methods are basically the same as before methods except that they get called after any primary method(s).
To add qualified methods to a multimethod, JSOS provides these aptly named functions:
- around() adds a "classical" decorator
- before() adds a function to be called before the primary method(s)
- after() adds a function to be called after the primary method(s)
It's important to note here that qualified methods do not decorate one specific primary method, they decorate all methods that match a set of dispatched values.
This means that you can decorate methods without having a direct reference to the method implementation.
Assuming there are multiple around, before, and after methods, the call pattern looks something like this:
call around method 1:
result = call around method 2:
call before method 1
call before method 2
result = call primary method
call after method 2
call after method 1
return result
end
return result
end
The numbers here represent the method's scoring result, with lower numbers representing a higher score.
Around functions can decide whether or not to call the next function, which can either be the next most specific around function, a before function or the primary method(s).
around(set, [JSObject, JSString, Any], (next, obj, key, value) => {
return next(obj, key, value);
});
Which Method Gets Called?
JSOS's multimethods by default use two characteristics to determine which method is the most appropriate to call for a given set of arguments.
In order of preference these are:
- identity (Object.is())
- is-a relationship (isA())
For each argument from left to right, where n
is the index of the signature
item:
If argument n
and comparator at n
are identical, then this is the highest
possible score for that item of the signature. Identity does not apply if the
argument is itself a type object.
If identity does not apply and argument n
is-a comparator at n
, then a
score is calculated for this item based on the specificity of the type used as
comparator n
.
The specificity is how far removed from the root node of the type graph, TAny, the type in question is.
The score for each item is multiplied by the number of dispatch values minus the item's index in the signature. This means that the first argument has the highest maximum score, the last argument the lowest.
The total score for the method is the sum of the score of each signature item.
The method with the highest score for a set of given arguments gets called and its result is returned to the caller of the multimethod.
The dispatch algorithm is not set in stone. It can be customized by changing the RULES for scoring or by using the prefer() function.
Advanced Usage
The features described in Basic Usage will likely be powerful enough for 95 percent of projects.
Sometimes you might need a bit more customization though. Don't worry, JSOS doesn't leave you hanging in this regard!
This section explains tons of ways in which the behavior of multimethods can be changed to your liking.
Custom Dispatchers
The Generic
proxy allows you to create a multimethod that uses custom
dispatch algorithms on the arguments supplied to it. A dispatch function in
JSOS is a function that is given an argument to the multimethod and returns
another value which is then used to decide which method is supposed to be
called for the given arguments.
The most basic example of this, and for which no actual function needs to be supplied, is dispatching on the value of an object's property:
const { multi } = Generic({dispatchers: ["foo"]});
This multimethod dispatches on the value of the foo
property of whatever
object is given to the multimethod as the first argument. This means that
you can now add methods to the multimethods that use a string as the first
comparator:
def(multi, ["bar"], () => "Called with `{foo: 'bar'}`");
def(multi, ["abc"], () => "Called with `{foo: 'abc'}`");
multi({foo: "bar"}); // Called with `{foo: 'bar'}}`
multi({foo: "abc"}); // Called with `{foo: 'abc'}}`
This works for arrays with numerical indexes as well! Of course, you can dispatch on as many arguments as you need.
As mentioned previously, you can also use functions to dispatch on the arguments of the multimethod. This gives you a very fine-grained control over how your multimethod can be used.
For example, let's say you want to switch the argument with an entirely different value for the dispatch, you could do it like this:
const mapping = {
foo: "bar",
bar: "baz"
};
const { multi } = Generic({dispatchers: [a => mapping[a], "bar"]});
def(multi, ["bar", "fizz"], () => "bar/fizz");
def(multi, ["baz", "buzz"], () => "baz/buzz");
multi("foo", {bar: "fizz"}); // bar/fizz
multi("bar", {bar: "buzz"}); // baz/buzz
Another option for dispatch in JSOS is to use a function instead of an array
when calling Generic()
. Such a function is then called with all the
arguments supplied as an array. This makes it possible to flip the order of the
arguments for the dispatch:
// Note: The `args` are shallow-copied before giving them to the
// function, so calling a mutating array method is safe in this case:
const { multi } = Generic({dispatchers: args => args.reverse()});
def(multi, ["bar", "baz"], () => "bar/baz");
def(multi, ["baz", "bar"], () => "baz/bar");
multi("baz", "bar"); // bar/baz
multi("bar", "baz"); // baz/bar
Method Combinators
At the heart of JSOS's multimethod dispatch algorithm lies something called a method combinator. A combinator is a function that determines how many primary methods get called and how their inputs and outputs are combined to produce a final result.
A multimethod's combinator function can be specified by supplying the
combinator
option during the multimethod's creation:
const { multi } = Generic({
combinator: /* combinator function goes here */
});
Built-in Combinators
The default method combinator is called genstd. It's the simplest and most straight-forward of all the combinator functions: All it does is call the single most specific primary method and return its result.
See Method Combinators for all available built-in method combinators.
Creating Your Own Combinator
To create your own combinator, you must implement a function that looks like this:
function gencustom(args, getNext, hasNext) {
/* ... */
}
The args
parameter is an array of arguments that were given to the
multimethod.
The getNext
parameter is a function that returns the next matching primary
method. What this function returns is not just a function, it's a spec
object describing the method. The actual implementation is the
.implementation
property of the spec object.
The hasNext
parameter is a function that returns true
if there are matching
methods left, false
if not.
In most cases, you can ignore the hasNext
function.
As an example of what a full combinator function can look like, here's the code of the genlist combinator which executes all matching methods in order and returns all the results in an array:
function genlist(args, getNext) {
let results = [];
let match = getNext();
while (match) {
results.push(match.implementation(...args));
match = getNext();
}
return results;
}
Changing The (Scoring) RULES
By default, the multimethod dispatch algorithm only uses identity and is-a relationships to determine the appropriate method to call.
However, JSOS is also able to utilize two other characteristics for the dispatch:
- predicate functions
- equality
Predicate functions are functions that return a boolean. They can examine the arguments to a multimethod based on arbitrary rules that the implementor of the method decides.
Equality is just what it sounds like: the argument and the comparator are compared structurally, item by item, property by property. JSOS uses its isEqual() function for this which does a deep equality check.
To specify your own order of scoring rules, you can create your multimethod
with the scoring
option:
import { Generic, PREDICATE, IDENTITY, EQUAL, ISA, TRAIT } from "@fed1/jsos";
const { multi } = Generic({
scoring: [PREDICATE, IDENTITY, TRAIT, EQUAL, ISA]
});
We don't use
isEqual
by default because it can slow down the code quite significantly.This is because while the other checks are usually simple (predicates can be an exception),
isEqual
has to check all values of an object recursively and compare it to the the properties of another object until it finds a difference or has compared all the values.Predicate functions are also not used by default because they can make the code much harder to reason about since they can introduce side effects to the multimethod dispatch.
(Technically, dependent types can have the same issue. However, they do serve an important part in the type system and can't be omitted.)