TypeScript Classes and OOP Explained — Patterns That Actually Scale
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.
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.
// A real-world bank account — classic OOP demo done properly class BankAccount { // private: only methods inside this class can touch the balance private balance: number; // public: anyone can read the account holder's name public readonly accountHolder: string; // protected: subclasses (e.g. SavingsAccount) can access this too protected transactionHistory: string[]; // The shorthand constructor: TypeScript declares AND assigns in one line constructor( accountHolder: string, initialDeposit: number ) { this.accountHolder = accountHolder; // Guard against negative opening balances at construction time if (initialDeposit < 0) { throw new Error('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 balance public deposit(amount: number): void { if (amount <= 0) { throw new Error('Deposit amount must be positive'); } this.balance += amount; this.transactionHistory.push(`Deposited $${amount}`); console.log(`Deposited $${amount}. New balance: $${this.balance}`); } public withdraw(amount: number): void { if (amount > this.balance) { throw new Error('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 world public get currentBalance(): number { return this.balance; } } // --- Usage --- const myAccount = new BankAccount('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
Withdrew $100. New balance: $600
Balance: $600
Holder: Alice
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.
// Abstract class: defines the contract, can't be instantiated directly abstract class PaymentProcessor { // Shared data every processor needs constructor( protected readonly providerName: string, protected readonly feesPercentage: number ) {} // Concrete method: shared logic all subclasses inherit as-is public calculateFee(amount: number): number { return parseFloat((amount * this.feesPercentage).toFixed(2)); } // Abstract method: every subclass MUST implement this — or TypeScript errors public abstract processPayment(amount: number, recipient: string): void; // Template method pattern: calls the abstract method inside a shared wrapper public initiateTransaction(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 implementation this.processPayment(amount, recipient); } } // Concrete subclass: must implement processPayment class StripeProcessor extends PaymentProcessor { private apiKey: string; constructor(apiKey: string) { // Call parent constructor first — mandatory super('Stripe', 0.029); // Stripe's 2.9% fee this.apiKey = apiKey; } // Fulfilling the abstract contract with Stripe-specific logic public processPayment(amount: number, recipient: string): void { console.log(`Stripe API call → charging $${amount} to ${recipient}`); console.log(`Using key: ${this.apiKey.substring(0, 8)}...`); } } class PayPalProcessor extends PaymentProcessor { constructor() { super('PayPal', 0.034); // PayPal's 3.4% fee } public processPayment(amount: number, recipient: string): void { console.log(`PayPal redirect → sending $${amount} to ${recipient}'s email`); } } // --- Usage --- const stripe = new StripeProcessor('sk_live_abc123xyz'); stripe.initiateTransaction(100, 'vendor@shop.com'); const paypal = new PayPalProcessor(); 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
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
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.
// The contract — no implementation, just the shape interface IUserRepository { findById(id: string): User | undefined; save(user: User): void; delete(id: string): boolean; } // A simple User type for context type User = { id: string; name: string; email: string; }; // Concrete implementation 1: talks to a real database (simplified) class PostgresUserRepository implements IUserRepository { // In real life this would hold a DB connection private db: Map<string, User> = new Map(); public findById(id: string): User | undefined { console.log(`[Postgres] Querying users table for id: ${id}`); return this.db.get(id); } public save(user: User): void { console.log(`[Postgres] Inserting/updating user: ${user.email}`); this.db.set(user.id, user); } public delete(id: string): boolean { console.log(`[Postgres] Deleting user id: ${id}`); return this.db.delete(id); } } // Concrete implementation 2: lives entirely in memory — perfect for unit tests class InMemoryUserRepository implements IUserRepository { private store: Map<string, User> = new Map(); public findById(id: string): User | undefined { return this.store.get(id); } public save(user: User): void { this.store.set(user.id, user); } public delete(id: string): boolean { return this.store.delete(id); } } // UserService depends on the INTERFACE, not a concrete class // Swap Postgres for InMemory and this class never changes — that's the power class UserService { constructor(private readonly userRepo: IUserRepository) {} public registerUser(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; } public getUser(id: string): User | undefined { return this.userRepo.findById(id); } } // --- Production usage: plug in the real DB implementation --- const prodRepo = new PostgresUserRepository(); const prodService = new UserService(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 = new InMemoryUserRepository(); const testService = new UserService(testRepo); testService.registerUser('Bob', 'bob@example.com');
User registered: Alice (alice@example.com)
[Postgres] Querying users table for id: user_1718000000000
Found user: Alice
User registered: Bob (bob@example.com)
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 class stores and retrieves items of type T — call it with DataCache 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.
// A generic, type-safe cache — works for any type you give it class DataCache<T> { // Static counter tracks how many caches have been created across the app private static instanceCount: number = 0; private readonly cacheId: number; private store: Map<string, T> = new Map(); private hitCount: number = 0; constructor(private readonly 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' public static create<U>(name: string): DataCache<U> { return new DataCache<U>(name); } public set(key: string, value: T): void { this.store.set(key, value); } // TypeScript knows the return type is T (whatever you chose), not 'any' public get(key: string): T | undefined { const value = this.store.get(key); if (value !== undefined) { this.hitCount++; } return value; } public getStats(): string { return `Cache "${this.cacheName}" | Keys: ${this.store.size} | Hits: ${this.hitCount}`; } // Static method: relevant to the class concept, needs no instance data public static getTotalCaches(): number { return DataCache.instanceCount; } } // --- Usage: two separate typed caches --- type Product = { id: string; name: string; price: number }; // TypeScript now knows this cache only holds Product objects const 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 strings const sessionCache = DataCache.create<string>('SessionCache'); sessionCache.set('session_abc', 'user_123'); // Retrieve — TypeScript infers the correct type automatically const 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
Cache #2 created: "SessionCache"
Found: Keyboard at $79
Session maps to user: user_123
Cache "ProductCache" | Keys: 2 | Hits: 1
Cache "SessionCache" | Keys: 1 | Hits: 1
Total caches created: 2
| 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 |
🎯 Key Takeaways
- 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. - 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.
- Designing
UserServiceto depend onIUserRepository(an interface) instead ofPostgresUserRepository(a class) is the difference between code you can unit-test in milliseconds and code that requires a running database to test at all. - 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting
super()in a subclass constructor — TypeScript throws 'Constructors for derived classes must contain a super call' at compile time — Fix: always callsuper(...)as the very first line of a subclass constructor, passing any arguments the parent constructor requires. - ✕Mistake 2: Making everything
publicby default — No immediate error, but any file in your project can mutate internal state, causing bugs that are almost impossible to trace — Fix: default toprivatefor all properties, make thingspubliconly when you consciously decide something is part of the class's external API. Use getters for read-only access. - ✕Mistake 3: Implementing an interface but forgetting a method — TypeScript gives '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 your IDE's 'Implement missing members' quick-fix to scaffold them instantly, then fill in the logic.
Interview Questions on This Topic
- QWhat's the difference between `private` in TypeScript and JavaScript's native private fields using `#`? When would you choose one over the other?
- QCan 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?
- QIf 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?
Frequently Asked Questions
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.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.