Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

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 โ€” public
account.balance; // Error: Property 'balance' is private
ModifierAccessible fromTypical use
public (default)AnywhereThe classโ€™s actual API surface
privateOnly within the same classInternal implementation details
protectedThe class and its subclassesShared 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 encapsulation
console.log(balance); // 1500 โ€” the "private" field, read anyway

For 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 version
class 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 automatically
class 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 property

readonly 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 class
const circle = new Circle(5);
console.log(circle.describe()); // inherited concrete method โ€” no need to reimplement per subclass

An 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.

extends

extends

inherits automatically

inherits automatically

abstract class Shape

(concrete: describe()

abstract: area(), perimeter())

class Circle

(must implement area(), perimeter())

class Rectangle

(must implement area(), perimeter())

describe() โ€” no reimplementation needed

describe() โ€” no reimplementation needed

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-instance

static 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 assigning
console.log(temp.fahrenheit); // calls the getter โ€” computed, not stored

Getters 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

SymptomLikely cause
โ€Property has no initializerโ€ under strict modeA field declared without a default value or constructor assignment โ€” add one, or mark it optional with ?
A โ€œprivateโ€ field is readable from outside the classCompile-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 directlyA factory function or default export accidentally does new Shape() somewhere instead of a concrete subclass
Getter/setter pair behaves unexpectedlyThe 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.