Multimethods Basic Usage Defining Multimethods Defining Methods Variable-length Signatures Multimethod Contracts Defining A Contract Qualified Methods Which Method Gets Called? Advanced Usage Custom Dispatchers Method Combinators Built-in Combinators Creating Your Own Combinator Changing The (Scoring) RULES

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 or T 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:

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.)