@arrows/multimethod
Table of contents
- Introduction
- Installation
- Usage
- API reference
- Articles
- License
Introduction
Multimethods are functions with superpowers - they can do all that ordinary functions can do, but additionally:
- can choose proper implementation based on the provided arguments, without explicit conditional logic,
- can be easily extended, without the need to modify the original code,
- allow you to write clean, concise and decoupled code.
The multimethod library provides a tiny set of higher-order functions to create powerful, immutable multimethods - in a functional way.
The library has built-in type definitions, which provide an excellent IDE support.
Installation
Via NPM:
npm i @arrows/multimethod
Via Yarn:
yarn add @arrows/multimethod
Usage
Note: You can find all the examples from this section (runnable) in the examples folder.
Quick example
import { multi, method, fromMulti } from '@arrows/multimethod'
const save = multi(
(data, format) => format,
method('json', (data, format) => {
console.log('Saving as JSON!')
}),
method('html', (data, format) => {
console.log('Saving as HTML!')
}),
method((data, format) => {
console.log('Default - saving as TXT!')
}),
)
const data = { name: 'Alice', score: 100 }
save(data, 'json')
save(data, 'html')
save(data, 'csv')
Let's add a new format, without touching the existing code:
const extendedSave = method('csv', (data, format) => {
console.log('Saving as CSV!')
})(save)
extendedSave(data, 'json')
extendedSave(data, 'html')
extendedSave(data, 'csv')
extendedSave(data, 'yaml')
We can also easily extend the original function with multiple methods:
const extendedSave2 = fromMulti(
method('csv', (data, format) => {
console.log('Saving as CSV!')
}),
method('yaml', (data, format) => {
console.log('Saving as YAML!')
}),
)(save)
extendedSave2(data, 'json')
extendedSave2(data, 'html')
extendedSave2(data, 'csv')
extendedSave2(data, 'yaml')
In both cases, the original save
function remains intact.
That's just a simple example, you can do much more!
Anatomy of the multimethod
const myFunction = multi(
optional_dispatch_function,
method(optional_case_value, corresponding_value),
...other_methods,
)
Dispatch function
Dispatch function produces values, by which multimethod should be dispatched. The return value of the dispatch function is compared with the case values of registered methods.
The matching algorithm uses deep strict equality to find the match. There are several exceptions to this rule, they are described in the case value section.
Note: The current implementation of the deep strict equal algorithms guarantees the correct results for JSON compatible types, Map
, Set
, and TypedArray
If you do not provide a dispatch function, the default one will be used. Default dispatch function returns all arguments as an array, or in case of single argument - as a standalone value.
For a collection of useful, ready-to-use dispatch functions, check out the @arrows/dispatch package.
Examples:
const colorToHex = multi(
method('red', '#ff0000'),
method('green', '#00ff00'),
method('blue', '#0000ff'),
)
colorToHex('green')
const mixLights = multi(
method(['red', 'green'], 'yellow'),
method(['red', 'blue'], 'magenta'),
method(['green', 'blue'], 'cyan'),
)
mixLights('red', 'blue')
mixLights('blue', 'red')
const mixLights = multi(
(colorA, colorB) => [colorA, colorB].sort(),
method(['green', 'red'], 'yellow'),
method(['blue', 'red'], 'magenta'),
method(['blue', 'green'], 'cyan'),
)
mixLights('red', 'blue')
mixLights('blue', 'red')
const store = {
add(text) {
console.log('todo added')
},
remove(id) {
console.log('todo removed')
},
toggle(id) {
console.log('todo toggled')
},
}
const handleAction = multi(
(action, store) => action.type,
method('ADD_TODO', (action, store) => store.add(action.text)),
method('REMOVE_TODO', (action, store) => store.remove(action.id)),
method('TOGGLE_TODO', (action, store) => store.toggle(action.id)),
)
handleAction({ type: 'ADD_TODO', text: 'Eat banana.' }, store)
handleAction({ type: 'TOGGLE_TODO', id: 0 }, store)
Case value
The case value is the first argument of the two-argument method
(if you provide only one argument to the method
, it will be treated as a default case).
Case value can be anything:
- an ordinary value
- a constructor / class
- a regular expression
- a wildcard (
_
) - a predicate function
- an array containing any of the cases above
You can mix all case value types in one multimethod.
Ordinary value
If the case value is neither a predicate function, regular expression, constructor nor a wildcard, it will be matched against the result of the dispatch function using the deep strict equal algorithm.
Examples:
const greet = multi(
method({ name: 'John', age: '30' }, 'Hello John!'),
method({ name: 'Jane', age: '25' }, 'Hi Jane!'),
method('Howdy stranger!'),
)
greet({ name: 'John', age: '30' })
greet({ name: 'Jane', age: '25' })
greet({ name: 'Jane', age: '40' })
Constructor / class
If the case value is a constructor, it will be matched against the result of the dispatch function by the strict equality (===
) operator,
and if that fails — by the instanceof
operator.
Example:
class Email {}
class SMS {}
const sendMessage = multi(
method(Email, 'Sending email...'),
method(SMS, 'Sending SMS...'),
)
sendMessage(new Email())
sendMessage(new SMS())
sendMessage(Email)
sendMessage(SMS)
Regular Expression
If the case value is a regular expression it will be matched against the result of the dispatch function by the strict equality (===
) operator,
and if that fails — by the RegExp.prototype.test()
method.
Example:
const productCategory = multi(
method(/wine/, 'wine'),
method(/cheese/, 'cheese'),
method(/bread/, 'bread'),
)
productCategory('blue cheese')
productCategory('red wine')
productCategory('white wine from Germany')
productCategory('breadcrumbs')
Wildcard
If the case value is the wildcard (_
) it matches any value.
Wildcards (especially paired with the predicate functions) are very useful when you need to check the correctness of the arguments, or shuffle them around (when multimethod has multiple valid signatures).
Wildcard is functionally equivalent to the predicate function that always return true: () => true
. It is a bit faster, because it does not execute unnecessary function.
Example:
const { multi, method, _ } = require('@arrows/multimethod')
const checkArgs = multi(
(...args) => args.map((arg) => typeof arg),
method([_, 'function', 'function'], () => {
throw new Error('To many functions')
}),
method(['object', _, 'function'], () => {
throw new Error('Wrong combination')
}),
method((a, b, c) => 'ok'),
)
checkArgs(
1,
() => 2,
() => 3,
)
checkArgs({ id: 1 }, 2, () => 3)
checkArgs(1, { id: 2 }, () => 3)
Predicate function
If the case value is a function it will be matched against the result of the dispatch function by the strict equality (===
) operator,
and if that fails — , and if that fails, the original dispatch function will be ignored,
and the case value function will be executed with all provided arguments.
Case value function should return a boolean value (or at least the output will be treated as such).
If the return value is truthy, then we have a match.
Example:
const router = multi(
method(
(req) => ['GET'].includes(req.method) && req.url === '/',
'Hello world!',
),
method(
(req) => ['GET', 'POST'].includes(req.method) && req.url === '/users',
[{ id: 1, name: 'John' }],
),
method('Oops!'),
)
router({ method: 'GET', url: '/' })
router({ method: 'POST', url: '/' })
router({ method: 'GET', url: '/users' })
Predicate functions inside an array
Predicate functions inside an array have slightly different behavior, they receive as an argument the value supplied by the dispatch function at the current position. They do not receive all arguments, and do not override dispatch function.
That way you can fine-tune checks for every argument, going beyond simple equality check.
Example:
const { multi, method, _ } = require('@arrows/multimethod')
const not = (y) => (x) => x !== y
const notIn = (...args) => (x) => !args.includes(x)
const checkArgs2 = multi(
(a, b) => [typeof a, typeof b],
method([not('number'), _], () => {
throw new Error('First argument should be a number')
}),
method([_, notIn('string', 'number')], () => {
throw new Error('Second argument should be a number or a string')
}),
method((a, b) => 'ok'),
)
checkArgs2('a', 1)
checkArgs2(1, [2, 3])
checkArgs2(1, 2)
checkArgs2(5, 'b')
Array with special cases
If the case value is an array that contains any of those:
- a constructor / class
- regular expression
- wildcard (
_
) - predicate function
then these values will be matched according to their specific algorithms (but only if they are at the first level of the array).
Everything else will be matched using the deep strict equal algorithm.
This allows you to dispatch multiple arguments using the best fit for every one of them.
Example:
class Article {}
class Recipe {}
class PDF {}
class HTML {}
const embed = multi(
method([Article, PDF], 'Embedding article inside PDF'),
method([Article, HTML], 'Embedding article inside HTML'),
method([Recipe, PDF], 'Embedding recipe inside PDF'),
method([Recipe, HTML], 'Embedding recipe inside HTML'),
)
embed(new Article(), new PDF())
embed(new Recipe(), new HTML())
You may also noticed that I already used this method in the wildcard and the predicate function examples!
Additional example of case values of different types in one multimethod:
const VIPs = ['john@vip.com', 'alice@vip.com']
const notify = multi(
(msg) => msg.type,
method(
(msg) => msg.type === 'email' && VIPs.includes(msg.email),
'Email from VIP!',
),
method('email', (msg) => `Email from ${msg.email}!`),
method('sms', (msg) => `SMS from ${msg.number}!`),
)
notify({ type: 'email', email: 'alice@vip.com' })
notify({ type: 'email', email: 'joe@ab.com' })
notify({ type: 'sms', number: '123456789' })
Corresponding value
The corresponding value is a second argument of the two-argument method
(or the first and only argument of the default method).
Corresponding value can be either:
- a function
- any other value
If the corresponding value is a function, it will be executed with passed arguments when its case value is matching.
If the corresponding value is not a function, it will be returned when its case value is matching.
There are many examples already, but here's another one:
const fib = multi(
method(0, 0),
method(1, 1),
method((n) => fib(n - 1) + fib(n - 2)),
)
fib(0)
fib(1)
fib(9)
Extending multimethods
The multimethod is immutable, so we can't add/override methods, but we can easily create a new multimethod based on the existing one. In fact, every time we execute the method
function, we create a new multimethod. This multimethod will have all cases the old one has, plus the new method (you can also "replace" a method by using the same caseValue as an existing one).
Extending with a single method
You can create a new multimethod with a new method by executing method
function and passing a base multimethod as an argument to the second chunk.
Examples:
const baseAdd = multi(
(a, b) => [typeof a, typeof b],
method(['number', 'number'], (a, b) => a + b),
method(['string', 'string'], (a, b) => `${a}${b}`),
)
const add = method(['bigint', 'bigint'], (a, b) => a + b)(baseAdd)
add(1, 2)
add('bat', 'man')
add(1n, 2n)
const { multi, method } = require('@arrows/multimethod')
const baseGreet = multi(
method('ru', 'Привет!'),
method('es', '¡Hola!'),
method('pl', 'Cześć!'),
)
const greet = method('Hello!')(baseGreet)
greet('ru')
greet('es')
greet('pl')
greet('fr')
Extending with multiple methods
You can create a new multimethod with multiple new methods either by generic compose
function, or by using a built-in fromMulti
function that gives you additional type checks and better error handling.
Examples:
const baseHandleHTTPError = multi(
method(400, 'Incorrect request.'),
method(404, 'The path does not exist.'),
)
const handleHTTPError = compose(
method(403, 'You do not have access to this resource.'),
method(418, 'We are all teapots!'),
)(baseHandleHTTPError)
handleHTTPError(400)
handleHTTPError(418)
const baseArea = multi(
(shape) => shape.type,
method('rectangle', (shape) => shape.a * shape.b),
method('square', (shape) => shape.a ** 2),
)
const area = fromMulti(
method('circle', (shape) => Math.PI * shape.r ** 2),
method('triangle', (shape) => 0.5 * shape.a * shape.h),
)(baseArea)
area({ type: 'square', a: 5 })
area({ type: 'circle', r: 3 })
Methods priority
When you execute the method
function, the case will be added to the front of the multimethod. In case of the partially applied method
functions passed to the multi
and fromMulti
functions, they will be added from bottom to top - so they will maintain their order in a final multimethod.
Order of the methods inside a multimethod determines their priority.
Examples:
const base = multi(
method('a', 'priority: 5'),
method('b', 'priority: 6'),
method('c', 'priority: 7'),
)
const extended = method('d', 'priority: 4')(base)
const evenMoreExtended = fromMulti(
method('e', 'priority: 1'),
method('f', 'priority: 2'),
method('g', 'priority: 3'),
)(extended)
const baseGradeExam = multi(
method((points) => points < 10, 'failed'),
method((points) => points <= 15, 'ok'),
method((points) => points > 15, 'good'),
)
const gradeExam = fromMulti(
method((points) => points === 0, 'terrible'),
method((points) => points > 20, 'excellent'),
)(baseGradeExam)
gradeExam(0)
gradeExam(5)
gradeExam(10)
gradeExam(15)
gradeExam(20)
gradeExam(25)
Currying
Multimethods support automatic currying and manual currying/chunking, based on dispatch function.
Automatic currying
Multimethods work with all generic curry
functions. Currying is based on the length of the dispatch function, so you should provide an explicit dispatch (default dispatch is a variadic function, so its length is 0
).
Example:
import curry from '@arrows/composition/curry'
import { multi, method } from '@arrows/multimethod'
const play = multi(
(type, source) => type,
method('audio', (type, source) => `Playing audio from: ${source}`),
method('video', (type, source) => `Playing video from: ${source}`),
)
const curriedPlay = curry(play)
const playAudio = curriedPlay('audio')
const playVideo = curriedPlay('video')
playAudio('songs.io/123')
playVideo('movies.com/123')
Manual currying
If you prefer manual currying, or you already have functions that use manual currying and want to use them as methods, you can easily do that with the multimethod. Just like with automatic currying, execution is based on the dispatch function.
Examples:
const mapArray = (fn) => (arr) => arr.map(fn)
const mapString = (fn) => (str) => [...str].map(fn)
const map = multi(
(fn) => (val) => (Array.isArray(val) ? 'array' : typeof val),
method('array', mapArray),
method('string', mapString),
)
map((char) => char.charCodeAt(0))('Hello')
map((item) => item * 2)([1, 2, 3])
const checkGrammar = multi(
(config = {}) => (text, language) => language,
method('en', 'Checking English grammar'),
method('pl', 'Checking Polish grammar'),
)
const checkTypos = checkGrammar({ typos: true })
checkTypos('mistkae', 'en')
checkTypos('błąt', 'pl')
Note: To make it possible, the multi
function counts chunks (segments) when multimethod is created, by executing dispatch function without arguments until a returned value is not a function or error is thrown. This comes with one limitation - you should not use any argument-based calculations as default values. If you do that, the library won't be able to correctly count segments.
For example, this will not work as intended:
const fn = multi(
(a) => (b = a.foo) => a,
method(),
method(),
)
Note: If you use custom caseValue functions when using manual currying, arguments for these caseValue functions are automatically flattened.
For example:
const fn = multi(
a => b => c => null,
method((a, b, c) => , a => b => c => ),
method((a, b, c) => , a => b => c => ),
)
Defining types
If you use TypeScript, you will probably want to make your multimethods type-safe. While the dynamic nature of the multimethods makes it hard for the compiler to guarantee that multimethod is put together correctly, you can still ensure correct usage of the multimethod by defining its interface.
TypeScript supports function overloading defined via interfaces (or type alias equivalents) and intersections.
Adding types via interfaces
Creating a new multimethod from scratch:
import { method, multi, Multi } from '@arrows/multimethod'
interface Add extends Multi {
(x: number, y: number): number
(x: string, y: string): string
}
const add: Add = multi(
<T>(x: T, x: T): T => [x, y],
method([Number, Number], (x: number, y: number): number => x + y),
method([String, String], (x: string, y: string): string => `${x}${y}`),
)
export { Add }
export default add
Creating a new multimethod from an existing one:
import { fromMulti, method } from '@arrows/multimethod'
import baseAdd, { Add as BaseAdd } from './add'
interface Add extends BaseAdd {
(x: bigint, y: bigint): bigint
}
const add: Add = fromMulti(
method([BigInt, BigInt], (x: bigint, y: bigint) => x + y),
)(baseAdd)
add(3, 3)
add('foo', 'bar')
add(3n, 5n)
add(3, [1, 2, 3])
Adding types via type aliases and intersections
Creating a new multimethod from scratch:
import { method, multi, Multi } from '@arrows/multimethod'
type Add = Multi & {
(x: number, y: number): number
(x: string, y: string): string
}
const add: Add = multi(
<T>(x: T, x: T): T => [x, y],
method([Number, Number], (x: number, y: number): number => x + y),
method([String, String], (x: string, y: string): string => `${x}${y}`),
)
export { Add }
export default add
Creating a new multimethod from an existing one:
import { fromMulti, method } from '@arrows/multimethod'
import baseAdd, { Add as BaseAdd } from './add'
type Add = BaseAdd & {
(x: bigint, y: bigint): bigint
}
const add: Add = fromMulti(
method([BigInt, BigInt], (x: bigint, y: bigint) => x + y),
)(baseAdd)
add(3, 3)
add('foo', 'bar')
add(3n, 5n)
add(3, [1, 2, 3])
Adding types using existing types
Usually, the best way to create a multimethod is to define component functions and their type aliases first - that way you can use a multimethod or a raw function - according to what fits best.
Doing so you can also reuse type aliases for specific components for multimethod type.
Creating a new multimethod from scratch:
import { method, multi, Multi } from '@arrows/multimethod'
type AddNumbers = (x: number, y: number) => number
const addNumbers: AddNumbers = (x, y) => x + y
type AddStrings = (x: string, y: string) => string
const addStrings: AddStrings = (x, y) => `${x}${y}`
type Add = Multi & AddNumbers & AddStrings
const add: Add = multi(
<T>(x: T, y: T): T => [x, y],
method([Number, Number], addNumbers),
method([String, String], addStrings),
)
export { addNumbers, addStrings, Add }
export default add
Creating a new multimethod from an existing one:
import { fromMulti, method } from '@arrows/multimethod'
import baseAdd, { Add as BaseAdd } from './add'
type AddBigInts = (x: bigint, y: bigint) => bigint
const addBigInts: AddBigInts = (x, y) => x + y
type Add = BaseAdd & AddBigInts
const add: Add = fromMulti(
method([BigInt, BigInt], addBigInts),
)(baseAdd)
add(3, 3)
add('foo', 'bar')
add(3n, 5n)
add(3, [1, 2, 3])
API reference
multi
Creates multimethod - a function that can dynamically choose proper implementation,
based on arbitrary dispatch of its arguments.
Parameters
first
- The first argument can be either a dispatch function or a partially-applied methodmethods
- Arbitrary number of partially applied methods (optional)
Returns
- Returns an immutable multimethod that can be used as an ordinary function
Interface
(dispatch | method, method?, method?, ..., method?) => multimethod
Examples
Create a multimethod with a custom dispatch and no methods:
const fn = multi((x) => typeof x)
fn('foo')
Create a multimethod with the default dispatch and some methods:
const makeSound = multi(
method('cat', () => 'Meow!'),
method('dog', () => 'Woof!'),
method(() => 'Hello!'),
)
makeSound('cat')
makeSound('dog')
makeSound('cow')
Create a multimethod with a custom dispatch and some methods:
const multiply = multi(
(multiplier, x) => [typeof multiplier, typeof x],
method(['number', 'number'], (multiplier, x) => x * multiplier),
method(['number', 'string'], (multiplier, x) => x.repeat(multiplier)),
)
multiply(2, 5)
multiply(3, 'Beetlejuice! ')
multiply(2, [1, 2, 3])
method
Adds a method to a multimethod
Parameters
-
caseValue
- The value to which the result of dispatch function is matched
- if function, then is executed with input arguments (always unchunked, even if dispatch is chunked,
- if constructor, then is matched by reference value first, if that fails, by instanceof operator.
-
correspondingValue
- The value that function should return on matching case
- if function then is executed with input arguments (chunked or unchunked, depending on the dispatch function)
-
multimethod
- Multimethod on which you want to base the new multimethod
Returns
- New multimethod (the base one is unchanged)
Interface
(caseValue?, correspondingValue) => (multimethod) => new_multimethod
Examples
Default method as a function:
const sayHello = multi((user) => user.lang)
const sayHelloWithDefault = method((user) => `Hello ${user.name}!`)(sayHello)
sayHelloWithDefault({ name: 'Alejandro', lang: 'es' })
Default method as other value:
const sayHello = multi((user) => user.lang)
const sayHelloWithDefault = method('Hello!')(sayHello)
sayHelloWithDefault({ name: 'Alejandro', lang: 'es' })
Method with caseValue as an ordinary value and correspondingValue as a function:
const add = multi((a, b) => [typeof a, typeof b])
const extendedAdd = method(['number', 'number'], (a, b) => a + b)(add)
extendedAdd(1, 2)
Method with caseValue and correspondingValue as ordinary values:
const getHexColor = multi((color) => color)
const extendedGetHexColor = method('red', '#FF0000')(getHexColor)
extendedGetHexColor('red')
Method with caseValue as a function and correspondingValue as an ordinary value:
class Enemy {}
const is = (prototype) => (value) => value instanceof prototype
const greet = multi((person) => person)
const extendedGreet = method(is(Enemy), 'Goodbye!')(greet)
extendedGreet(new Enemy())
Method with caseValue and correspondingValue as functions:
class Car {
drive() {
return 'driving...'
}
}
class Human {
walk() {
return 'walking...'
}
}
const is = (prototype) => (value) => value instanceof prototype
const go = multi(
method(is(Car), (entity) => entity.drive()),
)
const extendedGo = method(is(Human), (entity) => entity.walk())(go)
extendedGo(new Car())
extendedGo(new Human())
Method with caseValue as a constructor:
class Email {}
class SMS {}
const send = multi(
method(Email, 'Sending email...'),
)
const extendedSend = method(SMS, 'Sending SMS...')(send)
extendedSend(new Email())
extendedSend(new SMS())
fromMulti
Creates a new multimethods from the existing ones, convenient for adding multiple methods.
Parameters
methods
- Arbitrary number of partially applied methodsmultimethod
- Multimethod on which you want to base a new multimethod
Returns
- A new multimethod (the base one is unchanged)
Interface
(method, method?, ..., method?) => (multimethod) => new_multimethod
Example
Create a new multimethod using an existing one as a base:
const add = multi(
(a, b) => [typeof a, typeof b],
method(['number', 'number'], (a, b) => a + b),
method(['string', 'string'], (a, b) => `${a}${b}`),
)
const extendedAdd = fromMulti(
method(['bigint', 'bigint'], (a, b) => a + b),
method(['number', 'bigint'], (a, b) => BigInt(a) + b),
method(['bigint', 'number'], (a, b) => a + BigInt(b)),
)(add)
extendedAdd(1, 2)
extendedAdd('foo', 'bar')
extendedAdd(2n, 3n)
extendedAdd(5, 5n)
extendedAdd(9n, 2)
inspect
Retrieves the object with building blocks of the multimethod.
Note: You should not mutate any of its values.
Parameters
multimethod
- Multimethod which you want to inspect
Returns
- Inspection object with the following getter:
dispatch
- shows the dispatch functionentries
- shows all [caseValue, correspondingValue]
entriescases
- shows all caseValue
itemsvalues
- shows all correspondingValue
items
Interface
(multimethod) => inspection_object
Example
const { multi, method, inspect, _ } = require('@arrows/multimethod')
const mixColors = multi(
method(['yellow', 'red'], 'orange'),
method(['black', _], 'black'),
method(['red', 'blue'], 'purple'),
method('no idea'),
)
const inspectionObject = inspect(mixColors)
console.dir(inspectionObject.entries, { depth: null })
console.dir(inspectionObject.dispatch, { depth: null })
wildcard
Special placeholder that matches any value.
Example
const { multi, method, _ } = require('@arrows/multimethod')
const hasCallback = multi(
(a, b) => [typeof a, typeof b],
method(['function', __], true),
method([__, 'function'], true),
method(false),
)
hasCallback(() => {}, 'a')
hasCallback('b', () => {})
hasCallback(
() => {},
() => {},
)
hasCallback(3, 4)
Articles
- Polymorphism without objects via multimethods by Yehonathan Sharvit - link
License
Project is under open, non-restrictive ISC license.