Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~22 min· topic 2 of 8

Topic 6.2

public, private, protected and #private

In one line

Access modifiers decide who may touch a field or method. TypeScript's private and protected are checked only while compiling and vanish at runtime; JavaScript's #private is enforced for real. readonly and parameter properties cut boilerplate.

Think of it like this

A hotel. The lobby is public: anyone can walk in. The staff corridor is protected: only the hotel's own staff and the staff of its sister hotels (the subclasses) may use it. The safe in the manager's office is private: only that room's own people get in. Now the twist: TypeScript's version is a sign on the door, not a lock. The sign stops honest guests at the planning stage (while compiling), but at runtime there is no door. Only #private fits a real lock.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

public
Anyone can use the member. This is the default.
private
Only code inside the same class may use the member. Checked by TypeScript only.
protected
This class and its subclasses may use the member, outside code may not.
#private
A JavaScript private field whose name starts with #. The engine enforces it at runtime.
readonly
A field that can be set in its declaration or constructor and never after.
Parameter property
A constructor parameter with a modifier in front, which also declares and assigns a field.
Erasable syntax
TypeScript features that can be removed leaving valid JavaScript, such as annotations and interfaces.
Encapsulation
Hiding the inside of an object so other code can only use its public surface.
Nominal typing
Types match only when they come from the same declaration, not just by having the same shape.

Step by step

01Three doors on one class

Account has a public label, a protected owner and a private balance. Inside the class, all three are reachable. Outside, only label is.

Each wrong access gets its own error code, so you can tell which rule was broken.

account.tswhole filets
class Account {
  private balance = 100;
  protected owner = "Asha";
  public label = "main";

  show() {
    return this.balance; // fine: inside the class
  }
}

const a = new Account();
a.label;   // ok
a.balance; // error TS2341: Property 'balance' is private
a.owner;   // error TS2445: Property 'owner' is protected

02The sign is not a lock

TypeScript erases private. In the emitted JavaScript tsSecret is a normal property. The bracket access v["tsSecret"] is allowed on purpose (an escape hatch), and as any removes all checking.

#hashSecret is different. It is not a property at all; it lives in a hidden slot of the object that only the class body can reach.

The sign is not a lockdiagram
Rendering diagram…

03readonly and parameter properties

readonly id can only be assigned in the constructor. A constructor parameter written private total: number declares a private field called total and assigns it, with no body line needed.

You can combine them: public readonly paid = false declares a public, read-only field with a default in a single parameter.

order.tswhole filets
class Order {
  readonly id: string;
  constructor(id: string, private total: number, public readonly paid = false) {
    this.id = id;
  }
}
// same as writing the fields out and assigning each one yourself

04Constructors can be private too

When the constructor is private, no one can write new Logger(...) outside the class. A static method inside the class decides when and how to build the one instance.

Calling new Logger("other") outside is TS2673: Constructor of class 'Logger' is private and only accessible within the class declaration.

logger.tswhole filets
class Logger {
  private static instance: Logger | undefined;
  private constructor(public readonly name: string) {}

  static get(): Logger {
    Logger.instance ??= new Logger("app");
    return Logger.instance;
  }
}

Logger.get() === Logger.get(); // true: always the same object

05Keep the file erasable

Turn on erasableSyntaxOnly in tsconfig.json when your runtime strips types instead of compiling them (Node, Deno, Bun). Parameter properties, enum and runtime namespace become errors; use explicit fields and as const objects instead (Topic 5.3).

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "erasableSyntaxOnly": true
  }
}

Try it yourself

  1. 1

    Open the safe the legal way

    In the first example add a method peek() { return this.owner; } to Account and call a.peek(). Predict whether it errors.

    It is clean. The method is inside the class, so it may read protected and private members, and it can expose them if you choose to.

  2. 2

    Is it really hidden?

    In the Vault example, change private tsSecret to #tsSecret and change the three reads to v.#tsSecret. Predict which of the three console.log lines still work.

    None of them: outside the class, v.#tsSecret is a syntax error, and a syntax error stops the whole program before any line runs. Only reveal() can show it. That is the difference between a sign and a lock.

  3. 3

    Make the Order example clean

    Delete the three bad lines at the bottom of the Order example, then make paid settable by removing readonly from it. Predict the output of o.paid = true; console.log(o.paid);.

Code & diagrams

public, protected and private from outside

Both illegal lines are red in the Types panel, yet the console happily prints the values: private and protected were erased. Remove the two illegal lines to make the code clean.

Console

(nothing was printed)

Expected console output

main
100
Asha
TypeScript private versus #private at runtime

Only the first read is an error, yet all three reads work at runtime, and Object.keys lists tsSecret but not #hashSecret. Try adding console.log(v.#hashSecret): it is a syntax error that stops the whole program.

Console

(nothing was printed)

Expected console output

ts-private
ts-private
ts-private
[ 'tsSecret' ]
hash-private
readonly and parameter properties

Three errors, three promises broken: readonly id, readonly paid and private total. Parameter properties declared total and paid without any field lines.

Console

(nothing was printed)

Expected console output

A1: 250 false
250
erasableSyntaxOnly flags syntax that is not just types TypeScript 5.8+

The option is switched on for this example. Each of the four marked places makes TypeScript write extra JavaScript, so a tool that only deletes types could not run the file.

Console

(nothing was printed)

Expected console output

Order { id: 'A', total: 1 } 0 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Reach into the private field

In the first example, keep the line console.log(a.balance); and read it from outside the class.

terminal
$ Types panel
── what you'll see ──
index.ts(13,15): error TS2341: Property 'balance' is private and only accessible within class 'Account'.

