TypeScript Protected Fields — Leaked via Subclass Getters
Bank balances leaked via protected fields in TypeScript subclasses.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- TypeScript classes are blueprints for objects with built-in access control (public, private, protected).
- Constructor shorthand (
constructor(private name: string)) reduces boilerplate. - 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
privateas a runtime security boundary—it's compile-time only.
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.
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.
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.
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.private for sensitive data can create a false sense of security.# private fields for anything you truly need to protect at runtime.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 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.super()
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.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.
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.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.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.
- 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.
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.
private is a compile-time lie. Use # for runtime enforcement or accept that a malformed (instance as any).prop assignment will corrupt state silently.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 method that throws fly()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.
Protected Field Leaked via Subclass Getter
protected access modifier prevents all external access to the field.protected only restricts access outside the class hierarchy—subclasses have full visibility and can expose the field through public methods or getters.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.- 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'sprivate.
private is compile-time only. For true runtime privacy, refactor to use JavaScript's native # prefix or use WeakMaps stored outside the class.new on an abstract class. Ensure you're using a concrete subclass that implements all abstract methods.class itself as a factory method.tsc --noEmit --prettyReview the interface definition and ensure all method signatures match exactly (parameter names don't matter, but types do).Key takeaways
private, protected, public) aren't just style choicesUserService 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.Common mistakes to avoid
4 patternsForgetting super() in a subclass constructor
super(...) as the very first line of a subclass constructor, passing any arguments the parent constructor requires.Making everything public by default
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
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
Interview Questions on This Topic
What's the difference between `private` in TypeScript and JavaScript's native private fields using `#`? When would you choose one over the other?
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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's TypeScript. Mark it forged?
7 min read · try the examples if you haven't