Access Modifiers: public, private, protected


Understanding Access Control
Access modifiers are keywords that control how and where class members (properties and methods) can be accessed. They're fundamental to encapsulation—one of the core principles of object-oriented programming that lets you hide implementation details and expose only what's necessary.
Think of access modifiers as security clearance levels. Some information is public (anyone can access it), some is private (only you can access it), and some is protected (you and your trusted associates can access it). TypeScript gives you three access modifiers to implement this control: public, private, and protected.
Before TypeScript, JavaScript had no formal access control—everything was public. TypeScript's access modifiers give you compile-time enforcement of access rules, preventing accidental misuse of class internals. While these restrictions are removed in compiled JavaScript, the TypeScript compiler catches violations during development, which is when you want to find bugs.
The Three Access Modifiers
public: The Default
Members marked public can be accessed from anywhere—inside the class, outside the class, in subclasses, everywhere. In TypeScript, if you don't specify an access modifier, members are public by default:
class User {
public name: string; // Explicitly public
email: string; // Implicitly public (no modifier = public)
public constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
public greet(): string {
return `Hello, I'm ${this.name}`;
}
}
const user = new User("Alice", "[email protected]");
console.log(user.name); // ✅ Accessible
console.log(user.email); // ✅ Accessible
console.log(user.greet()); // ✅ AccessibleMost developers omit the public keyword since it's the default, but you can include it explicitly for clarity if you prefer.
private: Class-Only Access
Members marked private can only be accessed from within the class that defines them. They're completely hidden from outside code, including subclasses:
class BankAccount {
private balance: number;
public accountNumber: string;
constructor(accountNumber: string, initialBalance: number) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
public deposit(amount: number): void {
if (amount > 0) {
this.balance += amount; // ✅ Can access private member inside class
}
}
public getBalance(): number {
return this.balance; // ✅ Can access private member inside class
}
}
const account = new BankAccount("ACC001", 1000);
account.deposit(500);
console.log(account.accountNumber); // ✅ Public property accessible
console.log(account.getBalance()); // ✅ Public method accessible
console.log(account.balance); // ❌ Error: Property 'balance' is privateThe error message is clear: TypeScript prevents you from accessing balance directly because it's marked private. This forces you to use the public methods (deposit, getBalance) which can enforce business rules and validation.
protected: Class and Subclass Access
Members marked protected can be accessed from within the defining class and any class that extends it (subclasses), but not from outside code:
class Employee {
protected salary: number;
public name: string;
constructor(name: string, salary: number) {
this.name = name;
this.salary = salary;
}
protected calculateBonus(): number {
return this.salary * 0.1; // 10% bonus
}
}
class Manager extends Employee {
private teamSize: number;
constructor(name: string, salary: number, teamSize: number) {
super(name, salary);
this.teamSize = teamSize;
}
public getTotalCompensation(): number {
// ✅ Can access protected members from parent class
return this.salary + this.calculateBonus();
}
}
const manager = new Manager("Bob", 80000, 5);
console.log(manager.name); // ✅ Public property accessible
console.log(manager.getTotalCompensation()); // ✅ Public method accessible
console.log(manager.salary); // ❌ Error: Property 'salary' is protected
console.log(manager.calculateBonus()); // ❌ Error: Method 'calculateBonus' is protectedThe protected modifier is particularly useful for inheritance hierarchies where you want to share implementation details with subclasses but hide them from external code.
Access Modifier Visibility
Which contexts can access members with each modifier
| Access Modifier | Same Class Inside the class that defines the member | Subclass In a class that extends the defining class | External Code Outside the class and its subclasses |
|---|---|---|---|
public | |||
protected | |||
private |
Key Takeaways
- ✓public: Accessible everywhere (default)
- ✓protected: Class + subclasses only
- ✓private: Class only (most restrictive)
Quick Reference
Use public for your API (what users interact with), private for
implementation details (nobody else needs to see), and protected for
inheritance support (subclasses need access but external code doesn't).
Why Encapsulation Matters
Encapsulation is about hiding complexity and controlling access to an object's internal state. Access modifiers are the primary tool for achieving encapsulation in TypeScript.
Problem: Unrestricted Access
Without access modifiers, anyone can modify any property, leading to invalid states:
class Rectangle {
width: number;
height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
const rect = new Rectangle(10, 5);
console.log(rect.getArea()); // 50
// Nothing stops this nonsense:
rect.width = -20; // Negative width?!
rect.height = 0; // Zero height?!
console.log(rect.getArea()); // 0 (mathematically correct but logically wrong)The problem: external code can put the object into an invalid state. A rectangle with negative or zero dimensions doesn't make sense, but nothing prevents it.
Solution: Controlled Access
Use private properties and public methods to enforce invariants:
class Rectangle {
private width: number;
private height: number;
constructor(width: number, height: number) {
if (width <= 0 || height <= 0) {
throw new Error("Width and height must be positive");
}
this.width = width;
this.height = height;
}
public setWidth(width: number): void {
if (width <= 0) {
throw new Error("Width must be positive");
}
this.width = width;
}
public setHeight(height: number): void {
if (height <= 0) {
throw new Error("Height must be positive");
}
this.height = height;
}
public getWidth(): number {
return this.width;
}
public getHeight(): number {
return this.height;
}
public getArea(): number {
return this.width * this.height;
}
}
const rect = new Rectangle(10, 5);
console.log(rect.getArea()); // 50
rect.setWidth(20); // ✅ Valid
console.log(rect.getArea()); // 100
rect.setWidth(-10); // ❌ Error: Width must be positive
rect.width = -10; // ❌ Error: Property 'width' is privateNow the class maintains its invariants. The rectangle will always have positive dimensions because the public methods enforce validation.
Common Mistake
Making all properties public "just in case" defeats the purpose of
encapsulation. Start with private by default and only expose what's truly
needed through public methods. You can always make something more accessible
later, but removing public access is a breaking change.
Practical Patterns
Pattern 1: Private Implementation Details
Keep internal mechanics private, expose only the public interface:
class Cache {
private data: Map<string, any> = new Map();
private maxSize: number;
constructor(maxSize: number = 100) {
this.maxSize = maxSize;
}
public get(key: string): any | undefined {
return this.data.get(key);
}
public set(key: string, value: any): void {
// Private method handles eviction logic
this.evictIfNeeded();
this.data.set(key, value);
}
public has(key: string): boolean {
return this.data.has(key);
}
public clear(): void {
this.data.clear();
}
// Private helper method - implementation detail
private evictIfNeeded(): void {
if (this.data.size >= this.maxSize) {
// Simple eviction: remove oldest entry
const firstKey = this.data.keys().next().value;
this.data.delete(firstKey);
}
}
}
const cache = new Cache(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
cache.set("d", 4); // Triggers eviction internally
console.log(cache.has("a")); // false (evicted)
console.log(cache.has("d")); // true
// cache.evictIfNeeded(); // ❌ Error: Method 'evictIfNeeded' is private
// cache.data.clear(); // ❌ Error: Property 'data' is privateExternal code doesn't need to know about eviction logic. They just use get, set, and has. The implementation can change without breaking client code.
Pattern 2: Protected Template Methods
Base classes define protected methods that subclasses can override or extend:
abstract class DataProcessor {
protected data: any[];
constructor(data: any[]) {
this.data = data;
}
// Template method - public
public process(): any[] {
this.validate();
this.transform();
this.cleanup();
return this.data;
}
// Protected methods - subclasses can override
protected validate(): void {
if (!Array.isArray(this.data)) {
throw new Error("Data must be an array");
}
}
protected abstract transform(): void; // Must be implemented by subclass
protected cleanup(): void {
// Default cleanup (can be overridden)
this.data = this.data.filter((item) => item != null);
}
}
class NumberProcessor extends DataProcessor {
protected transform(): void {
// ✅ Can access protected method from parent
this.data = this.data.map((num) => {
if (typeof num !== "number") {
throw new Error("All items must be numbers");
}
return num * 2;
});
}
protected cleanup(): void {
// ✅ Override parent's cleanup
super.cleanup(); // Call parent's cleanup first
this.data = this.data.filter((num) => num > 0); // Additional filtering
}
}
const processor = new NumberProcessor([1, 2, null, 3, -4]);
const result = processor.process();
console.log(result); // [2, 4, 6] (doubled, nulls removed, negatives removed)
// processor.validate(); // ❌ Error: Method 'validate' is protected
// processor.transform(); // ❌ Error: Method 'transform' is protectedThe template method pattern uses protected to let subclasses customize behavior while keeping the overall algorithm structure private.
Pattern 3: Readonly Public, Private Setter
Sometimes you want a value readable from outside but only settable internally:
class Counter {
private _count: number = 0;
public get count(): number {
return this._count;
}
public increment(): void {
this._count++;
}
public decrement(): void {
if (this._count > 0) {
this._count--;
}
}
public reset(): void {
this._count = 0;
}
}
const counter = new Counter();
console.log(counter.count); // 0 (can read)
counter.increment();
counter.increment();
console.log(counter.count); // 2
// counter.count = 100; // ❌ Error: Cannot assign to 'count' because it is a read-only property
counter._count = 100; // ❌ Error: Property '_count' is privateExternal code can read count but can't set it directly. All modifications go through controlled methods.
Pattern 4: Private Constructor for Singletons
Use a private constructor to prevent direct instantiation:
class DatabaseConnection {
private static instance: DatabaseConnection;
private connectionString: string;
private isConnected: boolean = false;
private constructor(connectionString: string) {
this.connectionString = connectionString;
}
public static getInstance(connectionString?: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
if (!connectionString) {
throw new Error("Connection string required for first initialization");
}
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
public connect(): void {
if (!this.isConnected) {
console.log(`Connecting to ${this.connectionString}...`);
this.isConnected = true;
}
}
public disconnect(): void {
if (this.isConnected) {
console.log("Disconnecting...");
this.isConnected = false;
}
}
}
// const db = new DatabaseConnection("..."); // ❌ Error: Constructor is private
const db1 = DatabaseConnection.getInstance("postgres://localhost:5432");
const db2 = DatabaseConnection.getInstance(); // Returns same instance
console.log(db1 === db2); // true (same instance)The private constructor ensures only one instance exists—the singleton pattern.
Protected vs Private: When to Use Each
Use private When:
Implementation details that should never be exposed:
class PasswordManager {
private salt: string;
private hashAlgorithm: string = "sha256";
constructor() {
this.salt = this.generateSalt();
}
private generateSalt(): string {
return Math.random().toString(36).substring(2);
}
private hash(password: string): string {
// Complex hashing logic
return password + this.salt; // Simplified for example
}
public setPassword(password: string): string {
return this.hash(password);
}
}Nobody outside (including subclasses) needs to know about salt, hashAlgorithm, generateSalt, or hash. These are pure implementation details.
Use protected When:
Subclasses need access but external code doesn't:
class Vehicle {
protected engineOn: boolean = false;
public brand: string;
constructor(brand: string) {
this.brand = brand;
}
protected startEngine(): void {
this.engineOn = true;
console.log("Engine started");
}
protected stopEngine(): void {
this.engineOn = false;
console.log("Engine stopped");
}
}
class Car extends Vehicle {
public drive(): void {
if (!this.engineOn) {
this.startEngine(); // ✅ Subclass can access protected method
}
console.log("Driving...");
}
public park(): void {
console.log("Parking...");
this.stopEngine(); // ✅ Subclass can access protected method
}
}
const car = new Car("Toyota");
car.drive(); // Engine started, Driving...
car.park(); // Parking..., Engine stopped
// car.startEngine(); // ❌ Error: Method 'startEngine' is protectedSubclasses need startEngine and stopEngine, but external code shouldn't call them directly—they should use drive and park.
Use public When:
The member is part of the class's public API:
class ShoppingCart {
public items: string[] = [];
private total: number = 0;
public addItem(item: string, price: number): void {
this.items.push(item);
this.total += price;
}
public getTotal(): number {
return this.total;
}
public clear(): void {
this.items = [];
this.total = 0;
}
}items, addItem, getTotal, and clear are meant to be used by external code—they're the cart's public interface. total is private because it's managed internally.
Access Modifiers in Constructors
You can apply access modifiers to constructor parameters to automatically create and initialize properties:
class Product {
// Traditional way
private id: number;
private name: string;
public price: number;
constructor(id: number, name: string, price: number) {
this.id = id;
this.name = name;
this.price = price;
}
}
// Shorthand with parameter properties
class ProductShorthand {
constructor(
private id: number,
private name: string,
public price: number
) {
// Properties automatically created and assigned
}
}
const product = new ProductShorthand(1, "Laptop", 1200);
console.log(product.price); // ✅ 1200 (public)
// console.log(product.id); // ❌ Error: Property 'id' is private
// console.log(product.name); // ❌ Error: Property 'name' is privateThis shorthand is incredibly common in TypeScript—it reduces boilerplate while maintaining access control.
Runtime vs Compile-Time
Critical understanding: TypeScript access modifiers are compile-time only. They're erased when compiled to JavaScript:
class Secret {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
}
const secret = new Secret("super-secret-key");
// secret.apiKey; // ❌ TypeScript error: Property 'apiKey' is privateBut in the compiled JavaScript:
class Secret {
constructor(apiKey) {
this.apiKey = apiKey;
}
}
const secret = new Secret("super-secret-key");
console.log(secret.apiKey); // ✅ Works in JavaScript! No privacy at runtimeSecurity Notice
TypeScript access modifiers are compile-time only and provide no runtime
security. They're erased in the compiled JavaScript. For true runtime privacy,
use JavaScript's #privateField syntax or keep sensitive data server-side.
Implications:
- Access modifiers protect you during development (TypeScript compilation)
- They don't protect against runtime access (someone can bypass them in JavaScript)
- For true runtime privacy, use JavaScript private fields (
#propertyName) - TypeScript access modifiers are about code organization and compile-time safety, not security
True Runtime Privacy with # (ECMAScript Private Fields)
TypeScript supports ECMAScript private fields for actual runtime privacy:
class SecureVault {
#secretCode: string; // True private field
constructor(secretCode: string) {
this.#secretCode = secretCode;
}
public verify(code: string): boolean {
return code === this.#secretCode;
}
}
const vault = new SecureVault("1234");
console.log(vault.verify("1234")); // true
// console.log(vault.#secretCode); // ❌ Syntax error even in JavaScriptThe # syntax creates truly private fields that can't be accessed outside the class, even at runtime. However, private keyword is more common and integrates better with TypeScript's type system.
Best Practices
1. Start Private, Make Public When Needed
Default to private for new members. Make them protected or public only when you have a specific reason:
class UserManager {
private users: Map<string, User> = new Map();
private nextId: number = 1;
public addUser(name: string, email: string): User {
const id = this.nextId++;
const user = new User(id, name, email);
this.users.set(id.toString(), user);
return user;
}
public getUser(id: string): User | undefined {
return this.users.get(id);
}
// Started private, only expose if actually needed
// private getAllUsers(): User[] { ... }
// private deleteUser(id: string): boolean { ... }
}This principle maintains encapsulation and gives you flexibility to change implementation details later.
Refactoring Tip
If you're unsure whether a member should be public or private, start with private. It's easy to make something more accessible later, but making something less accessible is a breaking change that affects all existing code using your class.
2. Use Protected for Extensibility
If you're building a class library that others will extend, use protected for members that subclasses might need:
abstract class HttpClient {
protected baseURL: string;
protected timeout: number = 30000;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
protected buildURL(endpoint: string): string {
return `${this.baseURL}${endpoint}`;
}
public abstract request(endpoint: string): Promise<any>;
}
class RestClient extends HttpClient {
public async request(endpoint: string): Promise<any> {
const url = this.buildURL(endpoint); // ✅ Can use protected method
// Fetch logic...
return fetch(url, { timeout: this.timeout }); // ✅ Can use protected property
}
}3. Document Why Members Are Private/Protected
Comments help future maintainers understand access decisions:
class FileProcessor {
// Private: Internal buffer not meant for external access
// Use addData() instead to ensure proper validation
private buffer: string[] = [];
// Protected: Subclasses may override validation logic
protected validate(data: string): boolean {
return data.length > 0 && data.length < 1000;
}
// Public: Primary method for adding data
public addData(data: string): boolean {
if (!this.validate(data)) {
return false;
}
this.buffer.push(data);
return true;
}
}4. Validate in Public Methods
Public methods are the entry points—validate inputs there:
class Temperature {
private celsius: number;
constructor(celsius: number) {
this.setCelsius(celsius); // Use validation method
}
public setCelsius(value: number): void {
if (value < -273.15) {
throw new Error("Temperature below absolute zero");
}
this.celsius = value;
}
public getCelsius(): number {
return this.celsius;
}
public setFahrenheit(value: number): void {
const celsius = ((value - 32) * 5) / 9;
this.setCelsius(celsius); // Reuse validation
}
}Common Mistakes
Mistake 1: Making Everything Public
// ❌ Bad: Everything public
class Database {
public connection: any;
public queries: string[];
public cache: Map<string, any>;
public connect() {
/* ... */
}
public disconnect() {
/* ... */
}
public executeQuery(sql: string) {
/* ... */
}
public clearCache() {
/* ... */
}
}
// ✅ Better: Hide implementation details
class DatabaseGood {
private connection: any;
private queries: string[] = [];
private cache: Map<string, any> = new Map();
public connect() {
/* ... */
}
public disconnect() {
/* ... */
}
public executeQuery(sql: string) {
/* ... */
}
// Expose only what's needed
public getQueryHistory(): string[] {
return [...this.queries]; // Return copy
}
}Mistake 2: Exposing Mutable State
// ❌ Bad: Returning reference to private array
class TodoList {
private items: string[] = [];
public getItems(): string[] {
return this.items; // Returns reference!
}
}
const list = new TodoList();
const items = list.getItems();
items.push("Hack the system"); // Modifies private array!
// ✅ Better: Return a copy
class TodoListGood {
private items: string[] = [];
public getItems(): string[] {
return [...this.items]; // Return copy
}
public getItemCount(): number {
return this.items.length; // Or provide specific methods
}
}Mistake 3: Overly Restrictive Access
// ❌ Too restrictive: Subclasses can't access
class BaseComponent {
private state: any = {};
private setState(newState: any): void {
this.state = { ...this.state, ...newState };
}
}
class Button extends BaseComponent {
public click(): void {
// this.setState({ clicked: true }); // ❌ Error: 'setState' is private
}
}
// ✅ Better: Use protected for extensibility
class BaseComponentGood {
protected state: any = {};
protected setState(newState: any): void {
this.state = { ...this.state, ...newState };
}
}
class ButtonGood extends BaseComponentGood {
public click(): void {
this.setState({ clicked: true }); // ✅ Works
}
}What's Next
You now understand how to control access to class members with public, private, and protected. In the next posts, you'll learn:
- Getters and setters for controlled property access
- Static members for class-level properties and methods
- Abstract classes that enforce structure in inheritance hierarchies
- Class inheritance and the
superkeyword
Key Takeaways
Access modifiers give you:
✅ Encapsulation through controlled access to class internals ✅ Data integrity by preventing invalid state modifications ✅ Maintainability by hiding implementation details ✅ Flexibility to change internals without breaking external code ✅ Clear interfaces that show what's meant to be used publicly ✅ Inheritance support with protected members for subclasses
Use private by default, protected when subclasses need access, and public for your class's API. This discipline leads to more robust, maintainable code that's easier to refactor and extend over time.