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.
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 protected02The 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.
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.
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 yourself04Constructors 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.
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 object05Keep 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).
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}Try it yourself
- 1
Open the safe the legal way
In the first example add a method
peek() { return this.owner; }toAccountand calla.peek(). Predict whether it errors.It is clean. The method is inside the class, so it may read
protectedandprivatemembers, and it can expose them if you choose to. - 2
Is it really hidden?
In the Vault example, change
private tsSecretto#tsSecretand change the three reads tov.#tsSecret. Predict which of the threeconsole.loglines still work.None of them: outside the class,
v.#tsSecretis a syntax error, and a syntax error stops the whole program before any line runs. Onlyreveal()can show it. That is the difference between a sign and a lock. - 3
Make the Order example clean
Delete the three bad lines at the bottom of the Order example, then make
paidsettable by removingreadonlyfrom it. Predict the output ofo.paid = true; console.log(o.paid);.
Code & diagrams
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
AshaOnly 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-privateThree 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
250The 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 1Break 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.
Break #2
Call a private constructor
Add const x = new Logger("other"); to the Logger snippet, outside the class.
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 readthis.balance, but readingbalanceon a plainAccountargument is errorTS2446: 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. - ▸
#privatefields needtargetES2015 or higher (lower targets are a compile error). For targets ES2015 to ES2021 TypeScript imitates them with oneWeakMapper field, which is slower; from ES2022 on they are emitted as real#fields. They also have a different cost model fromprivate: each class that defines a#xmakes a brand check on every access, a small price for real hiding. - ▸
Parameter properties are one reason
erasableSyntaxOnlyand--experimental-strip-typesstyle tooling exist. A project that wants "types are just comments" semantics should write explicit fields and assign them in the constructor.
Remember this
- 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. **
publicis the default: anyone with the object can read and write the member. You rarely write the word.privatemeans only code written inside this same class may use the member.protectedmeans this class and its subclasses** (classes thatextendsit, Topic 6.3) may use it, but not outside code. Outside code that breaks the rule getsTS2341(private) orTS2445(protected). - 2
The most important fact: **
privateandprotectedexist 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 withobj["secret"], with a type cast toany, in the browser console, or withObject.keys. They are for code organisation (telling other developers what is internal), not for security. Never store a password in aprivatefield and call it protected. - 3
**
#privateis 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 writeobj.#balance; it does not show inObject.keys, and it cannot be reached withanyor brackets. Choose#privatewhen the hiding must hold at runtime; chooseprivatewhen you only want the compiler's help (and want to read the field in tests or debuggers). - 4
**
readonly** means: set it once, in the declaration or in the constructor, and never again. Writing to it elsewhere isTS2540. It is a compile-time promise only, likeprivate, and it is shallow: areadonly items: string[]field can't be reassigned, butitems.push("x")still works (Topic 5.1 showsReadonlyArray). - 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-typescannot do. The compiler option **erasableSyntaxOnly** (TypeScript 5.8) reports every such feature (parameter properties,enum,namespacewith code) so your code can run by just deleting the types. - 6
A
privateorprotectedmember also changes how types compare. Two classes with the same-namedprivatefield are not compatible with each other, because the field came from different declarations (errorTypes 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
Constructors can have modifiers too. A **
private constructor** stopsnewoutside the class (errorTS2673), which is how the classic singleton or a class with only static factory methods is written. A **protected constructor** means only subclasses can callnewthroughsuper.
Explain it without notes
What is the difference between TypeScript's private and JavaScript's #private?
When is protected useful, and who may access a protected member?
What are parameter properties and why might a team ban them?
Does readonly make a field deeply immutable?
Practice
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.
Rewrite class Point { constructor(public x: number, public y: number) {} } so it compiles under erasableSyntaxOnly.
Trade-offs
- ↔
privateis easy to inspect and test but not hidden at runtime;#privateis truly hidden but harder to reach in tests and debuggers and needs a moderntarget. - ↔
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,protectedandprivatemembers.I can show that
privateis erased and#privateis not.I can use
readonlyand 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
erasableSyntaxOnlyrejects and why.