Q31 of 38 · TypeScript

How do you design a type-safe Page Object class hierarchy in TypeScript with Playwright?

TypeScriptSeniortypescriptpage-objectsplaywrightclass-hierarchyabstract-classdesign-patterns

Short answer

Short answer: Use an abstract `BasePage` with a constructor accepting `Page` from Playwright. Each page class extends `BasePage`, declares its locators as `readonly` class fields, and methods return `this` or specific types for chaining. Abstract methods enforce that all pages implement navigation and readiness checks.

Detail

A well-typed Page Object hierarchy makes test code self-documenting, refactoring safe, and IDE-friendly.

Abstract base class: abstract class BasePage accepts Page in its constructor, declares shared utilities (wait for load, take screenshot), and declares abstract methods that every page must implement (waitForReady()).

Locators as readonly class fields: Declaring readonly usernameInput: Locator in the constructor body gives typed, named access to every element. Playwright's Locator is lazy — declaring it is safe even if the element doesn't exist yet.

Return types for chaining: Methods that navigate to another page should return an instance of that page: async login(): Promise<DashboardPage>. This creates a typed page chain that mirrors the user journey.

Generics for shared behaviour: A generic TablePage<RowType> can expose typed row data — getRows(): Promise<RowType[]> — while the row type is provided by the concrete subclass.

Fixture integration: Export instances via Playwright fixtures: test.extend<{ loginPage: LoginPage }>. The fixture type is inferred from the class.

// EXAMPLE

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

abstract class BasePage {
  constructor(protected readonly page: Page) {}
  abstract waitForReady(): Promise<void>;
  async takeScreenshot(name: string) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

class LoginPage extends BasePage {
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    super(page);
    this.usernameInput = page.getByLabel("Username");
    this.passwordInput = page.getByLabel("Password");
    this.submitButton  = page.getByRole("button", { name: "Sign in" });
  }

  async waitForReady(): Promise<void> {
    await this.usernameInput.waitFor({ state: "visible" });
  }

  async login(username: string, password: string): Promise<DashboardPage> {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    const dashboard = new DashboardPage(this.page);
    await dashboard.waitForReady();
    return dashboard; // typed return — caller gets DashboardPage
  }
}

// WHAT INTERVIEWERS LOOK FOR

Abstract base class with shared utilities and abstract required methods. Typed locators as class fields. Typed return values from navigation methods. Generic page classes for reusable patterns. This is a full senior-level design question — answers that mention only 'use a class' miss the depth.

// COMMON PITFALL

Returning `this.page` from action methods instead of returning the next page — callers lose type safety and have to construct the next page manually. The typed return pattern (`login(): Promise<DashboardPage>`) is what makes the hierarchy valuable.