DEV Community

Sam A. Horvath-Hunt
Sam A. Horvath-Hunt

Posted on • Updated on

Functional programming jargon for JavaScript devs

If you're looking into functional programming for the first time, the terminology can be really overwhelming. I think one of the easiest ways to learn is to try and map the terms to concepts that you likely already know and then branch out from there.

All of these terms have laws that express limitations which ensure that all instances behave reasonably. We shan't go over them here, but it's good to know that - even if we're not ready to look into them yet - they exist, that there is a rich mathematical backing to these concepts. If this piques your curiosity at all, the best resource is probably Typeclassopedia on HaskellWiki.

All of these examples will be written in both Haskell and TypeScript. The latter will be written with the fp-ts library.

For some reason, different languages sometimes call the same concepts different things. For example, Haskell has the Maybe type, whilst Rust and fp-ts have the identical Option type. Equally, Haskell and fp-ts have the Either type, whilst Rust has opted to call it Result. Don't let this discrepancy throw you off, they're otherwise identical.

Without any further ado let's get started!

Functor

A functor is some sort of container that allows you to map its contents. Arrays are the prototypical functor:

(*2) <$> [1, 2, 3] -- [2, 4, 6]
Enter fullscreen mode Exit fullscreen mode
[1, 2, 3].map(x => x * 2) // [2, 4, 6]
Enter fullscreen mode Exit fullscreen mode

Here we've taken each item in our array and applied our function to it. The very same concept applies to types like Option:

(*2) <$> (Just 5) -- Just 10
(*2) <$> Nothing  -- Nothing
Enter fullscreen mode Exit fullscreen mode
option.map(some(5), x => x * 2) // Some 10
option.map(none, x => x * 2)    // None
Enter fullscreen mode Exit fullscreen mode

If the value is Some, then we map the inner value, else if it's None then we short-circuit and essentially do nothing.

There's nothing that technically says that functors have to map over Some in the case of Option, or Right in the case of Either, except it's universally expected behavior and to do otherwise would be very odd.

Bifunctor

For types with (at least) two variants which you might want to map, for example tuples, or Either with its Left and Right variants, there is the concept of a bifunctor. This is the very same as functor, except as the name implies you can map "the other side" as well:

first (*2) (Left 5)   -- Left 10
first (*2) (Right 5)  -- Right 5
second (*2) (Left 5)  -- Left 5
second (*2) (Right 5) -- Right 10
Enter fullscreen mode Exit fullscreen mode
either.mapLeft(left(5), x => x * 2)  // Left 10
either.mapLeft(right(5), x => x * 2) // Right 5
either.map(left(5), x => x * 2)      // Left 5
either.map(right(5), x => x * 2)     // Right 10
Enter fullscreen mode Exit fullscreen mode

Monad

Ah, the scary sounding one, the monad! Monads build atop functors with one important addition, the idea of joining or flattening. As with the functor, we'll start by demonstrating how arrays are also monads:

