The API documentation for the JavaScript functional programming library Ramda.js contains symbolic abbreviations but does not provide a legend for understanding these. Is th
From the Ramda Wiki:
(Part 2 / 2 -- too long for a single SO answer!)
Sometimes we want to restrict the generic types we can use in a
signature in some way or another. We might want a maximum
function
that can operate on Numbers
, on Strings
, on Dates
, but not on
arbitrary Objects
. We want to describe ordered types, ones for which
a < b
will always return a meaningful result. We discuss details of
the type Ord
in the Types section; for our purposes, its
sufficient to say that it is meant to capture those types which have
some ordering operation that works with <
.
// maximum :: Ord a => [a] -> a
const maximum = vals => reduce((curr, next) => next > curr ? next : curr,
head(vals), tail(vals))
maximum([3, 1, 4, 1]); //=> 4
maximum(['foo', 'bar', 'baz', 'qux', 'quux']); //=> 'qux'
maximum([new Date('1867-07-01'), new Date('1810-09-16'),
new Date('1776-07-04')]); //=> new Date("1867-07-01")
This description [^maximum-note] adds a constraint section at the
beginning, separated from the rest by a right double arrow ("=>
" in
code, sometimes "⇒
" in other documentation.) Ord a ⇒ [a] → a
says that maximum takes a collection of elements of some type, but that
type must adhere to Ord
.
In the dynamically-typed Javascript, there is no simple way to enforce
this type constraint without adding type-checking to every parameter,
and even every value of each list.[^strong-types] But that's true of our
type signatures in general. When we require [a]
in a signature,
there's no way to guarantee that the user will not pass us [1, 2, 'a',
false, undefined, [42, 43], {foo: bar}, new Date, null]
. So our entire
type annotation is descriptive and aspirational rather than
compiler-enforced, as it would be in, say, Haskell.
The most common type-constraints on Ramda functions are those specified by the Javascript FantasyLand specification.
When we discussed a map
function earlier, we talked only about mapping
a function over a list of values. But the idea of mapping is more
general than that. It can be used to describe the application of a
function to any data structure holding some number of values of a
certain type, if it returns another structure of the same shape with new
values in it. We might map over a Tree
, a Dictionary
, a plain
Wrapper
that holds only a single value, or many other types.
The notion of something that can be mapped over is captured by an
algebraic type that other languages and FantasyLand borrow from abstract
mathematics, known as Functor. A Functor
is simply a type that
contains a map
method subject to some simple laws. Ramda's map
function will call the map
method on our type, assuming that if we
didn't pass a list (or other type known to Ramda) but did pass something
with map
on it, we expect it to act like a Functor
.
To describe this in a signature, we add a constraints section to the signature block:
// map :: Functor f => (a -> b) -> f a -> f b
Note that the constraint block does not have to have just one constraint on it. We can have multiple constraints, separated by commas and wrapped in parentheses. So this could be the signature for some odd function:
// weirdFunc :: (Functor f, Monoid b, Ord b) => (a -> b) -> f a -> f b
Without dwelling on what it does or how it uses Monoid or Ord, we at least can see what sorts of types need to be supplied for this function to operate correctly.
[^maximum-note]: There is a problem with this maximum function; it will fail on an empty list. Trying to fix that problem would take us too far afield.
[^strong-types]: There are some very good tools that address this shortcoming of Javascript, including in-language techniques such as Ramda's sister project, Sanctuary, extensions of Javascript to be more strongly typed, such as flow and TypeScript, and more strongly-typed languages that compile to Javascript such as ClojureScript, Elm, and PureScript.
Sometimes rather than trying to find the most generic version of a signature, it's more straightforward to list several related signatures separately. These are included in Ramda source code as two separate JSDoc tags, and end up as two distinct lines in the documentation. This is how we might write one in our own code:
// getIndex :: a -> [a] -> Number
// :: String -> String -> Number
const getIndex = curry((needle, haystack) => haystack.indexOf(needle));
getIndex('ba', 'foobar'); //=> 3
getIndex(42, [7, 14, 21, 28, 35, 42, 49]); //=> 5
And obviously we could do more than two signatures if we chose. But do note that this should not be too common. The goal is to write signatures generic enough to capture our usage, without being so abstracted that they actually obscure the usage of the function. If we can do so with a single signature, we probably should. If it takes two, then so be it. But if we have a long list of signatures, then we're probably missing a common abstraction.
There are several issues involved in porting this style signature from Haskell to Javascript. The Ramda team has solved them on an ad hoc basis, and these solutions are still subject to change.
In Haskell, all functions have a fixed arity. But Javsacript has to deal
with variadic functions. Ramda's flip
function is a good example. It's
a simple concept: accept any function and return a new function which
swaps the order of the first two parameters.
// flip :: (a -> b -> ... -> z) -> (b -> a -> ... -> z)
const flip = fn => function(b, a) {
return fn.apply(this, [a, b].concat([].slice.call(arguments, 2)));
};
flip((x, y, z) => x + y + z)('a', 'b', 'c'); //=> 'bac'
This[^flip-example] show how we deal with the possibility of variadic
functions or functions of fixed-but-unknown arity: we simply use
ellipses ("...
" in source, "``" in output docs) to show that there
are some uncounted number of parameters missing in that signature. Ramda
has removed almost all variadic functions from its own code-base, but
this is how it deals with external functions that it interacts with
whose signatures we don't know.
[^flip-example]: This is not Ramda's actual code, which trades a little simplicity for significant performance gains.
Any / *
TypeWe're hoping to change this soon, but Ramda's type signatures
often include an asterisk (*
) or the Any
synthetic type. This was
simply a way to report that although there was a parameter or return
here, we could infer nothing about its actual type. We've come to the
realization that there is only one place where this still makes sense,
which is when we have a list of elements whose types could vary. At that
point, we should probably report [Any]
. All other uses of an arbitrary
type can probably be replaced with a generic type name such as a
or
b
. This change might happen at any time.
There are several ways we could choose to represent plain Javascript
objects. Clearly we could just say Object
, but there are times when
something else seems to be called for. When an object is used as a
dictionary of like-typed values (as opposed to its other role as a
Record
), then the types of the keys and the values can become
relevant. In some signatures Ramda uses "{k: v}
" to represent this
sort of object.
// keys :: {k: v} -> [k]
// values :: {k: v} -> [v]
// ...
keys({a: 86, b: 75, c: 309}); //=> ['a', 'b', 'c']
values({a: 86, b: 75, c: 309}); //=> [86, 75, 309]
And, as always, these can be used as the results of a function call instead:
// makeObj :: [k,v]] -> {k: v}
const makeObj = reduce((obj, pair) => assoc(pair[0], pair[1], obj), {});
makeObj([['x', 10], ['y', 20]]); //=> {"x": 10, "y": 20}
makeObj([['a', true], ['b', true], ['c', false]]);
//=> {a: true, b: true, c: false}
Although this is probably not all that relevant to Ramda itself, it's
sometimes useful to be able to distinguish Javascript objects used as
records, as opposed to those used as dictionaries. Dictionaries are
simpler, and the {k: v}
description above can be made more specific as
needed, with {k: Number}
or {k: Rectangle}
, or even if we need it,
with {String: Number}
and so forth. Records we can handle similarly if
we choose:
// display :: {name: String, age: Number} -> (String -> Number -> String) -> String
const display = curry((person, formatter) =>
formatter(person.name, person.age));
const formatter = (name, age) => name + ', who is ' + age + ' years old.';
display({name: 'Fred', age: 25, occupation: 'crane operator'}, formatter);
//=> "Fred, who is 25 years old."
Record notation looks much like Object literals, with the values for fields replaced by their types. We only account for the field names that are somehow relevant to us. (In the example above, even though our data had an 'occupation' field, it's not in our signature, as it cannot be used directly.
over
So at this point, we should have enough information to understand the signature of the over function:
Lens s a -> (a -> a) -> s -> s
Lens s a = Functor f => (a -> f a) -> s -> f s
We start with the type alias, Lens s a = Functor f ⇒ (a → f a) →
s → f s
. This tells us that the type Lens
is parameterized by two
generic variables, s
, and a
. We know that there is a constraint on
the type of the f
variable used in a Lens
: it must be a Functor
.
With that in mind, we see that a Lens
is a curried function of two
parameters, the first being a function from a value of the generic type
a
to one of the parameterized type f a
, and the second being a value
of generic type s
. The result is a value of the parameterized type f
s
. But what does it do? We don't know. We can't know. Our type
signatures tell us a great deal about a function, but they don't answer
questions about what a function actually does. We can assume that
somewhere the map
method of f a
must be called, since that is the
only function defined by the type Functor
, but we don't know how or
why that map
is called. Still, we know that a Lens
is a function as
described, and we can use that to guide our understanding of over
.
The function over
is described as a curried function of three
parameters, a Lens a s
as just analyzed, a function from the generic
type a
to that same type, and a value of the generic type s
. The
whole thing returns a value of type s
.
We could dig a bit deeper and perhaps make some further deductions about
what over
must do with the types it receives. There is significant
research on the so-called free theorems demonstrating invariants
derivable just from type signatures. But this document is already far
too long. If you're interested, please see the further reading.
So now we know how to read and write these signatures. Why would we want to, and why are functional programmers so enamored of them?
There are several good reasons. First of all, once we become used to
them, we can gain a lot of insight about a function from a single line
of metadata, without the distraction of names. Names sound like a good
idea until you realize the names chosen by someone else are not the
name you would choose. Above we discussed the functions called
"maximum
" and "makeObj
". Is it helpful or confusing to know that in
Ramda, the equivalent functions are called "max
" and "fromPairs
"?
It's significantly worse with parameter names. And of course there are
often language barriers to consider as well. Even if English has become
the lingua franca of the Web, there are people who will not understand
our beautifully written, elegant prose about these functions. But none
of this matters with the signatures; they express concisely everything
important about a function except for what it actually does.
But more important than this is the fact that these signatures make it extremely easy to think about our functions and how they combine. If we were given this function:
foo :: Object -> Number
and map
, which we've already seen looks like
map :: (a -> b) -> [a] -> [b]
then we can immediately derive the type of the function map(foo)
by noting that if we substitute Object
for a
and Number
for
b
, we satisfy the signature of the first parameter to map
, and
hence by currying we will be left with the remainder:
map(foo) :: [Object] -> [Number]
This makes working with functions a bit like the proverbial "Insert Tab A into Slot A" instruction. We can recognize just by the shapes of our functions exactly how they can be plugged together to build larger functions. Being able to do this is one of the key features of functional programming. The type signatures make it much easier to do so.