DevLift
Back to Blog

Build Your Own Curry

Lodash's curry is 10 lines. Build curry, partial, compose, and pipe from scratch in JavaScript — then use them to write clean data pipelines without a single nested callback.

Admin
August 2, 20264 min read2 views

Build Your Own Curry

You've seen _.curry and R.curry a hundred times. Someone shows you add(1)(2)(3) and explains "partial application" like it's magic. Then they reach for a library.

The thing is — curry is about 10 lines. And once you have it, compose, pipe, and partial application fall out naturally. Together they give you a completely different way to structure data transformations: build tiny, single-purpose functions, then assemble them into pipelines.

We'll build all four utilities from scratch. The whole library lands under 40 lines.

What We're Building

Four functions that work together:

  • curry(fn) — converts a multi-argument function into one callable one argument at a time
  • partial(fn, ...args) — fix some arguments now, supply the rest later
  • compose(...fns) — chain functions right-to-left: compose(f, g)(x) = f(g(x))
  • pipe(...fns) — chain functions left-to-right: pipe(f, g)(x) = g(f(x))
Rendering diagram...

The key mechanic for curry is fn.length — JavaScript's built-in arity. function add(a, b, c) {} has .length === 3. Curry uses that to decide whether to run the function or wait for more arguments.

Step 1: curry()

1

The curry() implementation

// fp.js
 
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return function(...moreArgs) {
      return curried.apply(this, args.concat(moreArgs));
    };
  };
}

Two branches. If you've collected enough arguments, call the original function. If not, return a new function that merges what you have with whatever arrives next, then tries again.

The recursion goes through curried (not curry) intentionally — it preserves the accumulated args across multiple partial calls.

const add = curry((a, b, c) => a + b + c);
 
add(1)(2)(3);    // 6
add(1, 2)(3);    // 6 — multiple args at once still works
add(1)(2, 3);    // 6
add(1, 2, 3);    // 6 — so does passing everything upfront

All four styles produce the same result. The curried function doesn't care how you distribute the arguments — just that they all eventually show up.

⚠️

fn.length only counts explicit positional parameters. Rest params (...args) and default params don't contribute. function f(a, b = 0) {} has .length === 1. Keep your curried functions explicit — no default params, no rest args.

Step 2: partial() — Explicit Argument Fixing

Curry gives you partial application implicitly — call with fewer args than expected and you get a function back. But sometimes you want to fix a specific argument position and leave others open. That's where partial with placeholder support helps.

2

partial() with placeholder support

// Sentinel value — use this to skip an argument position
const _ = Symbol('placeholder');
 
function partial(fn, ...presetArgs) {
  return function(...laterArgs) {
    let laterIdx = 0;
    const finalArgs = presetArgs.map(arg =>
      arg === _ ? laterArgs[laterIdx++] : arg
    );
    // Append any remaining laterArgs that didn't fill placeholders
    finalArgs.push(...laterArgs.slice(laterIdx));
    return fn.apply(this, finalArgs);
  };
}
const divide = (a, b) => a / b;
 
// Fix b=2, leave a as a placeholder
const halve = partial(divide, _, 2);
halve(10);    // 5
halve(100);   // 50
 
// Fix a=100, leave b as a placeholder
const percentOf100 = partial(divide, 100, _);
percentOf100(4);    // 25
percentOf100(20);   // 5

The _ placeholder lets you skip positions. Both major libraries already do this — Ramda spells it R.__, Lodash exposes _.partial.placeholder (which is _ itself in the monolithic build), so _.partial(divide, _, 2)(10) gives you 5 exactly like ours does. Worth knowing before you reach for a library on the assumption that only one of them can do it.

Step 3: compose() — Right to Left

Once you have small, curried functions, you want to chain them without nesting. compose(f, g, h)(x) means f(g(h(x))) — rightmost function runs first.

3

compose() — chain functions right-to-left

function compose(...fns) {
  if (fns.length === 0) return x => x; // identity
  return function(...args) {
    // Reverse so we can use reduce left-to-right in execution order
    const [first, ...rest] = [...fns].reverse();
    return rest.reduce((result, fn) => fn(result), first(...args));
  };
}

The rightmost function gets called with all original arguments — so it can accept multiple params. Every subsequent function in the chain receives a single value.

