DEV Community

Samuel Rouse
Samuel Rouse

Posted on • Edited on

Making Curry: JavaScript Functional Programming

Let's build a functional programming mainstay: the curry function. We will work through identifying, writing, troubleshooting, and improving a curry function.

Getting Started

Code Examples

Most code examples can be run in isolation, though some expect definitions from a previous block to be available.

I use the pattern of a comment following the line to represent the output of that code.

// Example of operation and output
2 + 2;
// 4
Enter fullscreen mode Exit fullscreen mode

If you would like to follow along or try the code examples for yourself, I strongly recommend using RunJS.

What is Currying

Currying is an important part of functional programming, and a great example of higher-order functions.

Broadly speaking, currying creates a collector or accumulator of function arguments. A curried function returns another accumulator until it has all of the arguments needed to execute the function. Currying is a specific form of partial application.

Here is a simple example of the difference between regular and curried function calls.

// Regular example
let addThree = (a, b, c) => a + b + c;

addThree(1, 2, 3);

// Now, curried! Just an example, no curry function yet.
addThree = curry((a, b, c) => a + b + c);

addThree(1)(2)(3);
addThree(1, 2)(3);
addThree(1)(2, 3);
addThree(1, 2, 3);
Enter fullscreen mode Exit fullscreen mode

Some strict curry implementations may allow only one argument to be passed at a time. This can be useful, and we will discuss it later, but we aren't going to hold to that limitation.

Breaking it Down

What happens when we curry and then execute a function roughly works out to several steps:

  1. We pass a function to curry.
  2. Curry counts the number of arguments the function expects.
  3. Curry returns an accumulator function.
  4. We call the accumulator with some or all of the arguments.
  5. Curry returns an accumulator until all of the expected arguments of the original function are provided.
  6. When all the arguments are provided, curry executes the original function with the arguments.

Now that we have steps, we can try to create this behavior for ourselves. We will build some simple versions and improve upon them, trying to explain out thoughts and limitations along the way.

Getting Started: Manual Currying

First, let's build the most basic form: manual currying. Just as a proof-of-concept, we're going to make a function that adds two numbers, and then a curried version.

// Almost as simple as it gets
const add = (a, b) => a + b;
add(1, 2);
// 3

// Manually curried with two arrow functions
const curriedAdd = (a) => (b) => a + b;
add(1)(2);
// 3
Enter fullscreen mode Exit fullscreen mode

If you are not used to working with higher-order functions, that curried version may be difficult to read, so let's talk it through.

curriedAdd is a function which takes the argument a and returns a second function. The second function takes the argument b and returns a plus b.

The second function still has access to the first argument thanks to closures, so we can access both to complete the work.

If you are still unsure about the nested functions, here are a few more ways to write it that may help.

// With extra parentheses
const curriedAdd2 = (a) => ((b) => a + b);

// With braces
const curriedAdd3 = (a) => {
  return (b) => a + b;
};

// With old-school functions
const curriedAdd4 = function (a) {
  return function (b) {
    return a + b;
  };
};
Enter fullscreen mode Exit fullscreen mode

Hopefully one or more of the examples helped clarify the function-in-function behavior.

Real Life Currying

The examples above don't show how we would actually use currying. Let's take a look at why it might be useful.

Currying lets us split ownership of different arguments to make reusable functions.

// Some generic data
const distances = [ 1, 2, 4, 5, 8 ];

// Our curried multiply function
const multiply = curry((a, b) => a * b);

// Find the first value somewhere.
const factor = getConversion('mile', 'km');
// 1.6
const convertMileToKilometer = multiply(factor);

const newDistances = distances.map(convertMileToKilometer);
// [ 1.6, 3.2, 6.4, 8, 12.8 ]
Enter fullscreen mode Exit fullscreen mode

We can also see this being useful if we have to fetch parameters asynchronously.

// Some generic data
const distances = [ 1, 2, 4, 5, 8 ];

// Our curried add function
const multiply = curry((a, b) => a * b);

fetchConversionFactor('mile', 'km')
  .then((factor) => multiply(factor))
  .then(converter => distances.map(converter));

// Just for giggles, we can avoid the extra arrow functions

fetchConversionFactor('mile', 'km')
  .then(multiply)
  .then(distances.map.bind(distances));
Enter fullscreen mode Exit fullscreen mode

When values or operations come from different places, currying can allow different "owners" to contribute to code without the end consumer needing to know about the steps along the way.

Arity

Before we dive in, we need to learn a new term: arity.
Arity is the number of arguments a function accepts. This can be found by accessing the .length property on a function.

