QA How-To
Build Page Component Models with TypeScript Generics
Build TypeScript generics page component models that keep Playwright tests type-safe, reusable, and easy to extend across complex application screens.
20 min read | 2,546 words
TL;DR
Use TypeScript generics to let reusable page components retain the concrete data types they display or edit. Build small component objects around Playwright locators, constrain their type parameters, and compose them into domain-specific page models.
Key Takeaways
- Use generics where a page or component preserves a real domain type from setup through assertion.
- Model repeated widgets as component objects instead of duplicating selectors across page objects.
- Constrain generic parameters so invalid records fail during TypeScript compilation.
- Inject Playwright Page and Locator dependencies to keep models composable and testable.
- Create generic tables, forms, and page shells that retain concrete row and field types.
- Prefer explicit domain adapters over clever conditional types that hide test intent.
TypeScript generics page component models let you reuse browser interaction code without throwing away the domain types that make tests safe. A generic table can return UserRow on one page and OrderRow on another, while TypeScript verifies each assertion before the browser ever opens.
This tutorial builds that architecture with Playwright Test and strict TypeScript. For the broader decisions around fixtures, boundaries, compiler settings, and framework layers, read the TypeScript test framework design complete guide.
You will create a small application model made of a generic page shell, typed table, typed form, and domain page. Every example is complete enough to paste into a project, and every step includes a direct verification.
What You Will Build
By the end, you will have:
- A
ComponentModelbase class that scopes every component to a PlaywrightLocator. - A generic
DataTable<TRow>that converts visible cells into a concrete domain record. - A generic
FormComponent<TValues>with compile-time-safe field names. - A generic
AppPage<TMain>shell that exposes shared navigation and a typed main component. - A composed
UsersPagewhose tests work withUserRowandUserFormValues, not loose objects. - Compile-time and browser checks that catch different classes of failure.
The result is deliberately small. It demonstrates the pattern without introducing a dependency injection container, decorators, or a large inheritance tree.
Prerequisites
Use Node.js 22 LTS or a newer supported LTS release, npm, and a code editor with TypeScript language support. Start a clean Playwright project:
mkdir typed-page-models
cd typed-page-models
npm init playwright@latest
Choose TypeScript, accept the tests directory, and install Playwright browsers when prompted. Confirm the installed compiler and runner:
npx tsc --version
npx playwright --version
Keep Playwright and @playwright/test on the same version through the generated package. The examples use stable APIs including Page, Locator, getByRole, getByLabel, and web-first expect assertions.
Enable strict checking in tsconfig.json. The Playwright starter normally does this, but the important settings are:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true
},
"include": ["tests/**/*.ts", "models/**/*.ts"]
}
Run npx tsc --noEmit. A clean exit verifies that the compiler can inspect both models and tests.
Step 1: Define the Generic Component Boundary
Begin with composition. A component object needs a root locator, and every selector inside it should stay scoped to that root. Create models/ComponentModel.ts:
import type { Locator } from '@playwright/test';
export abstract class ComponentModel {
protected constructor(protected readonly root: Locator) {}
async isVisible(): Promise<boolean> {
return this.root.isVisible();
}
}
The class is abstract because tests should instantiate meaningful widgets, not an unconfigured root. The constructor is protected, so subclasses control how their root is selected. The readonly locator prevents a component from silently changing its identity halfway through a test.
Do not store selector strings and repeatedly call page.locator. A Playwright Locator is lazy, retryable, and naturally supports scoping. Passing it into a component preserves those behaviors.
Add a minimal component in models/Navigation.ts:
import type { Locator } from '@playwright/test';
import { ComponentModel } from './ComponentModel';
export class Navigation extends ComponentModel {
constructor(root: Locator) {
super(root);
}
async open(label: string): Promise<void> {
await this.root.getByRole('link', { name: label, exact: true }).click();
}
}
Verify the step: run npx tsc --noEmit. It should exit with code 0. Temporarily change root: Locator to root: string in Navigation; the super(root) call should produce a type error. Undo that change before continuing.
Step 2: Build TypeScript Generics Page Component Models for Tables
A reusable table cannot know whether its rows represent users, invoices, or builds. It can know how to locate a row and read its cells. Make the conversion from cells to a domain object an injected function.
Create models/DataTable.ts:
import type { Locator } from '@playwright/test';
import { ComponentModel } from './ComponentModel';
export type RowParser<TRow> = (cells: readonly string[]) => TRow;
export class DataTable<TRow> extends ComponentModel {
constructor(
root: Locator,
private readonly parseRow: RowParser<TRow>,
) {
super(root);
}
private rows(): Locator {
return this.root.getByRole('row').filter({
has: this.root.getByRole('cell'),
});
}
async all(): Promise<TRow[]> {
const rows = this.rows();
const result: TRow[] = [];
for (let index = 0; index < await rows.count(); index += 1) {
const cells = await rows.nth(index).getByRole('cell').allTextContents();
result.push(this.parseRow(cells.map((cell) => cell.trim())));
}
return result;
}
async find(predicate: (row: TRow) => boolean): Promise<TRow | undefined> {
return (await this.all()).find(predicate);
}
async openRow(name: string): Promise<void> {
await this.rows()
.filter({ has: this.root.getByRole('cell', { name, exact: true }) })
.getByRole('link', { name: /open|view|edit/i })
.click();
}
}
TRow flows through the parser, all, find, and the predicate callback. The component owns browser mechanics while the caller owns domain meaning. That division keeps a generic component honest.
The row locator filters out a header row by requiring a cell; accessible table headers normally have the columnheader role. If your application uses a grid, adapt the role strategy in one place rather than weakening the generic type.
Verify the step: create tests/table-types.spec.ts with a compile-only example:
import { test } from '@playwright/test';
import { DataTable } from '../models/DataTable';
test('table retains its row type', async ({ page }) => {
const table = new DataTable<{ id: number }>(
page.getByRole('table'),
(cells) => ({ id: Number(cells[0]) }),
);
const row = await table.find((item) => item.id === 42);
row?.id.toFixed();
});
Run npx tsc --noEmit. Then replace item.id with item.email; TypeScript should report that email does not exist. Restore the valid expression.
Step 3: Constrain a Generic Form Model
A form model should accept only known field keys. A useful constraint is a record whose values are strings, because Playwright's fill method receives text. Create models/FormComponent.ts:
import type { Locator } from '@playwright/test';
import { ComponentModel } from './ComponentModel';
type StringRecord = Record<string, string>;
export type FieldLabels<TValues extends StringRecord> = {
readonly [Key in keyof TValues]: string;
};
export class FormComponent<TValues extends StringRecord> extends ComponentModel {
constructor(
root: Locator,
private readonly labels: FieldLabels<TValues>,
) {
super(root);
}
async fill(values: Partial<TValues>): Promise<void> {
for (const key of Object.keys(values) as Array<keyof TValues>) {
const value = values[key];
if (value !== undefined) {
await this.root.getByLabel(this.labels[key], { exact: true }).fill(value);
}
}
}
async submit(buttonName = 'Save'): Promise<void> {
await this.root.getByRole('button', { name: buttonName, exact: true }).click();
}
}
The mapped FieldLabels type requires a label for every field. Partial<TValues> supports update forms, where a test changes one field without clearing the rest. The single assertion at the generic boundary is necessary because JavaScript's Object.keys returns string[]; the constraint and mapped labels keep its use controlled.
This approach is more transparent than a component that guesses labels by capitalizing property names. Your domain key can remain displayName while the accessible label is Full name.
Verify the step: add this compile check to a test:
type UserFormValues = Record<'displayName' | 'email' | 'role', string>;
const labels = {
displayName: 'Full name',
email: 'Email address',
role: 'Role',
} satisfies FieldLabels<UserFormValues>;
Import FieldLabels, then run npx tsc --noEmit. Remove role from labels; the compiler should report a missing property. Restore it before the next step.
Step 4: Create a Generic Application Page Shell
Pages often repeat navigation, headings, alerts, and a central feature component. Capture that layout without making every page inherit dozens of unrelated methods. Create models/AppPage.ts:
import { expect, type Locator, type Page } from '@playwright/test';
import { ComponentModel } from './ComponentModel';
import { Navigation } from './Navigation';
export abstract class AppPage<TMain extends ComponentModel> {
readonly navigation: Navigation;
readonly main: TMain;
protected readonly heading: Locator;
protected constructor(
protected readonly page: Page,
path: string,
mainFactory: (root: Locator) => TMain,
) {
this.path = path;
this.navigation = new Navigation(page.getByRole('navigation'));
this.heading = page.getByRole('heading', { level: 1 });
this.main = mainFactory(page.getByRole('main'));
}
private readonly path: string;
async goto(): Promise<void> {
await this.page.goto(this.path);
}
async expectLoaded(title: string): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(`${this.path}(?:[?#]|$)`));
await expect(this.heading).toHaveText(title);
}
}
TMain extends ComponentModel is a generic constraint. It lets the shell expose isVisible, while a concrete page retains all specialized methods of its main component. The factory receives the page's main landmark, which prevents the shell from knowing how the feature is constructed.
The shell owns navigation and readiness checks. It does not expose a generic clickButton or fillInput, because such methods erase the language of the feature.
Verify the step: run npx tsc --noEmit. Next, try declaring class BrokenPage extends AppPage<string> {} in a scratch test. TypeScript should reject string because it does not extend ComponentModel. Delete the scratch class.
Step 5: Compose a Domain-Specific Users Page
Now bind the reusable models to a real domain. Create models/UsersPage.ts:
import type { Locator, Page } from '@playwright/test';
import { AppPage } from './AppPage';
import { ComponentModel } from './ComponentModel';
import { DataTable } from './DataTable';
import { FormComponent } from './FormComponent';
export interface UserRow {
readonly name: string;
readonly email: string;
readonly role: string;
readonly status: 'Active' | 'Invited' | 'Suspended';
}
export type UserFormValues = Record<
'displayName' | 'email' | 'role',
string
>;
function requiredCell(cells: readonly string[], index: number): string {
const value = cells[index];
if (value === undefined || value.length === 0) {
throw new Error(`Expected a value in user table column ${index}`);
}
return value;
}
function parseStatus(value: string): UserRow['status'] {
if (value === 'Active' || value === 'Invited' || value === 'Suspended') {
return value;
}
throw new Error(`Unexpected user status: ${value}`);
}
class UsersMain extends ComponentModel {
readonly table: DataTable<UserRow>;
readonly form: FormComponent<UserFormValues>;
constructor(root: Locator) {
super(root);
this.table = new DataTable(root.getByRole('table', { name: 'Users' }), (cells) => ({
name: requiredCell(cells, 0),
email: requiredCell(cells, 1),
role: requiredCell(cells, 2),
status: parseStatus(requiredCell(cells, 3)),
}));
this.form = new FormComponent(root.getByRole('form', { name: 'User details' }), {
displayName: 'Full name',
email: 'Email address',
role: 'Role',
});
}
}
export class UsersPage extends AppPage<UsersMain> {
constructor(page: Page) {
super(page, '/admin/users', (root) => new UsersMain(root));
}
}
The parser validates browser text before claiming it is a UserRow. A type assertion such as status as UserRow['status'] would silence the compiler without protecting runtime data. For a systematic treatment of that boundary, see runtime validation for TypeScript test data with Zod.
Notice that the page is mostly composition. UsersPage supplies a URL and feature factory; UsersMain combines a table and form. Tests receive domain vocabulary through users.main.table and users.main.form.
Verify the step: run npx tsc --noEmit. Change one allowed status to Disabled inside parseStatus; the return statement should fail because Disabled is outside the union. Restore the code.
Step 6: Exercise the Models in a Browser Test
Use a tiny fixture page so the tutorial remains runnable without an external application. Create tests/users-page.spec.ts:
import { expect, test } from '@playwright/test';
import { UsersPage } from '../models/UsersPage';
test('reads and updates a user through typed models', async ({ page }) => {
await page.route('**/admin/users', async (route) => {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html>
<html><body>
<nav><a href="/admin/users">Users</a></nav>
<main>
<h1>Users</h1>
<table aria-label="Users">
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Action</th></tr></thead>
<tbody><tr>
<td>Ada Stone</td><td>ada@example.test</td><td>Admin</td><td>Active</td>
<td><a href="/admin/users/1">Open</a></td>
</tr></tbody>
</table>
<form aria-label="User details">
<label>Full name <input value="Ada Stone"></label>
<label>Email address <input value="ada@example.test"></label>
<label>Role <input value="Admin"></label>
<button type="button" onclick="this.textContent='Saved'">Save</button>
</form>
</main>
</body></html>`,
});
});
const users = new UsersPage(page);
await users.goto();
await users.expectLoaded('Users');
const ada = await users.main.table.find((user) => user.email === 'ada@example.test');
expect(ada).toEqual({
name: 'Ada Stone',
email: 'ada@example.test',
role: 'Admin',
status: 'Active',
});
await users.main.form.fill({ role: 'Owner' });
await users.main.form.submit();
await expect(page.getByRole('button', { name: 'Saved' })).toBeVisible();
});
The route handler supplies deterministic HTML. This is not a substitute for end-to-end coverage, but it gives the component contract a fast browser-level test. The same models can point at a deployed application because they depend only on accessible DOM behavior.
Use Playwright's expect for browser state and ordinary typed callbacks for domain selection. The returned ada value is UserRow | undefined, so the assertion accurately reflects that lookup may fail.
Verify the step: run:
npx playwright test tests/users-page.spec.ts
Expect one passing test. Run it with --headed if you want to inspect the rendered fixture page. If the heading or accessible form name changes, the web-first assertion or locator should fail with a useful Playwright trace.
Step 7: Add Type Tests and Guard the Abstraction
Runtime tests prove browser behavior. Compile-time tests prove that consumers cannot misuse your public API. Keep a small file compiled by tsc, even if the functions never execute. Create tests/model-types.ts:
import type { Page } from '@playwright/test';
import { UsersPage } from '../models/UsersPage';
async function validUsage(page: Page): Promise<void> {
const users = new UsersPage(page);
await users.main.form.fill({ email: 'new@example.test' });
const active = await users.main.table.find((user) => user.status === 'Active');
active?.email.toLowerCase();
}
async function rejectedUsage(page: Page): Promise<void> {
const users = new UsersPage(page);
// @ts-expect-error: phone is not part of UserFormValues.
await users.main.form.fill({ phone: '555-0100' });
// @ts-expect-error: UserRow has no lastLogin property.
await users.main.table.find((user) => user.lastLogin === 'today');
}
void validUsage;
void rejectedUsage;
@ts-expect-error is intentional here. Unlike @ts-ignore, it fails if the following line stops producing an error. That makes the forbidden usage part of the contract. Keep these comments narrowly scoped and explain the expected reason.
A discriminated union is a better result type when component actions can succeed, fail validation, or time out. Learn that extension in TypeScript discriminated unions for test results. It lets callers exhaustively handle outcomes instead of relying on nullable values or thrown strings.
Verify the step: run both checks:
npx tsc --noEmit
npx playwright test
Both should pass. Remove either @ts-expect-error comment and confirm that tsc reports the intended misuse, then restore it.
Step 8: Review TypeScript Generics Page Component Models
Generics are valuable when a reusable implementation preserves a caller-selected type. They are costly when they merely make a concrete model look flexible. Use this decision table during review:
| Situation | Best model | Reason |
|---|---|---|
| Same table mechanics, different row records | DataTable<TRow> |
The row type flows from parser to test |
| Same form mechanics, known domain fields | FormComponent<TValues> |
Keys and partial updates stay checked |
| Shared page shell, different main component | AppPage<TMain> |
Concrete component methods remain visible |
| One login form with fixed fields | Concrete LoginForm |
A type parameter adds no variation |
| Success, validation error, or transport error | Discriminated union | Callers must handle every outcome |
| Untrusted API or DOM data | Runtime schema plus inferred type | Generics cannot validate runtime values |
Prefer one or two meaningful type parameters. Names such as TRow, TValues, and TMain communicate the role better than T, U, and V. Put constraints as close as possible to the operation that needs them.
Avoid a universal PageModel<TData, TForm, TFilter, TResponse> base class. It creates coupling between features that only look similar. Start with concrete models, identify repeated browser mechanics, and extract the smallest stable component.
Verify the step: review each generic in the completed code and trace the type from construction to a test assertion. You should be able to state what varies and which operation consumes it. If a parameter appears only once, remove it or replace the abstraction with a concrete class. Run npx tsc --noEmit and the browser suite after the refactor.
Troubleshooting
Problem: Type 'T' does not satisfy the constraint -> Read the extends clause and supply a type with the required shape. For FormComponent, define form data as a string-valued record. Do not weaken the constraint to any.
Problem: Object.keys loses keyof TValues -> Keep the narrow assertion at the iteration boundary, as shown in fill. Do not spread assertions through locator code. Alternatively, pass an explicitly typed list of keys when runtime objects can contain extra properties.
Problem: the table includes its header as data -> Inspect accessible roles with Playwright's locator tools. Scope body rows to tbody, or filter for rows containing cell rather than columnheader. Match the application's semantic markup.
Problem: a locator matches elements outside the component -> Build child locators from this.root, not from the global page. Give repeated regions unique accessible names and assert them in component contract tests.
Problem: TypeScript accepts invalid status text from the browser -> Remember that a generic is erased at runtime. Parse and validate literal unions manually or with a schema. Never cast untrusted strings directly to the desired domain type.
Problem: subclasses cannot call the base constructor -> Use protected for a base constructor intended only for subclasses. Use public constructors on concrete components that tests or factories instantiate.
Best Practices and Common Mistakes
Do keep components small and rooted in meaningful DOM regions. Prefer accessible roles and labels, expose domain actions, return concrete types, and test both type contracts and browser behavior. Let generic models own repeated mechanics such as row traversal, not business-specific policy.
Do not use any to make generic errors disappear. any disables the exact feedback this architecture is meant to provide. Use unknown at untrusted boundaries, validate it, and return a precise type.
Do not hide every action behind a base page. Methods such as click, type, and wait merely reproduce Playwright with less context. A good component says submit, find, or openRow and centralizes the selectors needed for that intent.
Do not assume compile-time types validate the UI. The UserRow interface disappears when JavaScript runs. Keep guards such as requiredCell and parseStatus, then cover them with malformed-data tests.
Finally, prefer composition over deep inheritance. One page shell layer can be useful, but reusable tables, filters, dialogs, and forms should usually be fields on a domain component. This keeps changes local when the interface evolves.
Where To Go Next
Place these models inside the architecture from the complete TypeScript test framework design guide, then add stronger boundaries based on your application:
- Use runtime validation with Zod for TypeScript test data when API fixtures, storage state, or complex DOM records are untrusted.
- Model action outcomes with TypeScript discriminated unions for test results when callers need exhaustive success and failure handling.
- Explore TypeScript decorators for test metadata only when metadata solves a concrete reporting or registration problem. Do not use decorators to conceal ordinary component composition.
- Review Playwright locator strategy best practices before standardizing selectors across a large suite.
- Add Playwright fixtures for reusable test setup once multiple specs need authenticated, ready-to-use page models.
Your next practical move is to extract one repeated widget from an existing suite. Give it a root locator, identify the domain type it preserves, and add one compile-time misuse test before migrating more pages.
Interview Questions and Answers
Q: Why use a generic component model instead of returning Record<string, string>?
A generic retains the concrete domain shape selected at construction. Tests get autocomplete, refactoring support, and compile-time rejection of nonexistent properties. A loose record only proves that keys and values are strings.
Q: What does a generic constraint add to a page model?
A constraint states the minimum capability accepted by the reusable implementation. TMain extends ComponentModel guarantees that the main object has component behavior while preserving its concrete methods. It prevents invalid types from entering the model.
Q: Do TypeScript generics validate browser or API data at runtime?
No. Generics are erased during compilation. Validate untrusted strings with guards or a runtime schema before returning a typed domain record.
Q: When should you avoid a generic page object?
Avoid it when there is only one fixed domain shape or when the type parameter does not flow through meaningful operations. A concrete class is easier to read and change. Extract a generic only after stable repeated mechanics appear.
Q: Why inject a row parser into DataTable<TRow>?
The table knows how to locate and read cells, but it should not know every application's column meaning. The parser adapts raw text into a domain record and creates a clear runtime validation boundary.
Q: How do you test compile-time behavior?
Compile representative valid and invalid usage with tsc --noEmit. Mark intentionally invalid lines with narrowly scoped @ts-expect-error comments so the check fails if the API accidentally becomes permissive.
Q: Is inheritance always wrong for page models?
No. A shallow base for a genuinely shared page shell can be useful. Prefer composition for independent widgets because it avoids forcing unrelated features into one hierarchy.
Conclusion
TypeScript generics page component models work best when each generic represents real variation and carries a concrete type from component setup to test assertion. Scope components with Playwright locators, constrain inputs, validate runtime text, and compose small widgets into domain pages.
Apply the pattern to one repeated table or form first. Keep the abstraction only if it removes duplicated browser mechanics while making test intent and type errors clearer.
Interview Questions and Answers
Why would you use a generic component model in a Playwright framework?
I use one when browser mechanics repeat but the domain type varies. A `DataTable<TRow>` can centralize row traversal while returning `UserRow` or `OrderRow` to each test. This preserves autocomplete and compile-time refactoring without duplicating locator code.
What is the purpose of `TMain extends ComponentModel`?
It constrains the type parameter to objects with the required component capability. The page shell can safely use base behavior while consumers retain the specialized methods of the concrete main component. Invalid values such as strings fail during compilation.
Why can a generic type not replace runtime validation?
TypeScript removes generic types when it emits JavaScript. Browser text and API payloads can still violate the declared shape. I validate those boundaries with type guards or schemas before returning a trusted domain type.
How would you design a type-safe reusable form component?
I constrain its values to an appropriate record, map each `keyof TValues` to an accessible label, and accept `Partial<TValues>` for updates. The component owns filling and submission while a domain model supplies field names and labels.
What is a sign that a generic abstraction is overengineered?
A type parameter that appears only once or does not affect a consumer-visible operation is a warning. Several unrelated parameters and conditional types in a base page are another sign. I prefer the smallest abstraction that removes stable, repeated browser mechanics.
How do you verify the type contract of a page model?
I compile representative valid and intentionally invalid consumers with `tsc --noEmit`. Narrow `@ts-expect-error` cases document what must be rejected and fail if the API becomes too permissive. Browser tests separately verify locators and interactions.
Would you choose inheritance or composition for page components?
I prefer composition for independent widgets such as tables, filters, and dialogs. A shallow inherited shell can be reasonable for truly shared navigation and readiness checks. I avoid deep hierarchies because unrelated pages become coupled to base-class changes.
Frequently Asked Questions
What are TypeScript generics page component models?
They are reusable page or component objects parameterized by domain types such as a table row, form value set, or main page region. The generic preserves that concrete type through interactions and assertions without duplicating browser mechanics.
Do TypeScript generics make Playwright locators safer?
Generics make the values and operations around a locator type-safe, but they cannot prove that an element exists at runtime. Use scoped locators and Playwright web-first assertions for DOM behavior.
Should every Playwright page object be generic?
No. Use a generic only when one implementation genuinely works with several caller-selected types. A fixed login page or one-off dialog is usually clearer as a concrete model.
How do I validate data returned by a generic table component?
Parse visible cell text through explicit guards or a runtime schema before returning the row type. A TypeScript type parameter does not validate values because types are erased at runtime.
Can a generic form component support optional updates?
Yes. Accept `Partial<TValues>` so callers can fill a subset of known fields. Constrain values appropriately and maintain a typed mapping from domain keys to accessible labels.
How should I test generic page component models?
Run browser tests for locator and interaction behavior, plus `tsc --noEmit` checks for the public type contract. Use `@ts-expect-error` sparingly to document operations that must remain invalid.
Is composition better than inheritance for page component models?
Composition is usually better for tables, forms, dialogs, and navigation because each widget changes independently. A shallow generic page shell can still be appropriate when pages genuinely share layout and readiness behavior.
Related Guides
- AI powered visual testing with vision models (2026)
- Create Cypress Query Commands with TypeScript: Cypress Query Commands TypeScript Tutorial
- How to Build a BDD framework with Cucumber and Playwright (2026)
- How to Build a Playwright TypeScript framework from scratch (2026)
- Run Playwright Component Tests with Vite in CI: Tutorial
- Selenium Test Web Components Slots Tutorial: Test Web Component Slots with Selenium