const trim = s => s.trim();
const toLowerCase = s => s.toLowerCase();
const split = curry((sep, s) => s.split(sep));
const join = curry((sep, arr) => arr.join(sep));
const replace = curry((pattern, replacement, s) => s.replace(pattern, replacement));
 
const slugify = compose(
  join('-'),
  split(' '),
  replace(/[^a-z0-9 ]/g, ''),
  toLowerCase,
  trim
);
 
slugify('  Hello, World! 2024  ');   // 'hello-world-2024'
slugify('Build Your Own Curry');      // 'build-your-own-curry'

Right-to-left mirrors mathematical notation: (f ∘ g)(x) = f(g(x)). But reading a compose call requires mentally reversing execution order, which trips people up. Which brings us to pipe.

Step 4: pipe() — Left to Right

pipe is the same thing in reading order. pipe(f, g, h)(x) = h(g(f(x))).

4

pipe() — same as compose, left-to-right order

function pipe(...fns) {
  if (fns.length === 0) return x => x;
  return function(...args) {
    const [first, ...rest] = fns;
    return rest.reduce((result, fn) => fn(result), first(...args));
  };
}

Identical to compose except fns isn't reversed. The same slugify, now readable top-to-bottom:

const slugify = pipe(
  trim,
  toLowerCase,
  replace(/[^a-z0-9 ]/g, ''),
  split(' '),
  join('-')
);
 
slugify('  Hello, World! 2024  ');   // 'hello-world-2024'

Steps match execution order. Most teams end up preferring pipe for this reason.

compose and pipe differ only in whether fns is reversed before reducing. compose mirrors math notation. pipe mirrors shell pipelines (ls | grep | sort). Pick whichever your team reads more easily and use it consistently.

The Complete Library

Here's the full fp.js, then a real data-processing example:

// fp.js — complete functional utility library
 
const _ = Symbol('placeholder');
 
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args);
    return function(...more) {
      return curried.apply(this, args.concat(more));
    };
  };
}
 
function partial(fn, ...presetArgs) {
  return function(...laterArgs) {
    let idx = 0;
    const finalArgs = presetArgs.map(a => (a === _ ? laterArgs[idx++] : a));
    finalArgs.push(...laterArgs.slice(idx));
    return fn.apply(this, finalArgs);
  };
}
 
function compose(...fns) {
  if (!fns.length) return x => x;
  return function(...args) {
    const [first, ...rest] = [...fns].reverse();
    return rest.reduce((v, fn) => fn(v), first(...args));
  };
}
 
function pipe(...fns) {
  if (!fns.length) return x => x;
  return function(...args) {
    const [first, ...rest] = fns;
    return rest.reduce((v, fn) => fn(v), first(...args));
  };
}
 
module.exports = { _, curry, partial, compose, pipe };

Now a real pipeline — processing order data without a single nested callback:

const { _, curry, partial, pipe } = require('./fp');
 
// Domain functions — all curried, all single-purpose
const prop   = curry((key, obj) => obj[key]);
const propEq = curry((key, val, obj) => obj[key] === val);
const filter = curry((pred, arr) => arr.filter(pred));
const map    = curry((fn, arr) => arr.map(fn));
const sortBy = curry((keyFn, arr) => [...arr].sort((a, b) => keyFn(a) - keyFn(b)));
const take   = curry((n, arr) => arr.slice(0, n));
const sum    = arr => arr.reduce((a, b) => a + b, 0);
 
const orders = [
  { id: 1, userId: 42, amount: 99.99,  status: 'completed' },
  { id: 2, userId: 7,  amount: 149.00, status: 'pending'   },
  { id: 3, userId: 42, amount: 29.99,  status: 'completed' },
  { id: 4, userId: 99, amount: 299.00, status: 'completed' },
  { id: 5, userId: 42, amount: 59.00,  status: 'refunded'  },
];
 
// Specialized functions via partial application — curry doing its job
const isCompleted = propEq('status', 'completed');
const isPending   = propEq('status', 'pending');
const getAmount   = prop('amount');
 
const totalRevenue = pipe(
  filter(isCompleted),
  map(getAmount),
  sum
);
 
console.log(totalRevenue(orders));   // 428.98
 
// Swap the filter; everything else stays the same
const pendingRevenue = pipe(
  filter(isPending),
  map(getAmount),
  sum
);
 
console.log(pendingRevenue(orders));  // 149
 