There are some limitations because length does not include rest parameters and stops at the first default value. Here are some quick examples:

const example1 = (a, b) => a + b;
example1;
// 2

const example2 = (a, b = 1) => a + b;
example2.length;
// 1 - Stop at the default value

const example3 = (a, b = 1, c) => a + b + c;
example3.length;
// 1 - Stop at the default value

const example4 = (a, b, ...c) => a + b + sum(c);
example4.length;
// 2 - Do not include rest parameters

const example5 = (...a) => sum(a);
example5.length;
// 0 - No regular parameters
Enter fullscreen mode Exit fullscreen mode

Arity is important to making a proper curry function because we need to know when to stop accepting arguments and run the curried function.

Starting a Curry

Back in the What is Currying section, we identified several step that happen when currying. Let's see those again.

  1. We pass a function to curry.
  2. Curry counts the number of arguments the function expects.
  3. Curry returns an accumulator function.
  4. We call the accumulator with some or all of the arguments.
  5. Curry returns an accumulator until all of the expected arguments of the original function are provided.
  6. When all the arguments are provided curry executes the original function with the arguments.

We can use a simple currying example to test our progress:

const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
Enter fullscreen mode Exit fullscreen mode

1. We pass a function to curry.

const curry = (fn) => fn;

// Test it!
const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
// TypeError: curriedAdd(...) is not a function
Enter fullscreen mode Exit fullscreen mode

Not so good, yet. The second function call doesn't work.

2. Curry counts the number of arguments the function expects.

const curry = (fn) => {
  const arity = fn.length;
  return fn;
};

// Test it!
const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
// TypeError: curriedAdd(...) is not a function
Enter fullscreen mode Exit fullscreen mode

We counted, but didn't make any other change, so we need more.

3. Curry returns an accumulator function.

const curry = (fn) => {
  const arity = fn.length;
  const previousArgs = [];
  const accumulator = (arg) => {
    // Keep track of the arguments.
    previousArgs.push(arg);
  };
  return accumulator;
};

// Test it!
const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
// TypeError: curriedAdd(...) is not a function
Enter fullscreen mode Exit fullscreen mode

We're building up, but now we don't actually run the function, and we still have a type error for that second call.

4. We call the accumulator with some or all of the arguments.

This is actually already covered by our tests in the previous step, so no new code for this one.

5. Curry returns an accumulator until all of the expected arguments of the original function are provided.

const curry = (fn) => {
  const arity = fn.length;
  const previousArgs = [];
  const accumulator = (arg) => {
    previousArgs.push(arg);
    if (previousArgs.length < arity) return accumulator;
  };
  return accumulator;
};

// Test it!
const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
// undefined
Enter fullscreen mode Exit fullscreen mode

Well, we aren't throwing an error anymore, but we aren't running the original function, either. Our code should probably do that at some point...

6. When all the arguments are provided curry executes the original function with the arguments.

That's what we needed!

const curry = (fn) => {
  const arity = fn.length;
  const previousArgs = [];
  const accumulator = (arg) => {
    previousArgs.push(arg);
    if (previousArgs.length < arity) return accumulator;
    // Run the function when we have enough arguments
    return fn(...previousArgs);
  };
  return accumulator;
};

// Test it!
const add = (a, b) => a + b;
const curriedAdd = curry(add);
curriedAdd(1)(2);
// 3
Enter fullscreen mode Exit fullscreen mode

We did it!

Testing Capabilities

We've done a simple two-argument curry, but there are some limitations to our design. Let's revisit the addThree example at the beginning and see how we do:

addThree = curry((a, b, c) => a + b + c);

addThree(1)(2)(3);
// 6

addThree(7,8,9);
// 6

addThree(1, 2)(3);
// TypeError: addThree(...) is not a function
Enter fullscreen mode Exit fullscreen mode

I'm pretty sure 7 + 8 + 9 is not six, and we get errors when we try to pass arguments in more than one call, so we need to figure out what happened.

Don't Share Closures

It turns out we made a terrible mistake! We kept a single list of arguments in an outer closure, so we share a closure for all uses of the function. This means we can only use it one time, and if we tried to pass partial arguments to it several times, it would behave incorrectly.

const add = curry((a, b) => a + b);

// Try to make two different functions
const addTwo = add(2);
// ƒ accumulator()

// This doesn't return a function because of the shared accumulator.
const addThree = add(3);
// 5
Enter fullscreen mode Exit fullscreen mode

This is sort of the opposite of currying. Looking back at What is Currying section, there is some key language that describes the problem:

