Tests written as a wall of selectors and clicks turn brittle fast. Move the page-specific knowledge into a page object — one place that owns "what the login page looks like and how you interact with it" — and the tests start reading like a script rather than DOM archaeology. With TypeScript, page objects gain the same protections as everything else in your test code: typed locators, typed methods, typed return values. This lesson covers the canonical Playwright class pattern, the equivalent Cypress functional pattern, and the typed fixture data both rely on.
A typed Playwright page object
The standard pattern is a class that holds the Page and exposes typed methods for every interaction:
// pages/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";
export class LoginPage {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByTestId("email");
this.passwordInput = page.getByTestId("password");
this.submitButton = page.getByTestId("submit");
this.errorMessage = page.getByTestId("error-message");
}
async navigate(): Promise<void> {
await this.page.goto("/login");
}
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string): Promise<void> {
await expect(this.errorMessage).toContainText(message);
}
}Three things to notice:
private readonlyon every locator — they're set once in the constructor and never reassigned. The readonly modifier makes accidental mutation a compile error.Promise<void>on every action. Playwright's locator methods are async; the page object's methods inherit that. Thevoidreturn type signals "callers should not depend on the return."- No raw selectors leak into tests. The test file only knows the
LoginPageAPI; if adata-testidchanges, you update the constructor and every test follows.
Using it from a test:
import { test } from "../fixtures";
import { LoginPage } from "../pages/LoginPage";
test("rejects invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login("alice@test.com", "wrong-password");
await loginPage.expectError("Invalid credentials");
});Three method calls, every one typed, autocomplete on the page object class throughout. Compare to a wall of page.getByTestId(...) calls and the maintenance win is enormous.
A typed Cypress page object
Cypress's idiom is different — instead of classes (which work but are uncommon), most teams use a typed object literal exporting functions:
// cypress/support/pages/loginPage.ts
export const loginPage = {
navigate: () => cy.visit("/login"),
fillEmail: (email: string) => cy.get("[data-testid='email']").type(email),
fillPassword: (pw: string) => cy.get("[data-testid='password']").type(pw),
submit: () => cy.get("[data-testid='submit']").click(),
getError: () => cy.get("[data-testid='error-message']"),
};Used in a test:
import { loginPage } from "../support/pages/loginPage";
it("rejects invalid credentials", () => {
loginPage.navigate();
loginPage.fillEmail("alice@test.com");
loginPage.fillPassword("wrong-password");
loginPage.submit();
loginPage.getError().should("contain.text", "Invalid credentials");
});Each method returns a Cypress chainable, so callers can keep chaining with .should, .then, .click, etc. TypeScript infers each method's return type from the body — no manual annotations needed beyond the parameter types. If a data-testid changes, you fix one line and every test that uses the page object updates automatically.
The two patterns — class with locators (Playwright) and object of functions (Cypress) — solve the same problem in idiomatic ways for each framework. You'll see classes occasionally in Cypress projects too; both work, but the object-of-functions style is more common because it composes naturally with chainables.
Typing test fixture data
Page objects describe the page. Test fixtures describe the data — users, products, request bodies, expected results. Type those too, and the whole test becomes self-checking from input to assertion.
// fixtures/types.ts
export interface UserCredentials {
email: string;
password: string;
role: "admin" | "standard" | "read-only";
}
export interface TestUsers {
admin: UserCredentials;
standard: UserCredentials;
readOnly: UserCredentials;
}The TestUsers interface declares "the test users fixture has these three named users, each with this shape." Now the fixture file is checked at compile time:
// fixtures/users.ts
import { type TestUsers } from "./types";
export const testUsers: TestUsers = {
admin: { email: "admin@test.com", password: "AdminPass", role: "admin" },
standard: { email: "standard@test.com", password: "StandardPass", role: "standard" },
readOnly: { email: "readonly@test.com", password: "ReadOnlyPass", role: "read-only" },
};If a test later does testUsers.admin.eamil (typo) or accesses a fourth user that doesn't exist (testUsers.guest), the compiler catches it.
Loading typed fixtures from JSON
When the data lives in a .json file rather than .ts, you need to tell TypeScript what shape to expect. Two patterns:
Inferred from the JSON, with resolveJsonModule:
// tsconfig.json must have "resolveJsonModule": true
import testUsers from "../fixtures/users.json";
// ^ TypeScript infers the shape from the JSON literalThis works for stable fixtures — TypeScript reads the JSON at compile time and infers a precise type. The downside: if the JSON changes shape, every consumer's types change without you noticing.
Explicitly typed at the import:
import { type TestUsers } from "./types";
import users from "../fixtures/users.json";
const testUsers: TestUsers = users;
// ^ if users.json drifts from TestUsers, the assignment errorsThis is the safer pattern — the interface is the contract, and a fixture file that drifts from it is caught immediately. Use this style for fixtures shared across multiple tests; the inline-inferred style is fine for one-off helpers.
How a typed page object slots into a test
The test file is the thinnest layer — it composes a page object with a fixture and asserts the outcome. Every layer below is typed and reusable. When the page changes, the page object absorbs the change. When the data shape changes, the fixture interface absorbs it. The test stays small, readable, and stable.
When to extract a page object
Three signals it's time:
- The same selector appears in three or more tests. Move it into a page object before the fourth.
- A test contains six or more raw locator/click lines. Extract them into a method named for the user goal —
loginAs,addToCart,openSettings. - Two tests duplicate setup logic. Extract it into a page object method (or, in Playwright, a fixture).
A test should describe what the user does and assert the outcome. The how — the selectors, the timing, the wait strategies — belongs in the page object.
⚠️ Common mistakes
- Page object methods returning the wrong promise type. A Playwright page object method that performs an async action must return
Promise<void>(orPromise<T>if it returns a value). Forgetting the async or the return type makes callers stop awaiting — and the test races ahead before the page settles. Annotate every async method's return type explicitly. - Leaking selectors into test files. A test that contains
[data-testid='email']even once is a maintenance liability — change the testid and you have to grep every test. Move every selector behind a page object boundary; tests should never know what attribute selects what element. - Relying on inferred fixture types when the JSON might drift. A
.jsonfixture imported without an explicit interface infers its type from the literal — accurate today, silently wrong tomorrow. For shared fixtures, declare the interface and assign the import to it. The compile error when they diverge is the entire point.
🎯 Practice task
Build a typed page object suite. 30-45 minutes.
- Pick the framework you set up in the previous lesson — Cypress or Playwright.
- Create a
pages/(Playwright) orcypress/support/pages/folder. - Write a typed
LoginPage(class or object literal, depending on framework). Expose at least:navigate(),login(email, password),expectError(message)(or its Cypress equivalent), and onegetterfor the user-greeting element. - Create
fixtures/types.tswithUserCredentialsandTestUsersinterfaces. Createfixtures/users.ts(orusers.json) populated with admin/standard/read-only fixtures, typed against the interfaces. - Write two tests using the page object and the fixture: a happy path and a wrong-password path.
- Trigger every type check. After each, read the error and revert:
- Call
loginPage.login("alice")(missing password). - Access
testUsers.guest(a fourth user that doesn't exist). - Set
testUsers.admin.role = "owner"(not in the literal union). - Type the JSON fixture as
Partial<TestUsers>— confirm consumers now have to narrow before reading.
- Call
- Stretch: add a second page object (
DashboardPage) and write a test that composes both — login, then verify a dashboard widget. Confirm the chain of typed methods reads as a clean test scenario without any raw selectors leaking into the test file.
The chapter wraps next with the engine that feeds these page objects — type-safe test data factories, the helper pattern that turns "we need fifty users for this run" from a chore into one line.