Break #2

Call a private constructor

Add const x = new Logger("other"); to the Logger snippet, outside the class.

terminal
$ Types panel
── what you'll see ──
index.ts(12,11): error TS2673: Constructor of class 'Logger' is private and only accessible within the class declaration.

Myth vs fact

Myth

private makes the data secret.

Fact

It is erased. At runtime the property is public. Use #private when the engine must enforce hiding, and never rely on either for secrets such as passwords.

Myth

#private and private are two spellings of the same thing.

Fact

private is a TypeScript compile-time check on a normal property. #name is a real JavaScript private field with different runtime behaviour: not enumerable, not reachable from outside, with its own memory slot.

Myth

readonly makes an object frozen.

Fact

It only stops reassigning that field in the checker. The array or object it points to can still change, and at runtime nothing is frozen.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Protected access has a rule that surprises people: inside Savings extends Account, a method may read this.balance, but reading balance on a plain Account argument is error TS2446: Property 'balance' is protected and only accessible through an instance of class 'Savings'. This is an instance of class 'Account'. A subclass may only reach protected members through its own type, because a sibling class could have changed what they mean.

  • ▸

    Private and protected members make a class type nominal-like: another class with an identical private field is not assignable (Types have separate declarations of a private property 'secret'.), and an interface with a public member of the same name does not accept the class (Property 'secret' is private in type 'Ghost' but not in type 'Legal'.). Use this on purpose to "brand" a class; avoid it when you want easy mocking in tests.

  • ▸

    #private fields need target ES2015 or higher (lower targets are a compile error). For targets ES2015 to ES2021 TypeScript imitates them with one WeakMap per field, which is slower; from ES2022 on they are emitted as real # fields. They also have a different cost model from private: each class that defines a #x makes a brand check on every access, a small price for real hiding.

  • ▸

    Parameter properties are one reason erasableSyntaxOnly and --experimental-strip-types style tooling exist. A project that wants "types are just comments" semantics should write explicit fields and assign them in the constructor.

Remember this

  1. 1

    The problem this solves: If every field of an object is open to all code, any part of the program can change it into a bad state. Access modifiers limit who may touch each member. **public is the default: anyone with the object can read and write the member. You rarely write the word. private means only code written inside this same class may use the member. protected means this class and its subclasses** (classes that extends it, Topic 6.3) may use it, but not outside code. Outside code that breaks the rule gets TS2341 (private) or TS2445 (protected).

  2. 2

    The most important fact: **private and protected exist only in the checker**. When TypeScript erases types, the modifier disappears and the field becomes an ordinary JavaScript property. Anyone can still read it at runtime with obj["secret"], with a type cast to any, in the browser console, or with Object.keys. They are for code organisation (telling other developers what is internal), not for security. Never store a password in a private field and call it protected.

  3. 3

    **#private is the JavaScript feature (called a private field or private name**, TypeScript supports it since 3.8). The # is part of the name: #balance. The JavaScript engine enforces it. Code outside the class cannot read it, because it is a syntax error to even write obj.#balance; it does not show in Object.keys, and it cannot be reached with any or brackets. Choose #private when the hiding must hold at runtime; choose private when you only want the compiler's help (and want to read the field in tests or debuggers).

  4. 4

    **readonly** means: set it once, in the declaration or in the constructor, and never again. Writing to it elsewhere is TS2540. It is a compile-time promise only, like private, and it is shallow: a readonly items: string[] field can't be reassigned, but items.push("x") still works (Topic 5.1 shows ReadonlyArray).

  5. 5

    Parameter properties are a shortcut. Putting a modifier in front of a constructor parameter (constructor(public name: string, private total: number)) both declares the field and assigns it. It removes three repeated lines per field. The catch: this syntax is not erasable. It makes TypeScript write extra JavaScript (the assignment), which type-stripping tools like Node's --experimental-strip-types cannot do. The compiler option **erasableSyntaxOnly** (TypeScript 5.8) reports every such feature (parameter properties, enum, namespace with code) so your code can run by just deleting the types.

  6. 6

    A private or protected member also changes how types compare. Two classes with the same-named private field are not compatible with each other, because the field came from different declarations (error Types have separate declarations of a private property). So classes with private members behave like nominal types (matched by name and origin) while all other types are structural (matched by shape). Topic 6.5 builds on this.

  7. 7

    Constructors can have modifiers too. A **private constructor** stops new outside the class (error TS2673), which is how the classic singleton or a class with only static factory methods is written. A **protected constructor** means only subclasses can call new through super.

Explain it without notes

01

What is the difference between TypeScript's private and JavaScript's #private?

02

When is protected useful, and who may access a protected member?

03

What are parameter properties and why might a team ban them?

04

Does readonly make a field deeply immutable?

Practice

01

Write a class BankAccount with a #balance that starts at 0, a method deposit(n: number) and a getter-style method current(). Show that account.#balance fails and that Object.keys(account) is empty.

02

Rewrite class Point { constructor(public x: number, public y: number) {} } so it compiles under erasableSyntaxOnly.

Trade-offs

  • ↔

    private is easy to inspect and test but not hidden at runtime; #private is truly hidden but harder to reach in tests and debuggers and needs a modern target.

  • ↔

    Parameter properties are short but not erasable. Explicit fields are longer but work with every tool that only strips types.

Done when you can

  • I can explain who may access public, protected and private members.

  • I can show that private is erased and #private is not.

  • I can use readonly and parameter properties and say what each expands to.

  • I can write a class with a private constructor and a static factory.

  • I can explain what erasableSyntaxOnly rejects and why.