A curried function returns another accumulator until it has all of the arguments...

Each step of the currying process needs to have a unique set of arguments, so each step creates a reusable function. To do this, we'll need to pass our collection of arguments to each successive accumulator.

We could go about this a couple of ways... we could use Function.prototype.bind() to set the first value, or we could use an another arrow function to provide a closure. I'm going with the arrow function to avoid setting a context.

const curry = (fn) => {
  const arity = fn.length;
  const accumulator = (previousArgs) => (arg) => {
    const newArgs = [...previousArgs, arg];
    if (newArgs.length < arity) return accumulator(newArgs);
    // Run the function when we have enough arguments
    return fn(...newArgs);
  };
  // Start with no arguments passed.
  return accumulator([]);
};
Enter fullscreen mode Exit fullscreen mode

Now, each time we call the accumulator we first pass in the previous arguments and get back a function that takes the next argument. You can see our first call to accumulator passes an empty array. If we didn't do this, it would cause an error when spreading previousArgs. We could also have used a default parameter, if we wanted.

Let's see how this version works.

addThree(1)(2)(3); // 6
addThree(1, 2)(3); // ƒ ()
addThree(1)(2, 3); // ƒ ()
addThree(1, 2, 3); // ƒ ()
Enter fullscreen mode Exit fullscreen mode

No errors anymore, but we aren't successfully processing versions that pass more than one argument at a time. But that's an easy fix. We need to take ...args instead of arg.

Accept Multiple Arguments

I mentioned before that strict versions of curry accept only one argument at a time. This can be useful when you need to use the function in a place where you don't want all the arguments passed to it, like in Array.prototype.map(). Because the mapper receives three arguments – value, index, and array – it would misbehave if curry accepts multiple values.

const multipliers = [2, 4, 6, 8];
const multiply = curry((a, b) => a * b);

const multiplierFns = multipliers.map(multiply);

// With one argument at a time:
// [ ƒ (), ƒ (), ƒ (), ƒ () ]

// Supporting multiple arguments:
// [ 0, 4, 12, 24 ]
Enter fullscreen mode Exit fullscreen mode

While this might seem like we should only accept one argument at a time, our code is more flexible for all the other times. The Array prototype function issue is well-known, and a common example is seeing .map(parseInt) misbehave. For situations where we know we have a concern, like .map(), we can create a simple unary function wrapper to prevent the error.

const multipliers = [2, 4, 6, 8];
const multiply = curry((a, b) => a * b);

// Only one argument, no matter how curry is written
const multiplierFns = multipliers.map((value) => multiply(value));
// [ ƒ (), ƒ (), ƒ (), ƒ () ]
Enter fullscreen mode Exit fullscreen mode

It's a pretty good pattern. It allows curry to be flexible and you put the fix for "bad arguments" in the place where they happen. This is common enough that libraries like Lodash include a unary function.

So, let's not worry about those cases, and move forward with ...args.

const curry = (fn) => {
  const arity = fn.length;
  // Accept more than one argument at a time!
  const accumulator = (previousArgs) => (...args) => {
    const newArgs = [...previousArgs, ...args];
    if (newArgs.length < arity) return accumulator(newArgs);
    // Run the function when we have enough arguments
    return fn(...newArgs);
  };
  // Start with no arguments passed.
  return accumulator([]);
};
Enter fullscreen mode Exit fullscreen mode

And some quick testing:

addThree(1)(2)(3); // 6
addThree(1, 2)(3); // 6
addThree(1)(2, 3); // 6
addThree(1, 2, 3); // 6
Enter fullscreen mode Exit fullscreen mode

Now our various argument styles seem to be working as expected. To be sure, we should test the reusability of our functions, which was a problem with our early closure design.

const add = (a,b) => a + b;
const curriedAdd = curry(add);

const addTwo = curriedAdd(2);

addTwo(10); // 12
addTwo(5); // 7
addTwo(-2); // 0
Enter fullscreen mode Exit fullscreen mode

Looking good!

Setting Arity

Right now our curry function depends on the length property of the function, but as we mentioned when we learned about arity, there are reasons it can be "wrong", or at least different from what we need.

const addAll = (...args) => args.reduce((a, b) => a + b, 0);
addAll.length; // 0

const multiply = (a, b = -1) => a * b;
multiply.length; // 1
Enter fullscreen mode Exit fullscreen mode

For cases like these, we need to set the arity for currying to work correctly. It's easy enough to add an optional second argument to the curry function, and we can leverage how default parameters have access to the earlier parameters to add this with less code than before by moving arity into the arguments.

