Skip to content
chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

  • Home
  • Blogs
  • Git
  • Playwright
  • Typescript
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • C#
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • AI
    • AI Test Automation / MCP Testing
    • AI Prompts for QA
    • AI QA Careers
    • LLM Testing / AI Evaluation
    • AI Code Review & Risk-Based Testing
  • Cucumber
  • TestNG
  • Home
  • Blogs
  • Git
  • Playwright
  • Typescript
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • C#
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • AI
    • AI Test Automation / MCP Testing
    • AI Prompts for QA
    • AI QA Careers
    • LLM Testing / AI Evaluation
    • AI Code Review & Risk-Based Testing
  • Cucumber
  • TestNG
Close

Search

Subscribe
TypeScript Classes
BlogsTypescript

TypeScript Classes: Definition, Syntax & Examples (Constructors, Access Modifiers)

By Ajit Marathe
99 Min Read
0

If you have spent any real time writing TypeScript, you already know that these classes are not some optional decoration bolted onto the language. They are one of the primary ways you organize behavior, model real-world entities, and — if you come from a QA automation background like I do — structure your Page Object Models, API clients, test data builders, and utility wrappers so they don’t collapse into spaghetti after six months of sprint churn.

I have spent the better part of my career sitting at the intersection of two worlds: software architecture and quality assurance. I have built automation frameworks from scratch using Playwright and TypeScript for teams shipping fintech products, e-commerce platforms, and SaaS dashboards. And in every single one of those frameworks, these classes were the backbone. Not functions. Not plain objects. Classes.

So this is not going to be a dry, textbook-style walkthrough of syntax rules copied from a reference manual. I am going to walk you through these classes the way I explain them to junior engineers and SDETs joining my team — starting from the absolute fundamentals, moving into constructors, access modifiers, inheritance, abstract classes, generics, mixins, and decorators, and then landing on real, practical, production-grade examples pulled straight from automation architecture and general application development.

By the time you finish this guide, you should not just “know” TypeScript classes. You should understand why they exist, when to use them, when not to use them, and how to write them the way a senior engineer would — clean, typed, and intention-revealing.

Let’s get into it.

What Exactly Is a TypeScript Class?

A TypeScript class is a blueprint for creating objects that share the same structure and behavior. Think of it as a template. You define the shape once — the properties an object should have, the methods it should expose, how it should be initialized — and then you can stamp out as many instances of that object as you need, each with its own data but sharing the same underlying logic.

If you have worked with JavaScript before, you already know that JavaScript introduced classes in ES6 (ES2015) as syntactic sugar over its existing prototype-based inheritance model. TypeScript took that same class syntax and layered strong static typing on top of it, as documented in the official TypeScript Handbook on Classes. That’s really the core value proposition of the class-based approach: you get the same object-oriented structure JavaScript developers are used to, but now the compiler actively protects you from passing the wrong data type into a constructor, calling a method that doesn’t exist, or accessing a property that should have stayed hidden.

For someone coming from Java, C#, or even Python, such classes will feel instantly familiar. For someone coming purely from a JavaScript background, this construct will feel like JavaScript classes that have been given a spine.

Here’s the simplest possible example of a TypeScript class:

typescript

class User {
  name: string;
  age: number;
}

const user1 = new User();
user1.name = "Ananya";
user1.age = 29;

That’s a class. It’s not a particularly useful one yet, because we haven’t given it a constructor, but structurally it satisfies the definition: a blueprint (User) that describes the shape of an object (name and age properties), and an instance (user1) created from that blueprint using the new keyword.

Now let’s slow down and build this up properly, piece by piece, because every piece of TypeScript classes matters once you’re working in a real codebase with a real team.

Why TypeScript Classes Matter (Beyond the Syntax)

Before we go deeper into syntax, I want to address something a lot of tutorials skip: why should you actually care about them when you could just use plain objects, interfaces, and functions?

The honest answer is: you don’t always need classes. TypeScript supports multiple paradigms — functional, object-oriented, and a hybrid of both. Plenty of excellent TypeScript codebases lean almost entirely on functions and plain data structures.

But such classes earn their place in specific, recurring scenarios:

1. When you need to bundle state and behavior together. If an entity has data that changes over time and behavior that operates on that data, a class gives you a natural home for both. A ShoppingCart class that holds items and exposes addItem(), removeItem(), and getTotal() is a textbook example.

2. When you need multiple instances of the same “shape” with independent state. Every user session, every page object in a test suite, every database connection — these all need their own isolated state while sharing identical behavior. This is exactly where the class-based approach shines.

3. When you want to enforce initialization rules. Constructors let you guarantee that an object can never exist in an invalid state. You cannot create a User without a name, for instance, if your constructor requires it.

4. When you’re modeling hierarchies. If you have a BasePage and then LoginPage, DashboardPage, and CheckoutPage all inherit common behavior from it (like navigate(), waitForLoad(), or takeScreenshot()), inheritance through this pattern is the cleanest way to express that relationship.

5. When you need encapsulation. Access modifiers (which we’ll spend a huge chunk of this article on) let you hide internal implementation details and expose only a clean, controlled interface to the outside world.

In my automation architecture work specifically, I lean on these particular classes constantly for Page Object Models in Playwright, for wrapping API clients, for building custom reporters, and for creating reusable test data factories. I’ll show you real examples of all of these later in this guide.

Basic Class Syntax in TypeScript

Let’s establish the anatomy of a TypeScript class properly. A class declaration generally consists of:

  • The class keyword followed by the class name
  • Property (field) declarations, optionally typed
  • A constructor method
  • Instance methods
  • Optionally, static members, access modifiers, getters/setters, and inheritance clauses

Here’s a more complete, realistic example:

typescript

class Employee {
  employeeId: number;
  fullName: string;
  department: string;

  constructor(employeeId: number, fullName: string, department: string) {
    this.employeeId = employeeId;
    this.fullName = fullName;
    this.department = department;
  }

  getSummary(): string {
    return `${this.fullName} works in ${this.department} (ID: ${this.employeeId})`;
  }
}

const emp1 = new Employee(101, "Rohan Verma", "Quality Assurance");
console.log(emp1.getSummary());
// Output: Rohan Verma works in Quality Assurance (ID: 101)

Let’s break down what’s happening here, line by line, because understanding each piece deeply is what separates developers who “can write a class” from developers who truly understand the class model.

Property declarations. At the top of the class, we declare three properties: employeeId, fullName, and department, each with an explicit type. In TypeScript, unlike plain JavaScript, you typically declare your instance properties upfront (though, as we’ll see later, parameter properties let you skip this step). This upfront declaration is what allows the TypeScript compiler to know the shape of every Employee instance before any object is even created.

The constructor. The constructor is a special method that runs automatically whenever you create a new instance of the class using the new keyword. Its job, in most cases, is to initialize the instance’s properties using the values passed in.

Instance methods. getSummary() is a method available on every instance of Employee. Inside it, we use the this keyword to refer to the specific instance the method was called on.

Instantiation. new Employee(101, "Rohan Verma", "Quality Assurance") creates a new object, runs the constructor with the arguments provided, and returns the fully initialized instance, which we store in emp1.

This is the fundamental shape of virtually every TypeScript class you will write, and everything else we discuss in this guide builds on top of this foundation.

Class Properties (Fields) in Depth

Let’s slow down on class properties because there’s more nuance here than most beginners realize, and this nuance is a big part of why TypeScript classes feel so much safer than plain JavaScript classes.

Declaring Properties with Types

In TypeScript, you can declare a property and its type without assigning it a value immediately:

typescript

class Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
}

If you try to compile this with strictPropertyInitialization enabled (which is on by default under strict: true), TypeScript will actually throw an error here, because these properties are declared but never initialized — not in a constructor, not with a default value. This is one of the genuinely underrated safety features of these constructs. It prevents the classic JavaScript bug where you create an object, forget to set a property, and then get a silent undefined deep inside your business logic three files away.

Property Initializers (Default Values)

You can assign default values directly at the point of declaration:

typescript

class Product {
  id: number;
  name: string = "Unnamed Product";
  price: number = 0;
  inStock: boolean = true;
}

This is extremely useful for properties that have a sensible default and don’t necessarily need to come through the constructor every time.

Optional Properties

Sometimes a property might legitimately not exist on every instance. TypeScript lets you mark this using the ? symbol:

typescript

class Product {
  id: number;
  name: string;
  discountCode?: string;
}

Here, discountCode is optional. TypeScript will not force you to initialize it, and its type effectively becomes string | undefined.

readonly Properties

If a property should be assigned once (usually in the constructor) and never modified again, mark it readonly:

typescript

class Product {
  readonly id: number;
  name: string;

  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

const p = new Product(1, "Wireless Mouse");
p.name = "Wireless Mouse Pro"; // allowed
p.id = 2; // Error: Cannot assign to 'id' because it is a read-only property

I use readonly constantly when writing these classes for automation frameworks — things like locator strings, base URLs, and configuration values that should never accidentally get reassigned mid-test-run. It’s a small habit that prevents an entire category of bugs where a shared object gets mutated somewhere unexpected and breaks tests in ways that are painful to trace.

Constructors in TypeScript Classes: The Complete Guide

Now let’s get into the heart of this article — constructors inside TypeScript classes. If there’s one part of this class structure that trips people up the most (aside from access modifiers, which we’ll cover right after this), it’s the different ways constructors can be written and the subtle rules governing them.

What Is a Constructor?

A constructor is a special method inside a class that is automatically invoked when you create a new instance using the new keyword. Its primary responsibility is to initialize the object’s state — typically by assigning values to its properties.

typescript

class Rectangle {
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  area(): number {
    return this.width * this.height;
  }
}

const rect = new Rectangle(10, 5);
console.log(rect.area()); // 50

A few important rules govern constructors in this feature:

1. A class can have at most one constructor implementation. Unlike languages like Java or C#, TypeScript does not support true constructor overloading with multiple separate implementations. However, it does support overload signatures, which we’ll cover shortly.

2. If you don’t define a constructor, TypeScript provides a default one. For a base class, this default constructor takes no arguments and does nothing. For a derived class (one using extends), the default constructor automatically calls super(...args) with whatever arguments were passed.

3. In a derived class, if you define a constructor, you must call super() before you can access this. This trips up a lot of developers coming from other languages. We’ll cover this in detail in the inheritance section.

Parameter Properties: TypeScript’s Constructor Shorthand

This is one of my favorite features across all of the class system, and it’s something plain JavaScript simply cannot do. Instead of writing this verbose pattern:

typescript

class Rectangle {
  width: number;
  height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
}

You can collapse property declaration, constructor parameter, and assignment into a single line by adding an access modifier (or readonly) directly in front of the constructor parameter:

typescript

class Rectangle {
  constructor(public width: number, public height: number) {}

  area(): number {
    return this.width * this.height;
  }
}

This is called a parameter property, and it does three things simultaneously:

  1. Declares width and height as properties on the class.
  2. Accepts them as constructor parameters.
  3. Automatically assigns this.width = width and this.height = height behind the scenes.

You can mix and match access modifiers here too:

typescript

class Employee {
  constructor(
    public readonly employeeId: number,
    private salary: number,
    protected department: string
  ) {}
}

I use parameter properties heavily when writing Playwright Page Objects because it dramatically reduces boilerplate. Here’s a real pattern I use often:

typescript

import { Page, Locator } from "@playwright/test";

class LoginPage {
  private readonly usernameInput: Locator;
  private readonly passwordInput: Locator;
  private readonly loginButton: Locator;

  constructor(private readonly page: Page) {
    this.usernameInput = page.locator("#username");
    this.passwordInput = page.locator("#password");
    this.loginButton = page.locator("button[type='submit']");
  }

  async login(username: string, password: string): Promise<void> {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }
}

Notice constructor(private readonly page: Page). That single line saves us from writing private readonly page: Page; as a separate declaration and then this.page = page; inside the constructor body. It’s a small thing, but across a framework with fifty Page Object classes, it adds up to genuinely cleaner, more maintainable code.

Constructor Overload Signatures

Earlier I mentioned TypeScript doesn’t support true overloading with multiple constructor bodies, but it does let you declare multiple constructor signatures that all funnel into a single implementation:

typescript

class Coordinate {
  x: number;
  y: number;

  constructor(x: number, y: number);
  constructor(point: { x: number; y: number });
  constructor(xOrPoint: number | { x: number; y: number }, y?: number) {
    if (typeof xOrPoint === "object") {
      this.x = xOrPoint.x;
      this.y = xOrPoint.y;
    } else {
      this.x = xOrPoint;
      this.y = y as number;
    }
  }
}

const c1 = new Coordinate(10, 20);
const c2 = new Coordinate({ x: 5, y: 8 });

This pattern is useful, but I’ll be honest — I don’t reach for it often in day-to-day application or automation code. It tends to show up more in library code where you want to offer flexible, ergonomic APIs to consumers. For most business logic and test automation code, a single well-typed constructor (sometimes accepting an options object) is cleaner and easier to maintain, and it keeps your classes readable for the next engineer who opens the file.

Constructors with Default Parameter Values

Just like regular functions, constructor parameters can have default values:

typescript

class ApiClient {
  constructor(
    private baseUrl: string,
    private timeoutMs: number = 30000,
    private retries: number = 3
  ) {}
}

const client1 = new ApiClient("https://api.example.com");
const client2 = new ApiClient("https://api.example.com", 60000);
const client3 = new ApiClient("https://api.example.com", 60000, 5);

This is enormously useful in automation frameworks for things like HTTP clients, browser configuration wrappers, or retry-handling utilities, where most consumers are happy with sensible defaults but power users need the ability to override them.

Private Constructors and the Singleton Pattern

A constructor can also be marked private, which prevents the class from being instantiated with new from outside the class itself. This is the classic mechanism behind the Singleton design pattern, and it’s a very common use case for TypeScript classes in configuration management:

typescript

class ConfigManager {
  private static instance: ConfigManager;
  private config: Record<string, string> = {};

  private constructor() {
    this.config = {
      environment: "staging",
      baseUrl: "https://staging.example.com",
    };
  }

  static getInstance(): ConfigManager {
    if (!ConfigManager.instance) {
      ConfigManager.instance = new ConfigManager();
    }
    return ConfigManager.instance;
  }

