JavaScript Study Notes: Currying and Implementing a Curry Function
1. Concepts
1. What Is Currying?
Wikipedia defines currying as follows:
In mathematics and computer science, currying is the technique of translating a function that takes multiple arguments into a sequence of families of functions, each taking a single argument.
In other words, in mathematics and computer science, currying transforms a function that accepts multiple arguments into a sequence of functions that each accept one argument.
Visually:
// 普通函数:一次性传入所有参数
function add(a, b, c) {
return a + b + c;
}
add(1, 2, 3); // 6
// 柯里化后的函数:分多次传入参数
const curriedAdd = curry(add);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6Strict currying usually means each function accepts exactly one argument. JavaScript libraries often extend curry to allow several arguments at once, so mixed calls such as curriedAdd(1, 2)(3) are common.
2. Why Use Currying?
Reusing Arguments
Suppose we have a logging function:
function log(date, importance, message) {
console.log(`[${date.toISOString()}] [${importance}] ${message}`);
}Currying lets us fix some arguments in advance and create a function better suited to the current business scenario:
const curriedLog = curry(log);
const logNow = curriedLog(new Date());
const logInfoNow = logNow("INFO");
logInfoNow("用户登录成功");
logInfoNow("数据加载完毕");The timestamp and log level remain in a closure. Later calls need only supply the changing message.
Deferred Execution
Until enough arguments have been collected, a curried function returns another function waiting for more arguments rather than executing the original. That makes it useful for event binding, asynchronous callbacks, and preset configuration.
Working with Higher-Order Functions
Currying often combines with higher-order functions such as map, filter, and reduce, making code more declarative.
const prop = curry((key, obj) => obj[key]);
const users = [
{ id: 1, name: "Alice", age: 24 },
{ id: 2, name: "Bob", age: 28 },
{ id: 3, name: "Charlie", age: 22 },
];
const names = users.map(prop("name")); // ["Alice", "Bob", "Charlie"]
const ages = users.map(prop("age")); // [24, 28, 22]2. Implementing a Curry Function
We'll start simple and gradually build something closer to the experience of Lodash.curry.
1. The Simplest Hard-Coded Version
For a function with only two arguments, currying is just a nested closure:
function curryTwoArgs(fn) {
return function (a) {
return function (b) {
return fn(a, b);
};
};
}
function add(a, b) {
return a + b;
}
const curriedAdd = curryTwoArgs(add);
curriedAdd(1)(2); // 3This version handles only two arguments. For three, four, or more, hard-coding a fixed number of nested functions won't do.
2. Using fn.length and Recursion
JavaScript function objects have a length property indicating the number of formal parameters they expect.
function sum(a, b, c) {}
console.log(sum.length); // 3We can use fn.length to write a general version:
function curryV2(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return function (...nextArgs) {
return curried.apply(this, args.concat(nextArgs));
};
};
}Let's test it:
function sum3(a, b, c) {
return a + b + c;
}
const curriedSum = curryV2(sum3);
console.log(curriedSum(1, 2, 3)); // 6
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6This version already covers many everyday cases.
However, fn.length excludes rest parameters and is affected by default parameters.
function withDefault(a, b = 2, c) {}
console.log(withDefault.length); // 1Relying entirely on fn.length can therefore execute functions with defaults earlier than expected. V4 below allows an explicit argument count.
3. Supporting Placeholders
Sometimes we want to fix the first and third arguments while leaving the second for later, rather than supplying arguments strictly left to right:
const _ = curry.placeholder;
const curriedFn = curry(fn);
curriedFn(1, _, 3)(2);That requires keeping empty slots while collecting arguments. New arguments fill existing placeholders first on the next call.
An initial implementation is:
function curryV3(fn) {
return function curried(...args) {
const _ = curryV3.placeholder;
const validArgs = args.filter((arg) => arg !== _);
if (validArgs.length >= fn.length) {
return fn.apply(this, args);
}
return function (...nextArgs) {
const mergedArgs = args
.map((arg) => (arg === _ ? nextArgs.shift() : arg))
.concat(nextArgs);
return curried.apply(this, mergedArgs);
};
};
}
curryV3.placeholder = Symbol("placeholder");It handles some common cases:
const _ = curryV3.placeholder;
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
const curriedGreet = curryV3(greet);
console.log(curriedGreet("Hello", "John", "!")); // "Hello, John!"
console.log(curriedGreet("Hello")("John")("!")); // "Hello, John!"
console.log(curriedGreet("Hello", _, "!")("John")); // "Hello, John!"But its condition is flawed: it checks only whether there are enough valid arguments, not whether all of the first fn.length positions are filled.
curriedGreet(_, _, _, "Hello", "John", "!");This call has enough valid arguments, yet the first three positions are still placeholders. V3 executes prematurely and produces an incorrect result. Here it throws a TypeError because a template literal cannot directly interpolate a Symbol.
4. A More Complete Version: Placeholders and Default Parameters
This version needs to solve three problems:
Execute only when none of the first arity arguments is a placeholder.
Don't let shift() turn unfilled placeholders into undefined while merging arguments.
Allow an explicit argument count for functions with default parameters.
The implementation is:
function curryV4(fn, arity = fn.length) {
return function curried(...args) {
const _ = curryV4.placeholder;
const isComplete =
args.length >= arity &&
args.slice(0, arity).every((arg) => arg !== _);
if (isComplete) {
return fn.apply(this, args);
}
return function (...nextArgs) {
let nextIndex = 0;
const mergedArgs = args.map((arg) => {
if (arg !== _) {
return arg;
}
if (nextIndex < nextArgs.length) {
return nextArgs[nextIndex++];
}
return _;
});
return curried.apply(
this,
mergedArgs.concat(nextArgs.slice(nextIndex)),
);
};
};
}
curryV4.placeholder = Symbol("placeholder");arity means the number of arguments to collect before executing. It defaults to fn.length, but can be specified manually.
Ordinary Functions
const _ = curryV4.placeholder;
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
const curriedGreet = curryV4(greet);
console.log(curriedGreet("Hello", "John", "!")); // "Hello, John!"
console.log(curriedGreet("Hello")("John")("!")); // "Hello, John!"
console.log(curriedGreet("Hello", _, "!")("John")); // "Hello, John!"
console.log(curriedGreet(_, "John", _)(_, "!")("Hello")); // "Hello, John!"Even with many placeholders, it won't execute prematurely:
console.log(
curriedGreet(_, _, _, "Hello", "John", "!")(_, _, _, "Hello", "John", "!")(
"Hello",
"John",
"!",
),
); // "Hello, John!"Functions with Default Parameters
Default parameters affect fn.length:
function joinWithDefault(a, b = "B", c = "C") {
return `${a}-${b}-${c}`;
}
console.log(joinWithDefault.length); // 1With the default fn.length, execution happens after the first argument:
const curriedJoinFast = curryV4(joinWithDefault);
console.log(curriedJoinFast("A")); // "A-B-C"To keep collecting as though it were a three-argument function, specify arity manually:
const curriedJoin = curryV4(joinWithDefault, 3);
console.log(curriedJoin("A")("X")("Y")); // "A-X-Y"
console.log(curriedJoin("A", _, "Y")("X")); // "A-X-Y"
console.log(curriedJoin("A")(undefined)("Y")); // "A-B-Y"Passing undefined triggers JavaScript's default-parameter mechanism, so b still receives its default, "B".
3. Applications
1. Form Validation
Admin systems often need a variety of validation rules:
function check(reg, errMsg, value) {
if (!reg.test(value)) {
console.warn(errMsg);
return false;
}
return true;
}Without currying, each call must provide the regular expression and message:
check(/^1[3-9]\d{9}$/, "手机号格式不正确", "13800138000");
check(/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/, "邮箱格式不正确", "test@gmail.com");With currying, the rule can be fixed in advance:
const curriedCheck = curryV4(check);
const checkPhone = curriedCheck(/^1[3-9]\d{9}$/, "手机号格式不正确");
const checkEmail = curriedCheck(
/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/,
"邮箱格式不正确",
);
checkPhone("13800138000"); // true
checkEmail("test@gmail"); // false,并打印“邮箱格式不正确”Business code only needs to supply user input. The closure already holds the regular expression and error message.
2. Combining with Higher-Order Array Functions
const prop = curryV4((key, obj) => obj[key]);
const users = [
{ id: 1, name: "Alice", age: 24 },
{ id: 2, name: "Bob", age: 28 },
{ id: 3, name: "Charlie", age: 22 },
];
const names = users.map(prop("name"));
const ages = users.map(prop("age"));
console.log(names); // ["Alice", "Bob", "Charlie"]
console.log(ages); // [24, 28, 22]The intent of users.map(prop("name")) is direct: extract the name property from every user object.
3. Wrapping API Requests
function request(baseUrl, method, path) {
return fetch(`${baseUrl}${path}`, { method }).then((res) => res.json());
}
const curriedRequest = curryV4(request);
const myApi = curriedRequest("https://api.myproject.com");
const myApiGet = myApi("GET");
const myApiPost = myApi("POST");
myApiGet("/users/list").then((data) => console.log(data));
myApiPost("/users/create").then((data) => console.log("创建成功", data));Here, the base domain and HTTP method can be fixed ahead of time, leaving business code to provide only the endpoint path.
4. Test Cases
These tests run directly with Node.js and cover ordinary calls, mixed calls, placeholders, extreme placeholder cases, and default parameters.
const assert = require("node:assert/strict");
function curryV4(fn, arity = fn.length) {
return function curried(...args) {
const _ = curryV4.placeholder;
const isComplete =
args.length >= arity &&
args.slice(0, arity).every((arg) => arg !== _);
if (isComplete) {
return fn.apply(this, args);
}
return function (...nextArgs) {
let nextIndex = 0;
const mergedArgs = args.map((arg) => {
if (arg !== _) {
return arg;
}
if (nextIndex < nextArgs.length) {
return nextArgs[nextIndex++];
}
return _;
});
return curried.apply(
this,
mergedArgs.concat(nextArgs.slice(nextIndex)),
);
};
};
}
curryV4.placeholder = Symbol("placeholder");
const _ = curryV4.placeholder;
function sum3(a, b, c) {
return a + b + c;
}
const curriedSum = curryV4(sum3);
assert.equal(curriedSum(1, 2, 3), 6);
assert.equal(curriedSum(1)(2)(3), 6);
assert.equal(curriedSum(1, 2)(3), 6);
assert.equal(curriedSum(1)(2, 3), 6);
function greet(greeting, name, punctuation) {
return `${greeting}, ${name}${punctuation}`;
}
const curriedGreet = curryV4(greet);
assert.equal(curriedGreet("Hello", "John", "!"), "Hello, John!");
assert.equal(curriedGreet("Hello")("John")("!"), "Hello, John!");
assert.equal(curriedGreet("Hello", _, "!")("John"), "Hello, John!");
assert.equal(curriedGreet(_, "John", _)(_, "!")("Hello"), "Hello, John!");
const waitMoreArgs = curriedGreet(_, _, _)("Hello");
assert.equal(typeof waitMoreArgs, "function");
assert.equal(waitMoreArgs("John")("!"), "Hello, John!");
assert.equal(
curriedGreet(_, _, _, "Hello", "John", "!")(_, _, _, "Hello", "John", "!")(
"Hello",
"John",
"!",
),
"Hello, John!",
);
function list(a, b, c) {
return [a, b, c];
}
const curriedList = curryV4(list);
assert.deepEqual(curriedList(_, 2)(1, 3), [1, 2, 3]);
assert.deepEqual(curriedList(_, _, 3)(1)(2), [1, 2, 3]);
function joinWithDefault(a, b = "B", c = "C") {
return `${a}-${b}-${c}`;
}
assert.equal(joinWithDefault.length, 1);
const curriedJoinFast = curryV4(joinWithDefault);
assert.equal(curriedJoinFast("A"), "A-B-C");
const curriedJoin = curryV4(joinWithDefault, 3);
assert.equal(curriedJoin("A")("X")("Y"), "A-X-Y");
assert.equal(curriedJoin("A", _, "Y")("X"), "A-X-Y");
assert.equal(curriedJoin("A")(undefined)("Y"), "A-B-Y");
assert.equal(curriedJoin(_, _, "Y")("A")(undefined), "A-B-Y");
console.log("All curryV4 tests passed.");5. Summary
The core idea of currying is to turn a function that receives all its arguments at once into one that receives them in stages.
There are three key implementation points:
Use closures to preserve collected arguments.
Keep returning functions until enough arguments have arrived, then execute the original.
With placeholders, track both argument count and argument positions.
For a function without default parameters, curryV4(fn) is usually enough. If defaults exist and you want to keep collecting later arguments, use curryV4(fn, arity) to specify the count.