Deep-dive into TypeScript classes — public, private, protected, readonly, abstract, and the new #private fields. Understand parameter properties, class vs interface, static members, and how TypeScript's structural typing interacts with access modifiers.
TypeScript access modifiers are like the different rooms in a building. Public members are the lobby — anyone can walk in from outside. Protected members are the staff corridor — only employees (the class itself and its subclasses) can access them. Private members are the manager's office — only the person in that exact office (the class itself, no subclasses) can enter. TypeScript's private keyword enforces this at compile time; JavaScript's # syntax (hard private) enforces it at runtime as well, making it a true encapsulation boundary even in plain JavaScript.
class BankAccount {
public owner: string; // Accessible from anywhere (default)
protected balance: number; // Accessible in this class and subclasses
private pin: string; // Accessible only in this class
readonly accountId: string; // Can be set once (in constructor), then immutable
constructor(owner: string, pin: string) {
this.owner = owner;
this.pin = pin;
this.balance = 0;
this.accountId = crypto.randomUUID();
}
public getBalance(): number {
return this.balance; // Can access private/protected inside the class
}
private validatePin(input: string): boolean {
return input === this.pin; // Only this class can call validatePin
}
}
const account = new BankAccount("Alice", "1234");
account.owner; // OK — public
account.balance; // Error — protected
account.pin; // Error — private
account.accountId; // OK — readonly, but readable
account.accountId = "x"; // Error — readonly
TypeScript's parameter properties declare and initialise class properties directly from constructor parameters — eliminating boilerplate:
// Verbose — the traditional way
class User {
public name: string;
private email: string;
protected role: string;
constructor(name: string, email: string, role: string) {
this.name = name;
this.email = email;
this.role = role;
}
}
// Concise — parameter properties (access modifier in constructor signature)
class User {
constructor(
public name: string,
private email: string,
protected role: string,
readonly id: number = Math.random()
) {}
// name, email, role, id are automatically declared AND initialised
}
TypeScript's private is a compile-time-only check — at runtime the property is still publicly accessible on the object. JavaScript's # syntax creates a true runtime private field that cannot be accessed even via reflection:
class TokenService {
private tsPrivate = "secret"; // TypeScript only — erased at runtime
#jsPrivate = "also-secret"; // JavaScript private field — runtime enforced
getTokenTs() { return this.tsPrivate; }
getTokenJs() { return this.#jsPrivate; }
}
const svc = new TokenService();
(svc as any).tsPrivate; // Works at runtime — TypeScript private is compile-time only!
(svc as any)["#jsPrivate"]; // undefined — hard private, inaccessible even with any
svc.#jsPrivate; // Error at compile time AND runtime
For truly sensitive data (tokens, keys, passwords in memory), prefer#hard private fields. Use TypeScriptprivatefor API encapsulation enforced by the type system.
Abstract classes define a template — they can contain implemented methods AND abstract methods that subclasses must implement. They cannot be instantiated directly:
abstract class Repository<T> {
// Concrete method — shared implementation available to all subclasses
async findOrFail(id: string): Promise<T> {
const entity = await this.findById(id);
if (!entity) throw new Error(`Not found: ${id}`);
return entity;
}
// Abstract methods — subclasses MUST implement these
abstract findById(id: string): Promise<T | null>;
abstract save(entity: T): Promise<void>;
abstract delete(id: string): Promise<void>;
}
class UserRepository extends Repository<User> {
async findById(id: string): Promise<User | null> {
return db.users.findOne({ id });
}
async save(user: User): Promise<void> {
await db.users.upsert(user);
}
async delete(id: string): Promise<void> {
await db.users.delete({ id });
}
}
new Repository(); // Error — cannot instantiate abstract class
new UserRepository(); // OK
class Config {
private static instance: Config | null = null;
private settings: Record<string, string> = {};
// Singleton pattern — static factory method
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
return Config.instance;
}
// Static utility method — no instance needed
static fromEnv(): Config {
const cfg = new Config();
cfg.settings = { ...process.env } as Record<string, string>;
return cfg;
}
}
const cfg = Config.getInstance(); // Access via class, not instance
TypeScript uses structural typing — two types are compatible if their shapes match. But access modifiers affect structural compatibility in a subtle way:
class A { private secret = 1; public value = 2; }
class B { private secret = 1; public value = 2; }
// Despite looking identical, A and B are NOT structurally compatible
// because their private fields are declared in different classes
const a: A = new B(); // Error — A's private 'secret' is not from B
class C extends A {} // OK — C inherits A's private 'secret'
const c: A = new C(); // OK — C is structurally compatible with AMany developers think TypeScript's private keyword prevents runtime access — it only prevents access through the TypeScript type system. At runtime, (obj as any).privateField works fine. Use # for true runtime encapsulation.
Many developers think abstract classes and interfaces are interchangeable — abstract classes can contain implementations; interfaces cannot. Use an interface when you only want to define a contract. Use an abstract class when you want to share implementation between subclasses.
Many developers think private fields from different classes with the same structure are compatible in TypeScript's structural type system — they are not. Private fields break structural compatibility between unrelated classes, even if the shapes look identical.
Many developers think parameter properties are only syntactic sugar with no behavioral difference — they are identical at runtime, but parameter properties must have an access modifier (public, private, protected, or readonly) or TypeScript treats them as plain constructor parameters without property declarations.
Many developers think readonly means immutable deeply — readonly only prevents reassignment of the property itself. If the property holds an array or object, its contents are still mutable. Use Readonly<T> or as const for deeper immutability.
Many developers think static members are shared between subclasses automatically — each class has its own static properties. Subclasses inherit access to parent static methods but have their own static property slots.
Singleton services: Config.getInstance() pattern using static instance property is ubiquitous in backend TypeScript code for database connections, configuration managers, and logging services.
Repository pattern: abstract Repository<T> with abstract CRUD methods is a standard architecture pattern in NestJS, TypeORM, and Prisma-based backends — concrete repositories implement the abstract methods per entity.
Event emitter encapsulation: private listeners map in an EventEmitter class prevents external code from directly manipulating the listener registry, while protected methods let subclasses hook into the emit lifecycle.
React class components (legacy): public render(), private handleClick(), protected shouldRenderSidebar() map directly to the mental model of what API surface should be part of the contract vs internal implementation.
NestJS providers: TypeScript classes with @Injectable(), private readonly dependencies injected via constructor parameter properties, and public methods forming the service API — parameter properties save 30–50% of boilerplate in medium-size services.
Sensitive data holders: # hard private fields for token storage in auth utilities, ensuring that even injected runtime code or serialization libraries cannot accidentally expose the field — TypeScript private would not provide this guarantee.
What are access modifiers in TypeScript — public, private, protected, readonly?
What are abstract classes in TypeScript and when should you use them?
What is the difference between implements and extends in TypeScript?
How do decorators work in TypeScript, and what are they used for?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.