  get(key: string): string | undefined {
    return this.config[key];
  }
}

const config = ConfigManager.getInstance();
console.log(config.get("baseUrl"));

// const badConfig = new ConfigManager(); // Error: Constructor is private

I’ve used this exact pattern to build shared test configuration managers that load environment variables once and are reused across an entire Playwright test suite, avoiding redundant file reads or environment parsing on every single test file.

Access Modifiers in TypeScript Classes: public, private, protected

Now we arrive at what I consider the single most important conceptual leap these particular classes give you over plain JavaScript classes: access modifiers.

In plain JavaScript, classes historically had no real privacy mechanism (until the relatively recent addition of the # private field syntax in native JavaScript, which TypeScript also supports and which we’ll touch on). TypeScript, from its very first versions, gave developers public, private, and protected keywords to control the visibility and accessibility of class members at compile time. You can read the official reference on this in the TypeScript Handbook’s section on member visibility.

It’s critical to understand something upfront: access modifiers in the class-based approach are a compile-time construct, not a runtime one. Once your TypeScript code is compiled down to JavaScript, private and protected disappear (unless you’re using the native # syntax, which is different and does enforce true runtime privacy, as explained in MDN’s documentation on private class features). This means these access modifiers protect you from accidental misuse during development, but they are not a hard security boundary at runtime. Understanding this distinction is genuinely important, especially if anyone on your team assumes private in TypeScript behaves like private in Java with runtime enforcement — it does not, unless you use #.

With that caveat out of the way, let’s go through each modifier carefully.

public — The Default Modifier

Every property and method in this construct is public by default unless you specify otherwise. Public members can be accessed from anywhere — inside the class, by subclasses, and from outside code that holds a reference to an instance.

typescript

class Car {
  public brand: string;
  public model: string;

  constructor(brand: string, model: string) {
    this.brand = brand;
    this.model = model;
  }

  public displayInfo(): string {
    return `${this.brand} ${this.model}`;
  }
}

const myCar = new Car("Toyota", "Corolla");
console.log(myCar.brand); // Accessible
console.log(myCar.displayInfo()); // Accessible

Since public is the default, most developers omit it entirely unless they want to be explicit for readability or team conventions:

typescript

class Car {
  brand: string; // implicitly public
  model: string; // implicitly public
}

I generally recommend omitting the public keyword in application code (since it adds visual noise without adding information), but some teams — especially those enforcing strict style guides — prefer to write it explicitly everywhere for clarity. Both approaches are valid; just be consistent within a codebase.

private — Restricting Access to the Declaring Class

A member marked private can only be accessed from within the class that declares it. It cannot be accessed from outside the class, and — importantly — it also cannot be accessed from subclasses.

typescript

class BankAccount {
  private balance: number;

  constructor(initialBalance: number) {
    this.balance = initialBalance;
  }

  deposit(amount: number): void {
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount > this.balance) {
      throw new Error("Insufficient funds");
    }
    this.balance -= amount;
  }

  getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount(1000);
account.deposit(500);
console.log(account.getBalance()); // 1500

console.log(account.balance);
// Error: Property 'balance' is private and only accessible within class 'BankAccount'

This is encapsulation in action, and it’s one of the clearest illustrations of why access modifiers make such classes safer than plain objects. The outside world doesn’t get to directly poke at balance and set it to any arbitrary value (including a negative one, which would represent an invalid state). Instead, the class exposes controlled entry points — deposit(), withdraw(), and getBalance() — that enforce business rules around how balance can change.

I want to highlight why this matters so much in real projects. Early in my career, I worked on a test automation framework where a shared “TestContext” object had all its properties public. Over time, different test files started mutating that object directly instead of going through defined methods, and debugging flaky tests became a nightmare because state was being changed from a dozen different places with no clear audit trail. Rewriting those TypeScript classes with private fields and controlled public methods for mutation fixed this almost overnight, because now there was exactly one place — one method — responsible for changing any given piece of state.

protected — Accessible Within the Class and Its Subclasses

protected sits between public and private. A protected member is accessible within the declaring class and within any class that extends it, but not from outside code.

typescript

class Animal {
  protected name: string;

  constructor(name: string) {
    this.name = name;
  }

  protected makeSound(): string {
    return "Some generic sound";
  }
}

class Dog extends Animal {
  constructor(name: string) {
    super(name);
  }

  bark(): string {
    return `${this.name} says: Woof! (${this.makeSound()})`;
  }
}

const rex = new Dog("Rex");
console.log(rex.bark()); // Rex says: Woof! (Some generic sound)

console.log(rex.name);
// Error: Property 'name' is protected and only accessible within class 'Animal' and its subclasses

Notice that Dog can access this.name and call this.makeSound() because Dog extends Animal, but code outside the class hierarchy — like our console.log(rex.name) call — cannot.

This is exceptionally useful when designing base classes meant to be extended. In my Playwright frameworks, I frequently define a BasePage class where common properties like page: Page are marked protected, so every derived Page Object (like LoginPage, CheckoutPage, DashboardPage) can use this.page internally, but consumers of those page objects outside the class hierarchy cannot directly manipulate the underlying Playwright Page object — they’re forced to go through the methods you’ve deliberately exposed.

typescript

import { Page } from "@playwright/test";

abstract class BasePage {
  constructor(protected readonly page: Page) {}

  protected async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState("networkidle");
  }

  async takeScreenshot(name: string): Promise<void> {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

class DashboardPage extends BasePage {
  constructor(page: Page) {
    super(page);
  }

  async verifyDashboardLoaded(): Promise<void> {
    await this.waitForPageLoad();
  }
}

readonly — Not Technically an Access Modifier, But Closely Related

While readonly doesn’t control who can access a property, it controls whether it can be reassigned after initial assignment. It’s commonly used alongside access modifiers in well-designed classes:

typescript

class Invoice {
  private readonly invoiceNumber: string;
  public readonly issueDate: Date;

  constructor(invoiceNumber: string) {
    this.invoiceNumber = invoiceNumber;
    this.issueDate = new Date();
  }

  getInvoiceNumber(): string {
    return this.invoiceNumber;
  }
}

Once assigned inside the constructor, invoiceNumber and issueDate can never be reassigned — not even from within the class itself, outside of the constructor.

Combining Access Modifiers with Parameter Properties

As we saw earlier, access modifiers can be applied directly to constructor parameters to auto-declare and auto-assign properties:

typescript

class Order {
  constructor(
    public readonly orderId: string,
    private customerEmail: string,
    protected items: string[] = []
  ) {}

  getCustomerEmail(): string {
    return this.customerEmail;
  }

  addItem(item: string): void {
    this.items.push(item);
  }
}

This single constructor signature declares three properties with three different access levels, all without a single additional line of boilerplate — a genuinely nice piece of ergonomics baked into the language.

Native JavaScript Private Fields (#) vs TypeScript’s private

I promised I’d come back to this, because it’s a distinction that separates intermediate developers from advanced ones. Modern JavaScript (and TypeScript, which fully supports it) has its own native private field syntax using the # prefix:

typescript

class Wallet {
  #balance: number;

  constructor(initialBalance: number) {
    this.#balance = initialBalance;
  }

  getBalance(): number {
    return this.#balance;
  }
}

const wallet = new Wallet(200);
console.log(wallet.getBalance()); // 200
// console.log(wallet.#balance); // SyntaxError at compile time, enforced at runtime too

The key difference: #balance is truly private at the JavaScript runtime level. Even if you compile this down and someone tries to access wallet.#balance through clever runtime tricks or reflection, it simply isn’t accessible — the JavaScript engine itself enforces this boundary. TypeScript’s private keyword, by contrast, is purely a compile-time check; once compiled to plain JavaScript, that property is just a regular, publicly accessible property, and nothing stops determined runtime code from reading or writing it directly.

For the vast majority of application and test automation code, TypeScript’s private keyword is more than sufficient, because you’re protecting against accidental misuse by your own team during development — not against a malicious actor tampering with compiled JavaScript at runtime. I reach for native # private fields specifically when I’m building a library that will be consumed by external teams or published as an npm package, where I want an ironclad guarantee that internal state cannot be touched, even by someone bypassing TypeScript entirely and working with the compiled output directly. You can browse the full ECMAScript class fields proposal if you want the deeper history of how this feature landed in the language.

Static Members in TypeScript Classes

So far, everything we’ve discussed belongs to instances of a class. But sometimes you want a property or method to belong to the class itself, shared across all instances, rather than to any individual instance. That’s what static gives you inside a class.

typescript

class MathUtils {
  static readonly PI = 3.14159;

  static square(num: number): number {
    return num * num;
  }

  static circleArea(radius: number): number {
    return MathUtils.PI * MathUtils.square(radius);
  }
}

console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.square(5)); // 25
console.log(MathUtils.circleArea(4)); // 50.26544

Notice we never created an instance of MathUtils with new. Static members are accessed directly on the class itself.

Static members are frequently used for:

Utility functions that don’t depend on instance state, as shown above.

Counters or registries that track information across all instances:

typescript

class TestCase {
  private static totalTestCases = 0;
  readonly id: number;

  constructor(public name: string) {
    TestCase.totalTestCases += 1;
    this.id = TestCase.totalTestCases;
  }

  static getTotalTestCases(): number {
    return TestCase.totalTestCases;
  }
}

const test1 = new TestCase("Login validation");
const test2 = new TestCase("Checkout flow");
const test3 = new TestCase("Password reset");

console.log(TestCase.getTotalTestCases()); // 3
console.log(test1.id, test2.id, test3.id); // 1 2 3

This pattern is genuinely useful in automation frameworks for generating unique identifiers, tracking execution counts, or maintaining shared caches across an entire test run.

The Singleton pattern, which we already covered above using a private constructor combined with a static instance property and a static factory method.

Static factory methods, which are an alternative to constructors when object creation logic is more complex than a simple assignment:

typescript

class TestUser {
  private constructor(
    public readonly username: string,
    public readonly email: string,
    public readonly role: "admin" | "standard"
  ) {}

  static createAdmin(username: string, email: string): TestUser {
    return new TestUser(username, email, "admin");
  }

  static createStandardUser(username: string, email: string): TestUser {
    return new TestUser(username, email, "standard");
  }
}

const admin = TestUser.createAdmin("qa_admin", "admin@example.com");
const standard = TestUser.createStandardUser("qa_tester", "tester@example.com");

I use static factory methods often when generating test data for Playwright suites, because named factory methods like createAdmin() and createStandardUser() are far more readable at the call site than a constructor call with three positional arguments where you have to remember the exact order and meaning of each one.

Static Blocks

TypeScript (matching a newer JavaScript feature) also supports static initialization blocks, useful for more complex static setup logic inside the class model:

typescript

class EnvironmentConfig {
  static baseUrl: string;
  static apiKey: string;

  static {
    if (process.env.NODE_ENV === "production") {
      EnvironmentConfig.baseUrl = "https://api.production.com";
    } else {
      EnvironmentConfig.baseUrl = "https://api.staging.com";
    }
    EnvironmentConfig.apiKey = process.env.API_KEY ?? "default-key";
  }
}

This is handy when static initialization logic requires more than a simple expression — conditionals, loops, try/catch blocks, and so on.

Getters and Setters in TypeScript Classes

Getters and setters let you define custom logic that runs when a property is read or written, while still allowing consumers to use ordinary property-access syntax.

typescript

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) {
      throw new Error("Temperature cannot be below absolute zero");
    }
    this._celsius = value;
  }

  get fahrenheit(): number {
    return (this._celsius * 9) / 5 + 32;
  }

  set fahrenheit(value: number) {
    this.celsius = ((value - 32) * 5) / 9;
  }
}

const temp = new Temperature(25);
console.log(temp.fahrenheit); // 77
temp.fahrenheit = 100;
console.log(temp.celsius); // 37.77...

// temp.celsius = -300; // Error thrown: Temperature cannot be below absolute zero

Notice how temp.fahrenheit looks like a plain property access from the outside, but it’s actually running computed logic behind the scenes. This is the real power of getters and setters in these constructs — they let you enforce validation rules and derive computed values while keeping the public API clean and intuitive.

A common convention, which I follow in my own code and recommend to my teams, is to store the underlying private data with an underscore prefix (_celsius) and expose the public-facing getter/setter pair without the underscore (celsius). This makes it immediately obvious at a glance which is the “real” backing field and which is the public-facing accessor.

I use getter/setter pairs in automation frameworks for things like computed test result summaries, derived configuration values, and validation-heavy data models used in API testing, where I want to guarantee that invalid data can never even be constructed in memory, let alone sent to an endpoint under test.

Inheritance in TypeScript Classes: extends and super

Inheritance allows one class to acquire the properties and methods of another. This is where this class structure really starts to shine for building well-organized, DRY (Don’t Repeat Yourself) architectures.

Basic Inheritance

typescript

class Vehicle {
  constructor(protected brand: string, protected speed: number) {}

  accelerate(amount: number): void {
    this.speed += amount;
    console.log(`${this.brand} is now going ${this.speed} km/h`);
  }
}

class Motorcycle extends Vehicle {
  constructor(brand: string, speed: number, public hasSidecar: boolean) {
    super(brand, speed);
  }

  wheelie(): void {
    console.log(`${this.brand} pops a wheelie!`);
  }
}

const bike = new Motorcycle("Royal Enfield", 0, false);
bike.accelerate(60); // Royal Enfield is now going 60 km/h
bike.wheelie(); // Royal Enfield pops a wheelie!

A few critical rules to understand about extends and super when working with the class hierarchy:

1. The extends keyword establishes the parent-child relationship. Motorcycle extends Vehicle means every Motorcycle instance is also, structurally and behaviorally, a Vehicle.

2. super(...) must be called before you can use this in a derived class constructor. If Motorcycle defines its own constructor, it must call super(brand, speed) before accessing this.hasSidecar or any other this-based logic. TypeScript will throw a compile error if you forget this.

3. If the derived class doesn’t define a constructor at all, it automatically inherits the parent’s constructor.

typescript

class Truck extends Vehicle {
  loadCargo(weight: number): void {
    console.log(`Loading ${weight}kg onto the ${this.brand}`);
  }
}

const truck = new Truck("Volvo", 0); // uses Vehicle's constructor automatically
truck.loadCargo(500);

4. Method overriding. A derived class can redefine a method inherited from its parent, and you can still call the parent’s version using super.methodName().

typescript

class Vehicle {
  protected brand: string;
  protected speed: number = 0;

  constructor(brand: string) {
    this.brand = brand;
  }

  describe(): string {
    return `This is a ${this.brand} vehicle`;
  }
}

class SportsCar extends Vehicle {
  describe(): string {
    return `${super.describe()} — and it's a sports car built for speed!`;
  }
}

const car = new SportsCar("Ferrari");
console.log(car.describe());
// This is a Ferrari vehicle — and it's a sports car built for speed!

Why Inheritance Matters in Automation Architecture

Let me show you a real-world example of how inheritance shapes a solid Playwright automation framework built with the class system. This is close to a pattern I’ve used in production frameworks across multiple projects.

typescript

import { Page } from "@playwright/test";

abstract class BasePage {
  constructor(protected readonly page: Page) {}

  async navigateTo(path: string): Promise<void> {
    await this.page.goto(path);
  }

  async waitForLoad(): Promise<void> {
    await this.page.waitForLoadState("domcontentloaded");
  }

  async getTitle(): Promise<string> {
    return this.page.title();
  }

  abstract isLoaded(): Promise<boolean>;
}

class LoginPage extends BasePage {
  private readonly usernameField = this.page.locator("#username");
  private readonly passwordField = this.page.locator("#password");
  private readonly submitButton = this.page.locator("button#submit");
  private readonly errorMessage = this.page.locator(".error-message");

  async login(username: string, password: string): Promise<void> {
    await this.usernameField.fill(username);
    await this.passwordField.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage(): Promise<string> {
    return this.errorMessage.innerText();
  }

  async isLoaded(): Promise<boolean> {
    return this.usernameField.isVisible();
  }
}

class DashboardPage extends BasePage {
  private readonly welcomeBanner = this.page.locator(".welcome-banner");

  async isLoaded(): Promise<boolean> {
    return this.welcomeBanner.isVisible();
  }

  async getWelcomeText(): Promise<string> {
    return this.welcomeBanner.innerText();
  }
}

Every page object in this framework inherits navigateTo, waitForLoad, and getTitle from BasePage, so we’re not duplicating that logic across dozens of page classes. The abstract isLoaded() method (we’ll cover abstract classes fully in the next section) forces every derived page class to implement its own load-verification logic, guaranteeing consistency across the framework while allowing each page to define what “loaded” actually means for its own DOM structure.

Abstract Classes in TypeScript

An abstract class is a class that cannot be instantiated directly. Its purpose is to serve as a base class that defines a shared contract and, optionally, some shared implementation, while leaving specific pieces to be implemented by subclasses. Abstract classes are a natural companion to these classes whenever you’re designing a framework rather than a one-off script.

typescript

abstract class Shape {
  abstract calculateArea(): number;
  abstract calculatePerimeter(): number;

