One of the great strengths of JavaScript and TypeScript is the freedom we get as developers to mix and match programming paradigms and patterns as we see fit. However, as in many other programming languages, this can also make it hard to choose among the many different options.

One such choice is between using closures or classes to create objects. Previously, I have mostly used closures only to create functions that produce new functions at runtime with specialized properties:

function createMultiplier(x: number): (number) => number {
    return function(y) {
        return x * y;
    };
}

const multiply5 = createMultiplier(5);
console.log(multiply5(4)); // logs 20
console.log(multiply5(9)); // logs 45

However, there is nothing stopping us from returning an object from a closure:

function createCalculator(x: number) {
    const add = (y: number) => x + y;
    const multiply = (y: number) => x * y;

    return {
        add,
        multiply
    };
}

const calculator8 = createCalculator(8);
console.log(calculator8.multiply(4)); // logs 32
console.log(calculator8.multiply(9)); // logs 72
console.log(calculator8.add(4)); // logs 12
console.log(calculator8.add(7)); // logs 15

Now, compare this to a class with the same functions:

class Calculator {
    private readonly x: number;
    constructor(x: number) {
        this.x = x;
    }
    add(y: number) {
        return x + y;
    }
    multiply(y: number) {
        return x * y;
    }
}

const calculator8 = new Calculator(8);
console.log(calculator8.multiply(4)); // logs 32
console.log(calculator8.multiply(9)); // logs 72
console.log(calculator8.add(4)); // logs 12
console.log(calculator8.add(7)); // logs 15

In use, objects made from classes and closures are not so different, but there are a few things to note that I will go through below.

Asynchronous construction

One obvious difference between the two is that the class has a constructor and therefore must be created with the new keyword. This may look like mainly a syntactic difference, but also has a few important consequences.

One consequence is that constructors cannot be async, meaning that you cannot use await on any operations in the constructor. Depending on your application, this can lead to some awkward ergonomics of your object’s API, because you might have to create a partially constructed object before calling an async initialization function on it:

class Person {
    private readonly _firstName: string;
    private readonly _lastName: string;
    private _address: string | undefined;

    constructor(firstName: string, lastName: string) {
        this._firstName = firstName;
        this._lastName = lastName;
        this._address = undefined;
    }

    async init() {
        this.address = await retrieveAddress(this.firstName, this.lastName);
    }

    get address() {
        if (!this._address) {
            throw new Error('You need to call init first');
        }
        return this._address;
    }
}

const person = new Person('Some', 'Name');
const address = person.address; // ERROR: init not called
await person.init(); // oops, called too late

With closures, we could just make the closure async to avoid this issue:

function createPerson(firstName: string, lastName: string) {
    const address = await retrieveAddress(firstName, lastName);
    return {
        firstName,
        lastName,
        address
    };
}

const person = await createPerson('Some', 'Name');
const address = person.address; // no problem

To be fair, the above closure does not “protect” its members in the same way as the above class. However, I would argue that it is often unnecessary to add such protections to simple data objects. Especially if there are no invariants in your type. Those protections are very common in an object-oriented mindset, but tend to add a lot of extra code and complexity for things that are just… data.

Verbosity

You might have noticed in the above example that the closure version is a lot less verbose than the class version. The reason for this is that we need to repeat the fields for first and last name as

  • members of the class,
  • arguments to the constructor, and
  • assignments in the constructor. In addition, we also need to add the this keyword in front of every member when it is used in the constructor and other functions. And of course, the async init function adds a few lines to the class.

The dangers of this

A problem that easily pops up in JavaScript and TypeScript projects is that the this keyword refers to the wrong thing. There is a great list of possible risk factors in the TypeScript wiki. Quickly summarized, it typically pops when using a member function in a callback:

class Person {
    // ...
    printAddress() {
        console.log(this.address);
    }
}
// ...
window.addEventListener('click', person.printAddress, 10);

The this will not be the person object when the above function is invoked. Instead, it will be the window object. To work around this, you need to wrap the call in a closure to make sure the person object is captured:

window.addEventListener('click', () => person.printAddress(), 10);

However, if you used a closure to create the person object, the printAddress would be valid in the callback, because there is no this:

function createPerson(firstName: string, lastName: string) {
    const address = await retrieveAddress(firstName, lastName);
    const printAddress = () => {
        console.log(address);
    };
    return {
        firstName,
        lastName,
        address,
        printAddress
    };
}

window.addEventListener('click', person.printAddress, 10); // no problem

And thus, closures make it easier to avoid the language-specific pitfall of this in JavaScript.

Classes are perhaps more familiar and easier on the IDE

This far, I felt like closures have an upper hand on classes. However, there are a few arguments against closures as well. First, classes are more familiar to many programmers. Second, they are better handled by our tools and IDEs.

One important piece that I skipped in the above code for closures was to give a proper type to the resulting objects. This is important to be able to define the argument type in functions that expect an object of the returned type. To fix this, we can define an interface:

interface Person {
    firstName: string,
    lastName: string,
    address: string,
    printAddress: () => void;
}

function createPerson(firstName: string, lastName: string): Person {
    // ...
    return {
        firstName,
        lastName,
        address,
        printAddress
    };
}

function foo(person: Person) {
    // do something with person object
}

However, with this in place, our closures are getting closer to the verbosity of classes.

Further, when using an IDE closure-heavy code, jumping to the definition of Person only takes you to the interface. You will also need to look for all usages of the Person interface to figure out where it is created. However, if you use classes and get to the Person class, you can just look for its constructor to see how it is made.

Memory usage and performance

Finally, closures can be memory hungry and reduce performance somewhat in comparison with classes. This of course depends on your use and JavaScript engine, but the takeaway point here is that closures will in principle re-allocate all the functions on your objects for each new object. Classes, on the other hand, can share functions between all objects of the same type.

This will not matter much if you write something with few objects, but may be important if you have an application with millions of Person objects moving around.

Conclusion

In my recent experience, closures are very ergonomic and less verbose when prototyping and building a new architecture from scratch. It is also very convenient to throw function pointers around without having to think about the dangers of the this keyword in JavaScript. (I really wish TypeScript could warn me whenever a class function is passed to another function as an argument).

However, over time classes appear to be easier to understand and navigate both for existing and new developers working on a codebase. I would perhaps not default to classes from the get-go in a new project, but as a project matures, it seems reasonable to have some convention that is class-oriented.

The good thing is that you can create interfaces that are the same independent of whether you implement them as closures or classes. If all your functions are made to expect arguments that are interfaces and not specific classes or function signatures, then there is no problem to switch between closures and classes at a later point in time.

Or you can mix and match them, if that is what you prefer.