const curry = (fn, arity = fn.length) => {
  // Accept more than one argument at a time!
  const accumulator = (previousArgs) => (...args) => {
    const newArgs = [...previousArgs, ...args];
    if (newArgs.length < arity) return accumulator(newArgs);
    // Run the function when we have enough arguments
    return fn(...newArgs);
  };
  // Start with no arguments passed.
  return accumulator([]);
};

const add = (...args) => args.reduce((a, b) => a + b, 0);
curry(add, 4)(1)(2)(3)(4); // 10
curry(add, 2)(10, 5); // 15
Enter fullscreen mode Exit fullscreen mode

Other Considerations?

Context

Context can be a complex and annoying part of JavaScript, but we might need to add the ability to accept a context. We could pass the context at curry time, but if we know the context at that point, we can just pass a bound function to curry in the first place. Bind passes along the function length, so we don't have to make any changes to our existing curry function to support that.

curry(doSomethingWithContext.bind(this));
Enter fullscreen mode Exit fullscreen mode

If we need a dynamic context, it will be the context called last. For this to work we cannot use all arrow functions, as they use the context in which they were declared. Only the one function that actually receives new arguments needs to be changed, though.

const curry = (fn, arity = fn.length) => {
  // Accept more than one argument at a time!
  const accumulator = (previousArgs) => function (...args) {
    const newArgs = [...previousArgs, ...args];
    if (newArgs.length < arity) return accumulator(newArgs);
    // Run with context when we have enough arguments
    return fn.apply(this, newArgs);
  };
  // Start with no arguments passed.
  return accumulator([]);
};
Enter fullscreen mode Exit fullscreen mode

Now, a very contrived example for testing.


const makeMessage = function(quantity, color) {
  return `Find ${quantity} ${color} ${this.noun}!`;
};

const requestColor = curry(makeMessage)(4);

const myContext = {
  noun: 'airplanes',
  requestColor,
};

myContext.requestColor('blue'); // "Find 4 blue airplanes!"
Enter fullscreen mode Exit fullscreen mode

I included this in "Other Considerations" because it blends in parts of Object-Oriented Programming that we mostly try to avoid in Functional Programming. Adding support for context is simple enough and can enable the use of curried functionality inside OOP, but I try to avoid blending them together this way when I can help it.

Bad Types

Our curry doesn't do any validation that you passed it a function or that the arity is a number. Both of these could cause errors or unexpected behavior. If we are planning to put this curry function into production it might be worth that additional sanity check.

Empty Arguments

If you need to support an implicit undefined value, this version won't work for you.

const sum = (...args) => args.reduce((a,b) => a + b, 0);
const currySum = curry(sum, 4);

// Because we use ...args, we get an empty array.
// If we used the single arg, it would be undefined.
currySum()(1, 2)()(3, 4); // 10
Enter fullscreen mode Exit fullscreen mode

You can pass an explicit undefined as an argument, just not an implicit one. If you undo the work from the "Accept Multiple Arguments" section, your curry will accept implicit undefined, but of course loses the ability to pass multiple arguments at a time.

Too Many Arguments

Right now our curry function accepts more arguments than the arity.

const add = (...args) => args.reduce((a,b) => a + b, 0);
const addFour = curry(add, 4);

// What if I pass more than four?
addFour(1, 2, 3, 4, 5); // 15
Enter fullscreen mode Exit fullscreen mode

Both of these considerations are consistent with the behavior of the Lodash curry function, so I'm not calling them errors, merely considerations. For use cases where an implicit undefined or the exact number of arguments is required, it might be better to use a curry function which only accepts one argument at a time. If we switch back to using arg instead of ...args, both of these behaviors change.

Closing Remarks

I'm sure there are other issues and edge cases which aren't handled here, but I think our curry function works for most purposes. If something is missing, please comment and let me know!

It's been a long journey, but hopefully it was worth it to think through the design and decisions together. Thanks for reading!

Top comments (2)

Collapse
 
miketalbot profile image
Mike Talbot ⭐

This is an excellent article and is a great introduction.

When I did a similar article a couple of years ago I had the ability to pass a Missing Argument, meaning that you can create intermediates with any parameter missing - which is useful. Don't think I covered the arity stuff you did here.

Collapse
 
oculus42 profile image
Samuel Rouse

Thanks! Combining partial application and currying together like you did is an interesting approach! I like the use of the Symbol for missing arguments. Older libraries like Lo-dash have you pass the library itself as the "missing" value, e.g. _.partial(operation, _, 'two');, which was useful before we had tools like Symbol.