  describe(): string {
    return `Area: ${this.calculateArea()}, Perimeter: ${this.calculatePerimeter()}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) {
    super();
  }

  calculateArea(): number {
    return Math.PI * this.radius ** 2;
  }

  calculatePerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Square extends Shape {
  constructor(private side: number) {
    super();
  }

  calculateArea(): number {
    return this.side ** 2;
  }

  calculatePerimeter(): number {
    return 4 * this.side;
  }
}

// const shape = new Shape(); // Error: Cannot create an instance of an abstract class

const circle = new Circle(5);
console.log(circle.describe()); // Area: 78.5398..., Perimeter: 31.4159...

const square = new Square(4);
console.log(square.describe()); // Area: 16, Perimeter: 16

Notice the distinction: describe() has a concrete implementation in Shape and is inherited as-is by both Circle and Square. But calculateArea() and calculatePerimeter() are declared with the abstract keyword and have no body — they are contracts that every subclass must implement, or TypeScript will refuse to compile.

This is fundamentally different from a regular base class with empty or placeholder methods, because TypeScript’s compiler actively enforces the contract. If Square forgot to implement calculatePerimeter(), you’d get a compile-time error immediately, rather than discovering a runtime bug when someone eventually calls that missing method.

I lean on abstract classes heavily whenever I’m designing framework-level TypeScript classes — base API client classes, base reporter classes, and, as you’ve already seen, base Page Object classes. Whenever I know every subclass MUST provide a certain piece of behavior, but the exact implementation will differ per subclass, abstract classes give me a compiler-enforced guarantee instead of relying on a code review comment or a wiki page nobody reads.

Interfaces vs. Classes in TypeScript

A question I get asked constantly by engineers newer to TypeScript is: “If interfaces can also describe the shape of an object, why do I need classes at all?”

It’s a fair question, and the answer matters for how you architect your code correctly.

An interface describes a shape — it’s purely structural, exists only at compile time, and produces zero runtime JavaScript. A class, on the other hand, produces real, runtime JavaScript: it has an actual constructor function, it can hold private state, it can be instantiated with new, and it can carry implementation logic, not just type declarations.

typescript

interface Shape {
  calculateArea(): number;
}

class Circle implements Shape {
  constructor(private radius: number) {}

  calculateArea(): number {
    return Math.PI * this.radius ** 2;
  }
}

Here, implements tells TypeScript that Circle must satisfy the Shape interface’s contract — in this case, it must have a calculateArea() method that returns a number. Unlike extends, implements does not provide any inherited implementation; it’s purely a compile-time contract check.

A class can implement multiple interfaces, which is something you cannot do with extends (such classes only support single inheritance from one base class):

typescript

interface Loggable {
  log(): void;
}

interface Serializable {
  toJSON(): string;
}

class ApiResponse implements Loggable, Serializable {
  constructor(private statusCode: number, private body: unknown) {}

  log(): void {
    console.log(`Status: ${this.statusCode}`);
  }

  toJSON(): string {
    return JSON.stringify({ statusCode: this.statusCode, body: this.body });
  }
}

My rule of thumb, after years of building both application code and QA frameworks: use interfaces to describe pure data shapes and contracts that multiple unrelated classes might satisfy; use classes when you need actual behavior, state management, encapsulation, or instantiation. The two are not competitors — they’re complementary tools, and most well-architected TypeScript classes in a mature codebase implement one or more interfaces.

Generics in TypeScript Classes

Generics let these particular classes work with a variety of types while still preserving full type safety, instead of falling back to any. If you’ve written generic functions before, generic classes will feel very natural.

typescript

class Box<T> {
  private contents: T;

  constructor(value: T) {
    this.contents = value;
  }

  getContents(): T {
    return this.contents;
  }

  setContents(value: T): void {
    this.contents = value;
  }
}

const numberBox = new Box<number>(42);
const stringBox = new Box<string>("Hello TypeScript");

console.log(numberBox.getContents()); // 42
console.log(stringBox.getContents()); // Hello TypeScript

// numberBox.setContents("oops"); // Error: Argument of type 'string' is not assignable to type 'number'

Generics become genuinely powerful once you start building reusable infrastructure. Here’s a pattern I use often for API response wrapping in automation frameworks:

typescript

class ApiResult<T> {
  constructor(
    public readonly statusCode: number,
    public readonly data: T,
    public readonly success: boolean
  ) {}

  static ok<T>(data: T, statusCode = 200): ApiResult<T> {
    return new ApiResult(statusCode, data, true);
  }

  static failure<T>(statusCode: number, data: T): ApiResult<T> {
    return new ApiResult(statusCode, data, false);
  }
}

interface UserPayload {
  id: number;
  email: string;
}

const result = ApiResult.ok<UserPayload>({ id: 1, email: "qa@example.com" });
console.log(result.data.email); // qa@example.com

This ApiResult<T> class can wrap literally any payload type — a user object, a list of orders, an error body — while still giving you full autocomplete and compile-time type checking on result.data. I use this pattern constantly when writing API test clients with Playwright’s request context, because it means every API call in the framework returns a strongly typed, consistent result shape instead of any.

Generic constraints let you narrow down what types are acceptable:

typescript

interface HasId {
  id: number;
}

class Repository<T extends HasId> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  findById(id: number): T | undefined {
    return this.items.find((item) => item.id === id);
  }
}

interface Product extends HasId {
  name: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.add({ id: 1, name: "Keyboard", price: 49.99 });
console.log(productRepo.findById(1)?.name); // Keyboard

T extends HasId guarantees that whatever type you use with Repository<T>, it must at minimum have an id: number property — which is exactly what findById() relies on internally.

Polymorphism in TypeScript Classes

Polymorphism is the ability to treat objects of different derived classes as if they were the same base type, while still getting the correct, class-specific behavior at runtime. This is one of the most practical superpowers you unlock once you combine inheritance with method overriding.

typescript

abstract class Notification {
  abstract send(message: string): void;
}

class EmailNotification extends Notification {
  send(message: string): void {
    console.log(`Sending EMAIL: ${message}`);
  }
}

class SmsNotification extends Notification {
  send(message: string): void {
    console.log(`Sending SMS: ${message}`);
  }
}

class SlackNotification extends Notification {
  send(message: string): void {
    console.log(`Sending SLACK message: ${message}`);
  }
}

function notifyAll(channels: Notification[], message: string): void {
  for (const channel of channels) {
    channel.send(message);
  }
}

notifyAll(
  [new EmailNotification(), new SmsNotification(), new SlackNotification()],
  "Build failed on main branch"
);

notifyAll() doesn’t know or care which specific subclass of Notification it’s dealing with. It just calls send(), and polymorphism guarantees the correct, type-specific implementation runs. I use this exact pattern in test reporting pipelines — a single notify() call fans out across email, Slack, and dashboard reporters, each implemented as its own class, without a single if/else chain checking notification type.

Mixins in TypeScript

TypeScript classes support single inheritance only — a class can extend just one base class. But sometimes you want to combine reusable pieces of behavior from multiple sources. That’s what mixins are for.

typescript

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
  };
}

function Loggable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    log(message: string): void {
      console.log(`[LOG]: ${message}`);
    }
  };
}

class BaseTestResult {
  constructor(public testName: string, public passed: boolean) {}
}

class TestResult extends Timestamped(Loggable(BaseTestResult)) {}

const result = new TestResult("Login test", true);
result.log(`Test "${result.testName}" finished. Passed: ${result.passed}`);
console.log(result.createdAt);

Mixins are an advanced technique and I don’t reach for them in every project, but when you’re building a shared internal library of reusable behaviors — logging, timestamping, retry logic, serialization — that need to be composed across otherwise unrelated classes, mixins let you avoid duplicating that logic in every single class or forcing an awkward, unrelated inheritance chain.

Decorators and TypeScript Classes

Decorators are a special kind of declaration that can be attached to a class, method, accessor, property, or parameter, letting you modify or annotate behavior declaratively. They’re widely used in frameworks like Angular and NestJS, both of which are built almost entirely around decorated classes.

typescript

function LogClass(constructor: Function) {
  console.log(`Class created: ${constructor.name}`);
}

function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`Calling ${propertyKey} with arguments: ${JSON.stringify(args)}`);
    return originalMethod.apply(this, args);
  };
}

@LogClass
class Calculator {
  @LogMethod
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(3, 4);
// Class created: Calculator
// Calling add with arguments: [3,4]

Decorators require enabling experimentalDecorators (or using the newer stable decorators proposal depending on your TypeScript version) in your tsconfig.json, and the exact syntax has evolved as the underlying JavaScript proposal has matured. I recommend checking the current TypeScript decorators documentation before adopting them in a new project, since this is one of the areas of the language that has changed the most over the past few TypeScript releases.

In automation frameworks specifically, decorators show up in retry logic (@Retry(3) on a flaky test step), timing measurement (@MeasureExecutionTime on a slow API call), and dependency injection setups in frameworks like NestJS-based test orchestration services. That said, for most day-to-day Playwright and TypeScript test automation work, plain TypeScript classes without decorators are more than sufficient, and I’d encourage newer engineers to get fully comfortable with the fundamentals we’ve already covered before reaching for decorators.

A Complete, Real-World Example: TypeScript Classes in a Playwright Automation Framework

Let’s tie everything together with a fuller example that mirrors how I structure real automation frameworks. This example uses constructors, access modifiers, inheritance, abstract classes, and generics — everything we’ve walked through — in one cohesive piece of code.

typescript

import { Page, Locator, expect } from "@playwright/test";

// Base class — abstract, cannot be instantiated directly
abstract class BasePage {
  constructor(protected readonly page: Page) {}

  async navigateTo(path: string): Promise<void> {
    await this.page.goto(path);
    await this.waitForLoad();
  }

  protected async waitForLoad(): Promise<void> {
    await this.page.waitForLoadState("networkidle");
  }

  abstract isLoaded(): Promise<boolean>;
}

// Derived class representing the login page
class LoginPage extends BasePage {
  private readonly usernameInput: Locator = this.page.locator("#username");
  private readonly passwordInput: Locator = this.page.locator("#password");
  private readonly loginButton: Locator = this.page.locator("button#login");
  private readonly errorBanner: Locator = this.page.locator(".error-banner");

  async login(username: string, password: string): Promise<void> {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectError(message: string): Promise<void> {
    await expect(this.errorBanner).toHaveText(message);
  }

  async isLoaded(): Promise<boolean> {
    return this.usernameInput.isVisible();
  }
}

// Generic API result wrapper
class ApiResult<T> {
  private constructor(
    public readonly status: number,
    public readonly data: T
  ) {}

  static from<T>(status: number, data: T): ApiResult<T> {
    return new ApiResult(status, data);
  }
}

// Test data factory using a static factory method pattern
interface TestUserData {
  username: string;
  password: string;
  role: "admin" | "standard";
}

class TestUserFactory {
  private static counter = 0;

  static createStandardUser(): TestUserData {
    TestUserFactory.counter += 1;
    return {
      username: `qa_user_${TestUserFactory.counter}`,
      password: "SecurePass!123",
      role: "standard",
    };
  }
}

// Example usage inside a Playwright test
// test("user can log in successfully", async ({ page }) => {
//   const loginPage = new LoginPage(page);
//   await loginPage.navigateTo("/login");
//   const testUser = TestUserFactory.createStandardUser();
//   await loginPage.login(testUser.username, testUser.password);
// });

Every design decision in this snippet ties directly back to a concept from this guide: BasePage is abstract and holds a protected constructor parameter property; LoginPage extends it and marks its locators private; ApiResult<T> uses a private constructor with a generic static factory method; and TestUserFactory uses a private static counter to generate unique usernames across a test run. This is what mature, production-grade classes look like once every concept in this article is working together.

Common Mistakes Developers Make with TypeScript Classes

Having reviewed hundreds of pull requests across different teams, I keep seeing the same handful of mistakes with this pattern, so let’s address them directly.

Mistake 1: Making everything public by default. New TypeScript developers often skip access modifiers entirely, which means every property becomes freely mutable from anywhere in the codebase. Default to private and only widen visibility (to protected or public) when you have a concrete reason to.

Mistake 2: Forgetting super() in a derived constructor. If your derived class defines its own constructor, TypeScript will force you to call super() before touching this, but I’ve seen people work around this incorrectly by restructuring logic in ways that break the initialization order. Keep super() as literally the first line of your constructor whenever possible.

Mistake 3: Confusing interface and abstract class. Interfaces cannot hold implementation or private state; abstract classes can. If you find yourself wanting to share actual logic across multiple related classes, you likely want an abstract class, not an interface.

Mistake 4: Overusing inheritance where composition would be simpler. Deep inheritance chains (a class extending a class extending a class extending a class) become brittle and hard to reason about. If you find yourself three or four levels deep, consider whether composition — injecting collaborator objects rather than inheriting from them — would produce a cleaner design.

Mistake 5: Relying on TypeScript’s private for actual security. As discussed earlier, private in TypeScript classes disappears at runtime. If you genuinely need runtime-enforced privacy (for a published library, for instance), use native # private fields instead.

Mistake 6: Not using readonly where it clearly applies. IDs, timestamps, and configuration values that are set once and never change again should be marked readonly. It costs nothing and prevents an entire class of accidental mutation bugs.

Best Practices for Writing Clean TypeScript Classes

After years of writing and reviewing this kind of code in production automation frameworks, here’s the checklist I hold myself and my teams to:

  • Keep constructors focused purely on initialization — avoid doing heavy computation, network calls, or file I/O inside a constructor.
  • Prefer parameter properties for simple, one-line assignments to keep the class model concise.
  • Default to private, widen to protected only for base-class internals meant for subclasses, and reserve public for the genuinely intentional external API of the class.
  • Mark anything that shouldn’t change after construction as readonly.
  • Favor abstract classes for shared base behavior across a family of related classes, and interfaces for pure structural contracts.
  • Keep single classes focused on a single responsibility — a LoginPage class should manage login page interactions, not also handle API calls or database assertions.
  • Use static factory methods with descriptive names (createAdmin(), fromJson()) instead of overloaded constructors when object creation logic is non-trivial.
  • Write unit tests for classes with meaningful business logic, not just for standalone functions. Classes with private state deserve just as much test coverage as anything else in your codebase.

If you want to go deeper on some of these architectural choices, the TypeScript Deep Dive guide by Basarat Ali Syed is one of the more thorough community resources covering these patterns in more depth, and it’s a resource I still point junior engineers to.

Frequently Asked Questions About TypeScript Classes

Are TypeScript classes the same as JavaScript classes? These particular classes are built on top of JavaScript’s ES6 class syntax, but they add static typing, access modifiers (public, private, protected), abstract classes, interfaces via implements, generics, and parameter properties — none of which exist in plain JavaScript classes without a compiler.

Do TypeScript classes support multiple inheritance? No. A TypeScript class can only extend one base class at a time. However, a class can implement multiple interfaces, and you can achieve similar composition benefits using mixins, as shown earlier in this guide.

What’s the difference between interface and abstract class in TypeScript? An interface is a pure compile-time contract with no runtime output and no implementation. An abstract class produces real runtime code, can hold private state and constructors, and can provide shared implementation alongside abstract methods that subclasses must implement.

Is private in TypeScript classes actually secure? No, not at runtime. TypeScript’s private keyword is a compile-time check only; it’s stripped away when your code compiles to JavaScript. For genuine runtime privacy, use native # private fields instead.

When should I use a class instead of a plain object or function in TypeScript? Reach for a class when you need to bundle state and behavior together, need multiple independent instances of the same structure, need to enforce initialization rules through a constructor, or need encapsulation through access modifiers. For simple, stateless data transformations, a plain function is often simpler and just as effective.

Can TypeScript classes be generic? Yes. Just like functions, TypeScript classes can accept type parameters (for example, class Box<T>), letting the class work with different data types while preserving full compile-time type safety.

TypeScript Classes vs. JavaScript’s Prototypal Inheritance

To really understand these classes at a deep level, it helps to know what’s happening underneath the syntax, because the class keyword in both JavaScript and TypeScript is, technically speaking, sugar over something older: prototype-based inheritance.

JavaScript has never had “classes” in the classical, Java-style sense at the engine level. What it has always had is objects linked to other objects through something called the prototype chain. When you write a function and attach methods to SomeFunction.prototype, every object created with new SomeFunction() can access those methods through that chain. Before ES6, this was the only way to simulate class-like behavior in JavaScript:

javascript

function Animal(name) {
  this.name = name;
}

Animal.prototype.speak = function () {
  console.log(`${this.name} makes a sound.`);
};

function Dog(name) {
  Animal.call(this, name);
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function () {
  console.log(`${this.name} barks.`);
};

Compare that to the equivalent using TypeScript classes:

typescript

class Animal {
  constructor(protected name: string) {}

  speak(): void {
    console.log(`${this.name} makes a sound.`);
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} barks.`);
  }
}

If you compile this TypeScript down to JavaScript targeting ES5, the TypeScript compiler generates almost exactly the prototype-chain boilerplate we wrote by hand above, plus a small __extends helper function to correctly wire up the prototype chain and handle super calls. This is genuinely worth knowing, because it demystifies what such classes actually are: they are not a new runtime feature invented by TypeScript. They are a clean, safe, statically typed syntax layered on top of a mechanism JavaScript has had since its earliest days.

Understanding this also explains why methods defined inside TypeScript classes are shared across all instances rather than duplicated in memory for each object. When you write:

typescript

class Circle {
  constructor(private radius: number) {}

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

The area() method is not copied onto every single Circle instance. It lives once, on Circle.prototype, and every instance simply delegates to it through the prototype chain. If you create ten thousand Circle objects, you are not paying the memory cost of ten thousand copies of area() — you’re paying for ten thousand small objects that each hold a radius value and a reference to the shared prototype. This is one of the quiet efficiency wins baked into how these particular classes compile down to JavaScript, and it’s part of why classes tend to outperform naive factory-function patterns that redefine methods inside the function body on every call, capturing them as closures instead of sharing them on a prototype.

One nuance worth flagging: arrow function class properties do NOT behave this way. If you write:

typescript

class Button {
  onClick = () => {
    console.log("Clicked");
  };
}

Here, onClick is not placed on the prototype. It’s an instance property, created fresh on every single instance, because arrow functions capture this lexically and need their own closure per instance to bind correctly. We’ll come back to exactly why this matters in the section on this binding below, but it’s an important distinction to carry forward as you write more advanced TypeScript classes.

Class Expressions in TypeScript

Most of the classes we’ve written so far have used class declarations — the class Name { ... } syntax that binds a name in the enclosing scope. But TypeScript also supports class expressions, where a class is defined as part of an expression rather than a standalone statement.

typescript

const Vehicle = class {
  constructor(public brand: string) {}

  describe(): string {
    return `This is a ${this.brand}`;
  }
};

const v = new Vehicle("Honda");
console.log(v.describe()); // This is a Honda

This is an anonymous class expression assigned to a constant. You can also give class expressions their own internal name, which is only visible inside the class body itself — useful for recursive references:

typescript

const Factorial = class FactorialCalculator {
  static compute(n: number): number {
    return n <= 1 ? 1 : n * FactorialCalculator.compute(n - 1);
  }
};

console.log(Factorial.compute(5)); // 120

Class expressions inside TypeScript classes are particularly useful when you need to conditionally define a class, or when building factory functions that return dynamically composed classes — this is, in fact, the exact mechanism the mixin pattern from earlier in this guide relies on. Every mixin function we wrote (Timestamped, Loggable) returns an anonymous class expression that extends whatever base class was passed in.

Another practical use case is defining a small, single-purpose class inline, right where it’s needed, without polluting the module’s top-level scope with a name that’s only relevant in one function:

typescript

function createValidator(minLength: number) {
  return new (class {
    validate(value: string): boolean {
      return value.length >= minLength;
    }
  })();
}

const validator = createValidator(8);
console.log(validator.validate("shortpw")); // false
console.log(validator.validate("longenoughpassword")); // true

I don’t reach for class expressions often in day-to-day automation code, but understanding them matters if you’re reading library source code, working with mixins, or occasionally need to construct a class dynamically based on runtime configuration rather than static code structure.

Method Overloading Inside TypeScript Classes

We touched on constructor overload signatures earlier, but regular methods inside such classes support the exact same overloading pattern, and it’s a technique worth understanding well, because it lets you offer a flexible, well-typed API surface without resorting to any or excessive union types scattered through your logic.

typescript

class Formatter {
  format(value: number): string;
  format(value: Date): string;
  format(value: number | Date): string {
    if (value instanceof Date) {
      return value.toISOString();
    }
    return value.toFixed(2);
  }
}

const formatter = new Formatter();
console.log(formatter.format(19.5)); // "19.50"
console.log(formatter.format(new Date())); // ISO date string

Notice the pattern: you declare multiple method signatures with no implementation body, followed immediately by a single implementation signature that must be compatible with all the overloads above it. The implementation signature itself is not visible to callers — from the outside, Formatter appears to have two distinct, cleanly typed format() methods, even though there’s only one function body handling both cases internally.

This becomes genuinely valuable in automation frameworks when you’re building assertion helpers or data comparison utilities that need to behave differently — but still predictably — depending on the shape of the input:

typescript

class Assertions {
  static equals(actual: string, expected: string): void;
  static equals(actual: number, expected: number, tolerance?: number): void;
  static equals(actual: string | number, expected: string | number, tolerance = 0): void {
    if (typeof actual === "number" && typeof expected === "number") {
      if (Math.abs(actual - expected) > tolerance) {
        throw new Error(`Expected ${expected}, got ${actual}`);
      }
    } else if (actual !== expected) {
      throw new Error(`Expected "${expected}", got "${actual}"`);
    }
  }
}

Assertions.equals(19.999, 20, 0.01); // passes
Assertions.equals("staging", "staging"); // passes

Method overloading inside TypeScript classes gives your consumers autocomplete hints that precisely match how they intend to call a method, catching mismatched argument combinations at compile time rather than at runtime, deep inside a test run where the failure is much more expensive to diagnose.

Custom Error Classes and Exception Hierarchies

One of the most underused patterns I see in both application code and test automation code is custom error classes. Too many codebases throw generic Error objects everywhere, which means catching code has no reliable way to distinguish a network timeout from a validation failure from a business rule violation — everything just looks like “an Error” at the catch site.

These particular classes solve this cleanly, because you can extend the built-in Error class to create a whole hierarchy of meaningful, distinguishable error types:

typescript

class AppError extends Error {
  constructor(message: string, public readonly code: string) {
    super(message);
    this.name = this.constructor.name;
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

class ValidationError extends AppError {
  constructor(message: string, public readonly field: string) {
    super(message, "VALIDATION_ERROR");
  }
}

class ApiTimeoutError extends AppError {
  constructor(message: string, public readonly endpoint: string, public readonly timeoutMs: number) {
    super(message, "API_TIMEOUT");
  }
}

class AuthenticationError extends AppError {
  constructor(message: string) {
    super(message, "AUTH_ERROR");
  }
}

That Object.setPrototypeOf(this, new.target.prototype) line is not decoration — it’s a genuinely important fix for a known quirk when extending built-ins like Error in TypeScript classes compiled to older JavaScript targets. Without it, instanceof checks against subclasses can silently fail on some compilation targets, which defeats the entire purpose of building a typed error hierarchy in the first place.

With this hierarchy in place, catching code becomes precise and readable:

typescript

async function performLogin(page: Page, username: string, password: string): Promise<void> {
  try {
    await page.fill("#username", username);
    await page.fill("#password", password);
    await page.click("#submit");
  } catch (error) {
    if (error instanceof AuthenticationError) {
      console.error(`Authentication failed: ${error.message}`);
    } else if (error instanceof ApiTimeoutError) {
      console.error(`Timed out calling ${error.endpoint} after ${error.timeoutMs}ms`);
    } else if (error instanceof ValidationError) {
      console.error(`Invalid field "${error.field}": ${error.message}`);
    } else {
      throw error; // Unknown error, let it propagate
    }
  }
}

I use this exact structure in automation frameworks to distinguish between test failures caused by genuine application bugs versus failures caused by framework issues like flaky selectors, network hiccups, or environment misconfiguration. When your custom reporter can inspect error.code or check error instanceof ApiTimeoutError, it can automatically tag test failures with the right category instead of forcing a human to read stack traces line by line every single time a build goes red.

This is a case where these classes genuinely outperform plain error objects or string-based error codes, because the compiler enforces the shape of every error type, autocomplete tells you exactly what properties are available on a caught error once you’ve narrowed its type, and refactoring an error hierarchy later (adding a new field, renaming a code) is a safe, compiler-checked operation instead of a risky find-and-replace across the codebase.

The Builder Pattern with TypeScript Classes

The Builder pattern is one of the cleanest demonstrations of why fluent, chainable APIs pair so naturally with TypeScript classes. Instead of a constructor with ten optional parameters (which is painful to call correctly and even more painful to read at the call site), a builder class lets you construct complex objects step by step, with each step clearly labeled.

typescript

class RequestBuilder {
  private url = "";
  private method: "GET" | "POST" | "PUT" | "DELETE" = "GET";
  private headers: Record<string, string> = {};
  private body: unknown = null;

  setUrl(url: string): this {
    this.url = url;
    return this;
  }

  setMethod(method: "GET" | "POST" | "PUT" | "DELETE"): this {
    this.method = method;
    return this;
  }

  addHeader(key: string, value: string): this {
    this.headers[key] = value;
    return this;
  }

  setBody(body: unknown): this {
    this.body = body;
    return this;
  }

  build(): { url: string; method: string; headers: Record<string, string>; body: unknown } {
    if (!this.url) {
      throw new Error("URL is required to build a request");
    }
    return { url: this.url, method: this.method, headers: this.headers, body: this.body };
  }
}

const request = new RequestBuilder()
  .setUrl("https://api.example.com/orders")
  .setMethod("POST")
  .addHeader("Authorization", "Bearer token123")
  .addHeader("Content-Type", "application/json")
  .setBody({ productId: 42, quantity: 3 })
  .build();

The key detail that makes this pattern work smoothly with such classes is the this return type on each method. By returning this (typed as this, not as the concrete class name), TypeScript correctly preserves the specific subclass type through method chains, even if RequestBuilder were later extended by a more specialized subclass — something that would silently break if you typed those methods to return RequestBuilder explicitly instead of this.

I use builder-style TypeScript classes extensively for constructing complex test data payloads and API request objects in automation suites, where a fluent, readable call chain at the test level (.setUrl(...).setMethod(...).setBody(...).build()) is dramatically easier for other engineers to scan and modify than a giant object literal with dozens of optional fields, half of which are irrelevant to any given test case.

Dependency Injection with TypeScript Classes

Dependency injection is a design principle where a class receives its collaborators — the other objects it depends on — from the outside, rather than constructing them internally. These particular classes are extremely well suited to this pattern because constructor parameter properties give you a natural, low-ceremony place to declare dependencies.

typescript

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

interface HttpClient {
  get<T>(url: string): Promise<T>;
}

class FetchHttpClient implements HttpClient {
  async get<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json() as Promise<T>;
  }
}

class UserService {
  constructor(private readonly httpClient: HttpClient, private readonly logger: Logger) {}

