The short answer
Use a #private field for state that must stay inside the class; it is private at runtime too. Use @private when you need an ordinary property that the checker keeps other code away from:
export class Account {
/** @type {number} */
#balance = 0;
/**
* @private
* @type {string[]}
*/
log = [];
/** @param {number} cents */
deposit(cents) {
this.#balance += cents;
this.log.push(`deposit ${cents}`);
}
get balance() {
return this.#balance;
}
}
const account = new Account();
account.deposit(2500);
console.log(account.balance);export class Account {
#balance: number = 0;
private log: string[] = [];
deposit(cents: number): void {
this.#balance += cents;
this.log.push(`deposit ${cents}`);
}
get balance(): number {
return this.#balance;
}
}
const account = new Account();
account.deposit(2500);
console.log(account.balance);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Both are usable inside the class. Outside it, account.balance goes through the getter, and neither #balance nor log is open to other code.
Why it works
#balance is JavaScript syntax: the runtime refuses any access from outside the class body, and TypeScript checks the same rule. @private is a JSDoc tag that TypeScript reads as the private modifier; it changes nothing when the code runs, so account.log still works in plain JavaScript and in tests that skip the checker.
The same thing in TypeScript
The TypeScript equivalent tab keeps #balance (TypeScript supports it the same way) and turns @private into the private keyword. Writing private in a .js file is an error, which is why JavaScript uses the tag.
What goes wrong
Reading either one from outside the class fails, with different codes: TS18013 for the # field, and TS2341 for the @private member:
export class Account {
#balance = 0;
/**
* @private
* @type {string[]}
*/
log = [];
}
const account = new Account();
console.log(account.#balance);error TS18013: Property '#balance' is not accessible outside class 'Account' because it has a private identifier.
console.log(account.log.length);error TS2341: Property 'log' is private and only accessible within class 'Account'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} The difference shows when the checker is not looking. account.#balance outside the class is also a SyntaxError, so the file does not even load; account.log runs fine and returns the array.
Gotchas
#privateneeds a modern target. Private fields are ES2022; with an oldertarget, a build tool must rewrite them. Node.js and current browsers run them as they are.@privateis a promise, not a lock. Anything not checked by tsc, including other people’s JavaScript, can still use the member.- Use one style per class. Mixing the two makes it hard to tell which members are really hidden;
#is the safer default for new code.
Questions
Can a subclass read a #private field?
No. A #field exists only for code inside the class body that declares it. For members a subclass may use, @protected lets subclasses in and keeps other code out, in the checker's eyes only.
Do #private fields show up in JSON.stringify or Object.keys?
No. They are not properties of the object at all, so serialization and reflection never see them. A @private member is an ordinary property and appears in both.
Sources
- Private elements · MDN Web Docs
- JSDoc Reference (@public, @private and @protected) · TypeScript handbook
- Classes (member visibility) · TypeScript handbook
Last reviewed 21 October 2026.