I Thought I Understood Closures Until I Implemented Curry

null

Overview

Recently, I was working through a JavaScript coding challenge: implement a curry function.

At first glance, it seemed straightforward. I’ve worked with JavaScript professionally, understand recursion, use closures regularly, and have seen curried functions before. How hard could it be?

Then I spent far longer than I expected staring at my screen wondering where my arguments had gone.

The Challenge

The goal was to transform a function like this:

function add(a, b) {
    return a + b;
}

into something that could be called like this:

const curriedAdd = curry(add);

curriedAdd(1)(2); // 3

Simple enough, or so I thought.

My First Mistake Was Misunderstanding

arguments

My initial instinct was to inspect the arguments object inside the curry function.

function curry(fn) {
    console.log(arguments);
}

When I ran:

const curriedAdd = curry(add);

I saw:

[Arguments] { '0': [Function: add] }

At first, this confused me.

I kept thinking:

When I call curriedAdd(1), shouldn’t that 1 show up in curry’s arguments?

The answer is no.

That realization was my first breakthrough.

When you write:

curry(add)(1)

you’re actually performing two separate function calls.

First:

curry(add)

Then, after that returns:

returnedFunction(1)

The 1 never reaches curry. It belongs to the function returned by curry.

Once I understood that, things started making more sense.

My Second Mistake Was Forgetting to Return a Function

At one point I had:

function curry(fn) {
    console.log(arguments);
}

Then I tried:

const curriedAdd = curry(add);

curriedAdd(1);

Which produced:

TypeError: curriedAdd is not a function

The reason was obvious in hindsight.

My curry function wasn’t returning anything, so JavaScript returned undefined.

That meant:

const curriedAdd = undefined;

And trying to call undefined(1) naturally exploded.

Not exactly my finest debugging moment.

My Third Mistake Was Losing Arguments

After I started returning functions, I hit another wall.

I was repeatedly collecting new arguments but accidentally throwing away previous ones.

Conceptually, I was doing this:

Call 1: [1]
Call 2: [2]

instead of:

Call 1: [1]
Call 2: [1, 2]

I kept treating each invocation as a replacement instead of an accumulation.

That turned out to be the central idea behind currying.

Each function call isn’t replacing previous arguments.

It’s extending the collection.

The Missing Piece

fn.length

One thing I hadn’t considered was:

How does the curried function know when to stop collecting arguments and finally execute?

JavaScript already provides the answer:

function add(a, b) {}

console.log(add.length); // 2

The length property tells us how many parameters were declared.

That means we can compare:

args.length

against:

fn.length

If we’ve collected enough arguments, execute the original function.

Otherwise, keep collecting.

That was the moment the entire implementation clicked.

The Solution

Eventually, I arrived at:

function curry(fn) {
    return function curried(...args) {
        if (args.length >= fn.length) {
            return fn(...args);
        }

        return function (...nextArgs) {
            return curried(...args, ...nextArgs);
        };
    };
}

The implementation isn’t particularly long.

What’s interesting is what it’s doing.

The returned function remembers previous arguments through closures.

When I call:

curriedAdd(1)

it doesn’t return a number.

It returns another function that remembers the value 1.

Later, when I call:

curriedAdd(1)(2)

the second invocation has access to both the new argument and the previously captured argument.

That’s the part I intellectually understood before.

Actually implementing it forced me to see it in action.

What I Learned

The biggest lesson wasn’t about currying.

It was about closures.

I’ve used closures countless times in React hooks, event handlers, callbacks, and state management.

But implementing curry forced me to confront what closures actually do:

They preserve data after the surrounding function has finished executing.

The function isn’t magically waiting around.

The data survives because the returned function closes over the variables it needs.

That’s a concept I’ve known for years.

Yet somehow implementing a 15-line utility function made it click in a way documentation never did.

Sometimes the most valuable coding exercises aren’t the ones that teach you a new concept.

They’re the ones that expose gaps in concepts you thought you already understood.

Currying wasn’t difficult because it required advanced JavaScript.

It was difficult because it forced me to connect recursion, closures, function metadata, argument spreading, and higher-order functions into one mental model, and judging by the amount of time I spent staring at a function returning another function returning another function, I’d say it did its job.