Recipe · Classes

Should I use #private fields or @private in JavaScript?

  • Node.js (ES modules)
  • #private
  • @private
  • Classes
  • Checked with TypeScript 5.9

The problem I want a class field that other code cannot touch. JavaScript has #fields, and JSDoc has @private. Which should I use in a checked .js file, and what does each one actually stop?

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:

account.js account.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
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);
A #private field, and a @private one.
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:

account.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: account.js(13,21): error TS18013: Property '#balance' is not accessible outside class 'Account' because it has a private identifier. account.js(15,21): error TS2341: Property 'log' is private and only accessible within class 'Account'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. account.js(13,21): error TS18013: Property '#balance' is not accessible outside class 'Account' because it has a private identifier. account.js(15,21): error TS2341: Property 'log' is private and only accessible within class 'Account'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. account.js(13,21): error TS18013: Property '#balance' is not accessible outside class 'Account' because it has a private identifier. account.js(15,21): error TS2341: Property 'log' is private and only accessible within class 'Account'. checked 21 October 2026
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'.
Reading either kind of private member from outside the class.
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

  • #private needs a modern target. Private fields are ES2022; with an older target, a build tool must rewrite them. Node.js and current browsers run them as they are.
  • @private is 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

Last reviewed 21 October 2026.