  async fetchUser(id: number): Promise<{ id: number; name: string }> {
    this.logger.log(`Fetching user ${id}`);
    return this.httpClient.get(`https://api.example.com/users/${id}`);
  }
}

const userService = new UserService(new FetchHttpClient(), new ConsoleLogger());

Notice that UserService depends on the HttpClient and Logger interfaces, not on any concrete implementation. This is a massive advantage when it comes to testing: in a unit test, you can inject a mock HttpClient that returns canned data instantly, without making a real network call, and a mock Logger that captures messages for assertions instead of printing to the console.

typescript

class MockHttpClient implements HttpClient {
  async get<T>(url: string): Promise<T> {
    return { id: 1, name: "Test User" } as unknown as T;
  }
}

class MockLogger implements Logger {
  public messages: string[] = [];
  log(message: string): void {
    this.messages.push(message);
  }
}

const mockService = new UserService(new MockHttpClient(), new MockLogger());

This is dependency injection at its simplest — manual, constructor-based injection with no external framework required. For larger applications, frameworks like NestJS or InversifyJS layer decorator-based dependency injection containers on top of this same foundational pattern, automatically resolving and injecting dependencies based on type metadata. But the underlying principle is identical to what we just wrote by hand: TypeScript classes with constructor parameters typed against interfaces, not concrete implementations.

In automation architecture specifically, I use manual dependency injection constantly for API client classes, database connection wrappers, and reporting integrations, because it means my test suite’s core logic can run against fast, deterministic mocks in unit tests, while still running against real services in integration and end-to-end test tiers, without a single line of business logic changing between the two.

Common Design Patterns Implemented with TypeScript Classes

Design patterns are reusable solutions to recurring software design problems, and a huge proportion of the classic Gang of Four patterns map directly onto these classes. Let’s walk through three that show up constantly in both application code and automation frameworks.

The Observer Pattern

The Observer pattern lets one object (the subject) notify a list of other objects (observers) whenever its state changes, without the subject needing to know any details about who’s listening.

typescript

interface Observer {
  update(event: string, payload: unknown): void;
}

class EventEmitter {
  private observers: Observer[] = [];

  subscribe(observer: Observer): void {
    this.observers.push(observer);
  }

  unsubscribe(observer: Observer): void {
    this.observers = this.observers.filter((o) => o !== observer);
  }

  protected notify(event: string, payload: unknown): void {
    for (const observer of this.observers) {
      observer.update(event, payload);
    }
  }
}

class TestRunner extends EventEmitter {
  runTest(name: string): void {
    this.notify("test:started", { name });
    // ... run the test ...
    this.notify("test:completed", { name, passed: true });
  }
}

class ConsoleReporter implements Observer {
  update(event: string, payload: unknown): void {
    console.log(`Event: ${event}`, payload);
  }
}

const runner = new TestRunner();
runner.subscribe(new ConsoleReporter());
runner.runTest("Checkout flow validation");

The Strategy Pattern

The Strategy pattern lets you swap out an algorithm’s implementation at runtime by encapsulating each variant in its own class, all conforming to a shared interface.

typescript

interface RetryStrategy {
  shouldRetry(attempt: number, error: Error): boolean;
  getDelay(attempt: number): number;
}

class FixedDelayRetry implements RetryStrategy {
  constructor(private maxAttempts: number, private delayMs: number) {}

  shouldRetry(attempt: number): boolean {
    return attempt < this.maxAttempts;
  }

  getDelay(): number {
    return this.delayMs;
  }
}

class ExponentialBackoffRetry implements RetryStrategy {
  constructor(private maxAttempts: number, private baseDelayMs: number) {}

  shouldRetry(attempt: number): boolean {
    return attempt < this.maxAttempts;
  }

  getDelay(attempt: number): number {
    return this.baseDelayMs * 2 ** attempt;
  }
}

class RetryableAction {
  constructor(private strategy: RetryStrategy) {}

  async execute<T>(action: () => Promise<T>): Promise<T> {
    let attempt = 0;
    while (true) {
      try {
        return await action();
      } catch (error) {
        if (!this.strategy.shouldRetry(attempt, error as Error)) {
          throw error;
        }
        await new Promise((resolve) => setTimeout(resolve, this.strategy.getDelay(attempt)));
        attempt += 1;
      }
    }
  }
}

The Factory Pattern

We already touched on static factory methods earlier, but the Factory pattern can also be expressed as its own dedicated class, particularly useful when object creation logic depends on runtime configuration.

typescript

interface Notification {
  send(message: string): void;
}

class EmailNotification implements Notification {
  send(message: string): void {
    console.log(`Email: ${message}`);
  }
}

class SlackNotification implements Notification {
  send(message: string): void {
    console.log(`Slack: ${message}`);
  }
}

class NotificationFactory {
  static create(channel: "email" | "slack"): Notification {
    switch (channel) {
      case "email":
        return new EmailNotification();
      case "slack":
        return new SlackNotification();
    }
  }
}

const channel = NotificationFactory.create("slack");
channel.send("Deployment completed successfully");

Across all three of these patterns, the throughline is the same: TypeScript classes give you a natural, well-typed home for encapsulating a single algorithm, a single responsibility, or a single variant of behavior, and interfaces give you the contract that lets those interchangeable classes be swapped safely at compile time and runtime alike.

Testing TypeScript Classes with Jest and Vitest

If such classes hold real business logic, they deserve real test coverage — not just end-to-end coverage through Playwright, but focused unit tests that verify the class’s behavior in isolation. Let’s walk through how this looks in practice using Jest, though the same principles apply almost identically in Vitest.

typescript

class ShoppingCart {
  private items: { name: string; price: number; quantity: number }[] = [];

  addItem(name: string, price: number, quantity = 1): void {
    if (price < 0) {
      throw new Error("Price cannot be negative");
    }
    this.items.push({ name, price, quantity });
  }

  removeItem(name: string): void {
    this.items = this.items.filter((item) => item.name !== name);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  getItemCount(): number {
    return this.items.reduce((count, item) => count + item.quantity, 0);
  }
}

Here’s a focused Jest test suite covering the class’s behavior:

typescript

import { ShoppingCart } from "./ShoppingCart";

describe("ShoppingCart", () => {
  let cart: ShoppingCart;

  beforeEach(() => {
    cart = new ShoppingCart();
  });

  it("starts with a total of zero", () => {
    expect(cart.getTotal()).toBe(0);
  });

  it("calculates the total correctly across multiple items", () => {
    cart.addItem("Keyboard", 49.99, 2);
    cart.addItem("Mouse", 19.99, 1);
    expect(cart.getTotal()).toBeCloseTo(119.97);
  });

  it("removes items correctly", () => {
    cart.addItem("Monitor", 199.99);
    cart.removeItem("Monitor");
    expect(cart.getTotal()).toBe(0);
  });

  it("throws when adding an item with a negative price", () => {
    expect(() => cart.addItem("Broken Item", -10)).toThrow("Price cannot be negative");
  });

  it("tracks item count including quantities", () => {
    cart.addItem("Cable", 5.99, 3);
    cart.addItem("Adapter", 12.99, 1);
    expect(cart.getItemCount()).toBe(4);
  });
});

A few testing principles I hold to specifically when testing TypeScript classes:

Test through the public API, not the private internals. Since items is private, our tests never touch it directly — they only interact with addItem(), removeItem(), getTotal(), and getItemCount(), exactly as a real consumer of the class would. This means our tests stay valid even if the internal storage mechanism changes later, as long as the public contract stays the same.

Test the error paths, not just the happy path. The negative-price test is just as important as the successful-addition tests. Classes that enforce invariants (like “price cannot be negative”) need tests proving that enforcement actually works, or the validation logic could silently break during a future refactor without anyone noticing.

Use beforeEach to reset state between tests. Because these particular classes carry internal state across method calls, tests need a fresh instance every time to stay properly isolated from each other. Reusing a single shared instance across tests is a common source of flaky, order-dependent test suites.

For classes with injected dependencies — like the UserService we built in the dependency injection section — testing becomes even more valuable, because you can substitute real collaborators for lightweight mocks and verify behavior without any network calls, database connections, or filesystem access, keeping your unit test suite fast enough to run on every single commit.

Migrating Legacy JavaScript Classes to TypeScript

A huge number of teams I’ve worked with aren’t writing greenfield TypeScript projects — they’re migrating an existing JavaScript codebase, and a large part of that migration involves converting plain JavaScript classes into properly typed TypeScript classes. Here’s the process I follow, step by step, when leading this kind of migration.

Step 1: Rename the file and add explicit types to the constructor. Start with the most obvious win — turning implicit any parameters into real types.

javascript

// Before (JavaScript)
class Order {
  constructor(id, items, customerEmail) {
    this.id = id;
    this.items = items;
    this.customerEmail = customerEmail;
  }
}

typescript

// After (TypeScript)
interface OrderItem {
  productId: string;
  quantity: number;
  price: number;
}

class Order {
  constructor(
    public readonly id: string,
    private items: OrderItem[],
    private customerEmail: string
  ) {}
}

Step 2: Add explicit return types to every method. This immediately surfaces bugs where a method sometimes returns undefined in an edge case that was previously invisible.

Step 3: Tighten access modifiers. Legacy JavaScript classes almost always have every property implicitly public, because JavaScript never enforced otherwise. Go through each property and ask honestly: does this need to be touched from outside the class? If not, mark it private or protected. This step alone tends to surface a surprising number of places where external code was reaching in and mutating internal state in ways the original class author never intended.

Step 4: Replace loosely-typed conditionals with proper union types or enums. A pattern like if (status === "pending" || status === "shipped" || status === "delivered") scattered across a codebase is a strong signal that status should be a proper union type or enum, checked once at the boundary rather than re-validated ad hoc everywhere it’s used.

Step 5: Add unit tests before and after the conversion. Before touching the class, write characterization tests against the existing JavaScript behavior. After converting to TypeScript, run the same tests again. If they still pass, you have strong evidence the conversion didn’t silently change behavior — which matters enormously for classes buried deep in business-critical code paths.

I generally recommend migrating one class (or one tightly related cluster of classes) at a time, in its own pull request, rather than attempting a big-bang rewrite of an entire codebase’s class hierarchy. Small, reviewable, test-covered increments consistently produce safer migrations than large speculative rewrites, especially when the class in question is something as foundational as a shared BasePage, ApiClient, or Order model that dozens of other files depend on.

Serialization and Deserialization Patterns with TypeScript Classes

A subtlety that trips up a lot of developers: when you call JSON.parse() on data coming from an API, database, or file, you get back a plain object — never an actual instance of your class. This matters more than people expect, because it means any methods defined on your class are simply not available on that parsed object.

typescript

class Money {
  constructor(private amountInCents: number, private currency: string) {}

  format(): string {
    return `${(this.amountInCents / 100).toFixed(2)} ${this.currency}`;
  }
}

const json = '{"amountInCents": 1999, "currency": "USD"}';
const parsed = JSON.parse(json);

// parsed.format(); // Runtime error: parsed.format is not a function

parsed has the same shape as a Money instance, but it is not one — it has no prototype chain connecting it to Money.prototype, so none of the class’s methods are reachable on it. The fix is to explicitly reconstruct real instances from raw data, typically through a static factory method:

typescript

class Money {
  constructor(private amountInCents: number, private currency: string) {}

  format(): string {
    return `${(this.amountInCents / 100).toFixed(2)} ${this.currency}`;
  }

  static fromJSON(data: { amountInCents: number; currency: string }): Money {
    return new Money(data.amountInCents, data.currency);
  }

  toJSON(): { amountInCents: number; currency: string } {
    return { amountInCents: this.amountInCents, currency: this.currency };
  }
}

const json = '{"amountInCents": 1999, "currency": "USD"}';
const money = Money.fromJSON(JSON.parse(json));
console.log(money.format()); // 19.99 USD

The toJSON() method is a nice touch here too — JSON.stringify() automatically calls toJSON() on any object that defines it, so you get clean, predictable serialization behavior for free, without needing to remember to manually extract fields every time you serialize a Money instance.

This pattern becomes essential in automation frameworks whenever test data or API responses need to be rehydrated into fully behavioral objects rather than treated as inert data bags. I’ve seen entire test suites fail mysteriously because someone parsed an API response with JSON.parse() and then tried calling a domain method on the result, not realizing the parsed object was never actually an instance of the expected class in the first place.

Performance Considerations of TypeScript Classes

These classes are, for almost all practical purposes, exactly as fast as the equivalent hand-written JavaScript, because — as we established earlier — they compile down to prototype-based JavaScript that modern engines like V8 have spent over a decade optimizing. That said, there are a handful of genuine performance considerations worth knowing.

Prefer prototype methods over arrow function class properties for anything performance-sensitive. As covered earlier, a regular method lives once on the prototype, while an arrow function property is recreated fresh on every single instance. For a class instantiated thousands of times per second — think a hot path inside a data-processing pipeline — that difference in memory allocation and garbage collection pressure can genuinely matter.

Avoid deep inheritance chains in hot paths. Every level of inheritance adds a step to the prototype chain that the JavaScript engine has to walk when resolving a method call. For most application code this is utterly negligible, but in extremely performance-sensitive code (real-time rendering loops, high-frequency trading systems, and similar), shallow, composition-based designs can outperform deep inheritance hierarchies.

Be mindful of object shape consistency. V8 and similar engines optimize property access heavily based on an object’s “hidden class” — effectively, its shape. If instances of the same class end up with different property sets (for example, because some optional properties are sometimes never assigned), the engine may deoptimize property access for that class. Declaring every property upfront in the class body, even ones initialized as null or undefined, keeps object shapes consistent and access patterns fast.

For the overwhelming majority of application code, backend services, and test automation frameworks, none of this rises to the level of a real concern — premature optimization of TypeScript classes based on micro-benchmarks is rarely worth the readability cost. I mention it here because it’s genuinely useful to know the boundaries of where performance actually starts to matter, rather than either ignoring it completely or over-optimizing code that will never see enough throughput for it to be relevant.

Extending Playwright’s Native Fixture and Test Classes

Advanced Playwright users eventually reach for such classes not just for Page Objects, but to extend Playwright’s own test and fixture system. Playwright’s test.extend() API lets you compose custom fixtures, and pairing this with class-based Page Objects produces a remarkably clean, fully typed testing setup.

typescript

import { test as base, Page } from "@playwright/test";

class LoginPage {
  constructor(private page: Page) {}

