functional.js
Functional.js provides a set of functions
for working in a functional style with JavaScript and TypeScript
on lazy-evaluated sequences.
Build javascript into dist folder:
$ tsc
Try it with the interactive REPL:
$ ./repl
❯ filter(city => city[0] == 'S',
... map(p => p.city, values(data.people)))
[ 'Seattle',
'Stockholm',
'San Francisco',
... ]
❯
From NPM: yarn add func-js
Introduction
Let's start with something simple. Here's a map of some imaginary "users":
const users = new Map([
['bob', {name: 'Bob', age: 28}],
['anne', {name: 'Anne', age: 29}],
['robin', {name: 'Robin', age: 33}],
])
We can map
users to their names, resulting in a lazy Seq<string>
:
const names = map(([_, p]) => p.name, users)
We can iterate over names
with provides an Iterator
interface,
making it work with anything that accepts an iterator:
console.log('names:')
for (let name of names) {
console.log(' -', name)
}
Or just collect
all values into an array:
console.log('names:', collect(names))
Working with sequences
Okay. Here are some small tech start-ups you probably haven't heard of:
const companies = new Set([
{name:'Microsoft', founded: {year: 1975, month: 4}},
{name:'Apple', founded: {year: 1976, month: 4}},
{name:'Google', founded: {year: 1998, month: 9}},
{name:'Facebook', founded: {year: 2004, month: 2}},
])
We can take
only the first three values,
here also mapping each company to its name.
Since take
returns a lazy sequence, no values are actually generated here,
nor is our map function called.
const companyNames = map(c => c.name, companies)
const someCompanyNames = take(3, companyNames)
collect
all values of someCompanyNames into an array and log it:
console.log("first few companies' names:",
collect(someCompanyNames))
From here on, we will use a convenience function for logging to the console:
function show(message, v) {
console.log(message, ? collect(take(50, v)) : v)
}
It makes our code easier to read. (This function is not part of functional.js
.)
Let's see what the average founding year of these companies is.
For this task we can fold
the values together:
let foundingYears = map(x => x.founded.year, companies)
show('average founding year:',
fold((avgYear, year) => (avgYear + year) / 2,
foundingYears))
fold
is similar to collect
but instead of returning an array
of all values, it returns the accumulated value.
fold
operates left-to-right and is also knows as "reduce" and "foldl".
fold
can also take an initial value. When the initial value is omitted—as in our example above—the first value of the Seq is used as the initial accumulator.
Here we provide an explicit initial value:
show('average founding year, including this year:',
fold((avgyear, year) => (avgyear + year) / 2,
foundingYears, new Date().getFullYear()))
The foldr
function produces similar results to fold
, but in the reverse order (from right to left):
show('some company names in reverse:',
foldr((names, name) => `${names} > ${name}`,
someCompanyNames))
Note that foldr
uses more memory than fold
and is limited by the stack-depth limit of JS runtimes that don't support tail-call elimination.
Similarly to foldr
, reverse
reverses a sequence:
show('some company names in reverse, again:',
reverse(someCompanyNames))
As with foldr
: beware that reverse
requires as much memory as the sum of everything in the sequence, so don't use it on large sequences. If possible, create the initial sequence in reverse order instead of using the reverse
function. For instance, if you start out with an array of items, pass the array itself to reverse
before applying any other seqeunce operations.
drop
is a function similar to take
, but rather than limiting outout, it skips over some number of values:
show('all companies but the two oldest:',
drop(2, companyNames))
The filter
function can be used to skip values which doesn't pass some criteria:
show('companies which name ends in "le":',
map(c => c.name,
filter(c => c.name.substr(-2) == "le",
companies)))
The function passed to filter
decides if an item is included (if the function returns true), or skipped (when the function returns false.)
Let's test if a seq is empty
:
show('Is there any company which name ends in "le"?',
!empty(filter(c => c.name.substr(-2) == "le",
companies)))
show('Is there any company which name ends in "x"?',
!empty(filter(c => c.name.substr(-2) == "x",
companies)))
Again, since seq
s are lazy, in our first case above, only one of the filter functions are called. This is different from how the standard Array functions in JavaScript works where each operation is performed on every single item before continuing with another operation. In most cases dealing with Seqs is faster than using Array.prototype.map
, .filter
and friends.
Using the any
function, we can implement the above code in a more readable way:
show('Is there any company which name ends in "le"?',
any(c => c.name.substr(-2) == "le",
companies))
show('Is there any company which name ends in "x"?',
any(c => c.name.substr(-2) == "x",
companies))
The all
function can be used to check if all values fit a certain criteria:
show('Does all company names contain an "e"?',
all(c => c.name.indexOf("e") != -1,
companies))
const yearToday = new Date().getFullYear()
show('Were all companies founded in the last 50 years?',
all(c => yearToday - c.founded.year < 50,
companies))
zip
is a useful function that takes two sequences and produces a sequence of tuples containing the respective input sequences' values:
const namez = zip(map(c => c.name, companies),
map(p => p.name, values(users)))
show('zipping company name with user name:', namez)
Since many of the standard JavaScript collections accept Iterables (which Seq is), we can use zip to easily build things like Maps:
show('Map of company name to user name:', new Map(namez))
We can zip
any number of sequences together:
const ln = '\n '
show('A bit of history on some imaginary people:', ln +
join(ln,
map(([year, company, name, nickname]) =>
`${name} aka "${nickname}" at ${company} in ${year}`,
zip(map(x => x.founded.year, companies),
map(x => x.name, companies),
map(p => p.name, values(users)),
keys(users) ))))
The zipf
function allows us to produce anything; nost just lists of values:
show('A bit of history on some imaginary people:', ln +
join(ln,
zipf((year, company, name, nickname) =>
`${name} aka "${nickname}" at ${company} in ${year}`,
map(x => x.founded.year, companies),
map(x => x.name, companies),
map(p => p.name, values(users)),
keys(users) )))
Creating a Seq
A Seq
is simply an object that provides an iterator interface for producing values. Most functions in functional.js
that return a Seq returns a lazy sequence, meaning its values are generated only when needed.
To create a lazy sequence from some existing data, pass anything to the seq
function:
show('items of an array:', seq([1, 2, 3]))
show('characters of text:', seq("hello😀"))
show('keys and values of an object:',
seq({
bob: "Happy",
Anne: "Hungry",
"Frans-Harald": "Bored"
}))
Oftentimes you have data that's constant or somehow predefined, in which case the seq
function can efficiently convert anything into a Seq
. The neat thing about this design is that any item implementing the iterable protocol is a valid Seq
. This includes all collection types of ES5 (Array, TypedArray, string, etc); when such an item is passed to the seq
function, the item is simply returned. The only case where seq
creates a new Seq
object is when the item provided is an object and
Use the charseq
function to get a Seq of UTF-16 codepoints of some string (instead of grapheme clusters, as is the case with seq
on strings):
show('characters as UTF-16 codepoints:',
charseq("hello😀"))
range
is a useful function for declaring ranges of numbers with an optional "step" arguments that controls the step increment:
show('range(0,4): ', range(0,4))
show('range(2,5): ', range(2,5))
show('range(-3,3): ', range(-3,3))
show('range(0,20,5): ', range(0,20,5))
Because Seq
s are lazy, we can even declare infinite ranges by leaving out the end
argument, or by using Infinite
:
show('take(4, range()): ', take(4, range()))
show('take(4, range(100)): ', take(4, range(100)))
show('take(4, range(100, Infinity, 100)): ',
take(4, range(100, Infinity, 100)))
More complex sequences can be created by providing a function that creates Iterators:
show('Custom iterable with generator:',
seq(function*(){
for (let i = 3; i; --i) {
yield '#' + Math.random().toFixed(3)
}
}))
show('Custom iterable with function:',
seq(() => ({
i: 3,
next() { return {
value: '#' + Math.random().toFixed(3),
done: --this.i < 0
}}
})))
Conveniences
apply
is a convenience function for causing side-effects, like printing something to the console. It's like forEach
but operates on lazy sequences:
apply(console.log, foundingYears)
join
glues values together into a string:
show('Company months:',
join('/', map(c => c.founded.month,
companies)))
avg
calculates the average of all numbers:
show('average founding year:',
avg(foundingYears))
sum
, min
and max
returns the sum, smallest and largest number, respectively:
const userAges = map(([_, p]) => p.age, users)
show("sum of users' age:", sum(userAges))
show("youngest user age:", min(userAges))
show("oldest user age: ", max(userAges))
nth
returns the value at a certain "index" into the sequence:
show('4th company in the list:',
nth(3, companies).name)
Note that this means generating values for—and throwing away the results of—intermediate values.