join [[1, 2], [3, 4]] -- [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode
[[1, 2], [3, 4]].flat() // [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

And likewise with nested Options:

join (Just (Just 5))  -- Just 5
join (Just (Nothing)) -- Nothing
join Nothing          -- Nothing
Enter fullscreen mode Exit fullscreen mode

With this newfound ability to flatten things, we can now also bind or chain things.

Let's imagine we have a function parse which takes a string, tries to parse it as a number, and returns Option<number>, and to start with we have an Option<string>. Thus far, the only way we could make this work would be to map with a functor, giving us back Option<Option<number>>, and then join down to Option<number>. That works, but is a bit tedious and we can imagine needing to perform this combination of operations quite often.

This is what bind is for!

Just "5" >>= parse -- Just 5
Just "x" >>= parse -- Nothing
Nothing  >>= parse -- Nothing
Enter fullscreen mode Exit fullscreen mode
option.chain(some('5'), parse) // Some 5
option.chain(some('x'), parse) // None
option.chain(none, parse)      // None
Enter fullscreen mode Exit fullscreen mode

What else do we know in JavaScript-land which is monad-like? The promise! A promise is - imprecisely - a functor, a bifunctor, and a monad, among other things. When we .then, we're either functor mapping or monad binding depending upon whether we're returning another promise (JavaScript handles this implicitly), and when we .catch we're either bifunctor mapping or sort of monad binding over the left side. Promises aren't really monads because of these slightly different behaviours, but they absolutely are analagous.

Further, async/await is like a specialised form of Haskell's do notation. In this example in Haskell, IO is just another monad, but any monad supports this syntax:

f :: String -> IO Int
f x = do
    res <- getData x
    res * 2
Enter fullscreen mode Exit fullscreen mode
const f = async (x: string): Promise<number> => {
    const res = await getData(x);
    return res * 2;
};
Enter fullscreen mode Exit fullscreen mode

Before we move on, if you were wondering why JavaScript's promise isn't a proper functor or monad, here's the legacy of that unfortunate decision:

Comment for #94

domenic avatar
domenic commented on

Yeah this is really not happening. It totally ignores reality in favor of typed-language fantasy land, making a more awkward and less useful API just to satisfy some peoples' aesthetic preferences that aren't even applicable to JavaScript. It misses the point of promises (modeling synchronous control flow from imperative languages), albeit in a novel way from the usual misunderstandings.

It is also hilariously inaccurate, as the thenable described comes nowhere near satisfying the spec. My guess is that it would pass approximately one of the ~500 tests in our test suite.

Someone more diplomatic than me should probably chime in too.

It hasn't aged particularly well. This also happens to be whence the fantasy-land specification derived its name.

Semigroup

Semigroups define how to concatenate two items of the same type. For example, arrays are semigroups:

[1, 2] <> [3, 4] -- [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode
[1, 2].concat([3, 4]) // [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

You could equally define a semigroup instance for numbers under addition and multiplication, or for booleans under conjunction and disjunction. If the underlying mathematics interest you, you can read more about semigroups on Wikipedia.

We can also define semigroups for arbitrary types! Let's imagine we have the type Cocktail, and we want to be able to combine any two of them together. Given a definition for the type as follows:

data Cocktail = Cocktail
    { name :: String
    , ingredients :: [String]
    }
Enter fullscreen mode Exit fullscreen mode
type Cocktail = {
    name: string;
    ingredients: string[];
};
Enter fullscreen mode Exit fullscreen mode

We can then define a formal semigroup instance which will allow us to combine any pair of cocktails together:

instance Semigroup Cocktail where
    a <> b = Cocktail (name a <> " " <> name b) (ingredients a <> ingredients b)

mojito = Cocktail "Mojito" ["rum", "mint"]
robroy = Cocktail "Rob Roy" ["scotch", "bitters"]

combined = mojito <> robroy -- Cocktail { name = "Mojito Rob Roy", ingredients = ["rum", "mint", "scotch", "bitters"] }
Enter fullscreen mode Exit fullscreen mode
const semigroupCocktail: Semigroup<Cocktail> = {
    concat: (a, b) => ({
        name: a.name + ' ' + b.name,
        ingredients: a.ingredients.concat(b.ingredients),
    }),
};

const mojito: Cocktail = { name: 'Mojito', ingredients: ['rum', 'mint'] };
const robroy: Cocktail = { name: 'Rob Roy', ingredients: ['scotch', 'bitters'] };

const combined = semigroupCocktail.concat(mojito, robroy); // { name: 'Mojito Rob Roy', ingredients: ['rum', 'mint', 'scotch', 'bitters'] }
Enter fullscreen mode Exit fullscreen mode

Monoid

Like how the monad derives most of its abilities from the functor, as does the monoid from the semigroup. A monoid is a semigroup with one extra thing - an identity element, which means essentially a sort of "default" element which, when concatenated with others of its type, will result in the same output.

Here are some example identity elements in mathematics:

  • Addition/subtraction: 0, 5 + 0 == 5 & 5 - 0 == 5
  • Multiplication/division: 1, 5 * 1 == 5 & 5 / 1 == 5

See how when we apply the identity element to an operation alongside n we always get said n back again. We can do the same thing with types when we're programming. Once again, let's start with arrays:

[1, 2] <> [] -- [1, 2]
Enter fullscreen mode Exit fullscreen mode
[1, 2].concat([]) // [1, 2]
Enter fullscreen mode Exit fullscreen mode

If we concatenate an empty array with any other array, we'll get said other array back. The same goes for strings which can be thought of conceptually as arrays of characters, which happens to be exactly what they are in Haskell.

What about our Cocktail type from earlier? Given the two fields are each already monoids, or easy to treat as monoids - a string and an array - this will be quite simple:

instance Monoid Cocktail where
    mempty = Cocktail mempty mempty
Enter fullscreen mode Exit fullscreen mode
const monoidCocktail: Monoid<Cocktail> = {
    ...semigroupCocktail,
    empty: { name: '', ingredients: [] },
};
Enter fullscreen mode Exit fullscreen mode

This is cool, but truth be told it's relatively rare that we need to concatenate only two items of an arbitrary type. What I find myself wanting to do far more regularly is fold over an array of said items, which is trivially possible using our monoid instance. Here we'll just fold over small arrays, but this can work for arrays of any size at all:

mconcat []               -- Cocktail { name = "", ingredients = [] }
mconcat [mojito]         -- Cocktail { name = "Mojito", ingredients = ["rum", "mint"] }
mconcat [mojito, robroy] -- Cocktail { name = "Mojito Rob Roy", ingredients = ["rum", "mint", "scotch", "bitters"] }
Enter fullscreen mode Exit fullscreen mode
fold(monoidCocktail)([])               // { name: '', ingredients: [] }
fold(monoidCocktail)([mojito])         // { name: 'Mojito', ingredients: ['rum', 'mint'] }
fold(monoidCocktail)([mojito, robroy]) // { name: 'Mojito Rob Roy', ingredients: ['rum', 'mint', 'scotch', 'bitters'] }
Enter fullscreen mode Exit fullscreen mode

This is equivalent to reducing over an array of items using the semigroup concatenation operation as the function and the monoidal identity element as the starting value.

Sequence

Here's one that's super useful but you mightn't have heard of. Sequencing is the act of inverting the relationship between two types:

sequenceA [Just 5, Just 10] -- Just [5, 10]
sequenceA [Just 5, Nothing] -- Nothing
Enter fullscreen mode Exit fullscreen mode
const seqOptArr = array.sequence(option);

seqOptArr([some(5), some(10)]) // some([5, 10])
seqOptArr([some(5), none])     // none
Enter fullscreen mode Exit fullscreen mode

This is something you've probably done plenty of times but never knew that this is what it was - this is what you're doing when you call Promise.all in JavaScript! Think in terms of types: We take an array of promises, and we convert it to a promise of an array. We inverted the relationship or, as we now know to call it, we sequenced!

As with Promise.all, the sequence will short-circuit to the fail-case if anything fails.

Traverse

Hot on the heels of sequencing is traversal, which is essentially just a combination of sequencing with a functor map after-the-fact. You'll find that operations which are very common like this often have functions predefined in the likes of Haskell.

traverse (fmap (*2)) [Just 5, Just 10] -- Just [10, 20]
traverse (fmap (*2)) [Just 5, Nothing] -- Nothing
Enter fullscreen mode Exit fullscreen mode
const traverseOptArr = array.traverse(option);

traverseOptArr([some(5), some(10)], option.map(x => x * 2)) // some([10, 20])
traverseOptArr([some(5), none],     option.map(x => x * 2)) // none
Enter fullscreen mode Exit fullscreen mode

Just like with sequencing, this will short-circuit if the type we're inverting is already in its failure state.


This post can also be found on my personal blog: https://www.samhh.com/blog/js-fp-jargon

Top comments (0)