// Sort ascending by amount, take the two cheapest completed orders
const cheapestCompleted = pipe(
  filter(isCompleted),
  sortBy(getAmount),
  take(2),
  map(prop('id'))
);
 
console.log(cheapestCompleted(orders));  // [3, 1]

Notice how each pipeline is composed from the same small pieces. isCompleted, getAmount, and sum are reused across multiple pipelines — nothing is reimplemented.

Testing It

Paste this into test.js and run with node test.js:

const { _, curry, partial, compose, pipe } = require('./fp');
 
// Don't use console.assert here. In Node it prints to stderr and keeps going,
// so a broken build still ends with "All assertions passed." Throw instead.
function assert(actual, expected, label) {
  const a = JSON.stringify(actual), e = JSON.stringify(expected);
  if (a !== e) throw new Error(`${label}: expected ${e}, got ${a}`);
  console.log(`ok  ${label}`);
}
 
// curry: all calling styles produce identical results
const add3 = curry((a, b, c) => a + b + c);
assert(add3(1)(2)(3),   6, 'curry: one at a time');
assert(add3(1, 2)(3),   6, 'curry: batch then one');
assert(add3(1)(2, 3),   6, 'curry: one then batch');
assert(add3(1, 2, 3),   6, 'curry: all at once');
 
// partial: placeholder support
const divide = (a, b) => a / b;
const halve = partial(divide, _, 2);
assert(halve(10),  5,  'partial: placeholder first arg');
assert(halve(100), 50, 'partial: placeholder still works');
assert(partial(divide, 100, _)(4), 25, 'partial: placeholder in second position');
 
// compose: right-to-left
const inc    = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
// compose(square, double, inc)(3) = square(double(inc(3))) = square(double(4)) = square(8) = 64
assert(compose(square, double, inc)(3), 64, 'compose: right-to-left execution');
 
// pipe: left-to-right — same math, readable order
assert(pipe(inc, double, square)(3), 64, 'pipe: left-to-right execution');
 
// the empty case, and the multi-arg first function
assert(pipe()(7), 7, 'pipe: no functions is identity');
assert(pipe((a, b) => a + b, double)(3, 4), 14, 'pipe: first fn receives all args');
 
// pipe with curried domain functions
const sum    = arr => arr.reduce((a, b) => a + b, 0);
const mapFn  = curry((fn, arr) => arr.map(fn));
const filterFn = curry((pred, arr) => arr.filter(pred));
const sumOfEvenSquares = pipe(
  filterFn(x => x % 2 === 0),
  mapFn(x => x * x),
  sum
);
// [2, 4] -> [4, 16] -> 20
assert(sumOfEvenSquares([1, 2, 3, 4, 5]), 20, 'pipe: filter-map-sum pipeline');
 
console.log('All assertions passed.');

What We Skipped

Variadic functions.length counts only the parameters before the first default or rest param, so (...args) => {} reports 0 and (a, ...rest) => {} reports 1. Either way auto-detected arity is wrong, and curry fires on the first call. Production versions take an explicit arity: curry(fn, arity = fn.length). One-line change, worth adding.

this across partial callsfn.apply(this, args) only does its job on the call that completes the application. obj.method(1, 2) keeps this; obj.method(1)(2) does not, because the intermediate function is invoked standalone. Same for the first function in a pipe. Curry and methods don't mix — curry free functions and pass the receiver in explicitly.

Async pipelinespipe with async functions doesn't compose correctly as-is: each step gets a Promise, not a value. Ramda's answer is R.pipeWith, which lets you supply the combinator — R.pipeWith((f, res) => Promise.resolve(res).then(f)) threads promises through the chain. Otherwise reach for fp-ts or hand-roll a pipeAsync that awaits between steps.

TypeScript types — typing variadic pipe/compose is genuinely hard. TypeScript's approach uses overloaded function signatures with hard-coded tuple lengths (you'll see pipe<A, B>(f: (a: A) => B): ..., pipe<A, B, C>(f: ..., g: ...): ..., up to N). fp-ts has this boilerplate generated to an uncomfortable depth.

Performance — each partial call allocates a closure and a new args array. Fine for request handlers and data munging; measure before you put it inside a loop over millions of records.

If you want to go deeper: Ramda's source on GitHub is readable and well-commented. Study R.curry and R.pipe once you've built your own — you'll recognize exactly where the extra complexity comes from and whether you need it.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read