  async login(username: string, password: string): Promise<void> {
    await this.page.fill("#username", username);
    await this.page.fill("#password", password);
    await this.page.click("#submit");
  }
}

class DashboardPage {
  constructor(private page: Page) {}

  async getWelcomeMessage(): Promise<string> {
    return this.page.locator(".welcome").innerText();
  }
}

type Fixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

// test("logs in successfully", async ({ loginPage, dashboardPage }) => {
//   await loginPage.login("qa_user", "SecurePass!123");
//   const welcome = await dashboardPage.getWelcomeMessage();
//   expect(welcome).toContain("Welcome");
// });

Every fixture here is backed by a real TypeScript class instance, fully typed, injected automatically into every test that declares it as a parameter. This is the pattern I use across every serious Playwright framework I build now — Page Object classes are never instantiated manually inside individual test files. Instead, they’re wired up once as fixtures, and every test simply asks for the page objects it needs by name, letting Playwright’s dependency resolution handle instantiation and cleanup automatically.

Structural Typing and TypeScript Classes

One of the most important — and most frequently misunderstood — characteristics of TypeScript classes is that TypeScript’s type system is structural, not nominal. In languages like Java or C#, two classes are only considered compatible if one explicitly extends or implements the other. TypeScript doesn’t work that way. It compares shapes. If two classes happen to have the same public members with the same types, TypeScript treats them as compatible, even if they have no declared relationship to each other whatsoever.

typescript

class Point2D {
  constructor(public x: number, public y: number) {}
}

class Vector {
  constructor(public x: number, public y: number) {}
}

function printCoordinates(point: Point2D): void {
  console.log(`(${point.x}, ${point.y})`);
}

const v = new Vector(3, 4);
printCoordinates(v); // Perfectly valid — no error, despite Vector never extending or implementing Point2D

This surprises developers coming from nominally typed languages, where this code simply wouldn’t compile. In TypeScript, Vector is considered assignable to Point2D because it has all the same public properties with compatible types. This is called structural typing, or sometimes “duck typing” at compile time — if it has the properties of a duck, TypeScript treats it as a duck.

This has real, practical consequences for how you design these particular classes. Because private and protected members do participate in this structural comparison (unlike public members alone), two classes with identically named private fields are still considered structurally incompatible unless one is a genuine subclass of the other:

typescript

class BankAccount {
  private balance: number = 0;
}

class Wallet {
  private balance: number = 0;
}

function printBalance(account: BankAccount): void {
  console.log(account);
}

// printBalance(new Wallet());
// Error: Property 'balance' is private in type 'Wallet' but not in type 'BankAccount'... (nominally distinguished)

TypeScript specifically treats private members as “branding” a class — even though Wallet and BankAccount have structurally identical private fields, TypeScript still refuses to treat them as interchangeable, because each class’s private field is considered to originate from that specific class declaration. This is actually a deliberate, useful escape hatch: if you want to force nominal-style typing onto otherwise structurally similar TypeScript classes, adding even a single private field is enough to make them mutually incompatible.

I’ve used this exact trick — sometimes called the “branding” or “nominal typing” pattern — when building strongly distinct ID types in automation frameworks, so a UserId and an OrderId (both structurally just wrappers around a string) can never be accidentally passed into the wrong function, even though nothing about their public shape would otherwise stop that mistake:

typescript

class UserId {
  private readonly _brand = "UserId";
  constructor(public readonly value: string) {}
}

class OrderId {
  private readonly _brand = "OrderId";
  constructor(public readonly value: string) {}
}

function fetchUser(id: UserId): void {
  console.log(`Fetching user ${id.value}`);
}

const orderId = new OrderId("ord_123");
// fetchUser(orderId); // Error: structurally incompatible thanks to the private _brand field

Understanding structural typing is genuinely important once you’re designing shared these classes meant to be consumed across a large team, because it changes how you think about compatibility, refactoring safety, and what “matches this type” actually means in a codebase that leans heavily on classes and interfaces together.

Immutability Patterns with TypeScript Classes

Mutable state is one of the most common sources of hard-to-trace bugs, especially in test automation frameworks where shared objects (configuration, test context, fixtures) get passed around across many files and async operations. TypeScript classes give you several layered tools for enforcing immutability, ranging from soft compile-time hints to hard runtime guarantees.

Layer 1: readonly properties. As we covered earlier, readonly prevents reassignment after construction — but only at the compiler level. It does nothing at runtime.

typescript

class ImmutablePoint {
  constructor(public readonly x: number, public readonly y: number) {}
}

const point = new ImmutablePoint(1, 2);
// point.x = 5; // Compile-time error only

If someone bypasses TypeScript entirely (working with the compiled JavaScript output, or using type assertions like as any), readonly offers zero protection. It’s a development-time safety net, not a runtime lock.

Layer 2: Object.freeze() for genuine runtime immutability. If you need an actual runtime guarantee that an object cannot be mutated — even by code that bypasses TypeScript’s type checker — Object.freeze() is the tool:

typescript

class ImmutableConfig {
  readonly environment: string;
  readonly baseUrl: string;

  constructor(environment: string, baseUrl: string) {
    this.environment = environment;
    this.baseUrl = baseUrl;
    Object.freeze(this);
  }
}

const config = new ImmutableConfig("staging", "https://staging.example.com");
(config as any).environment = "production"; // Silently fails in non-strict mode, throws in strict mode
console.log(config.environment); // Still "staging"

Calling Object.freeze(this) as the final line of the constructor genuinely locks the instance at the JavaScript engine level. Even a determined attempt to mutate it through a type assertion will fail (silently in non-strict mode, or with a thrown TypeError in strict mode, which ES modules and most modern TypeScript output use by default).

Layer 3: Returning new instances instead of mutating. For classes representing value objects — money amounts, dates, coordinates, configuration snapshots — the cleanest immutability pattern is to have “mutating” methods actually return a brand-new instance, leaving the original untouched:

typescript

class Money {
  constructor(private readonly amountInCents: number, private readonly currency: string) {
    Object.freeze(this);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies");
    }
    return new Money(this.amountInCents + other.amountInCents, this.currency);
  }

  format(): string {
    return `${(this.amountInCents / 100).toFixed(2)} ${this.currency}`;
  }
}

const price = new Money(1000, "USD");
const tax = new Money(80, "USD");
const total = price.add(tax);

console.log(price.format()); // 10.00 USD — unchanged
console.log(total.format()); // 10.80 USD — a brand-new instance

This immutable-value-object pattern, built entirely from such classes with frozen instances and non-mutating methods, eliminates an entire category of bugs where one part of a codebase mutates a shared object and another part is silently affected by that change without ever being told. I lean on this pattern heavily for test configuration objects and computed test data in automation frameworks, specifically because parallel test execution makes shared mutable state genuinely dangerous — if two tests running concurrently share a mutable config object and one mutates it mid-run, the other test can fail for reasons that have nothing to do with the actual feature being tested.

Modeling State Machines with TypeScript Classes

A recurring, high-value use case for TypeScript classes is modeling entities that move through a well-defined set of states — an order going from pending to shipped to delivered, or a test run going from queued to running to passed or failed. Encoding this as a class with strict transition rules prevents an entire category of bugs where an entity ends up in an invalid or nonsensical state.

typescript

type OrderStatus = "pending" | "paid" | "shipped" | "delivered" | "cancelled";

class OrderStateMachine {
  private status: OrderStatus = "pending";

  private readonly allowedTransitions: Record<OrderStatus, OrderStatus[]> = {
    pending: ["paid", "cancelled"],
    paid: ["shipped", "cancelled"],
    shipped: ["delivered"],
    delivered: [],
    cancelled: [],
  };

  getStatus(): OrderStatus {
    return this.status;
  }

  transitionTo(newStatus: OrderStatus): void {
    const allowed = this.allowedTransitions[this.status];
    if (!allowed.includes(newStatus)) {
      throw new Error(`Cannot transition from "${this.status}" to "${newStatus}"`);
    }
    this.status = newStatus;
  }
}

const order = new OrderStateMachine();
order.transitionTo("paid");
order.transitionTo("shipped");
order.transitionTo("delivered");

console.log(order.getStatus()); // delivered

// order.transitionTo("pending"); // Throws: Cannot transition from "delivered" to "pending"

Because status is private and can only be changed through transitionTo(), there is no way for any part of the codebase to force an order into an invalid state like jumping straight from pending to delivered, or moving backward from shipped to paid. The class itself is the single, authoritative source of truth for what transitions are legal.

In test automation specifically, this pattern is incredibly useful for modeling the lifecycle of a test run, a CI pipeline stage, or a background job your tests are waiting on:

typescript

type JobStatus = "queued" | "running" | "succeeded" | "failed";

class BackgroundJobPoller {
  constructor(private jobId: string, private apiClient: { getStatus(id: string): Promise<JobStatus> }) {}

  async waitForCompletion(timeoutMs = 30000, intervalMs = 1000): Promise<JobStatus> {
    const startTime = Date.now();

    while (Date.now() - startTime < timeoutMs) {
      const status = await this.apiClient.getStatus(this.jobId);
      if (status === "succeeded" || status === "failed") {
        return status;
      }
      await new Promise((resolve) => setTimeout(resolve, intervalMs));
    }

    throw new Error(`Job ${this.jobId} did not complete within ${timeoutMs}ms`);
  }
}

Modeling state explicitly through these particular classes, rather than scattering ad hoc boolean flags (isShipped, isDelivered, isCancelled) across an object, keeps invalid combinations of flags (like isShipped: true and isCancelled: true simultaneously) structurally impossible, because there’s only ever one status field, and it can only ever hold one of the values defined in the type.

Advanced Generic Constraints in TypeScript Classes

We covered basic generics earlier, but TypeScript classes support considerably more advanced generic patterns once your codebase grows in complexity. Let’s look at a few that come up often in real frameworks.

Multiple type parameters. A class isn’t limited to a single generic parameter — you can define as many as the design calls for:

typescript

class Pair<K, V> {
  constructor(public key: K, public value: V) {}

  toTuple(): [K, V] {
    return [this.key, this.value];
  }
}

const entry = new Pair<string, number>("age", 29);
console.log(entry.toTuple()); // ["age", 29]

Default generic parameters. Just like function parameters, generic type parameters on these classes can have defaults, reducing verbosity for the most common use case while still allowing full flexibility when needed:

typescript

class Cache<T = string> {
  private store = new Map<string, T>();

  set(key: string, value: T): void {
    this.store.set(key, value);
  }

  get(key: string): T | undefined {
    return this.store.get(key);
  }
}

const stringCache = new Cache(); // defaults to Cache<string>
const numberCache = new Cache<number>();

Constraining generics with keyof. This is a particularly powerful pattern for building generic, reusable data-access utilities without losing type safety:

typescript

class EntityUpdater<T extends object> {
  constructor(private entity: T) {}

  update<K extends keyof T>(key: K, value: T[K]): void {
    this.entity[key] = value;
  }

  get(): T {
    return this.entity;
  }
}

interface Profile {
  name: string;
  age: number;
  active: boolean;
}

const updater = new EntityUpdater<Profile>({ name: "Meera", age: 31, active: true });
updater.update("age", 32); // Valid — 'age' expects a number
// updater.update("age", "thirty-two"); // Error: Argument of type 'string' is not assignable to type 'number'

K extends keyof T constrains key to only the actual property names that exist on T, and T[K] (an indexed access type) automatically resolves to the correct value type for whichever key was passed. This is the kind of type-safe generic API design that would be genuinely painful to express safely in plain JavaScript, but falls out naturally once you combine generics with TypeScript classes and a few of TypeScript’s structural utility operators.

I use patterns like this heavily when building generic, reusable test data builders and typed wrappers around configuration objects in automation frameworks, where I want a single update()-style method to work safely across dozens of different data shapes without writing a bespoke setter for every single field on every single class.

A Complete Case Study: Refactoring a Monolithic Test File into Class-Based Page Objects

Let’s close the technical portion of this guide with a full, realistic case study, because seeing a “before and after” often cements these concepts better than isolated snippets. Here’s a genuinely common starting point — a single, unstructured Playwright test file that grew organically over months without ever being refactored:

typescript

// Before: everything inline, no reuse, no structure
import { test, expect } from "@playwright/test";

test("user can complete checkout", async ({ page }) => {
  await page.goto("/login");
  await page.fill("#username", "qa_user");
  await page.fill("#password", "SecurePass!123");
  await page.click("button#login");
  await page.waitForURL("**/dashboard");

  await page.click("nav >> text=Shop");
  await page.click(".product-card >> nth=0 >> button:has-text('Add to Cart')");
  await page.click("#cart-icon");
  await page.click("button:has-text('Checkout')");

  await page.fill("#shipping-address", "221B Baker Street");
  await page.fill("#shipping-city", "London");
  await page.click("button:has-text('Place Order')");

  const confirmation = await page.locator(".order-confirmation").innerText();
  expect(confirmation).toContain("Thank you");
});

This works, but it’s fragile in every direction. If a single selector changes anywhere in the checkout flow, this test breaks, and so does every other test in the suite that happens to duplicate the same inline selectors. There’s no reuse, no clear separation of “what the test is asserting” from “how the UI happens to be structured today,” and no way to reuse this login-to-checkout flow across the dozens of other tests that will inevitably need it.

Here’s the same flow refactored into a proper set of such classes, following everything we’ve covered in this guide:

typescript

import { Page, Locator, expect } from "@playwright/test";

abstract class BasePage {
  constructor(protected readonly page: Page) {}

  protected async waitForNavigation(urlPattern: string): Promise<void> {
    await this.page.waitForURL(urlPattern);
  }
}

class LoginPage extends BasePage {
  private readonly usernameInput: Locator = this.page.locator("#username");
  private readonly passwordInput: Locator = this.page.locator("#password");
  private readonly loginButton: Locator = this.page.locator("button#login");

  async login(username: string, password: string): Promise<DashboardPage> {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
    await this.waitForNavigation("**/dashboard");
    return new DashboardPage(this.page);
  }
}

class DashboardPage extends BasePage {
  private readonly shopLink: Locator = this.page.locator("nav >> text=Shop");

  async goToShop(): Promise<ShopPage> {
    await this.shopLink.click();
    return new ShopPage(this.page);
  }
}

class ShopPage extends BasePage {
  private readonly firstAddToCartButton: Locator = this.page.locator(
    ".product-card >> nth=0 >> button:has-text('Add to Cart')"
  );
  private readonly cartIcon: Locator = this.page.locator("#cart-icon");

  async addFirstProductToCart(): Promise<this> {
    await this.firstAddToCartButton.click();
    return this;
  }

  async openCart(): Promise<CartPage> {
    await this.cartIcon.click();
    return new CartPage(this.page);
  }
}

class CartPage extends BasePage {
  private readonly checkoutButton: Locator = this.page.locator("button:has-text('Checkout')");

  async proceedToCheckout(): Promise<CheckoutPage> {
    await this.checkoutButton.click();
    return new CheckoutPage(this.page);
  }
}

class CheckoutPage extends BasePage {
  private readonly addressInput: Locator = this.page.locator("#shipping-address");
  private readonly cityInput: Locator = this.page.locator("#shipping-city");
  private readonly placeOrderButton: Locator = this.page.locator("button:has-text('Place Order')");
  private readonly confirmation: Locator = this.page.locator(".order-confirmation");

  async fillShippingDetails(address: string, city: string): Promise<this> {
    await this.addressInput.fill(address);
    await this.cityInput.fill(city);
    return this;
  }

  async placeOrder(): Promise<void> {
    await this.placeOrderButton.click();
  }

