TypeScript public, private, and protected
Plain JavaScript classes have no built-in way to say “this property is internal, don’t touch it from outside.” TypeScript fixes that with three access modifiers — public, private, and protected — that let you control exactly where a class member can be read or written. This matters because it turns implicit conventions (like naming a field _balance and hoping nobody touches it) into rules the compiler actually checks for you, catching accidental misuse before your code ever runs.
Overview: How Access Modifiers Work
Every property and method in a TypeScript class has an access level. If you don’t write one, it defaults to public, meaning it’s reachable from anywhere — inside the class, from subclasses, and from any code holding an instance. The other two levels restrict that reach:
public— accessible from anywhere. This is the default, so writing it explicitly is optional (but sometimes done for clarity or symmetry withprivate/protected).private— accessible only from inside the declaring class itself. Not even a subclass can reach it directly.protected— accessible from inside the declaring class and from any class that extends it, but not from outside code holding an instance.
The critical thing to understand is that these modifiers are a compile-time-only concept. TypeScript’s type checker refuses to compile code that violates them, but once the code is compiled to JavaScript, all trace of public/private/protected is erased — the emitted JavaScript is a completely ordinary class with completely ordinary, freely-accessible properties. There is no runtime protection at all: if someone bypasses the type checker (for example by using // @ts-ignore, plain .js files, or accessing the compiled output directly), nothing stops them from reading a “private” field at runtime. This is different from JavaScript’s own native #privateField syntax, which is enforced at runtime by the JS engine itself. TypeScript’s modifiers are purely a tool for catching mistakes during development, not a security boundary.
Access modifiers also have a subtle effect on structural typing. Normally, TypeScript compares object and class types structurally — if two types have the same shape, they’re considered compatible, regardless of name. But a class with a private or protected member breaks that: a value is only assignable to a type with a private member if it originates from the exact same class declaration (or, for protected, the same class or one of its subclasses). This makes classes with private members behave more like nominally-typed classes, which is exactly what you want — it prevents two unrelated classes that happen to share a shape from being mixed up.
Syntax
You can apply a modifier in two places: on a regular field/method declaration, or as a shorthand directly in the constructor parameter list (called a parameter property).
class ClassName {
public field1: Type;
private field2: Type;
protected field3: Type;
constructor(
public param1: Type,
private param2: Type,
protected param3: Type
) {
// parameter properties are automatically assigned to `this`
}
public methodName(): ReturnType { /* ... */ }
private helperMethod(): ReturnType { /* ... */ }
protected hookMethod(): ReturnType { /* ... */ }
}
| Modifier | Same class | Subclass | Outside code |
|---|---|---|---|
public (default) |
yes | yes | yes |
protected |
yes | yes | no |
private |
yes | no | no |
The parameter property shorthand (writing the modifier directly before a constructor parameter) is very common in real code — it declares the field and assigns it from the argument in one line, instead of declaring the field separately and writing this.x = x; in the constructor body.
Examples
Example 1: Basic modifiers on a class
class BankAccount {
public owner: string;
private balance: number;
protected accountType: string;
constructor(owner: string, initialBalance: number) {
this.owner = owner;
this.balance = initialBalance;
this.accountType = "checking";
}
public deposit(amount: number): void {
this.balance += amount;
console.log(`Deposited ${amount}. New balance: ${this.balance}`);
}
public getBalance(): number {
return this.balance;
}
}
const acc = new BankAccount("Ada", 100);
acc.deposit(50);
console.log(acc.getBalance());
console.log(acc.owner);
Output:
Deposited 50. New balance: 150
150
Ada
balance is private, so it can only be read or changed through the class’s own methods (deposit and getBalance) — there is no way to write acc.balance from outside. owner is public, so it’s freely readable. accountType is protected, which behaves like private from the outside, but (unlike private) would also be reachable from a subclass, as the next example shows.
Example 2: Parameter properties (constructor shorthand)
class Employee {
constructor(
public name: string,
private salary: number,
protected department: string
) {}
public describe(): string {
return `${this.name} works in ${this.department}`;
}
public raiseSalary(amount: number): void {
this.salary += amount;
console.log(`${this.name}'s new salary: ${this.salary}`);
}
}
const emp = new Employee("Grace", 75000, "Engineering");
console.log(emp.describe());
emp.raiseSalary(5000);
console.log(emp.name);
Output:
Grace works in Engineering
Grace's new salary: 80000
Grace
Notice the constructor body is empty ({}) — writing public, private, or protected directly before a constructor parameter tells TypeScript to both declare that field on the class and assign the argument to this automatically. This is purely a convenience; it compiles to the exact same JavaScript as declaring the fields separately and assigning them by hand.
Example 3: protected across an inheritance chain
class Animal {
protected name: string;
private id: number;
constructor(name: string, id: number) {
this.name = name;
this.id = id;
}
protected getId(): number {
return this.id;
}
}
class Dog extends Animal {
constructor(name: string, id: number) {
super(name, id);
}
public bark(): void {
console.log(`${this.name} (#${this.getId()}) says Woof!`);
}
}
const dog = new Dog("Rex", 42);
dog.bark();
Output:
Rex (#42) says Woof!
Dog can use this.name and this.getId() because both are protected in Animal, and protected members remain visible to subclasses. But id itself is private to Animal — Dog cannot read this.id directly, which is why Animal exposes a protected getId() method as a controlled way for subclasses to reach it.
Under the Hood: What the Compiler Does
When you write code that touches a class member, the type checker performs a lookup: it finds the member’s declared access level and the location of the code doing the accessing, then checks whether that location is allowed to see it.
- For a
publicmember, the check always passes — there’s nothing to enforce. - For a
privatemember, the checker verifies the accessing code lexically appears inside the exact class body where the member was declared. Even code in a subclass fails this check, because a subclass body is a different class declaration. - For a
protectedmember, the checker verifies the accessing code is inside the declaring class or inside some subclass of it (walking up theextendschain). Code outside any class in that hierarchy fails.
If a check fails, tsc reports an error and refuses to consider the program well-typed (though with default settings it may still emit JavaScript unless noEmitOnError is set). Crucially, this is all done using static analysis of your source — once compilation finishes, the emitted .js file has no concept of public/private/protected at all. Every field becomes a regular, fully mutable JavaScript property. Modifiers only ever affect what the type checker permits while you’re writing and building the code, never what JavaScript permits while it runs.
Common Mistakes
Mistake 1: Reaching into a private field from outside the class
class Wallet {
private balance: number = 0;
addFunds(amount: number): void {
this.balance += amount;
}
}
const wallet = new Wallet();
wallet.balance += 10;
This fails with something like Property 'balance' is private and only accessible within class 'Wallet'. The fix is to expose a method that performs the mutation safely, keeping the internal representation hidden:
class Wallet {
private balance: number = 0;
addFunds(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
const wallet = new Wallet();
wallet.addFunds(10);
console.log(wallet.getBalance());
Mistake 2: Assuming a subclass can see a protected member from an unrelated instance
class Base {
protected secret: string = "hidden";
}
class Derived extends Base {
reveal(): void {
console.log(this.secret);
}
}
const d = new Derived();
console.log(d.secret);
The call inside reveal() (this.secret) is fine, since Derived extends Base. But console.log(d.secret) at the bottom fails with Property 'secret' is protected and only accessible within class 'Base' and its subclasses. — protected still blocks access from ordinary outside code, even code sitting right next to the class. The fix is to only access secret through a method like reveal(), never directly on an instance from outside.
Best Practices
- Default to
privatefor internal state, and only widen toprotectedorpublicwhen you have a concrete reason — it’s much easier to loosen access later than to tighten it after other code depends on it. - Use
protectedspecifically to design an extension point for subclasses (like a hook method), not just as a slightly-less-strict version ofprivatechosen out of indecision. - Prefer parameter properties (
constructor(private x: Type)) for simple field assignment — it removes boilerplate and keeps the modifier and the field declaration in one place. - Remember modifiers are erased at runtime; never rely on them for actual security or data protection (e.g. hiding secrets from untrusted code). Use real runtime mechanisms (native
#privateField, closures, or server-side checks) when you need that. - Expose behavior, not state: provide methods like
getBalance()ordeposit()instead of making a fieldpublicjust so external code can read or write it directly. - Avoid mixing
publicfield declarations with unrelated private ones in a way that obscures your class’s real public API — group and order members so the public surface is easy to scan.
Practice Exercises
- Write a
Carclass with aprivatefieldmileage: numberand apublicfieldmodel: string. Add a public methoddrive(distance: number): voidthat increasesmileage, and a methodgetMileage(): numberto read it. Confirm that trying to writemyCar.mileage = 0;from outside the class produces a compiler error. - Create a base class
Shapewith aprotectedmethodarea(): numberthat returns0, and apublicmethoddescribe(): stringthat callsthis.area(). Create a subclassSquare extends Shapethat overridesarea()using aprivatefieldsideLength: number. Verifydescribe()correctly reports the square’s area. - Given a class with a
privatefield, try assigning an instance of it to a variable typed as a plain object literal type with the same property names and types. Observe (and explain in your own words) why TypeScript rejects the assignment even though the shapes look identical — this is the structural-typing effect of private members discussed in the Overview.
Summary
public(the default) is accessible from anywhere;protectedis accessible within the class and its subclasses;privateis accessible only within the declaring class.- Parameter properties let you declare and assign a field in one step by putting a modifier directly on a constructor parameter.
- All three modifiers are compile-time-only: TypeScript enforces them while type-checking, but the compiled JavaScript has no access restrictions at all.
- Classes with
privateorprotectedmembers are compared more strictly (nominally) than plain structural types, which prevents unrelated classes with matching shapes from being confused with each other. - Prefer exposing behavior (methods) over raw state (public fields) to keep a class’s internals safely encapsulated.
