
Introduction
Object-Oriented Programming (OOP) in TypeScript isn’t just about syntax—it’s about understanding how your code transforms and executes at runtime. While many tutorials focus on the “what” and “how,” this article dives deep into the “why” and “what’s actually happening” when you write object-oriented code.
Whether you’re building a simple application or a complex enterprise system, understanding TypeScript’s OOP mechanics will dramatically improve your code quality and make your software more maintainable, performant, and scalable.
What Makes TypeScript’s OOP Special?
TypeScript brings static typing to JavaScript’s prototype-based object system, creating a unique hybrid that combines compile-time safety with runtime flexibility. But here’s what most developers don’t realize: TypeScript’s classes are just syntactic sugar over JavaScript’s prototype system.
Let’s start by understanding what happens when you run TypeScript code:
- Compile Time: TypeScript checks your types and transforms your code
- Runtime: Pure JavaScript executes using prototypes and closures
- Memory: Objects are allocated and linked through prototype chains
The Foundation: Understanding Objects at Memory Level
Before diving into classes, let’s understand what objects really are in JavaScript/TypeScript:
// What you write
const user = {
name: "Imad",
age: 25,
greet() {
return `Hello, I'm ${this.name}`;
},
};
// What actually happens in memory
/*
user → {
name: "Imad", // Property stored in object
age: 25, // Property stored in object
greet: function() {...}, // Method stored in object
__proto__: Object.prototype // Hidden prototype link
}
*/
Every object in JavaScript has a hidden [[Prototype]] property (accessible via __proto__) that links to another object. This creates the prototype chain—the foundation of JavaScript’s inheritance system.
Classes: The Elegant Illusion
When you write a TypeScript class, you’re creating a blueprint that gets compiled into a constructor function with a prototype. This is fundamentally different from languages like Java, where classes are first-class citizens in the runtime.
Java vs TypeScript - A Key Difference:
// Java: Classes exist at runtime
public class Animal {
private String name;
private int energy = 100;
public Animal(String name) {
this.name = name;
}
public void move() {
this.energy -= 10;
System.out.println(this.name + " moves. Energy: " + this.energy);
}
}
// The .class file contains actual class metadata
// Animal.class exists at runtime
// TypeScript: Classes are compile-time constructs
class Animal {
protected name: string;
private energy: number = 100;
constructor(name: string) {
this.name = name;
}
public move(): void {
this.energy -= 10;
console.log(`${this.name} moves. Energy: ${this.energy}`);
}
protected rest(): void {
this.energy += 20;
}
}
// What TypeScript compiles to (simplified)
function Animal(name) {
this.name = name;
this.energy = 100;
}
Animal.prototype.move = function () {
this.energy -= 10;
console.log(this.name + " moves. Energy: " + this.energy);
};
Animal.prototype.rest = function () {
this.energy += 20;
};
The Key Insight: In Java, you have actual class objects with metadata, reflection capabilities, and runtime class information. In TypeScript/JavaScript, you have constructor functions with prototype objects—a much more dynamic and flexible system.
The Constructor Magic
When you write new Animal("Lion"), here’s what happens step by step:
- Object Creation: A new empty object is created
- Prototype Linking: The object’s
[[Prototype]]is set toAnimal.prototype - Constructor Execution: The constructor function runs with
thispointing to the new object - Return: The new object is returned (unless the constructor explicitly returns something else)
// Behind the scenes of: const lion = new Animal("Lion");
// Step 1: Create empty object
const newObject = {};
// Step 2: Set prototype
Object.setPrototypeOf(newObject, Animal.prototype);
// or: newObject.__proto__ = Animal.prototype;
// Step 3: Call constructor with 'this' bound to newObject
Animal.call(newObject, "Lion");
// Step 4: Return the object
const lion = newObject;
Access Modifiers: Compile-Time vs Runtime Reality
Here’s a crucial insight: TypeScript’s access modifiers (private, protected, public) only exist at compile time. At runtime, all properties are accessible! This is radically different from Java’s approach.
Java: Runtime Enforcement
public class BankAccount {
private double balance = 1000.0;
public void deposit(double amount) {
this.balance += amount;
}
}
BankAccount account = new BankAccount();
// account.balance = 5000; // ❌ Compilation error AND runtime protection
// Even with reflection, you need special permissions to access private fields
TypeScript: Compile-Time Only
class BankAccount {
private balance: number = 1000;
public deposit(amount: number): void {
this.balance += amount;
}
}
const account = new BankAccount();
// TypeScript error at compile time
// account.balance = 5000; // ❌ Property 'balance' is private
// But at runtime (after compilation), this works!
// (account as any).balance = 5000; // ✅ Actually accessible!
Why the Difference?
- Java: The JVM enforces access control at runtime. Private members are truly inaccessible without special reflection APIs.
- TypeScript: Compiles to JavaScript, which has no native concept of private members (until recent ES2022 private fields). Access modifiers are purely for developer tooling and code organization.
Why does this matter? Understanding this helps you realize that TypeScript’s encapsulation is for developer experience and code organization, not security. For true privacy, you need other patterns:
class SecureBankAccount {
#balance: number = 1000; // Private field (ES2022)
// Or using closure for true privacy
constructor() {
let privateBalance = 1000;
this.getBalance = () => privateBalance;
this.deposit = (amount: number) => {
privateBalance += amount;
};
}
}
Inheritance: The Prototype Chain in Action
Inheritance in TypeScript creates a prototype chain, which is fundamentally different from Java’s class-based inheritance model.
Java: Class-Based Inheritance
public class Animal {
protected String name;
public Animal(String name) {
this.name = name;
}
public void move() {
System.out.println(this.name + " is moving");
}
}
public class Dog extends Animal {
private String breed;
public Dog(String name, String breed) {
super(name); // Calls parent constructor
this.breed = breed;
}
@Override
public void move() {
System.out.println(this.name + " is running on four legs");
}
}
In Java, inheritance creates a true class hierarchy. The JVM knows that Dog extends Animal and can perform runtime type checking.
TypeScript: Prototype-Based Inheritance
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
move(): void {
console.log(`${this.name} is moving`);
}
}
class Dog extends Animal {
breed: string;
constructor(name: string, breed: string) {
super(name); // Calls parent constructor
this.breed = breed;
}
bark(): void {
console.log(`${this.name} is barking`);
}
// Override parent method
move(): void {
console.log(`${this.name} is running on four legs`);
}
}
const myDog = new Dog("Buddy", "Golden Retriever");
In TypeScript/JavaScript, inheritance is achieved through prototype linking—a much more dynamic system.
The Prototype Chain Visualization
myDog → {
name: "Buddy",
breed: "Golden Retriever",
__proto__: Dog.prototype
}
Dog.prototype → {
bark: function() {...},
move: function() {...}, // Overridden method
__proto__: Animal.prototype
}
Animal.prototype → {
move: function() {...}, // Original method
__proto__: Object.prototype
}
Object.prototype → {
toString: function() {...},
__proto__: null
}
Method Resolution: How JavaScript Finds Methods
When you call myDog.move(), JavaScript searches the prototype chain:
- Look for
moveinmyDogobject → Not found - Look for
moveinDog.prototype→ Found! Use this one - (If not found, would continue to
Animal.prototype, thenObject.prototype)
The Super Keyword: Constructor Chaining
The super() call is crucial for proper inheritance, but it works differently in TypeScript/JavaScript compared to Java.
Java: Automatic Super Call
class Animal {
protected int energy;
public Animal(int initialEnergy) {
this.energy = initialEnergy;
System.out.println("Animal constructor called");
}
}
class Dog extends Animal {
private String name;
public Dog(String name) {
// Java automatically calls super() if you don't specify it
// But you can also explicitly call it:
super(100); // Must be first statement if used
this.name = name;
}
}
TypeScript: Explicit Super Required
class Animal {
protected energy: number;
constructor(initialEnergy: number) {
this.energy = initialEnergy;
console.log("Animal constructor called");
}
}
class Dog extends Animal {
private name: string;
constructor(name: string) {
// super() MUST be called before using 'this'
super(100); // Calls Animal constructor
this.name = name; // Now we can use 'this'
}
}
// Behind the scenes:
// 1. Dog constructor starts
// 2. super(100) calls Animal constructor
// 3. Animal constructor sets this.energy = 100
// 4. Control returns to Dog constructor
// 5. Dog constructor sets this.name = name
Key Differences:
- Java: If you don’t call
super(), Java automatically calls the no-argument constructor of the parent class - TypeScript: You must explicitly call
super()and it must be the first statement in the constructor - Java: Compile-time error if
super()isn’t the first statement - TypeScript: Runtime error if you try to use
thisbefore callingsuper()
Polymorphism: Dynamic Method Dispatch
Polymorphism in TypeScript works through the prototype chain and dynamic method lookup, while Java uses virtual method tables (vtables) for more efficient dispatch. Both achieve the same result but through different mechanisms.
Java: Virtual Method Tables
abstract class Shape {
public abstract double calculateArea();
public String describe() {
return "This shape has an area of " + calculateArea();
}
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
class Rectangle extends Shape {
private double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
// Java uses vtables for fast method dispatch
// The JVM knows the exact method to call without searching
TypeScript: Prototype Chain Lookup
abstract class Shape {
abstract calculateArea(): number;
describe(): string {
return `This shape has an area of ${this.calculateArea()}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
calculateArea(): number {
return Math.PI * this.radius ** 2;
}
}
class Rectangle extends Shape {
constructor(
private width: number,
private height: number
) {
super();
}
calculateArea(): number {
return this.width * this.height;
}
}
function printShapeInfo(shapes: Shape[]): void {
shapes.forEach(shape => {
// Polymorphism in action: JavaScript searches the prototype chain
// to find the correct calculateArea method at runtime
console.log(shape.describe());
});
}
const shapes = [new Circle(5), new Rectangle(4, 6)];
printShapeInfo(shapes);
// Output:
// This shape has an area of 78.54 (Circle's calculateArea called)
// This shape has an area of 24 (Rectangle's calculateArea called)
Performance Comparison:
- Java: O(1) method lookup using vtables—extremely fast
- TypeScript/JavaScript: O(n) prototype chain traversal—slower but more flexible
Interfaces vs Abstract Classes: When to Use Which
Understanding the compilation differences helps you choose correctly. Java provides a great comparison point here because it has both interfaces and abstract classes with clear distinctions.
Java: Both Have Runtime Presence
// Java Interface - Contract definition
public interface Flyable {
void fly();
double getAltitude();
}
// Java Abstract Class - Partial implementation
public abstract class Vehicle {
protected String model;
public Vehicle(String model) {
this.model = model;
}
public abstract void start();
public void stop() {
System.out.println("Vehicle stopped");
}
}
// Both interfaces and abstract classes exist at runtime in Java
// You can use instanceof, reflection, and other runtime checks
TypeScript: Different Runtime Behaviors
Interfaces (Compile-time only)
interface Flyable {
fly(): void;
altitude: number;
}
// Compiles to nothing! Interfaces are purely for TypeScript
// At runtime, there's no trace of this interface
// No instanceof checks possible
Abstract Classes (Runtime structures)
abstract class Vehicle {
protected model: string;
constructor(model: string) {
this.model = model;
}
abstract start(): void;
stop(): void {
console.log("Vehicle stopped");
}
}
// Compiles to a real constructor function
// Creates actual prototype chain
// Cannot be instantiated directly
// instanceof checks work
Key Differences from Java:
- TypeScript interfaces disappear completely at runtime—they’re purely for compile-time type checking
- Java interfaces exist at runtime and support reflection, instanceof checks, and dynamic proxy creation
- TypeScript abstract classes behave similarly to Java but use prototype chains instead of true class hierarchies
Rule of thumb:
- Use interfaces for pure contracts and type checking (like Java interfaces but with no runtime presence)
- Use abstract classes when you need shared implementation and runtime inheritance (similar to Java abstract classes)
Memory and Performance Considerations
Understanding the underlying mechanics helps you write more efficient code:
Method Storage
class User {
name: string;
constructor(name: string) {
this.name = name;
// ❌ Bad: Creates new function for each instance
this.greet = function () {
return `Hello, ${this.name}`;
};
}
// ✅ Good: Stored once on prototype, shared by all instances
introduce(): string {
return `I'm ${this.name}`;
}
}
The Cost of Deep Inheritance
// Each level adds to the prototype chain lookup cost
class A {
methodA() {}
}
class B extends A {
methodB() {}
}
class C extends B {
methodC() {}
}
class D extends C {
methodD() {}
}
const instance = new D();
// Calling methodA requires walking up 4 levels of prototype chain
instance.methodA(); // Slower than instance.methodD()
Advanced Patterns: Composition over Inheritance
Sometimes, the prototype chain isn’t the best solution:
// Instead of deep inheritance
class FlyingCar extends Car {
// extends Vehicle, extends Machine, etc.
}
// Use composition
interface Flyable {
fly(): void;
}
interface Drivable {
drive(): void;
}
class FlyingCar implements Flyable, Drivable {
private flightSystem = new FlightSystem();
private driveSystem = new DriveSystem();
fly(): void {
this.flightSystem.takeOff();
}
drive(): void {
this.driveSystem.start();
}
}
Practical Tips for Better OOP Code
1. Understand the Compilation Target
// Different compilation targets affect your output
// ES5: Uses function constructors and prototypes
// ES2015+: Uses native class syntax
// Check your tsconfig.json target setting!
2. Leverage TypeScript’s Structural Typing
interface Point {
x: number;
y: number;
}
class MyPoint {
constructor(
public x: number,
public y: number
) {}
}
// This works! MyPoint structurally matches Point
const point: Point = new MyPoint(1, 2);
3. Use Readonly for Immutability
class ImmutableUser {
readonly id: string;
readonly createdAt: Date;
constructor(id: string) {
this.id = id;
this.createdAt = new Date();
}
}
Debugging OOP Code: Tools and Techniques
Inspecting the Prototype Chain
const dog = new Dog("Buddy", "Golden");
// Inspect the prototype chain
console.log(Object.getPrototypeOf(dog)); // Dog.prototype
console.log(Object.getPrototypeOf(Object.getPrototypeOf(dog))); // Animal.prototype
// Check if method exists on prototype
console.log(dog.hasOwnProperty("bark")); // false (it's on prototype)
console.log("bark" in dog); // true (found in prototype chain)
Using the Debugger
class DebuggableClass {
method() {
debugger; // Browser will pause here
// Examine 'this', prototype chain, and call stack
}
}
Common Pitfalls and How to Avoid Them
1. The this Binding Problem
class EventHandler {
name = "Handler";
handleClick() {
console.log(this.name); // 'this' might not be what you expect!
}
// Solution: Use arrow functions
handleClickSafe = () => {
console.log(this.name); // 'this' is lexically bound
};
}
2. Forgetting to Call Super
class Parent {
constructor(protected value: number) {}
}
class Child extends Parent {
constructor(
value: number,
private extra: string
) {
// ❌ Forgot super() - TypeScript will catch this!
// super(value); // Must call this first
this.extra = extra;
}
}
3. Assuming Private Means Secure
class UnsecureClass {
private secret = "not really secret";
}
// At runtime, this works:
const instance = new UnsecureClass();
console.log((instance as any).secret); // "not really secret"
TypeScript vs Java: A Summary of Key Differences
After exploring all these concepts, it’s helpful to understand the fundamental philosophical differences between TypeScript and Java’s approach to OOP:
| Aspect | Java | TypeScript |
|---|---|---|
| Class System | True classes with metadata | Syntactic sugar over prototypes |
| Runtime Type Info | Full reflection, instanceof works everywhere | Limited runtime type information |
| Access Modifiers | Runtime enforced by JVM | Compile-time only |
| Inheritance | Class-based with vtables | Prototype-based with chain lookup |
| Method Dispatch | O(1) vtable lookup | O(n) prototype traversal |
| Memory Model | Objects reference class definitions | Objects linked via prototype chain |
| Polymorphism | Static dispatch with virtual methods | Dynamic prototype chain lookup |
| Compilation | Bytecode with preserved structure | JavaScript with transformed syntax |
When to Choose Which Approach:
Choose Java-style OOP when:
- You need true runtime encapsulation and security
- Performance is critical and you want predictable method dispatch
- You’re building large enterprise applications with complex hierarchies
- You need extensive reflection and runtime introspection
Choose TypeScript OOP when:
- You want the flexibility of JavaScript with type safety
- You’re building web applications or Node.js services
- You need dynamic behavior and runtime adaptability
- You want to leverage the vast JavaScript ecosystem
Conclusion: Mastering the Mental Model
Understanding OOP in TypeScript isn’t just about memorizing syntax—it’s about building a mental model of how your code transforms and executes. When you write a class, think about:
- The prototype chain being created
- Memory allocation for instances vs shared methods
- Compilation output and what actually runs
- Performance implications of your design choices
- How it differs from traditional OOP languages like Java
This deeper understanding will make you a more effective TypeScript developer, helping you write code that’s not just correct, but efficient and maintainable.
Remember: TypeScript’s OOP features are powerful tools, but they’re built on JavaScript’s foundations. Master both the high-level concepts and the underlying mechanics, and you’ll be equipped to tackle any object-oriented challenge.
The Bottom Line: TypeScript gives you the best of both worlds—the developer experience of traditional OOP with the flexibility and dynamism of JavaScript. Understanding how it differs from languages like Java will help you leverage its unique strengths while avoiding common pitfalls.
Want to dive deeper? Try implementing your own simple class system using only functions and prototypes, then compare it to a Java class. It’s a great exercise to solidify your understanding of what’s happening under the hood!