  async getConfirmationText(): Promise<string> {
    return this.confirmation.innerText();
  }
}

And here’s the resulting test — dramatically shorter, and reading almost like plain English describing user intent, not implementation detail:

typescript

import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test("user can complete checkout", async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = await loginPage.login("qa_user", "SecurePass!123");

  const shopPage = await dashboardPage.goToShop();
  await shopPage.addFirstProductToCart();

  const cartPage = await shopPage.openCart();
  const checkoutPage = await cartPage.proceedToCheckout();

  await checkoutPage.fillShippingDetails("221B Baker Street", "London");
  await checkoutPage.placeOrder();

  const confirmation = await checkoutPage.getConfirmationText();
  expect(confirmation).toContain("Thank you");
});

Every single design principle from this guide shows up in this refactor: BasePage is an abstract class holding shared, protected behavior; every derived page class encapsulates its own locators as private; methods return either this (for fluent chaining within a page) or the next page object in the flow, which gives the test file a natural, guided structure that mirrors the actual user journey step by step. If a selector changes anywhere in this flow, exactly one file needs to change — the specific page class — and every test using that page object is automatically fixed, instead of needing to hunt down and update the same broken selector across a dozen scattered test files.

This is, ultimately, the entire argument for taking TypeScript classes seriously in automation architecture. It’s not about following object-oriented programming for its own sake — it’s about building a framework where change is cheap, intent is readable, and the compiler catches entire categories of mistakes before a single test ever runs against a real browser.

Extended FAQ: More Questions About TypeScript Classes

Can a TypeScript class extend a JavaScript class? Yes. These particular classes can extend plain JavaScript classes without issue, though TypeScript will only know about the types it can infer or that you explicitly declare through a .d.ts type declaration file for the JavaScript source. This is common during incremental migrations from JavaScript to TypeScript, where new classes are written in TypeScript but still need to extend older, not-yet-converted JavaScript base classes.

Can I use TypeScript classes without a build step, directly in the browser? No, not directly. TypeScript is not natively understood by browsers or Node.js — it must be compiled (or transpiled, via tools like esbuild, SWC, or the TypeScript compiler itself) down to plain JavaScript first. Frameworks like Playwright, Next.js, and Vite handle this compilation step automatically as part of their build or test-running pipeline.

Do TypeScript classes support method chaining out of the box? Not automatically — but it’s trivial to enable by returning this from any method that would otherwise return void, as demonstrated in the builder pattern and Page Object sections above. TypeScript correctly infers and preserves the specific subclass type across a chain when you type these methods to return this.

Is it bad practice to have too many properties in a single class? Generally, yes. A class with a large number of unrelated properties and methods is often a sign that it’s taking on too many responsibilities — commonly referred to as violating the Single Responsibility Principle. When a class grows unwieldy, it’s usually a signal to split it into smaller, more focused classes that each handle one clear concern, and compose them together where needed.

How do TypeScript classes interact with strict mode in tsconfig.json? Enabling strict: true turns on several checks that directly affect classes, most notably strictPropertyInitialization (which forces every property to be initialized in the constructor or given a default value) and strictNullChecks (which prevents properties from silently allowing undefined or null unless explicitly typed to permit it). I strongly recommend enabling strict mode on any serious project using TypeScript classes, since it closes off entire categories of the most common runtime bugs.

Should I use classes or React hooks for state management in a React application? For component-level state in modern React, hooks (useState, useReducer, custom hooks) are generally the idiomatic choice, and React’s function-component model doesn’t pair naturally with class-based state anymore. That said, these classes remain extremely valuable in a React application for everything outside the component tree itself — API clients, domain models, validation logic, state machines, and service layers that components consume, but don’t need to inherit from.

Can two classes extend the same abstract base class and still be swapped interchangeably? Yes, and this is one of the most practical payoffs of polymorphism covered earlier in this guide. As long as both subclasses correctly implement every abstract member the base class requires, code written against the base class’s type can accept either subclass without modification. This is exactly the mechanism behind the Notification example we walked through — EmailNotification, SmsNotification, and SlackNotification are all fully interchangeable anywhere a plain Notification is expected, because they each honor the same abstract contract.

Do TypeScript classes get erased entirely, or does any type information survive into the compiled JavaScript? Type annotations themselves are fully erased during compilation — parameter types, return types, and interface implementations leave no trace in the output JavaScript. However, the structural aspects of a class survive completely intact: the constructor function, the prototype chain, every property assignment, and every method body compile down faithfully into real, runtime JavaScript. What disappears is purely the compile-time type-checking layer; the actual object-oriented structure and behavior you wrote is exactly what runs in production.

Is it possible to make a class property both private and static at the same time? Yes — the two modifiers are entirely independent and combine naturally. A private static member is scoped to the class itself (not any individual instance) and is only accessible from within the declaring class, exactly as demonstrated in the TestCase.totalTestCases counter and the ConfigManager singleton pattern covered earlier in this guide. This combination is extremely common for internal bookkeeping — counters, caches, and singleton instance references — that the class needs to track across every instance, but that no code outside the class should ever touch directly.

Do TypeScript classes need to be exported to be used across multiple files? Yes, in any module-based project (which is the standard setup for virtually every modern TypeScript codebase), a class must be explicitly exported — either as a named export (export class LoginPage {}) or a default export (export default class LoginPage {}) — before it can be imported and used in another file. This is standard ES module behavior rather than anything specific to classes, but it trips up newcomers occasionally when a class works fine within its own file during quick testing but then throws an import error the moment another file tries to bring it in. The fix is almost always a missing export keyword, easy to overlook when a class is first sketched out locally and only later split across a multi-file project structure.

TypeScript Classes and Enums Working Together

Enums and classes solve different problems, but they pair extremely well together, and this combination shows up constantly in well-architected TypeScript classes across both application code and automation frameworks.

An enum gives you a fixed, named set of possible values — genuinely useful for things like HTTP methods, environment names, or test priority levels, where the set of valid options is small, known in advance, and unlikely to change often:

typescript

enum TestPriority {
  Low = "LOW",
  Medium = "MEDIUM",
  High = "HIGH",
  Critical = "CRITICAL",
}

class TestCase {
  constructor(
    public readonly name: string,
    public readonly priority: TestPriority
  ) {}

  shouldRunInSmokeTestSuite(): boolean {
    return this.priority === TestPriority.Critical || this.priority === TestPriority.High;
  }
}

const loginTest = new TestCase("Login validation", TestPriority.Critical);
console.log(loginTest.shouldRunInSmokeTestSuite()); // true

Using an enum here, rather than a raw string, means every call site gets autocomplete for the valid priority values, and there’s no risk of a typo like "Hihg" silently slipping through as an unvalidated string. Combined with a class, the enum becomes part of a strongly typed contract enforced at every constructor call across the entire codebase.

It’s worth knowing that TypeScript also supports a lighter-weight alternative to enums — string literal union types — which many teams (myself included, for most cases) now prefer over traditional enums, specifically because literal unions have zero runtime footprint, while enums compile down to an actual JavaScript object at runtime:

typescript

type TestPriorityLiteral = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";

class TestCase {
  constructor(
    public readonly name: string,
    public readonly priority: TestPriorityLiteral
  ) {}
}

Both approaches work well inside such classes, and the choice mostly comes down to whether you need the enum’s runtime object (useful for iterating over all possible values programmatically, or when interoperating with external systems that expect the enum’s literal runtime shape) or whether a pure compile-time union is sufficient, which is the case for most internal domain modeling.

Async Patterns Inside TypeScript Classes

Modern applications and automation frameworks are drenched in asynchronous operations — network requests, file I/O, waiting for UI elements, waiting for background jobs. TypeScript classes handle async logic gracefully, but there are a few patterns worth knowing well.

Async methods return Promises, and TypeScript tracks this correctly through the type system:

typescript

class ApiClient {
  constructor(private baseUrl: string) {}

