Abstract classes provide partial implementation; subclasses must fill in missing methods.
Interfaces define contracts—use them for dependency injection and testability.
Generics make classes type-safe across different types without losing compile-time checks.
Biggest mistake: treating private as a runtime security boundary—it's compile-time only.
✦ Definition~90s read
What is TypeScript Classes and OOP?
TypeScript classes are a structured way to model objects with behavior and state, solving the problem of organizing code in large-scale applications where plain functions and objects become unwieldy. They provide a blueprint for creating objects with shared methods and properties, and they integrate with TypeScript's type system to enforce contracts at compile time.
★
Think of a class like a blueprint for a house.
However, unlike true OOP languages, TypeScript classes are transpiled to JavaScript prototypes — meaning access modifiers like protected are only enforced during development, not at runtime. This is critical to understand because a protected field in a parent class can be exposed via a getter in a subclass, leaking internal state to consumers who shouldn't have direct access.
In the ecosystem, TypeScript classes compete with functional patterns (e.g., closures for encapsulation) and plain objects with factory functions; use classes when you need inheritance, polymorphism, or when working with frameworks like Angular or NestJS that expect class-based DI. Avoid them for simple data containers — interfaces or type aliases are lighter and avoid the prototype overhead.
Real-world teams at Microsoft and Google use classes for domain models and services, but they often pair them with lint rules (e.g., no-protected-access) to prevent accidental leaks like the one this article covers.
Plain-English First
Think of a class like a blueprint for a house. You draw the blueprint once — it says every house gets a front door, a kitchen, and a roof. Then you build as many houses as you want from that same blueprint, and each house is its own thing. TypeScript classes work exactly like that: you define the blueprint once, and every 'object' you create from it gets all the same features but holds its own data. The 'OOP' part just means you organise your whole program around these blueprints instead of loose instructions scattered everywhere.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
Most JavaScript projects start clean and end messy. Functions multiply, global state leaks everywhere, and six months later nobody dares touch the auth module. TypeScript classes exist to stop that slide before it starts. They're not just syntactic sugar over JavaScript prototypes — they're a contract. A class tells every developer on your team exactly what a thing is, what it can do, and what data it owns. That's architecture, not just syntax.
The real problem OOP solves is the same one your brain solves every day: managing complexity by grouping related things together. A User isn't a loose collection of a name string, an email string, and a login function floating in the void. A User is a single, self-contained unit that owns its data and exposes only the behaviour it intends to. TypeScript enforces that intent at compile time, catching mistakes before they ever hit production.
By the end of this article you'll know how to design a TypeScript class that's genuinely reusable, how to use access modifiers to protect your data like a pro, how inheritance and abstract classes let you build flexible hierarchies without copy-pasting code, and how to use interfaces as contracts that make your whole codebase more predictable. You'll also walk away knowing exactly what interviewers are hunting for when they ask about OOP in TypeScript.
What TypeScript Classes Actually Do — Beyond the Syntax
TypeScript classes are syntactic sugar over JavaScript's prototype-based inheritance, adding static type checking at compile time. The core mechanic: you define a blueprint with fields, constructors, and methods, and TypeScript enforces access modifiers (public, private, protected) and type contracts during development. At runtime, it's just JavaScript classes — no extra overhead, no runtime enforcement.
Key properties that matter: protected fields are accessible within the class and its subclasses, but not from external callers. However, TypeScript's protected is a compile-time illusion — it does not prevent runtime access. A subclass can expose a protected field via a public getter, effectively leaking it. This is not a bug; it's a design choice that trades runtime safety for simplicity. The compiler won't warn you because the getter is a valid method.
Use protected when you want to share internal state with subclasses but hide it from the outside. It's useful for framework base classes or abstract data structures where subclasses need controlled access. But beware: if you rely on protected for security or encapsulation, you're misusing it. Real systems use it for convenience, not protection. Prefer private with explicit getter methods if you need true encapsulation.
⚠ Protected Is Not Private
TypeScript's protected modifier is a compile-time check only. At runtime, any code can access a protected field — it's just a regular property.
📊 Production Insight
A team used protected fields in a payment processing base class, then a subclass exposed them via a public getter for logging. A developer accidentally logged the raw credit card number to production logs.
Symptom: PCI compliance violation — sensitive data leaked through a getter that was never intended to be public.
Rule: Never store sensitive data in protected fields. If a subclass can read it, assume it will be leaked. Use # private fields for true runtime privacy.
🎯 Key Takeaway
Protected fields are a compile-time contract, not a runtime guarantee.
Subclass getters can trivially expose protected state — audit them.
Use # private fields for secrets; use protected only for internal API convenience.
thecodeforge.io
Typescript Classes Oop
Classes, Constructors, and Access Modifiers — The Foundation You Can't Skip
A TypeScript class is a named template that bundles state (properties) and behaviour (methods) into one coherent unit. The constructor runs once when you create an instance, and it's where you set the initial state. But the part most tutorials gloss over is access modifiers — and they're what separates a well-designed class from a bag of variables.
public means anyone, anywhere can read or write that property. It's the default, but writing it explicitly signals intent. private means only code inside that same class can touch it. protected is the middle ground: the class itself and any class that extends it can use it, but outside code cannot.
Why does this matter? Because without access control, any part of your codebase can mutate any property at any time. You end up debugging why a user's balance is negative and discovering it was modified in twelve different files. Private fields make that physically impossible. The shorthand constructor syntax — declaring private name: string directly in the constructor parameters — cuts the boilerplate in half and is the pattern you'll see in every serious TypeScript codebase.
BankAccount.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// A real-world bank account — classic OOP demo done properlyclassBankAccount {
// private: only methods inside this class can touch the balanceprivate balance: number;
// public: anyone can read the account holder's namepublicreadonly accountHolder: string;
// protected: subclasses (e.g. SavingsAccount) can access this tooprotected transactionHistory: string[];
// The shorthand constructor: TypeScript declares AND assigns in one lineconstructor(
accountHolder: string,
initialDeposit: number
) {
this.accountHolder = accountHolder;
// Guard against negative opening balances at construction timeif (initialDeposit < 0) {
thrownewError('Initial deposit cannot be negative');
}
this.balance = initialDeposit;
this.transactionHistory = [`Account opened with $${initialDeposit}`];
}
// Public method: this is the ONLY way outsiders can change the balancepublicdeposit(amount: number): void {
if (amount <= 0) {
thrownewError('Deposit amount must be positive');
}
this.balance += amount;
this.transactionHistory.push(`Deposited $${amount}`);
console.log(`Deposited $${amount}. New balance: $${this.balance}`);
}
publicwithdraw(amount: number): void {
if (amount > this.balance) {
thrownewError('Insufficient funds');
}
this.balance -= amount;
this.transactionHistory.push(`Withdrew $${amount}`);
console.log(`Withdrew $${amount}. New balance: $${this.balance}`);
}
// A getter exposes balance as read-only to the outside worldpublic get currentBalance(): number {
returnthis.balance;
}
}
// --- Usage ---const myAccount = newBankAccount('Alice', 500);
myAccount.deposit(200);
myAccount.withdraw(100);
console.log(`Balance: $${myAccount.currentBalance}`);
console.log(`Holder: ${myAccount.accountHolder}`);
// This line would cause a TypeScript COMPILE ERROR — balance is private:// console.log(myAccount.balance); // Error: Property 'balance' is private
Instead of declaring a property, then listing it as a constructor parameter, then assigning it — just write constructor(private balance: number). TypeScript does all three steps for you. Every senior TypeScript dev uses this pattern; it's cleaner and harder to get wrong.
📊 Production Insight
Relying on TypeScript's private for sensitive data can create a false sense of security.
Compiled JavaScript exposes all properties—anyone can read or mutate them via the console.
Rule: Use native # private fields for anything you truly need to protect at runtime.
🎯 Key Takeaway
Access modifiers are compile-time guardrails, not runtime walls.
Shorthand constructor is the default pattern in production codebases.
Default to private, expose only what's part of the class API.
Inheritance and Abstract Classes — Reuse Without Copy-Pasting
Inheritance lets one class build on top of another. The child class (subclass) gets everything the parent has, and then adds or overrides what it needs. This is where people often go wrong: they reach for inheritance to share code, when really they should be asking 'Is a SavingsAccount genuinely a type of BankAccount?' If yes, inheritance makes sense. If you're just trying to reuse a helper method, composition is the better tool.
Abstract classes are the real power move. An abstract class says: 'I define the common structure and some shared behaviour, but I'm deliberately incomplete — you must extend me and fill in the blanks.' You can't instantiate an abstract class directly. This is perfect when you have a concept (like a PaymentProcessor) that absolutely must have a processPayment method, but the implementation differs completely between Stripe, PayPal, and bank transfer.
The super() call in a subclass constructor is mandatory when the parent has a constructor — it runs the parent's setup before your own. Forgetting it is a compile error, which is TypeScript doing you a favour.
PaymentProcessor.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// Abstract class: defines the contract, can't be instantiated directlyabstractclassPaymentProcessor {
// Shared data every processor needsconstructor(
protectedreadonly providerName: string,
protectedreadonly feesPercentage: number
) {}
// Concrete method: shared logic all subclasses inherit as-ispubliccalculateFee(amount: number): number {
returnparseFloat((amount * this.feesPercentage).toFixed(2));
}
// Abstract method: every subclass MUST implement this — or TypeScript errorspublicabstractprocessPayment(amount: number, recipient: string): void;
// Template method pattern: calls the abstract method inside a shared wrapperpublicinitiateTransaction(amount: number, recipient: string): void {
const fee = this.calculateFee(amount);
console.log(`\n[${this.providerName}] Starting transaction...`);
console.log(`Amount: $${amount} | Fee: $${fee} | Total: $${amount + fee}`);
// Delegates to the subclass-specific implementationthis.processPayment(amount, recipient);
}
}
// Concrete subclass: must implement processPaymentclassStripeProcessorextendsPaymentProcessor {
private apiKey: string;
constructor(apiKey: string) {
// Call parent constructor first — mandatorysuper('Stripe', 0.029); // Stripe's 2.9% feethis.apiKey = apiKey;
}
// Fulfilling the abstract contract with Stripe-specific logicpublicprocessPayment(amount: number, recipient: string): void {
console.log(`StripeAPI call → charging $${amount} to ${recipient}`);
console.log(`Using key: ${this.apiKey.substring(0, 8)}...`);
}
}
classPayPalProcessorextendsPaymentProcessor {
constructor() {
super('PayPal', 0.034); // PayPal's 3.4% fee
}
publicprocessPayment(amount: number, recipient: string): void {
console.log(`PayPal redirect → sending $${amount} to ${recipient}'s email`);
}
}
// --- Usage ---const stripe = newStripeProcessor('sk_live_abc123xyz');
stripe.initiateTransaction(100, 'vendor@shop.com');
const paypal = newPayPalProcessor();
paypal.initiateTransaction(50, 'buyer@email.com');
// This would be a compile error — can't instantiate an abstract class:// const processor = new PaymentProcessor('Generic', 0.01); // ERROR
Output
[Stripe] Starting transaction...
Amount: $100 | Fee: $2.9 | Total: $102.9
Stripe API call → charging $100 to vendor@shop.com
Using key: sk_live_...
[PayPal] Starting transaction...
Amount: $50 | Fee: $1.7 | Total: $51.7
PayPal redirect → sending $50 to buyer@email.com's email
Don't inherit just to share code. If EmailService extends DatabaseService just to reuse a formatDate helper, you've broken the 'is-a' rule. A service isn't a database. Extract the helper into a utility function or inject it as a dependency instead. Inheritance hierarchies deeper than two levels almost always become a maintenance trap.
📊 Production Insight
Deep inheritance hierarchies are fragile: a change in the base class can break all subclasses.
In production, such hierarchies quickly become 'untouchable' modules.
Rule: Prefer composition over inheritance—extract shared logic into injectable dependencies.
🎯 Key Takeaway
Inheritance is for 'is-a' relationships, not code reuse.
Abstract classes enforce contracts while sharing implementation.
Limit hierarchy depth to two levels—otherwise it becomes a maintenance burden.
thecodeforge.io
Typescript Classes Oop
Interfaces as Contracts — The TypeScript Superpower Most Devs Underuse
An interface in TypeScript is a pure contract. It has no implementation — it's just a description of what shape an object must have. Any class that implements an interface is promising to provide everything that interface describes. If it doesn't, TypeScript refuses to compile.
Here's why interfaces beat abstract classes for most real-world scenarios: a class can implement multiple interfaces, but it can only extend one abstract class. This is huge. Your UserRepository class can implement both Readable and Writable interfaces without the fragile single-inheritance chain.
Interfaces also enable genuine dependency inversion — one of the SOLID principles. Instead of your UserService depending on PostgresUserRepository (a concrete thing), it depends on IUserRepository (a contract). That means you can swap the Postgres implementation for an in-memory mock in tests without changing a single line in UserService. This is how professional-grade TypeScript codebases are structured.
The implements keyword is your compile-time safety net — it tells TypeScript to check the contract at build time, not at runtime when real users are already hitting your app.
UserRepository.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// The contract — no implementation, just the shapeinterfaceIUserRepository {
findById(id: string): User | undefined;
save(user: User): void;
delete(id: string): boolean;
}
// A simple User type for contexttypeUser = {
id: string;
name: string;
email: string;
};
// Concrete implementation 1: talks to a real database (simplified)classPostgresUserRepositoryimplementsIUserRepository {
// In real life this would hold a DB connectionprivate db: Map<string, User> = newMap();
publicfindById(id: string): User | undefined {
console.log(`[Postgres] Querying users table for id: ${id}`);
returnthis.db.get(id);
}
publicsave(user: User): void {
console.log(`[Postgres] Inserting/updating user: ${user.email}`);
this.db.set(user.id, user);
}
publicdelete(id: string): boolean {
console.log(`[Postgres] Deleting user id: ${id}`);
returnthis.db.delete(id);
}
}
// Concrete implementation 2: lives entirely in memory — perfect for unit testsclassInMemoryUserRepositoryimplementsIUserRepository {
private store: Map<string, User> = newMap();
publicfindById(id: string): User | undefined {
returnthis.store.get(id);
}
publicsave(user: User): void {
this.store.set(user.id, user);
}
publicdelete(id: string): boolean {
returnthis.store.delete(id);
}
}
// UserService depends on the INTERFACE, not a concrete class// Swap Postgres for InMemory and this class never changes — that's the powerclassUserService {
constructor(privatereadonly userRepo: IUserRepository) {}
publicregisterUser(name: string, email: string): User {
const newUser: User = {
id: `user_${Date.now()}`,
name,
email,
};
this.userRepo.save(newUser);
console.log(`User registered: ${newUser.name} (${newUser.email})`);
return newUser;
}
publicgetUser(id: string): User | undefined {
returnthis.userRepo.findById(id);
}
}
// --- Production usage: plug in the real DB implementation ---const prodRepo = newPostgresUserRepository();
const prodService = newUserService(prodRepo);
const alice = prodService.registerUser('Alice', 'alice@example.com');
console.log('Found user:', prodService.getUser(alice.id)?.name);
// --- Test usage: swap to in-memory, zero infrastructure needed ---const testRepo = newInMemoryUserRepository();
const testService = newUserService(testRepo);
testService.registerUser('Bob', 'bob@example.com');
When an interviewer asks 'when would you use an interface over an abstract class?', the winning answer is: use an interface when you want to define a contract that multiple unrelated classes can fulfil, especially when you need a class to satisfy multiple contracts. Use an abstract class when you have shared implementation (real code) to reuse alongside the contract. TypeScript lets you use both together — an abstract class can implement an interface.
📊 Production Insight
Without interfaces, unit tests require heavy infrastructure setup.
A concrete class dependency forces your test to bring up a database, mock an HTTP server, or bootstrap a whole service.
Rule: Every external service interaction should be behind an interface so you can swap it out in tests.
🎯 Key Takeaway
Interfaces enable dependency inversion—the core of testable architecture.
One class can implement many interfaces, avoiding single-inheritance constraints.
Design for swap-ability: depend on interfaces, not concrete classes.
Static Members and Generics in Classes — When You Need Class-Level Behaviour
Static properties and methods belong to the class itself, not to any instance. Every instance of BankAccount has its own balance, but if you want to track how many accounts exist in total, that counter belongs to the class — it's the same number no matter which instance you ask.
Use statics for: factory methods (creating instances with controlled logic), shared counters or caches, utility behaviour that doesn't need this instance data, and singleton patterns. Overusing statics leads to the same problems as global variables — tight coupling and untestable code — so keep them intentional.
Generics let you write a class once and have it work with any type, while TypeScript still enforces that you're consistent. A DataCache<T> class stores and retrieves items of type T — call it with DataCache<User> and TypeScript ensures you never accidentally store a Product in your user cache. It's the difference between a box that holds anything (and breaks at runtime) and a labelled box that refuses the wrong items at compile time.
DataCache.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// A generic, type-safe cache — works for any type you give itclassDataCache<T> {
// Static counter tracks how many caches have been created across the appprivatestatic instanceCount: number = 0;
privatereadonly cacheId: number;
private store: Map<string, T> = newMap();
private hitCount: number = 0;
constructor(privatereadonly cacheName: string) {
// Static member accessed on the class, not on 'this'DataCache.instanceCount++;
this.cacheId = DataCache.instanceCount;
console.log(`Cache #${this.cacheId} created: "${cacheName}"`);
}
// Static factory method: named constructor pattern — clearer intent than 'new'publicstatic create<U>(name: string): DataCache<U> {
returnnewDataCache<U>(name);
}
publicset(key: string, value: T): void {
this.store.set(key, value);
}
// TypeScript knows the return type is T (whatever you chose), not 'any'publicget(key: string): T | undefined {
const value = this.store.get(key);
if (value !== undefined) {
this.hitCount++;
}
return value;
}
publicgetStats(): string {
return `Cache"${this.cacheName}" | Keys: ${this.store.size} | Hits: ${this.hitCount}`;
}
// Static method: relevant to the class concept, needs no instance datapublicstaticgetTotalCaches(): number {
returnDataCache.instanceCount;
}
}
// --- Usage: two separate typed caches ---typeProduct = { id: string; name: string; price: number };
// TypeScript now knows this cache only holds Product objectsconst productCache = DataCache.create<Product>('ProductCache');
productCache.set('prod_001', { id: 'prod_001', name: 'Keyboard', price: 79 });
productCache.set('prod_002', { id: 'prod_002', name: 'Mouse', price: 45 });
// TypeScript knows this cache only holds stringsconst sessionCache = DataCache.create<string>('SessionCache');
sessionCache.set('session_abc', 'user_123');
// Retrieve — TypeScript infers the correct type automaticallyconst keyboard = productCache.get('prod_001');
console.log(`Found: ${keyboard?.name} at $${keyboard?.price}`);
const session = sessionCache.get('session_abc');
console.log(`Session maps to user: ${session}`);
console.log(productCache.getStats());
console.log(sessionCache.getStats());
// Static method called on the class, not an instance
console.log(`Total caches created: ${DataCache.getTotalCaches()}`);
// This would be a compile error — wrong type for this cache:// productCache.set('bad_key', 'just a string'); // Error: string not assignable to Product
Static factory methods like DataCache.create<User>('users') let you give intent to construction. Unlike new, they can have descriptive names (User.fromEmail(), User.fromOAuthProfile()), they can return subclasses, and they can enforce async creation patterns. This pattern shows up constantly in well-architected TypeScript codebases.
📊 Production Insight
Overusing statics creates global state that makes testing and debugging harder.
A static cache shared across requests can leak data between users if not scoped properly.
Rule: Use statics sparingly; prefer dependency injection for testable shared state.
🎯 Key Takeaway
Statics belong to the class, not to instances—use them for factories, counters, and caches.
Generics make classes reusable across types without losing type safety.
Named constructors via static methods are more expressive than new.
Polymorphism and Method Overriding — The Real-World Power of Subtypes
Polymorphism means you can treat different classes that share a common parent as if they were the same type. In TypeScript, this works naturally with inheritance: you can write code that operates on a base class (or abstract class) and have it work with any subclass. The actual method that runs depends on the runtime type of the object, not the declared type.
This is where abstract classes really shine. The initiateTransaction method in PaymentProcessor doesn't know which subclass is calling it—it just calls processPayment, and the correct implementation runs. You can write an array of payment processors and call the same method on each, getting different behaviour every time.
Polymorphism reduces conditionals. Instead of if (type === 'stripe') stripe.process(...) else if (type === 'paypal') paypal.process(...), you just call processor.initiateTransaction(...). That's fewer branches, less risk, and cleaner code.
The key rule is the Liskov Substitution Principle: a subclass must be able to replace its parent without breaking the program. If a subclass's processPayment throws a new type of error that the parent's contract didn't expect, you've violated it—and TypeScript won't catch it.
PolymorphicProcessors.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Using the PaymentProcessor abstract class from earlier// Polymorphism: treat all processors through the base typefunctionprocessAllPayments(processors: PaymentProcessor[], amount: number, recipient: string): void {
for (const processor of processors) {
// The same method call—different behaviour for each subclass
processor.initiateTransaction(amount, recipient);
}
}
const stripe = newStripeProcessor('sk_live_abc123xyz');
const paypal = newPayPalProcessor();
const processors: PaymentProcessor[] = [stripe, paypal];
processAllPayments(processors, 100, 'vendor@shop.com');
// --- Output ---// [Stripe] Starting transaction...// Amount: $100 | Fee: $2.9 | Total: $102.9// Stripe API call → charging $100 to vendor@shop.com// Using key: sk_live_...//// [PayPal] Starting transaction...// Amount: $100 | Fee: $3.4 | Total: $103.4// PayPal redirect → sending $100 to vendor@shop.com's email// Liskov Substitution violation example (TypeScript compiles but breaks at runtime):classBrokenProcessorextendsPaymentProcessor {
constructor() {
super('Broken', 0.01);
}
publicprocessPayment(amount: number, recipient: string): void {
// Throws a new error type not expected by the base class contractthrow { code: 'UNEXPECTED', detail: 'Simulated break' };
// This violates LSP: callers expecting a normal Error object may crash
}
}
Output
[Stripe] Starting transaction...
Amount: $100 | Fee: $2.9 | Total: $102.9
Stripe API call → charging $100 to vendor@shop.com
Using key: sk_live_...
[PayPal] Starting transaction...
Amount: $100 | Fee: $3.4 | Total: $103.4
PayPal redirect → sending $100 to vendor@shop.com's email
Polymorphism lets you write code that works for any subclass—you don't need to know the exact type at the call site.
The declared type (base class) determines what methods you can call.
The runtime type (actual subclass) determines which method implementation runs.
This decouples callers from concrete implementations—adding a new subclass doesn't change the caller's code.
Conditional logic (if/switch) is replaced by method dispatch—fewer bugs, more extensibility.
📊 Production Insight
Polymorphism eliminates condition chains—but only if subclasses honour the parent's contract.
A single LSP violation can cascade: callers expecting a standard error format get something else, and error handling breaks silently.
Rule: Abstract classes should document the expected contract, and subclasses should be tested against it.
🎯 Key Takeaway
Polymorphism allows uniform treatment of diverse subtypes.
Liskov Substitution must hold—subclasses must behave as their parent expects.
Prefer polymorphic dispatch over switch/if chains for extensibility.
Encapsulation Isn't Privacy — It's a Contract for Sanity
Junior devs think private means "hide the data from hackers." No. It means "prevent your future self from breaking the internal clockwork." Encapsulation forbids external code from touching your class's guts. That is a contract. You promise the outside world a stable interface. In return, you are free to refactor internals without hunting down every instance.property = x in the codebase. Use # (ES2024 native private fields) over TypeScript's private keyword. Why? private is erased at runtime. # throws a real TypeError when someone tries to bypass it. That is a faster bug—less debugging. Encapsulation is not paranoia. It is the difference between changing a tax rate calculation in one place versus twenty.
TaxCalculator.tsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforgeclassTaxCalculator {
#baseRate = 0.2; // ES2024 private field — runtime enforced
#surchargeRate = 0.05;
constructor(public readonly jurisdiction: string) {}
calculate(amount: number): number {
return amount * (this.#baseRate + this.#surchargeRate);
}
// Controlled exposure — not direct property accesssetSurcharge(rate: number): void {
if (rate < 0 || rate > 0.3) {
thrownewError(`Surcharge ${rate} out of allowed range`);
}
this.#surchargeRate = rate;
}
}
// Production useconst calc = newTaxCalculator('NY');
console.log(calc.calculate(1000)); // 250// calc.#baseRate = 0.5; // SyntaxError in strict mode — cannot touch private field
calc.setSurcharge(0.1);
console.log(calc.calculate(1000)); // 300
TypeScript's private is a compile-time lie. Use # for runtime enforcement or accept that a malformed (instance as any).prop assignment will corrupt state silently.
🎯 Key Takeaway
Encapsulation is not hiding data from users—it is hiding implementation details from yourself.
thecodeforge.io
Typescript Classes Oop
Composition over Inheritance — When 'Is-A' Becomes a Lie
Every fresh bootcamp grad draws a deep inheritance tree. Vehicle > Car > SportsCar > Ferrari. Looks clean. Then requirements change. You need FlyingCar that extends Car but also Aircraft. Multiple inheritance? Not in TypeScript. So you hack a shared interface. Now your Ferrari has a fly() method that throws NotImplementedError. That is a design smell. Composition says: "has-a" not "is-a." Give your class a engine, a wings, a paymentProcessor. Inject behavior through constructor parameters. You swap engines without breaking the car. You test the engine in isolation. Inheritance locks you into a brittle hierarchy. Composition gives you a toolbox. When your manager says "add drone mode to the delivery truck," you instantiate a DroneController and pass it to Truck. No abstract class surgery required.
Start with interfaces and constructor injection. Reach for inheritance only when you need to override every method in the parent—and even then, reconsider. Composition is the default.
🎯 Key Takeaway
Inheritance is for strict polymorphism. Composition is for adapting to reality. Choose composition first.
● Production incidentPOST-MORTEMseverity: high
Protected Field Leaked via Subclass Getter
Symptom
Bank account balances could be read via the UI even though the base class marked them as protected.
Assumption
The protected access modifier prevents all external access to the field.
Root cause
protected only restricts access outside the class hierarchy—subclasses have full visibility and can expose the field through public methods or getters.
Fix
Mark the field as private in the base class. If subclasses need read access, provide a controlled protected getter. For true runtime privacy, use JavaScript's native private fields (#balance) which are enforced even after compilation.
Key lesson
Protected is not a security boundary—it's a design intention signal.
Always treat access modifiers as compile-time guides, not runtime protections.
Audit subclass overrides that could leak internal state.
When dealing with sensitive data, prefer # private fields over TypeScript's private.
Production debug guideCommon compile-time and runtime OOP problems and their fixes4 entries
Symptom · 01
Private field is still accessible at runtime
→
Fix
TypeScript's private is compile-time only. For true runtime privacy, refactor to use JavaScript's native # prefix or use WeakMaps stored outside the class.
Symptom · 02
'Cannot read properties of undefined' when calling a class method
→
Fix
The method is likely being detached from its instance (e.g., passed as a callback). Use an arrow function in class definition or bind the method in the constructor.
Symptom · 03
'Abstract class cannot be instantiated' error
→
Fix
Check that you're not directly calling new on an abstract class. Ensure you're using a concrete subclass that implements all abstract methods.
Symptom · 04
'Property is missing in type X but required in type Y' during object literal assignment
→
Fix
When creating an object that satisfies a class contract, ensure you provide all required properties. Consider using the class itself as a factory method.
★ Quick OOP Debug Cheat SheetImmediate commands and checks for common TypeScript class issues
Class incorrectly implements interface−
Immediate action
Add `--noEmit --pretty` to your `tsc` command to get full error details.
Commands
tsc --noEmit --pretty
Review the interface definition and ensure all method signatures match exactly (parameter names don't matter, but types do).
Fix now
Use your IDE's 'Implement Missing Members' quick action to scaffold the missing methods.
Circular dependency between classes+
Immediate action
Check import statements for cycles.
Commands
npx madge --circular src/
Look for A imports B imports A pattern. Break the cycle by introducing an interface or using lazy require.
Fix now
Extract the shared dependency into an interface and place it in a separate module that both can import.
Generic type not inferred correctly+
Immediate action
Explicitly specify the type parameter when calling the generic class constructor.
Commands
const cache = new Cache<User>();
Check if the type parameter is constrained correctly. If inference still fails, you may need to pass a type witness.
Fix now
Add <Type> after the class name when instantiating: new MyClass<MyType>().
Interface vs Abstract Class
Feature
Interface
Abstract Class
Can contain implementation
No — contract only
Yes — concrete methods allowed
Can be instantiated directly
No
No
Multiple adoption per class
Yes — implement many interfaces
No — extend only one class
Can have constructor logic
No
Yes
Compiled to JavaScript
Erased completely — zero runtime cost
Compiles to a real JS class
Best used for
Defining contracts, enabling DI patterns
Sharing base behaviour + enforcing overrides
Supports generics
Yes
Yes
Can extend another of its kind
Yes — interfaces can extend interfaces
Yes — classes can extend classes
⚙ Quick Reference
7 commands from this guide
File
Command / Code
Purpose
BankAccount.ts
class BankAccount {
Classes, Constructors, and Access Modifiers
PaymentProcessor.ts
abstract class PaymentProcessor {
Inheritance and Abstract Classes
UserRepository.ts
interface IUserRepository {
Interfaces as Contracts
DataCache.ts
class DataCache {
Static Members and Generics in Classes
PolymorphicProcessors.ts
function processAllPayments(processors: PaymentProcessor[], amount: number, reci...
Polymorphism and Method Overriding
TaxCalculator.ts
class TaxCalculator {
Encapsulation Isn't Privacy
DeliverySystem.ts
class Engine {
Composition over Inheritance
Key takeaways
1
Access modifiers (private, protected, public) aren't just style choices
they're compile-time guardrails that prevent unintended mutations and make refactoring safe across large codebases.
2
Use abstract classes when you have real shared implementation to distribute to subclasses. Use interfaces when you only need a contract
especially when multiple unrelated classes need to satisfy the same shape.
3
Designing UserService to depend on IUserRepository (an interface) instead of PostgresUserRepository (a class) is the difference between code you can unit-test in milliseconds and code that requires a running database to test at all.
4
Static members belong to the class blueprint, not to any house built from it
perfect for shared counters, factory methods, and caches that live once for the whole application.
5
Polymorphism eliminates conditional chains
but only if subclasses honour the parent's contract. The Liskov Substitution Principle is your safety guarantee.
Common mistakes to avoid
4 patterns
×
Forgetting super() in a subclass constructor
Symptom
TypeScript throws a compile error: 'Constructors for derived classes must contain a super call.'
Fix
Always call super(...) as the very first line of a subclass constructor, passing any arguments the parent constructor requires.
×
Making everything public by default
Symptom
No immediate error, but any file in the project can mutate internal state, causing bugs that are extremely difficult to trace.
Fix
Default to private for all properties; make things public only when you consciously decide the property is part of the class's external API. Use getters for read-only access.
×
Implementing an interface but forgetting a method
Symptom
TypeScript error: 'Class X incorrectly implements interface Y — Property Z is missing.'
Fix
As soon as you write implements IUserRepository, implement every method it requires immediately. Use the IDE's 'Implement missing members' quick-fix to scaffold them, then fill in the logic.
×
Extending a class for code reuse instead of composition
Symptom
Fragile base class syndrome: changing the parent class breaks multiple subclasses, and the hierarchy becomes too deep to maintain safely.
Fix
If you're inheriting solely to reuse a method, extract that method into a utility function or inject it via a dependency. Use inheritance only when the subclass genuinely is a kind of the base class.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What's the difference between `private` in TypeScript and JavaScript's n...
Q02SENIOR
Can you explain the Liskov Substitution Principle and show me a TypeScri...
Q03SENIOR
If a class implements two interfaces that both declare a method with the...
Q04JUNIOR
What is the difference between `implements` and `extends` in TypeScript?
Q01 of 04SENIOR
What's the difference between `private` in TypeScript and JavaScript's native private fields using `#`? When would you choose one over the other?
ANSWER
TypeScript's private is a compile-time construct that disappears after compilation—the property is still a normal public property at runtime. JavaScript's # private fields are natively enforced by the engine, providing true encapsulation.
Choose private when you need only compile-time protection and maximum performance (no runtime cost). Choose # when you need actual runtime privacy, for example when dealing with sensitive data or writing library code that will be consumed by others.
Performance consideration: # fields have a slight runtime overhead (property access via WeakMap internally). In most applications the difference is negligible, but for hot-path code, private is faster.
In practice, most teams use TypeScript's private for internal code and reserve # for library/module boundaries where you must absolutely prevent external access.
Q02 of 04SENIOR
Can you explain the Liskov Substitution Principle and show me a TypeScript example where violating it would cause a runtime bug even though TypeScript compiles cleanly?
ANSWER
Liskov Substitution Principle (LSP) states that a subclass should be replaceable for its parent without altering the correctness of the program. In other words, if code works with the parent class, it should also work with any subclass.
Example violation:
``typescript
class Bird {
fly(): void { console.log('Flying!'); }
}
class Penguin extends Bird {
fly(): void {
throw new Error('Penguins cannot fly');
}
}
function makeBirdFly(bird: Bird): void {
bird.fly(); // Works for Bird, crashes for Penguin
}
makeBirdFly(new Penguin()); // Runtime error!
`
TypeScript compiles fine because the method signatures match. But at runtime, Penguin.fly violates the contract: the parent's fly method never throws an error. This breaks any code that assumes birds can fly without exceptions.
The fix: either don't make Penguin extend Bird (use composition instead) or redesign the hierarchy so that Bird doesn't assume all birds can fly (e.g., extract an IFlyable` interface).
Q03 of 04SENIOR
If a class implements two interfaces that both declare a method with the same name but different signatures, what happens in TypeScript and how do you resolve it?
ANSWER
TypeScript requires the method signature to be compatible with both interfaces simultaneously. If the signatures are incompatible (different parameter types, different return types), TypeScript will give a compile error.
Example:
``typescript
interface UserRepo {
get(id: string): User;
}
interface AdminRepo {
get(id: number): User;
}
// Error: incompatible signatures
class CombinedRepo implements UserRepo, AdminRepo {
// ???
}
`
Resolution options:
1. Refactor one interface to use a compatible signature (e.g., both use string or both use discriminated union string | number).
2. Use a multi-purpose method signature that satisfies both (e.g., get(id: string | number): User`) – this works if the merged signature covers both.
3. Remove the conflicting method from one interface and rename it.
4. Consider composition: the class can implement one interface directly and use a separate method for the other pattern.
In production, this conflict usually signals a design issue—the two interfaces shouldn't share a method name with different meanings.
Q04 of 04JUNIOR
What is the difference between `implements` and `extends` in TypeScript?
ANSWER
extends is used for class-to-class inheritance: a class inherits properties and methods from another class (including abstract classes). It creates an 'is-a' relationship and passes down the prototype chain. A class can only extend one base class.
implements is used to enforce a contract on a class: the class must provide the shape defined by an interface. No code is inherited; it's a structural check only. A class can implement multiple interfaces.
Example:
- class Dog extends Animal – Dog inherits from Animal.
- class Dog implements IWalker, IFeeder – Dog must have walk() and feed() methods as defined in those interfaces.
You can combine them: class Dog extends Animal implements IWalker, IFeeder.
01
What's the difference between `private` in TypeScript and JavaScript's native private fields using `#`? When would you choose one over the other?
SENIOR
02
Can you explain the Liskov Substitution Principle and show me a TypeScript example where violating it would cause a runtime bug even though TypeScript compiles cleanly?
SENIOR
03
If a class implements two interfaces that both declare a method with the same name but different signatures, what happens in TypeScript and how do you resolve it?
SENIOR
04
What is the difference between `implements` and `extends` in TypeScript?
JUNIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between an abstract class and an interface in TypeScript?
An abstract class can contain both fully implemented methods and abstract methods that subclasses must override. An interface is a pure contract with no implementation at all. The key practical difference: a class can implement multiple interfaces but can only extend one abstract class. Use abstract classes when you have shared code to reuse; use interfaces when you're purely defining a shape or contract.
Was this helpful?
02
Does TypeScript's `private` keyword actually prevent access at runtime in JavaScript?
No — TypeScript's private is compile-time only. It prevents access in your TypeScript source, but once compiled to JavaScript the property is a regular, publicly accessible property. If you need true runtime privacy, use JavaScript's native private fields with the # prefix (e.g., #balance), which TypeScript also supports. For most applications compile-time enforcement is sufficient.
Was this helpful?
03
When should I use a class versus a plain TypeScript object or type?
Use a class when your data has behaviour attached to it (methods), when you need encapsulation (private state), when you want instances with shared methods via the prototype, or when you need inheritance. Use a plain type or interface when you're just describing the shape of data that gets passed around without methods — like API response objects or config objects. Adding a class where a type would do is unnecessary complexity.
Was this helpful?
04
How does polymorphism work in TypeScript?
Polymorphism in TypeScript works through inheritance: a variable declared as a base class type can hold an instance of any subclass. When you call a method on that variable, the actual runtime type's implementation runs. This allows you to write code that works with any subclass without knowing the exact type at compile time. TypeScript supports both inheritance-based polymorphism (via extends) and interface-based polymorphism (via implements).
Was this helpful?
05
Can you give an example where violating Liskov Substitution Principle causes a bug in TypeScript?
Consider a Bird base class with a fly() method. A Penguin subclass overrides fly() to throw an error because penguins can't fly. TypeScript will compile this fine because signatures match. But code that expects birds to fly without exceptions will break at runtime. This is an LSP violation: the subclass doesn't honour the parent's contract. The fix is to not model penguins as birds that fly, or to separate the flying behaviour into an interface (IFlyable) that only flying birds implement.