Home / Articles

Closures vs private vs #: What Each Protects in TypeScript and JavaScript

TypeScript private is a compile-time check, hash fields are enforced at runtime, and closures capture lexical scope. A side-by-side look at what each one does.

Publisher: REACTAPP.TOP7 min read1514 words

This article is published in English.

Sources: Closures vs Private in TypeScript: If We Have private and #, Why Do We Still Need Closures?

TypeScript has private and JavaScript has #, so a fair question is why closures are still needed to protect variables. The answer looks obvious until you check how JavaScript behaves at runtime, and then it gets more interesting. Closures come later. First, what does TypeScript's private really do?

TypeScript private

Take a class with one private property:

class User {
	private secret: string = "hidden";
}

Now build an instance and read the property from outside the class:

const me = new User();
console.log(me.secret);

The key line is this one:

console.log(me.secret);

The compiler, tsc, rejects it with an error, so the variable appears protected. The catch is that private is mainly a feature of the type system. When TypeScript turns into JavaScript, the keyword doesn't survive as a runtime privacy mechanism. This source:

class User {
    private secret: string = "hidden";
}

can be emitted as roughly:

class User {
    constructor() {
        this.secret = "hidden";
    }
}

At that point JavaScript has no idea secret was meant to be private, since it's an ordinary property. So the following:

const me = new User();
console.log(me.secret);

can print:

hidden

The distinction matters. What private tells the compiler is that code using the type may not touch the property. It doesn't tell the runtime that the property must be impossible to reach, and those are very different promises.

What # changes

TypeScript also supports JavaScript's native private class fields:

class User {
    #secret: string = "hidden";
}

Trying to read the field from outside now fails differently:

const me = new User();console.log(me.#secret);

This isn't just a compiler warning. The field is private at runtime too, because the # syntax creates a genuine private class field. Reading it from outside the class is illegal:

console.log(me.#secret);

and reading a plain property of the same name doesn't reach it either:

console.log(me.secret);

since #secret isn't an ordinary property called secret. It might be tempting to picture it as a string key:

me["#secret"]

but that doesn't work. The # isn't a naming convention, because the runtime has a real notion of private fields. We therefore have two separate things. The first is enforced mostly by the type system:

private secret: string;

and the second by JavaScript semantics:

#secret: string;

Where closures fit

Before private class fields existed, developers still needed data that couldn't just be reached from outside, and one of the strongest tools the language already had was the closure. Here is a factory function:

function createUser() {
    let secret = "hidden";

It returns an object exposing a single accessor:

    return {
        getSecret() {
            return secret;
        }
    };
}const user = createUser();console.log(user.getSecret());

Calling that accessor prints:

hidden

Notice what's missing. There is no property access like this:

user.secret

The returned object doesn't even have a secret property. The variable lives in the lexical environment created when createUser() ran, and the returned getSecret() function remembers that environment. That's the core of a closure: a function keeps access to variables from the scope where it was created, even after the outer function has finished. After this call:

const user = createUser();

the outer function is done. You'd normally expect its local variable:

let secret = "hidden";

to vanish. Yet getSecret() still reaches it, because it closes over secret, which is where the name comes from.

Private state without exposing it

Closures can hold private state with no exposed variable. A counter is a good example:

function myCounter() {
    let count = 0;
    return {
        increment() {
            count++;
        },        getCount() {
            return count;
        }
    };
}const counter = myCounter();counter.increment();
counter.increment();console.log(counter.getCount());

Calling the increment method twice and then asking for the count prints:

2

whereas this fails to work:

console.log(counter.count);

because count isn't a property of the counter. It's a variable inside the captured lexical environment. The object offers behavior, while the actual state is kept elsewhere, a form of encapsulation built from lexical scoping.

Is # simply better?

Not necessarily. They solve related problems and aren't the same feature. Here is the class version:

class User {
    #secret = "hidden";
    getSecret() {
        return this.#secret;
    }
}

In it #secret is explicit private class state, owned by the object, and the runtime knows about it. In the closure version:

function createUser() {
    let secret = "hidden";
    return {
        getSecret() {
            return secret;
        }
    };
}

the variable isn't an object property at all and sits in the surrounding lexical environment. The mechanisms are fundamentally different. A # field says that this object has a private field, while a closure says that this function can use state from its surrounding scope. That difference shapes API design.

State shared between functions

Closures offer more than privacy: several functions can share one piece of state. A bank account shows it:

function createBankAccount(initialBalance) {
    let balance = initialBalance;
    return {
        deposit(amount) {
            balance += amount;
        },        withdraw(amount) {
            balance -= amount;
        },        getBalance() {
            return balance;
        }
    };
}

Using it looks like this:

const account = createBankAccount(1000);
account.deposit(500);console.log(account.getBalance());

and the result is:

1500

There's no public property to read:

account.balance

deposit(), withdraw() and getBalance() all see the same closed-over variable:

balance

That's one reason closures matter so much in JavaScript. They aren't only a trick for hiding variables but a way to keep state across calls.

Correcting a first guess

A tempting summary is that closures supply the hidden runtime mechanism while private and # merely give compile-time alerts. It's close but off. TypeScript private is mainly compile-time access checking, and JavaScript #private is privacy enforced at runtime. Closures are neither another keyword nor purely compile-time. They follow from lexical scoping, which lets functions retain access to variables of their surrounding environment. That can implement encapsulation, but closures are far broader than privacy. A function factory shows a use with no privacy in it:

function createMultiplier(x) {
    return function (y) {
        return x * y;
    };
}
const double = createMultiplier(2);console.log(double(10));

Here the returned function remembers x, and that's a closure. The same mechanism powers private state, factories, callbacks, event handlers, memoization and plenty more.

A mental model of the three

For TypeScript private:

class User {
    private secret = "hidden";
}

read it as the type system stopping you from touching the member. For JavaScript #private:

class User {
    #secret = "hidden";
}

read it as the runtime treating the field as truly private. For a closure:

function createUser() {
    let secret = "hidden";
    return {
        getSecret() {
            return secret;
        }
    };
}

read it as a function remembering variables from the scope where it was made. The last is the key one. Closures aren't obsolete now that # exists, because #private is a feature designed for private class fields, whereas closures are a consequence of lexical scoping and first-class functions and serve many purposes unrelated to privacy.

Under the hood

A subtle point remains. It would be wrong to say that private and # both end up as ordinary JavaScript properties. That holds for TypeScript's private in the usual emitted output, but not for native #private fields:

class User {
    #secret = "hidden";
}

This uses a runtime feature. Depending on the compilation target, TypeScript may keep the syntax or downlevel it for older JavaScript, yet the language semantics still demand private-field behavior. So don't file private and #private in the same bucket: they look alike in TypeScript but are enforced differently, and closures are an entirely different mechanism.

So are closures still needed?

Yes, and a better question than why we need closures despite private and # is what problem each mechanism solves. Each answers a different need. private limits access inside the type checker, #private makes the runtime itself enforce hidden fields, and closures let functions keep using the scope they were born in. Once that's clear, closures stop looking like an old workaround for private variables and show their real role as a basic building block of JavaScript's programming model. Learning a newer feature doesn't make an older concept irrelevant; it gives you a sharper question to ask about it, namely why the mechanism was useful at all. For closures the answer goes much deeper than hiding a variable.