  async get<T>(path: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`);
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json() as Promise<T>;
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json() as Promise<T>;
  }
}

Every consumer of ApiClient gets full autocomplete and type inference on the resolved response shape, simply by specifying the generic type parameter at the call site — client.get<UserPayload>("/users/1") resolves to Promise<UserPayload>, and TypeScript will correctly flag any mismatched usage of the resolved data downstream.

Async iterators and generators inside classes are a more advanced but genuinely useful pattern, especially for paginated API responses:

typescript

class PaginatedApiClient {
  constructor(private baseUrl: string) {}

  async *fetchAllPages<T>(path: string): AsyncGenerator<T[]> {
    let page = 1;
    let hasMore = true;

    while (hasMore) {
      const response = await fetch(`${this.baseUrl}${path}?page=${page}`);
      const data = await response.json();
      yield data.items as T[];
      hasMore = data.hasNextPage;
      page += 1;
    }
  }
}

// async function processAllUsers(client: PaginatedApiClient) {
//   for await (const page of client.fetchAllPages<UserPayload>("/users")) {
//     console.log(`Processing ${page.length} users`);
//   }
// }

This async * syntax defines an async generator method directly on a class, letting consumers use for await...of to lazily iterate over paginated results without ever needing to manually track page numbers or “has more” flags themselves — the class encapsulates all of that bookkeeping internally.

Avoid returning a Promise from a constructor. This is a mistake I still see occasionally: constructors in these particular classes cannot be async, and cannot meaningfully return a Promise (JavaScript constructors always return the newly constructed instance, not whatever value you explicitly return, unless you return an object — but a returned Promise is not the instance itself). If a class needs asynchronous initialization, the correct pattern is a private constructor combined with a static async factory method:

typescript

class DatabaseConnection {
  private constructor(private readonly connection: unknown) {}

  static async connect(connectionString: string): Promise<DatabaseConnection> {
    const connection = await establishConnection(connectionString);
    return new DatabaseConnection(connection);
  }
}

declare function establishConnection(connectionString: string): Promise<unknown>;

// const db = await DatabaseConnection.connect("postgres://localhost/mydb");

This pattern guarantees that a DatabaseConnection instance can never exist in a half-initialized state — by the time you have an actual instance in hand, the async setup work has already completed successfully, because the only path to construction runs through the await-ed static factory method.

Building a Custom Playwright Reporter as a TypeScript Class

Playwright’s reporter API is a perfect real-world showcase of TypeScript classes implementing a well-defined interface to plug into a larger framework. Let’s build a custom reporter that posts a summary to a webhook whenever a test run finishes — a common real-world need for teams that want CI results piped into Slack, Teams, or an internal dashboard.

typescript

import type { Reporter, TestCase, TestResult, FullResult } from "@playwright/test/reporter";

class WebhookReporter implements Reporter {
  private passed = 0;
  private failed = 0;
  private skipped = 0;
  private startTime = 0;

  constructor(private webhookUrl: string) {}

  onBegin(): void {
    this.startTime = Date.now();
    console.log("Test run starting...");
  }

  onTestEnd(test: TestCase, result: TestResult): void {
    switch (result.status) {
      case "passed":
        this.passed += 1;
        break;
      case "failed":
      case "timedOut":
        this.failed += 1;
        console.error(`FAILED: ${test.title}`);
        break;
      case "skipped":
        this.skipped += 1;
        break;
    }
  }

  async onEnd(result: FullResult): Promise<void> {
    const durationMs = Date.now() - this.startTime;
    const summary = {
      status: result.status,
      passed: this.passed,
      failed: this.failed,
      skipped: this.skipped,
      durationMs,
    };

    await fetch(this.webhookUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(summary),
    });
  }
}

export default WebhookReporter;

This class implements Playwright’s Reporter interface, meaning Playwright’s test runner calls onBegin(), onTestEnd(), and onEnd() automatically at the appropriate points during a run — you never call these methods yourself. All the internal state (passed, failed, skipped, startTime) is kept private, exactly as we’ve emphasized throughout this guide, so nothing outside the reporter can accidentally interfere with the counters mid-run.

Wiring it into a project is a one-line addition to playwright.config.ts:

typescript

import { defineConfig } from "@playwright/test";

export default defineConfig({
  reporter: [["./reporters/WebhookReporter.ts", { webhookUrl: "https://hooks.example.com/ci-results" }]],
});

This is a genuinely practical, production-ready pattern — I’ve shipped variants of this exact class across multiple teams to pipe automated test results into Slack channels, giving engineers real-time visibility into CI health without anyone needing to manually check a dashboard.

TypeScript Classes Compared Across Languages

If you’re coming to these classes from another object-oriented language, it helps to see the syntax side by side, because the concepts map closely even where the exact keywords differ.

Java:

java

public class Employee {
    private String name;
    protected int employeeId;

    public Employee(String name, int employeeId) {
        this.name = name;
        this.employeeId = employeeId;
    }

    public String getName() {
        return name;
    }
}

C#:

csharp

public class Employee
{
    private string name;
    protected int employeeId;

    public Employee(string name, int employeeId)
    {
        this.name = name;
        this.employeeId = employeeId;
    }

    public string GetName() => name;
}

Python:

python

class Employee:
    def __init__(self, name, employee_id):
        self._name = name  # convention, not enforced
        self._employee_id = employee_id

    def get_name(self):
        return self._name

TypeScript classes, the equivalent:

typescript

class Employee {
  constructor(private name: string, protected employeeId: number) {}

  getName(): string {
    return this.name;
  }
}

A few notable differences worth calling out for engineers moving between these languages. Java and C# enforce access modifiers at the runtime and bytecode/IL level — genuinely, not just at compile time. Python has no real enforced privacy at all; the underscore-prefix convention (_name) is purely a social contract between developers, not something the interpreter enforces in any way. TypeScript classes sit in between: compile-time enforcement that catches mistakes during development (closer to Java and C# in spirit), but no runtime enforcement once compiled to plain JavaScript (closer to Python’s honor-system approach), unless you specifically opt into native # private fields, which do carry real runtime enforcement matching Java and C#’s guarantees.

Another difference: such classes support parameter properties (the constructor(private name: string) shorthand shown above), which none of Java, C#, or Python offer natively — this remains one of the more distinctively convenient pieces of syntax unique to how TypeScript approaches class design.

A Glossary of Key TypeScript Class Terms

Given how much ground we’ve covered, here’s a consolidated glossary you can use as a quick reference whenever a term from this guide needs a refresher.

Class — A blueprint for creating objects with shared structure and behavior, instantiated using the new keyword.

Instance — A specific object created from a class using new, holding its own independent state.

Constructor — The special method that runs automatically when a new instance is created, typically responsible for initializing properties.

Property (field) — A piece of data attached to a class, either at the instance level or, when marked static, at the class level.

Method — A function defined inside a class that operates on an instance’s data via this.

Access modifier — A keyword (public, private, or protected) controlling where a class member can be accessed from.

public — The default access level; accessible from anywhere.

private — Accessible only from within the declaring class.

protected — Accessible from the declaring class and its subclasses, but not from outside code.

readonly — A modifier preventing a property from being reassigned after its initial assignment.

Parameter property — TypeScript’s shorthand for declaring, typing, and assigning a property directly from a constructor parameter in a single step.

Static member — A property or method that belongs to the class itself rather than to any individual instance.

Getter / setter — Special methods that let you run custom logic when a property is read (get) or written (set), while keeping ordinary property-access syntax for callers.

Inheritance — The mechanism by which one class (extends) acquires the properties and methods of another.

super — A keyword used inside a derived class to call the parent class’s constructor or methods.

Abstract class — A class that cannot be instantiated directly, meant to serve as a base defining a shared contract for subclasses.

Interface — A purely structural, compile-time-only contract describing an object’s shape, implemented by classes via implements.

Polymorphism — The ability to treat objects of different derived classes uniformly through a shared base type, while still invoking the correct, class-specific behavior at runtime.

Generic class — A class parameterized by one or more types, allowing it to work with different data types while preserving full type safety.

Mixin — A function that takes a base class and returns a new, extended class, used to compose reusable behavior across otherwise unrelated class hierarchies.

Decorator — A special declaration attached to a class or its members to modify or annotate behavior declaratively.

Singleton — A design pattern ensuring only one instance of a class can ever exist, typically enforced with a private constructor and a static factory method.

Structural typing — TypeScript’s approach to type compatibility, where two types are considered compatible if their shapes match, regardless of any explicit inheritance relationship.

Encapsulation — The principle of hiding internal implementation details behind a controlled, intentional public interface.

Immutability — The property of an object whose state cannot be changed after creation, often enforced in TypeScript classes through readonly properties combined with Object.freeze().

An Expanded Team Checklist for Writing TypeScript Classes

Building on the best practices covered earlier, here’s a more detailed checklist I use during code review whenever a pull request introduces new classes to a codebase I’m responsible for.

Does the constructor do only initialization? Any network call, file read, or heavy computation inside a constructor is a smell. Push that logic into an async static factory method instead.

Is every property’s visibility deliberate? For each property, ask: does anything outside this class genuinely need to read or write this value directly? If not, it should be private. If only subclasses need it, protected. Only the genuinely intentional external API should be public.

Are mutable properties that shouldn’t change marked readonly? IDs, creation timestamps, and injected dependencies almost always qualify.

Does the class have a single, clear responsibility? If you can’t describe what the class does in one sentence without using the word “and” more than once, it’s probably doing too much.

Are dependencies injected rather than constructed internally? A class that calls new SomeDependency() directly inside its own constructor or methods is much harder to test in isolation than one that receives its dependencies as constructor parameters.

Is inheritance actually the right tool here, or would composition be simpler? If a subclass only uses a fraction of its parent’s public API, or overrides most of its behavior, that’s often a sign the relationship should be composition (holding a reference to a collaborator object) rather than inheritance (extending it).

Does the class have test coverage proportional to its complexity? A class with validation logic, computed properties, or branching business rules deserves focused unit tests exercising those specific behaviors and their edge cases.

Are error cases handled with meaningful, typed errors rather than generic Error objects or silent failures? As covered in the custom error class section, a typed error hierarchy makes calling code dramatically easier to reason about and handle correctly.

Running new TypeScript classes through this checklist consistently, as a matter of team habit rather than individual discipline, is what separates codebases that stay pleasant to work in for years from ones that quietly accumulate the kind of tangled, undisciplined class design that eventually requires a painful, large-scale rewrite to untangle.

Understanding this Binding in TypeScript Classes

Earlier in this guide, when discussing why arrow function class properties don’t live on the prototype the way regular methods do, I promised we’d come back to exactly why this binding matters so much. This is genuinely one of the most common sources of confusion — and production bugs — among engineers who are otherwise comfortable with these classes, so let’s dig into it properly.

In JavaScript (and therefore TypeScript), the value of this inside a regular method is determined by how the method is called, not by where it’s defined. This is fundamentally different from languages like Java or C#, where this always refers to the current instance, full stop, regardless of how a method happens to be invoked.

typescript

class Counter {
  private count = 0;

  increment(): void {
    this.count += 1;
    console.log(this.count);
  }
}

const counter = new Counter();
counter.increment(); // 1 — works fine, "this" correctly refers to counter

const detachedIncrement = counter.increment;
// detachedIncrement(); // Runtime error: Cannot read properties of undefined (reading 'count')

The moment you extract increment as a standalone reference (const detachedIncrement = counter.increment) and call it without the counter. prefix, this inside the method is no longer bound to counter at all — it becomes undefined in strict mode (which virtually all modern TypeScript output uses). This is not a bug in your code; it’s simply how this has always worked in JavaScript, and TypeScript classes inherit this exact behavior because, as we covered earlier, they compile down to ordinary JavaScript functions and prototypes.

This becomes a genuinely common real-world problem the moment you pass a class method as a callback:

typescript

class Button {
  private clickCount = 0;

  handleClick(): void {
    this.clickCount += 1;
    console.log(`Clicked ${this.clickCount} times`);
  }
}

const button = new Button();
// document.querySelector("#btn")?.addEventListener("click", button.handleClick);
// When the event fires, "this" inside handleClick is NOT the button instance —
// it's whatever the event listener API sets it to, breaking "this.clickCount"

There are three standard fixes, and it’s worth understanding all three, because you’ll see each of them in different codebases.

Fix 1: Bind explicitly in the constructor. This was the standard pattern before class field syntax became widely supported, and you’ll still see it in older TypeScript classes:

typescript

class Button {
  private clickCount = 0;

  constructor() {
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick(): void {
    this.clickCount += 1;
  }
}

Function.prototype.bind() returns a new function permanently bound to the given this value, regardless of how it’s later called. This works reliably, but it does add constructor boilerplate, and it means the class carries an extra bound function reference per instance.

Fix 2: Use an arrow function class property, exactly as flagged earlier in this guide. Because arrow functions capture this lexically from their enclosing scope (which, at the point of a class field declaration, is the constructor’s scope — meaning the specific instance), this reliably solves the binding problem without any explicit .bind() call:

typescript

class Button {
  private clickCount = 0;

  handleClick = (): void => {
    this.clickCount += 1;
    console.log(`Clicked ${this.clickCount} times`);
  };
}

const button = new Button();
// document.querySelector("#btn")?.addEventListener("click", button.handleClick);
// Now this works correctly — "this" is permanently bound to the specific button instance

This is the trade-off we flagged earlier: you gain reliable this binding, but you lose the memory efficiency of a shared prototype method, because each instance gets its own copy of handleClick. For the overwhelming majority of TypeScript classes — event handlers, callback-heavy UI code, Playwright step definitions passed as references — this trade-off is well worth it, since the number of instances involved rarely reaches a scale where the memory difference is measurable.

Fix 3: Bind at the call site instead of inside the class. Rather than solving the binding problem inside the class itself, you can wrap the call in an arrow function exactly where you’re passing it as a callback:

typescript

class Button {
  private clickCount = 0;

  handleClick(): void {
    this.clickCount += 1;
  }
}

const button = new Button();
// document.querySelector("#btn")?.addEventListener("click", () => button.handleClick());

This keeps handleClick as a normal prototype method (preserving the memory-sharing benefit), at the cost of needing to remember to wrap every single call site correctly. I generally prefer Fix 2 for methods I know will regularly be passed around as standalone references (event handlers, .then() callbacks, array method callbacks), and I leave methods as regular prototype methods everywhere else, reserving Fix 3 for the occasional one-off case where wrapping at the call site is clearly simpler than restructuring the class.

Understanding this distinction thoroughly is, in my experience, one of the clearest signals separating engineers who are comfortable writing TypeScript classes from engineers who deeply understand how those classes actually behave once JavaScript’s runtime semantics get involved.

Data Validation Using TypeScript Classes

A pattern I use constantly in both application code and API test automation is encoding validation rules directly inside a class’s constructor, guaranteeing that an invalid instance can literally never exist in memory. This is sometimes called the “parse, don’t validate” philosophy — instead of constructing an object and then separately checking if it’s valid, you make invalid construction impossible in the first place.

typescript

class EmailAddress {
  private readonly value: string;

  constructor(value: string) {
    if (!EmailAddress.isValid(value)) {
      throw new Error(`Invalid email address: ${value}`);
    }
    this.value = value;
  }

  private static isValid(value: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  }

  toString(): string {
    return this.value;
  }
}

// const email = new EmailAddress("not-an-email"); // Throws immediately
const email = new EmailAddress("qa@example.com"); // Succeeds

Once you have an EmailAddress instance in hand, anywhere in your codebase, you know with absolute certainty that it holds a validated, well-formed email address — no defensive re-validation needed at every point it’s used later. Compare this to the far more common pattern of passing raw strings around and validating (or, worse, forgetting to validate) at various inconsistent points throughout a codebase.

This pattern scales naturally to more complex validation rules involving multiple fields:

typescript

class DateRange {
  constructor(public readonly startDate: Date, public readonly endDate: Date) {
    if (startDate > endDate) {
      throw new Error("Start date must be before end date");
    }
  }

  getDurationInDays(): number {
    const msPerDay = 1000 * 60 * 60 * 24;
    return Math.round((this.endDate.getTime() - this.startDate.getTime()) / msPerDay);
  }

  overlaps(other: DateRange): boolean {
    return this.startDate <= other.endDate && this.endDate >= other.startDate;
  }
}

// const invalid = new DateRange(new Date("2026-06-10"), new Date("2026-06-01")); // Throws
const validRange = new DateRange(new Date("2026-06-01"), new Date("2026-06-10"));
console.log(validRange.getDurationInDays()); // 9

For API testing specifically, I lean on this pattern heavily when building strongly typed request payload classes, because it means a malformed test data object fails immediately and loudly, right at construction, with a clear error message pointing at exactly what was wrong — instead of silently sailing through several layers of the framework and eventually causing a confusing, hard-to-diagnose assertion failure deep inside an actual test run.

typescript

class CreateOrderRequest {
  constructor(
    public readonly productId: string,
    public readonly quantity: number,
    public readonly shippingAddress: string
  ) {
    if (quantity <= 0) {
      throw new Error("Quantity must be greater than zero");
    }
    if (!productId.trim()) {
      throw new Error("Product ID is required");
    }
    if (!shippingAddress.trim()) {
      throw new Error("Shipping address is required");
    }
  }
}

Constructing invalid test data becomes structurally impossible, which means any test that does exercise an “invalid input” scenario has to do so explicitly and deliberately — typically by wrapping the construction attempt in an expect(() => ...).toThrow() assertion — rather than accidentally, through a typo or missing field that nobody noticed until much later.

Organizing Large Codebases Built on TypeScript Classes

As a codebase — application or automation framework alike — grows past a handful of files, how you organize your TypeScript classes starts to matter as much as how you write any individual one. Here’s the structure I default to on new projects, refined over several large frameworks.

One class per file, named identically to the class. LoginPage.ts contains exactly one exported class, LoginPage. This makes navigation trivial — anyone can guess the file location of a class purely from its name, without needing an IDE’s “go to definition” shortcut, and it keeps diffs in code review focused on a single class’s changes at a time.

Group by feature or domain, not by technical layer. Rather than folders like /classes, /interfaces, and /types scattered flatly, I organize by what the code is about:

src/
  pages/
    LoginPage.ts
    DashboardPage.ts
    CheckoutPage.ts
  api-clients/
    UserApiClient.ts
    OrderApiClient.ts
  models/
    Order.ts
    Money.ts
    EmailAddress.ts
  errors/
    AppError.ts
    ValidationError.ts
    ApiTimeoutError.ts
  factories/
    TestUserFactory.ts

This keeps everything related to a single concern (say, checkout) discoverable together, rather than forcing engineers to jump between three or four technically-organized folders just to understand one feature.

Use barrel files sparingly, and only at meaningful module boundaries. A barrel file (an index.ts that re-exports everything in a folder) can make imports cleaner, but overusing them across a large codebase can introduce circular dependency issues and make it genuinely harder to trace where a class actually lives. I typically reserve barrel files for the outermost boundary of a well-defined module — for instance, one index.ts exporting the public API of an entire pages/ folder — rather than nesting them at every subfolder level.

Keep base classes and abstract classes in an obviously named location. A BasePage, BaseApiClient, or BaseRepository should live somewhere unmistakable — often directly at the root of the folder it applies to — so new engineers immediately understand the inheritance hierarchy they’re working within before they start extending it themselves.

Name classes for what they are, not for their technical pattern. UserRepository is a better name than UserDataAccessObjectImpl. RetryableApiClient is better than AbstractRetryStrategyWrapperFactoryImpl. Verbose, pattern-heavy naming conventions borrowed from older enterprise Java conventions tend to make TypeScript classes harder to read, not easier, and TypeScript’s structural type system rarely benefits from the same naming ceremony those older conventions were originally designed to signal.

Common Interview Questions About TypeScript Classes

Whether you’re preparing for an interview or conducting one, these are the questions I see come up most often around TypeScript classes, along with the kind of answer I’d expect from a genuinely strong candidate.

“What happens if you don’t call super() in a derived class constructor?” If a derived class defines its own constructor, TypeScript requires super() to be called before any access to this. Omitting it entirely is a compile-time error. This exists because, until super() runs, the parent class’s portion of the instance hasn’t been initialized yet, and accessing this before that point would mean touching an incompletely constructed object.

“What’s the practical difference between an abstract class and an interface?” An interface is a pure, structural, compile-time-only contract with zero runtime footprint and no ability to hold implementation or private state. An abstract class produces real runtime JavaScript, can hold constructors, private fields, and concrete method implementations alongside abstract ones, and only supports single inheritance, unlike interfaces, which a class can implement in any number.

“Are TypeScript’s access modifiers actually secure?” No — they’re a compile-time-only construct. Once compiled to JavaScript, private and protected properties are ordinary, publicly accessible properties, unless you specifically use native # private fields, which are enforced by the JavaScript engine itself at runtime.

“How would you make a class that can only ever have one instance?” The Singleton pattern: a private constructor (preventing external instantiation via new), a private static field holding the single instance, and a public static method (commonly getInstance()) that creates the instance on first call and returns the cached instance on every subsequent call.

“Why might you prefer composition over inheritance when designing a class?” Inheritance creates a tight, permanent coupling between a base class and its subclasses — changes to the base class can ripple unpredictably through every subclass, and deep inheritance chains become hard to reason about. Composition — injecting collaborator objects rather than extending them — tends to produce more flexible, more testable, and more loosely coupled designs, particularly once a class hierarchy grows beyond two or three levels.

“What does readonly actually guarantee, and what doesn’t it guarantee?” readonly guarantees, at compile time, that a property cannot be reassigned after its initial assignment. It does not provide any runtime enforcement — a type assertion (as any) or plain JavaScript code operating on the compiled output can still mutate a readonly property, unless the object has also been explicitly frozen with Object.freeze().

“How do generics improve a class design compared to using any?” Generics preserve the specific type information passed in at the point of instantiation, giving you full compile-time type checking and autocomplete on every subsequent use of that type, whereas any disables type checking entirely for that value, silently allowing any operation on it — including ones that would fail at runtime — without the compiler ever flagging a mistake.

“What’s a real scenario where you’d reach for a mixin instead of standard inheritance?” When you need to compose multiple, unrelated pieces of reusable behavior — say, logging, timestamping, and serialization — across classes that don’t share a natural single-inheritance relationship. Since TypeScript classes only support extending one base class, mixins let you layer several independent behaviors onto a class without forcing an artificial, unrelated inheritance hierarchy just to reuse that logic.

Strong answers to these questions consistently go beyond reciting the syntax — they demonstrate an understanding of why the language behaves this way, what trade-offs each design choice involves, and when a given pattern is (and isn’t) the right tool for the situation at hand. That deeper understanding is exactly what this entire guide has been building toward.

Debugging Common Runtime Errors in TypeScript Classes

Even with the compiler’s help, certain runtime errors involving classes show up often enough that it’s worth walking through them directly, along with what’s actually going wrong under the hood and how to fix each one.

“Cannot read properties of undefined (reading ‘x’)” when calling a method. This is almost always the this binding problem covered in detail earlier — a method has been detached from its instance (passed as a bare reference to a callback, event handler, or .then()) and called without its original this context. The fix is one of the three patterns from that section: bind in the constructor, use an arrow function class property, or wrap the call at the call site.

“Class constructor X cannot be invoked without ‘new’.” This happens when a class is called like a regular function — SomeClass() instead of new SomeClass(). Unlike regular functions, class constructors in JavaScript (and therefore in compiled TypeScript classes) cannot be invoked without the new keyword; the engine enforces this directly. The fix is simply remembering to instantiate with new, though this error also commonly surfaces when a class is accidentally passed where a plain factory function was expected.

“Property ‘x’ has no initializer and is not definitely assigned in the constructor.” This is strictPropertyInitialization doing its job — it caught a property declared without a default value and never assigned inside the constructor. The fix is one of three: assign it in the constructor, give it a default value at declaration, or, if you’re certain it will always be assigned before use through some mechanism the compiler can’t see (like a separate initialize() method called immediately after construction), use the definite assignment assertion operator: name!: string;. I’d caution against reaching for that last option too often, though — it’s effectively telling the compiler to trust you, which reintroduces exactly the class of bug strictPropertyInitialization exists to catch.

“Maximum call stack size exceeded” inside a getter or setter. This almost always means a getter and setter are referencing each other by the same name, creating infinite recursion:

typescript

class Broken {
  get value() {
    return this.value; // Infinite recursion — calls itself
  }
}

The fix is the underscore-prefix convention mentioned earlier — store the actual data in a differently named backing field (_value), and have the getter and setter reference that backing field instead of themselves.

Instance methods silently returning undefined instead of the expected value. This usually traces back to a forgotten return statement, but in classes specifically, it can also happen when a method accidentally shadows a property with the same name, or when an async method’s Promise is never awaited by the caller, leading to code that reads the “result” before the async operation has actually resolved. Adding explicit return types to every method (which we recommended in the migration section) surfaces most of these mistakes immediately, since a method that’s supposed to return string but sometimes falls through without a return will fail to compile.

instanceof checks unexpectedly returning false for a custom error subclass. As covered in the custom error classes section, this is a known quirk when extending built-ins like Error on certain compilation targets, and the fix is explicitly calling Object.setPrototypeOf(this, new.target.prototype) in the base error class’s constructor.

Debugging TypeScript classes effectively comes down to keeping a mental model of what the class actually compiles to — a constructor function, a prototype object holding shared methods, and instance objects linked to that prototype — rather than treating “class” as an entirely opaque, magic construct. Every one of the errors above becomes much more intuitive once you can trace it back to that underlying prototype-based reality.

TypeScript Classes in Modern Frontend Frameworks

It’s worth addressing directly where TypeScript classes fit — and don’t fit — in the current frontend ecosystem, since this has shifted meaningfully over the past several years.

Angular remains built almost entirely around TypeScript classes. Components, services, directives, and pipes are all defined as decorated classes (@Component, @Injectable, @Directive), and Angular’s dependency injection system is designed specifically around constructor parameter properties — the exact pattern we covered in the dependency injection section of this guide. If you’re working in Angular, deep fluency with TypeScript classes isn’t optional; it’s the foundation the entire framework is built on.

React, by contrast, moved away from class components toward function components and hooks starting with React 16.8, and the vast majority of modern React code no longer uses class MyComponent extends React.Component. That said, TypeScript classes remain extremely relevant in a React codebase for everything surrounding the component tree — API clients, domain models, validation classes, state machines, and service layers, exactly as discussed earlier in this guide. The shift away from class components was specifically about component state and lifecycle management, not a rejection of classes as a general-purpose tool for structuring application logic.

Vue 3, with its Composition API, similarly favors function-based composables over class-based components for the component layer itself, while still working perfectly well with TypeScript classes for the surrounding application architecture — services, stores, and domain logic.

Backend frameworks tell a different story. NestJS is built entirely around decorated TypeScript classes, mirroring Angular’s architecture directly (unsurprising, since NestJS was explicitly inspired by Angular’s design). Express and Fastify applications, while not requiring classes, very commonly use them for controllers, services, and repositories in any reasonably large codebase, for exactly the encapsulation and dependency injection benefits covered throughout this guide.

The honest summary: TypeScript classes never went away, and claims that “classes are dead” in modern frontend development conflate a specific shift (component definition in React) with the language feature as a whole. Anywhere you need to bundle state and behavior, enforce initialization rules, or build a hierarchy of related, encapsulated types — which describes most non-component application logic — TypeScript classes remain a first-class, actively recommended tool.

Choosing Between Class-Based and Functional Architecture for Playwright Frameworks

I want to close with a direct, practical answer to a question I get asked constantly when consulting on new automation frameworks: should you build your Playwright framework around TypeScript classes, or around plain functions and objects?

Here’s the honest breakdown, based on what I’ve actually seen work — and fail — across dozens of framework builds.

Choose class-based Page Objects when:

  • Your application has a meaningful number of distinct pages or components, each with several related interactions (this is the overwhelming majority of real-world applications).
  • You want compiler-enforced consistency across page objects, via a shared abstract BasePage.
  • Your team includes engineers coming from Java, C#, or other strongly object-oriented backgrounds, where class-based structure will feel immediately familiar and reduce onboarding friction.
  • You need to model complex, stateful interactions — multi-step wizards, shopping carts, in-progress form state — where bundling data and behavior together in one place genuinely clarifies the code.

Consider a lighter, functional approach when:

  • You’re testing a very small application with only a handful of pages, where the overhead of a full class hierarchy exceeds the actual complexity being managed.
  • Your team strongly prefers functional composition patterns already, and introducing classes would be fighting against established team conventions rather than working with them.
  • You’re building narrowly scoped, single-purpose test utilities (a date formatter, a random data generator) that hold no meaningful state and gain nothing from being wrapped in a class.

In practice, the frameworks I’ve built that have aged the best use a hybrid: TypeScript classes for anything with real state and identity — Page Objects, API clients, domain models, test data builders — and plain functions for genuinely stateless utilities — formatters, pure calculations, one-off helpers. This mirrors the same architectural principle we established at the very start of this guide: classes earn their place when there’s state and behavior to bundle together, and they add unnecessary ceremony when there isn’t.

What I’d actively discourage, regardless of which approach a team chooses, is inconsistency within a single framework — half the codebase built around classes, half around loosely related functions passed around ad hoc, with no clear rule for when to reach for which. That inconsistency, far more than the choice of paradigm itself, is what makes frameworks genuinely painful to maintain and onboard new engineers into over time.

Building a Typed, Multi-Environment Configuration System with TypeScript Classes

Let’s close with one more fully worked example, because configuration management is a problem every single automation framework and application eventually has to solve, and it’s a genuinely excellent showcase of nearly everything covered in this guide working together — singletons, readonly properties, static factory methods, and strong typing all in one cohesive class.

Most frameworks need to run against multiple environments — local, staging, production-like, and CI — each with its own base URL, credentials, timeout thresholds, and feature flags. A common mistake is scattering process.env.SOMETHING calls throughout dozens of files, with no single source of truth and no compile-time guarantee that a required variable actually exists. Here’s how I solve this with TypeScript classes:

typescript

type Environment = "local" | "staging" | "production";

interface EnvironmentConfig {
  baseUrl: string;
  apiUrl: string;
  defaultTimeoutMs: number;
  retries: number;
  featureFlags: {
    newCheckoutFlow: boolean;
    darkMode: boolean;
  };
}

class TestConfig {
  private static instance: TestConfig;

  readonly environment: Environment;
  readonly baseUrl: string;
  readonly apiUrl: string;
  readonly defaultTimeoutMs: number;
  readonly retries: number;
  readonly featureFlags: EnvironmentConfig["featureFlags"];

  private static readonly configs: Record<Environment, EnvironmentConfig> = {
    local: {
      baseUrl: "http://localhost:3000",
      apiUrl: "http://localhost:4000",
      defaultTimeoutMs: 10000,
      retries: 0,
      featureFlags: { newCheckoutFlow: true, darkMode: true },
    },
    staging: {
      baseUrl: "https://staging.example.com",
      apiUrl: "https://api-staging.example.com",
      defaultTimeoutMs: 30000,
      retries: 2,
      featureFlags: { newCheckoutFlow: true, darkMode: false },
    },
    production: {
      baseUrl: "https://example.com",
      apiUrl: "https://api.example.com",
      defaultTimeoutMs: 30000,
      retries: 3,
      featureFlags: { newCheckoutFlow: false, darkMode: false },
    },
  };

  private constructor(environment: Environment) {
    const config = TestConfig.configs[environment];
    this.environment = environment;
    this.baseUrl = config.baseUrl;
    this.apiUrl = config.apiUrl;
    this.defaultTimeoutMs = config.defaultTimeoutMs;
    this.retries = config.retries;
    this.featureFlags = config.featureFlags;
    Object.freeze(this);
  }

  static getInstance(): TestConfig {
    if (!TestConfig.instance) {
      const env = (process.env.TEST_ENV as Environment) ?? "local";
      TestConfig.instance = new TestConfig(env);
    }
    return TestConfig.instance;
  }
}

const config = TestConfig.getInstance();
console.log(config.baseUrl);
console.log(config.featureFlags.newCheckoutFlow);

Every design decision here is deliberate, and worth calling out explicitly. The constructor is private, forcing every consumer through getInstance(), which guarantees the configuration is loaded exactly once per test run rather than re-parsed on every import. Every public property is readonly, and the entire instance is frozen with Object.freeze(this), meaning no part of the framework — however deeply nested — can accidentally mutate configuration mid-run, which matters enormously once you’re running tests in parallel across multiple worker processes. The configs lookup table is private static readonly, keeping the raw environment definitions encapsulated inside the class rather than scattered across the codebase as loose constants. And the whole thing is fully typed against the EnvironmentConfig interface, meaning if someone adds a new environment without providing every required field, TypeScript catches the mistake immediately at compile time, rather than the framework silently falling back to undefined somewhere deep inside a test run three weeks later.

This single class replaces what, in a less disciplined framework, would typically be dozens of scattered process.env.X calls with no validation, no defaults, and no way to know at compile time whether a given environment variable is guaranteed to exist. Every one of these problems is solved simultaneously by combining the patterns from this guide — private constructors, readonly properties, static factory methods, and frozen instances — into one well-designed class.

Template Literal Types with TypeScript Classes

A more advanced but increasingly common pattern pairs TypeScript classes with template literal types — a feature that lets you build new string types by combining literal string patterns, similar to how template literals work at the value level, but entirely at the type level.

typescript

type Selector = `#${string}` | `.${string}` | `[data-testid="${string}"]`;

class TypedLocatorPage {
  constructor(private page: Page) {}

  private locate(selector: Selector) {
    return this.page.locator(selector);
  }

  clickElement(selector: Selector): Promise<void> {
    return this.locate(selector).click();
  }
}

Here, Selector restricts the accepted strings to only those matching an ID selector, a class selector, or a data-testid attribute selector pattern — TypeScript will reject a plain, unprefixed string like "submit-button" at compile time, catching an entire category of selector typos before a test ever runs against a real browser.

typescript

declare const page: Page;
const p = new TypedLocatorPage(page);
p.clickElement("#submit"); // valid
p.clickElement(".btn-primary"); // valid
p.clickElement('[data-testid="checkout-button"]'); // valid
// p.clickElement("submit"); // Error: not assignable to type 'Selector'

This pairs particularly well with the generic and structural typing concepts covered earlier — it’s another example of how TypeScript classes benefit enormously from the broader type system surrounding them, not just from the class syntax in isolation. I’d caution against over-engineering every string parameter in a framework this way, since overly restrictive template literal types can sometimes create more friction than value for genuinely free-form strings. But for a narrow, high-value case like enforcing a consistent selector strategy across an entire framework, it’s a genuinely effective technique.

Alternative Privacy: Using WeakMap for Truly Hidden State

We’ve covered TypeScript’s private (compile-time only) and native # fields (true runtime privacy) as the two standard privacy mechanisms for TypeScript classes. There’s a third, older pattern worth knowing about, because you’ll still encounter it in some codebases and libraries predating widespread # field support: using a module-scoped WeakMap to store per-instance private state entirely outside the class itself.

typescript

const balances = new WeakMap<BankAccount, number>();

class BankAccount {
  constructor(initialBalance: number) {
    balances.set(this, initialBalance);
  }

  deposit(amount: number): void {
    const current = balances.get(this) ?? 0;
    balances.set(this, current + amount);
  }

  getBalance(): number {
    return balances.get(this) ?? 0;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log((account as any).balances); // undefined — truly inaccessible from outside

Because balances is a variable scoped to the module, not a property on the class instance at all, there is no property name for outside code to even attempt to access — not _balance, not #balance, nothing. The data genuinely doesn’t exist on the object itself; it exists in a separate map, keyed by object identity, that only code within the same module has a reference to. Using a WeakMap specifically (rather than a regular Map) also means the stored data is automatically garbage collected once the corresponding instance is no longer referenced anywhere else, avoiding the memory leak a regular Map would introduce by holding a permanent reference to every instance ever created.

This pattern is more ceremony than most codebases need today, now that native # private fields are well supported across all modern JavaScript runtimes and fully supported by TypeScript classes. I mention it primarily so that if you encounter it in an older library’s source code, you recognize it immediately for what it is — a privacy-enforcement workaround from before # fields existed — rather than mistaking it for some more exotic pattern.

Abstract Properties, Not Just Abstract Methods

We covered abstract methods extensively earlier, but it’s worth knowing that TypeScript classes also support abstract properties — fields that every subclass must define, without the abstract base class providing any default value itself.

typescript

abstract class ReportGenerator {
  abstract readonly reportName: string;
  abstract readonly fileExtension: string;

  generateFileName(): string {
    const timestamp = new Date().toISOString().split("T")[0];
    return `${this.reportName}-${timestamp}.${this.fileExtension}`;
  }

  abstract generate(): string;
}

class PdfReportGenerator extends ReportGenerator {
  readonly reportName = "test-summary";
  readonly fileExtension = "pdf";

  generate(): string {
    return `Generating PDF report: ${this.generateFileName()}`;
  }
}

class CsvReportGenerator extends ReportGenerator {
  readonly reportName = "test-results";
  readonly fileExtension = "csv";

  generate(): string {
    return `Generating CSV report: ${this.generateFileName()}`;
  }
}

const pdfGen = new PdfReportGenerator();
console.log(pdfGen.generate());

If CsvReportGenerator forgot to declare either reportName or fileExtension, TypeScript would refuse to compile, exactly as it would for a missing abstract method. This is a genuinely useful pattern for enforcing that every subclass provides certain identifying metadata — a name, a file extension, a display label — without needing to pass that same information redundantly through every constructor call. The base class’s generateFileName() method can rely on those abstract properties being present, fully typed, without ever needing to know which concrete subclass it’s actually operating on at runtime.

Declaration Merging Between Classes and Interfaces

A more advanced TypeScript feature worth understanding, particularly if you work with typed third-party libraries, is declaration merging — the ability for an interface sharing the same name as a class to automatically merge its members onto that class’s type, without altering the class’s actual implementation.

typescript

class Vehicle {
  constructor(public brand: string) {}
}

interface Vehicle {
  topSpeed: number;
}

const car = new Vehicle("Tesla") as Vehicle;
car.topSpeed = 250; // Type-checks correctly, thanks to declaration merging

Here, the standalone interface Vehicle declaration merges its topSpeed member into the type of the Vehicle class, even though the class’s actual runtime implementation never declared that property itself. This is a niche but genuinely important pattern for extending the type surface of classes defined in external libraries, where you don’t control (and shouldn’t modify) the original source, but still need TypeScript to recognize additional properties that some other part of your application attaches at runtime.

This shows up most often in practice when augmenting global or third-party types — for instance, extending Playwright’s built-in fixture types, or adding custom properties to Express’s Request class in a Node.js backend:

typescript

declare global {
  namespace Express {
    interface Request {
      currentUser?: { id: string; role: string };
    }
  }
}

I’d stress that declaration merging is a targeted tool for extending types you don’t own, not a general-purpose way to add properties to your own TypeScript classes — for classes you control directly, simply adding the property to the class body itself is always the clearer, more maintainable choice.

Common Pitfalls When Extending Third-Party Classes

A final, practical topic worth covering: extending TypeScript classes that come from an external library — a base test class, a UI component class, a database model class — comes with a handful of pitfalls that are worth knowing in advance.

The library’s internal implementation details can change between versions, silently breaking your subclass. If you override a method and call super.someMethod() expecting specific internal behavior, and the library changes that method’s internals in a minor version bump, your subclass’s assumptions can break without any compile error, because TypeScript only checks the type signature, not the runtime behavior. Pin dependency versions carefully and read changelogs when extending library classes you don’t control.

Not every exported class is actually designed to be extended. Some libraries expose classes purely for instantiation, without designing their internals to be safely overridden. Extending a class that wasn’t designed for extension can produce subtle bugs — a private method the library relies on internally might not behave correctly once your subclass overrides a public method it depends on. Check the library’s documentation specifically for guidance on intended extension points before building a large amount of framework code on top of an inheritance relationship the library authors never explicitly supported.

Type definitions for third-party classes are sometimes incomplete or slightly incorrect, particularly for less actively maintained packages. If TypeScript’s type checker seems to be fighting you unexpectedly when extending a third-party class, it’s worth checking the package’s .d.ts files directly (often found in node_modules/@types/ or bundled with the package itself) to confirm the actual declared shape matches what you’d expect, rather than assuming your own code is at fault.

I’ve seen automation frameworks run into real trouble extending Playwright’s own internal classes in ways the library wasn’t designed to support, particularly around test fixtures and worker-scoped state. The safer, more future-proof approach — and the one this entire guide has consistently pointed toward — is composition through the officially supported extension points (like test.extend(), which we covered earlier) rather than reaching directly into a library’s class hierarchy and extending internals that were never part of its public, documented contract.

Making TypeScript Classes Iterable with Symbol.iterator

One last genuinely useful pattern: TypeScript classes can implement the built-in iterator protocol, letting instances be used directly with for...of loops, the spread operator, and array destructuring — exactly like a native array or Map.

typescript

class TestSuite {
  private tests: string[] = [];

  addTest(name: string): void {
    this.tests.push(name);
  }

  [Symbol.iterator](): Iterator<string> {
    let index = 0;
    const tests = this.tests;

    return {
      next(): IteratorResult<string> {
        if (index < tests.length) {
          return { value: tests[index++], done: false };
        }
        return { value: undefined, done: true };
      },
    };
  }
}

const suite = new TestSuite();
suite.addTest("Login validation");
suite.addTest("Checkout flow");
suite.addTest("Password reset");

for (const testName of suite) {
  console.log(testName);
}

const allTests = [...suite];
console.log(allTests.length); // 3

By implementing [Symbol.iterator](), the class defines exactly how it should behave when consumed by any language construct expecting an iterable — for...of, the spread operator (...), array destructuring, and Array.from() all work correctly without any additional code, because JavaScript’s iteration protocol is a well-defined contract that TypeScript classes can opt into directly.

A cleaner, less verbose way to achieve the same result — when you’re simply wrapping an existing iterable structure like an array — is to delegate directly using a generator method:

typescript

class TestSuite {
  private tests: string[] = [];

  addTest(name: string): void {
    this.tests.push(name);
  }

  *[Symbol.iterator](): Generator<string> {
    yield* this.tests;
  }
}

The *[Symbol.iterator]() syntax defines a generator method directly as the class’s iterator, and yield* this.tests delegates iteration straight to the underlying array, producing identical behavior to the manual next() implementation above with dramatically less boilerplate.

This pattern is particularly elegant for domain classes that fundamentally wrap a collection — a TestSuite wrapping test names, a Cart wrapping line items, a SearchResults wrapping matched records — because it lets consumers interact with your class using the exact same familiar syntax they’d use with a plain array, while your class still fully controls its internal representation, validation rules, and any additional behavior layered on top.

Wrapping Up

these classes give you a level of structure, safety, and expressiveness that plain JavaScript objects simply can’t match. Constructors let you guarantee valid initialization. Access modifiers let you enforce genuine encapsulation during development. Inheritance and abstract classes let you build shared, extensible architectures instead of copy-pasted logic scattered across a dozen files. Generics let your classes stay flexible without sacrificing type safety.

Whether you’re building a large-scale application, a backend service, or — like me — an automation framework meant to be maintained by a growing QA team for years, mastering TypeScript classes is not optional. It’s foundational. Get comfortable with everything in this guide — constructors, public/private/protected, static members, inheritance, abstract classes, generics, and the architectural judgment to know when a class is the right tool in the first place — and you’ll write TypeScript that’s not just correct, but genuinely maintainable by the next person who has to work in it, including future you.

If you want to keep exploring, the official TypeScript Handbook remains the most authoritative and consistently updated reference as the language continues to evolve, and the Playwright documentation is an excellent place to see these same class-based patterns applied directly to real-world test automation.

Where TypeScript Classes Go From Here

It’s worth closing with a brief, honest look at where this part of the language is actually headed, because TypeScript classes have not stood still, and they’re unlikely to over the coming years. The class fields and native # private field proposals we discussed took years to move through the formal TC39 standardization process before landing as stable JavaScript, and TypeScript’s own decorator implementation went through a similarly long journey — from an early, TypeScript-specific experimentalDecorators flag to alignment with the newer, standardized ECMAScript decorators proposal. If you’re starting a new project today, it’s worth checking the current state of decorator support in your specific TypeScript version before committing to either implementation, since the two are not fully interchangeable and migrating between them later can require real rework.

More broadly, the trajectory of TypeScript classes has consistently been toward closer alignment with native JavaScript semantics, rather than TypeScript inventing its own parallel class system. Features like public and private class fields, static blocks, and the accessor keyword for auto-generated getter/setter pairs all originated as JavaScript proposals first, with TypeScript adopting and type-checking them once they stabilized, rather than TypeScript pushing its own divergent syntax. This matters practically: code you write using TypeScript classes today is, increasingly, just well-typed, standard JavaScript — which means the investment you make in learning these patterns thoroughly continues paying off even as tooling, bundlers, and runtimes evolve around it.

For teams building long-lived automation frameworks and applications, this stability is genuinely reassuring. The constructors, access modifiers, inheritance patterns, and design patterns covered throughout this guide are not trends likely to be replaced by some entirely different paradigm next year — they’re a mature, well-understood foundation that has already proven itself across more than a decade of production TypeScript codebases, and shows every sign of remaining exactly that for a long time to come. Investing the time to genuinely understand TypeScript classes — not just the syntax, but the reasoning behind constructors, encapsulation, inheritance, and the compiler guarantees they provide — is one of the more durable skills you can build as a TypeScript developer, regardless of which specific framework, library, or automation tool you happen to be pairing it with today.

🔥 Continue Your Learning Journey

Want to go beyond Playwright with Typescript setup and crack interviews faster? Check these hand-picked guides:

👉 🚀 Master TestNG Framework (Enterprise Level)
Build scalable automation frameworks with CI/CD, parallel execution, and real-world architecture
➡️ Read: TestNG Automation Framework – Complete Architect Guide

👉 🧠 Learn Cucumber (BDD from Scratch to Advanced)
Understand Gherkin, step definitions, and real-world BDD framework design
➡️ Read: Cucumber Automation Framework – Beginner to Advanced Guide

👉 🔐 API Authentication Made Simple
Master JWT, OAuth, Bearer Tokens with real API testing examples
➡️ Read: Ultimate API Authentication Guide

👉 ⚡ Crack Playwright Interviews (2026 Ready)
Top real interview questions with answers and scenarios
➡️ Read: Playwright Interview Questions Guide

Tags:

Access Modifiers TypeScriptObject Oriented Programming TypeScriptPage Object ModelPlaywright AutomationPlaywright TypeScriptTest Automation FrameworkTypeScript ClassesTypeScript ConstructorsTypeScript for BeginnersTypeScript GenericsTypeScript Interview QuestionsTypeScript OOPTypeScript TutorialTypeScript vs JavaScript
Author

Ajit Marathe

Follow Me
Other Articles
TypeScript Objects
Previous

TypeScript Objects: Typing, Optional Properties & Read-only Fields

TypeScript Functions
Next

TypeScript Functions: Typing Parameters, Return Types & Examples

No Comment! Be the first one.

    Leave a Reply Cancel reply

    Your email address will not be published. Required fields are marked *

    Recent Posts

    • TypeScript Record: Typed Key-Value Collections Explained
    • TypeScript Generics: Definition, Syntax & Examples (Beginner-Friendly Guide)
    • TypeScript Functions: Typing Parameters, Return Types & Examples
    • TypeScript Classes: Definition, Syntax & Examples (Constructors, Access Modifiers)
    • TypeScript Objects: Typing, Optional Properties & Read-only Fields

    Categories

    • AI
    • AI Code Review & Risk-Based Testing
    • AI Prompts for QA
    • AI QA Careers
    • AI Test Automation / MCP Testing
    • AI Test Case Generation
    • AI-Powered Test Maintenance
    • API Authentication
    • API Testing
    • API Testing Interview Questions
    • Blogs
    • C#
    • Cucumber
    • Git
    • Java
    • Java coding
    • Java Interview Prepartion
    • LLM Testing / AI Evaluation
    • Playwright
    • REST Assured Interview Questions
    • Selenium
    • Test Lead/Test Manager
    • TestNG
    • Typescript
    • About
    • Privacy Policy
    • Contact
    • Disclaimer
    Copyright © 2026 — QATRIBE. All rights reserved. Learn • Practice • Crack Interviews