Q31 of 38 · TypeScript
How do you design a type-safe Page Object class hierarchy in TypeScript with Playwright?
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
}
}