05.09.2025 • 14 min read

Mastering OOP in TypeScript - A Deep Dive Under the Hood

OOP TypeScript Cover

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:

  1. Compile Time: TypeScript checks your types and transforms your code
  2. Runtime: Pure JavaScript executes using prototypes and closures
  3. 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:

  1. Object Creation: A new empty object is created
  2. Prototype Linking: The object’s [[Prototype]] is set to Animal.prototype
  3. Constructor Execution: The constructor function runs with this pointing to the new object
  4. 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:

  1. Look for move in myDog object → Not found
  2. Look for move in Dog.prototype → Found! Use this one
  3. (If not found, would continue to Animal.prototype, then Object.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 this before calling super()

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:

  1. TypeScript interfaces disappear completely at runtime—they’re purely for compile-time type checking
  2. Java interfaces exist at runtime and support reflection, instanceof checks, and dynamic proxy creation
  3. 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:

AspectJavaTypeScript
Class SystemTrue classes with metadataSyntactic sugar over prototypes
Runtime Type InfoFull reflection, instanceof works everywhereLimited runtime type information
Access ModifiersRuntime enforced by JVMCompile-time only
InheritanceClass-based with vtablesPrototype-based with chain lookup
Method DispatchO(1) vtable lookupO(n) prototype traversal
Memory ModelObjects reference class definitionsObjects linked via prototype chain
PolymorphismStatic dispatch with virtual methodsDynamic prototype chain lookup
CompilationBytecode with preserved structureJavaScript 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:

  1. The prototype chain being created
  2. Memory allocation for instances vs shared methods
  3. Compilation output and what actually runs
  4. Performance implications of your design choices
  5. 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!