Classes in TypeScript: Access Modifiers, Abstract Classes & Inheritance
Interfaces (covered earlier in this series) are pure compile-time contracts with zero runtime presence. Classes are the opposite: they generate real, runtime JavaScript โ a genuine constructor function, a genuine prototype chain, something instanceof can actually check. TypeScript layers access control, abstract members, and stricter typing on top of JavaScriptโs ES2015 class syntax, and this guide covers exactly what that layer adds โ and where itโs compile-time-only versus where it changes runtime behavior.
Basic Class Syntax With Types
class User { id: string; name: string; email: string;
constructor(id: string, name: string, email: string) { this.id = id; this.name = name; this.email = email; }
greet(): string { return `Hello, ${this.name}`; }}
const user = new User("1", "Alice", "alice@example.com");console.log(user.greet());Every field must either be assigned in the constructor or given a default value โ TypeScriptโs strictPropertyInitialization (part of strict: true, covered in the project setup guide) enforces that a declared field is never left undefined at runtime without the type honestly reflecting that possibility.
class Draft { title: string; // Error under strict mode โ never assigned in the constructor publishedAt?: Date; // fine โ explicitly optional, so undefined is expected status: string = "draft"; // fine โ has a default value}Access Modifiers: public, private, protected
class BankAccount { public accountNumber: string; private balance: number; protected accountType: string;
constructor(accountNumber: string, initialBalance: number) { this.accountNumber = accountNumber; this.balance = initialBalance; this.accountType = "checking"; }
public deposit(amount: number): void { this.balance += amount; }
public getBalance(): number { return this.balance; }}
const account = new BankAccount("123", 1000);account.deposit(500); // fine โ publicaccount.balance; // Error: Property 'balance' is private| Modifier | Accessible from | Typical use |
|---|---|---|
public (default) | Anywhere | The classโs actual API surface |
private | Only within the same class | Internal implementation details |
protected | The class and its subclasses | Shared internals meant to be extended, not exposed externally |
The critical caveat: these are compile-time checks only. private and protected are erased at compile time exactly like every other TypeScript-only construct โ the compiled JavaScript has no concept of private fields at all, and nothing stops determined runtime code from accessing account["balance"] via bracket notation, since bracket notation bypasses TypeScriptโs static property access checking.
const balance = (account as any).balance; // Works at runtime โ TypeScript's "private" is not real encapsulationconsole.log(balance); // 1500 โ the "private" field, read anywayFor genuine runtime privacy, JavaScriptโs native # private fields are the real mechanism:
class SecureAccount { #balance: number; // truly private โ enforced by the JavaScript runtime itself, not just the compiler
constructor(initialBalance: number) { this.#balance = initialBalance; }
getBalance(): number { return this.#balance; }}
const secure = new SecureAccount(1000);(secure as any)["#balance"]; // Doesn't work โ # fields aren't accessible via string-keyed bracket notation at all#balance is enforced by the JavaScript engine itself โ thereโs no bracket-notation escape hatch, because #-prefixed fields arenโt ordinary string-keyed properties at all. TypeScriptโs private keyword predates native # fields (which arrived in ES2022) and remains useful for compile-time API discipline within a team, but understanding that itโs not real encapsulation matters if youโre protecting something security-sensitive rather than just organizing code.
Parameter Properties โ A TypeScript-Only Shorthand
// Without parameter properties โ the repetitive versionclass Product { private id: string; private name: string; private price: number;
constructor(id: string, name: string, price: number) { this.id = id; this.name = name; this.price = price; }}
// With parameter properties โ access modifier in the constructor signature does all of this automaticallyclass ProductShort { constructor( private id: string, private name: string, public price: number ) {}}Adding an access modifier directly to a constructor parameter is TypeScript-only shorthand that simultaneously declares the field, types it, and assigns it from the matching constructor argument โ eliminating the declare-then-assign duplication above. This is genuinely just syntax sugar (the compiled JavaScript output is equivalent to the verbose version), but itโs common enough in real TypeScript codebases that recognizing it on sight matters.
Readonly Class Properties
class ImmutablePoint { readonly x: number; readonly y: number;
constructor(x: number, y: number) { this.x = x; this.y = y; }
translate(dx: number, dy: number): ImmutablePoint { return new ImmutablePoint(this.x + dx, this.y + dy); // return a new instance instead of mutating }}
const p = new ImmutablePoint(1, 2);p.x = 5; // Error: Cannot assign to 'x' because it is a read-only propertyreadonly on a class field allows assignment only inside the constructor (or at the declaration itself) โ this is the standard way to model value objects that should never change after construction, encouraging patterns like translate() above that return a new instance rather than mutating the existing one.
Abstract Classes
abstract class Shape { abstract area(): number; abstract perimeter(): number;
describe(): string { return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`; }}
class Circle extends Shape { constructor(private radius: number) { super(); }
area(): number { return Math.PI * this.radius ** 2; }
perimeter(): number { return 2 * Math.PI * this.radius; }}
const shape = new Shape(); // Error: Cannot create an instance of an abstract classconst circle = new Circle(5);console.log(circle.describe()); // inherited concrete method โ no need to reimplement per subclassAn abstract class cannot be instantiated directly โ it exists specifically to be extended, combining concrete, shared behavior (describe(), implemented once) with abstract members that every subclass is required to implement itself (area(), perimeter(), with no body in the base class). This differs from an interface in a genuinely important way: an abstract class can hold real, shared implementation and real constructor logic; an interface holds neither โ itโs purely a shape description, so any shared logic has to be duplicated or composed some other way when using interfaces alone.
When to choose abstract class over interface + separate implementation: reach for an abstract class when subclasses genuinely share concrete behavior (not just a shape) and you want that behavior implemented exactly once. Reach for an interface (implemented separately by each unrelated class, as in the interfaces guide) when the classes are otherwise unrelated and share no actual code, only a contract.
Inheritance and super
class Animal { constructor(protected name: string) {}
speak(): string { return `${this.name} makes a sound`; }}
class Dog extends Animal { constructor(name: string, private breed: string) { super(name); // must call super() before using `this` in a derived class constructor }
speak(): string { return `${super.speak()}, specifically a bark from a ${this.breed}`; }}
const rex = new Dog("Rex", "Labrador");console.log(rex.speak());super(name) calls the parent classโs constructor โ required before any use of this inside a derived classโs constructor, both in plain JavaScript and TypeScript (TypeScript just catches the ordering mistake at compile time instead of at runtime). super.speak() inside the overriding method calls the parentโs implementation explicitly, letting a subclass extend rather than completely replace inherited behavior โ the same pattern covered from the JavaScript side in the prototypal-inheritance material, with TypeScript adding compile-time checking that every abstract member is actually implemented.
Static Members
class Counter { private static count = 0;
static increment(): number { return ++Counter.count; }
static get current(): number { return Counter.count; }}
Counter.increment();Counter.increment();console.log(Counter.current); // 2 โ shared across all usage, not per-instancestatic members belong to the class itself, not to any instance โ thereโs exactly one Counter.count, shared globally, regardless of how many (zero, in this case) Counter instances exist. This is the standard pattern for utility methods, factory functions, and shared counters/caches that conceptually belong to the class as a whole rather than to any single object it produces.
Getters and Setters
class Temperature { private _celsius: number = 0;
get celsius(): number { return this._celsius; }
set celsius(value: number) { if (value < -273.15) throw new Error("Below absolute zero"); this._celsius = value; }
get fahrenheit(): number { return this._celsius * 9 / 5 + 32; }}
const temp = new Temperature();temp.celsius = 25; // calls the setter โ validates before assigningconsole.log(temp.fahrenheit); // calls the getter โ computed, not storedGetters and setters let a class expose what looks like a plain property from the outside while running real logic (validation, computed derivation) underneath โ temp.celsius = 25 looks like a direct field assignment to any code calling it, but it actually runs through the setterโs validation, and temp.fahrenheit looks like reading a stored field but is computed fresh on each access.
Polymorphism in Practice
abstract class PaymentMethod { abstract process(amount: number): string;}
class CreditCard extends PaymentMethod { process(amount: number): string { return `Charged $${amount} to credit card`; }}
class PayPal extends PaymentMethod { process(amount: number): string { return `Charged $${amount} via PayPal`; }}
function checkout(method: PaymentMethod, amount: number): string { return method.process(amount); // doesn't know or care which concrete subclass it received}
checkout(new CreditCard(), 99.99);checkout(new PayPal(), 49.99);checkout depends only on the PaymentMethod abstract type, never on CreditCard or PayPal directly โ this is polymorphism doing real work: adding a new BankTransfer payment method later requires zero changes to checkout at all, only a new subclass implementing process(). TypeScriptโs role here is making sure every subclass genuinely honors the abstract contract at compile time, rather than discovering a missing process() implementation only when a specific payment method is actually used in production.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
| โProperty has no initializerโ under strict mode | A field declared without a default value or constructor assignment โ add one, or mark it optional with ? |
| A โprivateโ field is readable from outside the class | Compile-time-only privacy โ bracket notation and as any both bypass it; use native # fields for real enforcement |
| โMust call super before accessing โthisโโ | A subclass constructor references this before calling super(...) |
| Abstract class error when you didnโt try to instantiate it directly | A factory function or default export accidentally does new Shape() somewhere instead of a concrete subclass |
| Getter/setter pair behaves unexpectedly | The setter has validation logic that silently rejects a value โ check for a thrown error or an early return inside it |
Frequently Asked Questions
Is TypeScriptโs private a real security boundary? No โ itโs compile-time-only, and bracket notation (obj["privateField"]) or a type assertion (as any) bypasses it entirely at runtime. For genuine runtime privacy, use native # private fields (ES2022+), which the JavaScript engine itself enforces with no escape hatch.
When should I use an abstract class instead of an interface? When subclasses share real, concrete implementation (not just a shape) that should be written once in a shared base class. If classes are otherwise unrelated and only need to agree on a contract with zero shared code, an interface is the better fit.
Can a class implement multiple interfaces but extend only one class? Yes โ this mirrors JavaScript/TypeScriptโs single-inheritance model for classes (extends takes exactly one parent) while implements can list as many interfaces as needed, since interfaces contribute no actual implementation that would create ambiguity about which parentโs code runs.
Do parameter properties (constructor(private x: string)) work the same as manually declared fields? Yes, functionally identical โ itโs purely a syntax shorthand that TypeScript expands into the equivalent declare-and-assign code during compilation, with no runtime behavior difference.
Why does TypeScript require super() before using this in a subclass constructor? This mirrors a real JavaScript runtime rule for classes using extends โ the parent constructor must initialize the object before the subclass can reference it via this. TypeScript simply surfaces this as a compile-time error instead of letting you discover a ReferenceError at runtime.
Whatโs Next
Classes bring TypeScriptโs compile-time checking to code that genuinely exists at runtime โ unlike interfaces, types, and the mapped/conditional constructs covered earlier in this series. Next: Enums in TypeScript, covering numeric, string, and const enums, plus when a literal union (from the union types guide) is actually the better choice.