When test.extend types your Playwright fixtures, TypeScript knows loginPage is a LoginPage instance in every test that imports it, not any. Method name typos are caught at compile time, and autocomplete works without any per-file configuration. This article covers the TypeScript patterns that matter at suite scale: generic fixtures, discriminated union types for order and session states, utility types for test data factories, null narrowing for DOM methods, and the strict mode settings beyond strict: true.
Type aliases vs interfaces: the practical rule
Every TypeScript tutorial eventually explains that interfaces and type aliases overlap significantly. Both can describe object shapes. Both support extension. Both work in the same places most of the time. The useful rule for test code is simpler than the language specification suggests.
Use interfaces when you're describing a data shape that other types will extend. Use type aliases when you're building unions, intersections, or anything that isn't a plain object shape.
// Interface: a shape that extensions make sense for
interface User {
id: number;
email: string;
role: 'admin' | 'viewer';
}
// Extending an interface: natural, readable
interface AdminUser extends User {
permissions: string[];
}
// Type alias: the right choice for unions
type UserRole = 'admin' | 'viewer' | 'guest';
// Type alias: the right choice for intersections
type AuthenticatedUser = User & { token: string; expiresAt: Date };
// Type alias: the right choice when you're naming a primitive or tuple
type UserId = number;
type Credentials = [string, string]; // [email, password]The reason interfaces work better for extensible shapes is declaration merging: you can declare the same interface twice and TypeScript merges the declarations. This is useful in fixture files where different parts of your test suite add properties to the same fixture type. Type aliases don't support merging; a duplicate declaration is an error.
[!note]
Neither interfaces nor type aliases produce any JavaScript output. They exist only in the TypeScript layer and are stripped out during compilation. There's no runtime cost to either.
The mistake to avoid: spending time debating which to use. Pick interfaces for object shapes, type aliases for everything else, and move on. Both will work correctly either way. This is a code organization decision, not a correctness one.
Typing test data: users, orders, and form objects
Test fixtures live and die by their data. A user object with the wrong shape silently breaks five tests before anyone notices. TypeScript makes the shape explicit.
// types/test-data.ts
export interface UserCredentials {
email: string;
password: string;
}
export interface UserProfile {
id: number;
email: string;
firstName: string;
lastName: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string; // ISO date string from the API
}
export interface Order {
id: string;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
items: OrderItem[];
totalAmount: number;
currency: 'USD' | 'EUR' | 'GBP';
}
export interface OrderItem {
productId: string;
name: string;
quantity: number;
unitPrice: number;
}
export interface RegistrationForm {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}With these interfaces defined, your test data files become self-documenting and compiler-checked:
// fixtures/test-users.ts
import { UserCredentials, UserProfile } from '../types/test-data';
export const adminCredentials: UserCredentials = {
email: 'admin@example.com',
password: 'Admin$ecure1!',
};
export const viewerProfile: UserProfile = {
id: 42,
email: 'viewer@example.com',
firstName: 'Alex',
lastName: 'Rivera',
role: 'viewer',
createdAt: '2025-01-15T09:00:00Z',
};
// TypeScript catches this immediately — 'superadmin' is not a valid role
export const invalidUser: UserProfile = {
id: 99,
email: 'super@example.com',
firstName: 'Super',
lastName: 'Admin',
role: 'superadmin', // Error: Type '"superadmin"' is not assignable to type '"admin" | "editor" | "viewer"'
createdAt: '2025-03-01T00:00:00Z',
};When the API contract changes and role gains a new value, you add it to the interface once and the compiler flags every place in your tests that doesn't handle the new case.
Typing Page Object classes
Page Object classes benefit from explicit typing more than any other part of a test suite. A typed Page Object documents its own API: the constructor signature, what each method accepts, and what it returns.
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { UserCredentials } from '../types/test-data';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorAlert: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorAlert = page.getByRole('alert');
}
async navigate(): Promise<void> {
await this.page.goto('/login');
}
async login(credentials: UserCredentials): Promise<void> {
await this.emailInput.fill(credentials.email);
await this.passwordInput.fill(credentials.password);
await this.submitButton.click();
}
async getErrorMessage(): Promise<string | null> {
if (await this.errorAlert.isVisible()) {
return this.errorAlert.textContent();
}
return null;
}
async isSubmitEnabled(): Promise<boolean> {
return this.submitButton.isEnabled();
}
}Several things in this class are worth explaining. The private readonly modifiers on locators mean they can't be accidentally reassigned from outside the class. The constructor parameter private readonly page: Page is TypeScript shorthand for declaring a property and assigning it in one step. The return type Promise on getErrorMessage tells callers they must handle both cases; the compiler will warn if they use the result as if it could never be null.
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { Order } from '../types/test-data';
export class DashboardPage {
constructor(private readonly page: Page) {}
async getOrderCount(): Promise<number> {
const rows = await this.page.getByRole('row').count();
return rows - 1; // subtract header row
}
async getFirstOrderStatus(): Promise<Order['status']> {
const statusCell = this.page.getByRole('row').nth(1).getByTestId('status');
const text = await statusCell.textContent();
// Type narrowing ensures we return a valid status
return text?.toLowerCase() as Order['status'];
}
}The return type Order['status'] is an indexed access type: it reads the status property type directly from the Order interface. If you change the status union in Order, the return type here updates automatically.
Generic fixtures: extending PlaywrightTestArgs
This is where TypeScript pays its biggest dividend in test infrastructure. Playwright's test.extend uses generics to ensure your custom fixture properties have the correct types throughout your test suite.
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { UserCredentials } from '../types/test-data';
import { adminCredentials } from './test-users';
// The shape of all custom fixtures
type AppFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: DashboardPage;
};
// Worker-scoped fixtures that are shared across tests in a worker
type WorkerFixtures = {
adminUser: UserCredentials;
};
export const test = base.extend<AppFixtures, WorkerFixtures>({
// Page-scoped fixtures: recreated for each test
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
// A fixture that sets up an authenticated session
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login(adminCredentials);
const dashboard = new DashboardPage(page);
await use(dashboard);
},
// Worker-scoped: created once per worker process
adminUser: [async ({}, use) => {
await use(adminCredentials);
}, { scope: 'worker' }],
});
export { expect };The generic parameters tell TypeScript exactly what types your fixtures produce. When a test destructures { loginPage }, TypeScript knows it's a LoginPage instance, not any. Autocomplete works. Method name typos are caught at compile time.
// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('admin login shows dashboard', async ({ loginPage, authenticatedPage }) => {
// TypeScript knows loginPage is LoginPage — full autocomplete
await loginPage.login({ email: 'admin@example.com', password: 'Admin$1!' });
// TypeScript knows authenticatedPage is DashboardPage
const count = await authenticatedPage.getOrderCount();
expect(count).toBeGreaterThan(0);
});[!tip]
Create a barrel export atfixtures/index.tsthat re-exportstestandexpectfrom your fixture file. Tests import from../fixturesinstead of@playwright/test. When you add new page objects to the fixture type, every test file that imports from../fixturesautomatically sees them. No changes needed in individual test files.
Utility types in test code
TypeScript ships with a set of utility types that transform existing types into new ones. Four of them appear regularly in test code.
Partial makes all properties of T optional. Use it when a function creates an object and you want to allow callers to override specific properties:
import { UserProfile } from '../types/test-data';
// Creates a valid user profile with sensible defaults
// Callers can override any subset of properties
function createTestUser(overrides: Partial<UserProfile> = {}): UserProfile {
return {
id: Math.floor(Math.random() * 10000),
email: `user${Date.now()}@example.com`,
firstName: 'Test',
lastName: 'User',
role: 'viewer',
createdAt: new Date().toISOString(),
...overrides,
};
}
// Use defaults
const defaultUser = createTestUser();
// Override only what matters for this test
const adminUser = createTestUser({ role: 'admin', email: 'admin@example.com' });Required makes all properties mandatory, the opposite of Partial. Use it when you're building a function that needs all fields to be present:
interface CheckoutForm {
firstName?: string;
lastName?: string;
address?: string;
city?: string;
cardNumber?: string;
}
// This function needs every field — Required<T> makes the contract explicit
async function fillCheckoutForm(page: Page, data: Required<CheckoutForm>): Promise<void> {
await page.getByLabel('First name').fill(data.firstName);
await page.getByLabel('Last name').fill(data.lastName);
// ...
}Pick creates a new type with only the properties you name. Use it when a function only needs a subset of a larger type:
import { UserProfile } from '../types/test-data';
// The login function only needs email and password, not the full profile
type LoginData = Pick<UserProfile, 'email'>;
async function verifyEmailDisplayed(
page: Page,
user: Pick<UserProfile, 'email' | 'firstName' | 'lastName'>
): Promise<void> {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(`${user.firstName} ${user.lastName}`)).toBeVisible();
}Record creates an object type where all keys are type K and all values are type V. It shows up in test data factories and API mock definitions:
import { Order } from '../types/test-data';
// A map of named test orders — keys are strings, values are Order objects
const testOrders: Record<string, Order> = {
pendingOrder: {
id: 'ord-001',
status: 'pending',
items: [{ productId: 'p1', name: 'Widget', quantity: 2, unitPrice: 9.99 }],
totalAmount: 19.98,
currency: 'USD',
},
confirmedOrder: {
id: 'ord-002',
status: 'confirmed',
items: [{ productId: 'p2', name: 'Gadget', quantity: 1, unitPrice: 49.99 }],
totalAmount: 49.99,
currency: 'USD',
},
};
// TypeScript knows testOrders['pendingOrder'] is Order
const pending = testOrders['pendingOrder'];Union types for test states
Many tests need to behave differently depending on whether a user is logged in, what role they have, or what state a record is in. Union types model these states explicitly.
// The two states a user session can be in
type SessionState =
| { status: 'authenticated'; userId: number; role: 'admin' | 'editor' | 'viewer' }
| { status: 'guest' };
// Order lifecycle — each state maps to a set of valid UI actions
type OrderState =
| { status: 'pending'; canCancel: true; canShip: false }
| { status: 'confirmed'; canCancel: true; canShip: true }
| { status: 'shipped'; canCancel: false; canShip: false; trackingNumber: string }
| { status: 'delivered'; canCancel: false; canShip: false }
| { status: 'cancelled'; canCancel: false; canShip: false; cancelReason: string };These discriminated unions let you write helper functions that behave differently based on the state:
async function verifyOrderActions(page: Page, order: OrderState): Promise<void> {
const cancelButton = page.getByRole('button', { name: 'Cancel order' });
const shipButton = page.getByRole('button', { name: 'Ship order' });
if (order.status === 'shipped') {
// TypeScript knows order.trackingNumber exists here
await expect(page.getByText(order.trackingNumber)).toBeVisible();
await expect(cancelButton).not.toBeVisible();
} else if (order.status === 'confirmed') {
// TypeScript knows canShip is true here
await expect(shipButton).toBeEnabled();
await expect(cancelButton).toBeEnabled();
}
}The status property acts as a discriminant: TypeScript uses it to narrow the type inside each branch. When you access order.trackingNumber inside the status === 'shipped' branch, TypeScript knows that property exists on that specific variant.
Type narrowing in tests
Type narrowing is how TypeScript refines a broad type to a specific one inside a conditional block. You use it constantly in test code without necessarily recognizing it as a formal concept.
// typeof narrowing: distinguishing primitive types
function formatDisplayValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.trim(); // TypeScript knows value is string here
}
if (typeof value === 'number') {
return value.toFixed(2); // TypeScript knows value is number here
}
return value ? 'Yes' : 'No'; // TypeScript knows value is boolean here
}// in narrowing: checking for property existence on union types
type ApiSuccess = { data: unknown; status: 'success' };
type ApiError = { message: string; code: number; status: 'error' };
type ApiResponse = ApiSuccess | ApiError;
function assertApiSuccess(response: ApiResponse): asserts response is ApiSuccess {
if (response.status === 'error') {
throw new Error(`API error ${response.code}: ${response.message}`);
}
}
async function testOrderCreation(): Promise<void> {
const response: ApiResponse = await createOrder({ productId: 'p1', quantity: 1 });
assertApiSuccess(response); // Throws if error, narrows type if not
// TypeScript now knows response is ApiSuccess
expect(response.data).toBeDefined();
}// instanceof narrowing: useful when working with class instances
import { LoginPage, DashboardPage } from '../pages';
type AppPage = LoginPage | DashboardPage;
async function takeScreenshotWithContext(appPage: AppPage): Promise<void> {
if (appPage instanceof LoginPage) {
// TypeScript knows appPage is LoginPage here
await appPage.navigate();
} else {
// TypeScript knows appPage is DashboardPage here
const count = await appPage.getOrderCount();
console.log(`Dashboard showing ${count} orders`);
}
}The pattern that appears most often in test fixtures is null narrowing: handling the fact that DOM methods like textContent() return string | null:
async function getHeadingText(page: Page): Promise<string> {
const text = await page.getByRole('heading').first().textContent();
// Without this check, TypeScript errors: 'text' might be null
if (text === null) {
throw new Error('Heading element has no text content');
}
return text; // TypeScript knows text is string here
}Strict mode settings that catch real bugs
TypeScript's strict flag in tsconfig.json enables a group of checks that are individually configurable but almost always worth enabling together.
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}strict: true is a shorthand that enables six individual checks. The two that catch the most bugs in test code are strictNullChecks and noImplicitAny.
strictNullChecks makes null and undefined separate from every other type. Without it, string and string | null are treated the same, which disables the entire point of null checking.
// With strictNullChecks: false (dangerous — the default before strict mode)
const text: string = null; // No error. Runtime explosion when you call text.trim()
// With strictNullChecks: true
const text: string = null; // Error: Type 'null' is not assignable to type 'string'
const safeText: string | null = null; // Fine — you've declared the possibilitynoImplicitAny requires explicit types anywhere TypeScript can't infer them. In test code, this catches untyped function parameters before they become bugs:
// noImplicitAny catches this
function fillForm(data) { // Error: Parameter 'data' implicitly has an 'any' type
// data could be anything — no protection
}
// Correct: explicit type forces you to define the shape
function fillForm(data: RegistrationForm) {
// TypeScript validates every field access
}noUncheckedIndexedAccess is worth enabling separately. It adds | undefined to array element access and object index signatures, because accessing array[0] on an empty array returns undefined at runtime:
// Without noUncheckedIndexedAccess
const rows: string[] = [];
const first: string = rows[0]; // TypeScript allows this, but rows[0] is undefined at runtime
// With noUncheckedIndexedAccess
const rows: string[] = [];
const first: string | undefined = rows[0]; // TypeScript forces you to handle undefined
if (first !== undefined) {
console.log(first.toUpperCase()); // Now safe
}[!warning]
Adding these strict settings to an existing JavaScript-to-TypeScript migration will produce many errors at once. If you're converting an existing project, add"strict": truefirst and fix those errors before enablingnoUncheckedIndexedAccess. Trying to fix all errors simultaneously makes the migration feel endless.
The exactOptionalPropertyTypes flag is useful for test data factories. Without it, setting an optional property to undefined explicitly is treated the same as omitting it, which they aren't when the property gets serialized to JSON:
interface UpdateRequest {
email?: string;
firstName?: string;
}
// With exactOptionalPropertyTypes: true
const partial: UpdateRequest = { email: undefined }; // Error: undefined is not assignable to string
const correct: UpdateRequest = { email: 'new@example.com' }; // Fine — only include what you're updatingFAQ
When should I use a generic type parameter instead of a specific type?When you're writing a function or fixture that works with multiple types but needs to preserve the relationship between its input type and output type. If you're writing a factory function that returns whatever type you pass as a parameter, that's a generic. If you're writing a function that always works with UserProfile, use UserProfile directly. Don't add generics for flexibility you don't need yet.
!) in tests?
Occasionally, yes. When you know from context that a value can't be null but TypeScript can't verify it (for example, after asserting an element is visible), using value! is reasonable. The risk is using it to silence legitimate errors. If you find yourself writing ! frequently, that's a sign your types don't accurately describe your data.
Prefer inference where TypeScript can clearly determine the type: const user = createTestUser() doesn't need an annotation if createTestUser has a return type. Add explicit annotations on function parameters, return types, and class properties. This gives you the benefits of type checking at the boundaries without cluttering every line.
type Foo = Bar and interface Foo extends Bar?
Both create a type named Foo that includes all the properties of Bar. The practical difference: interface extension is cleaner when you're adding properties to an existing shape and you want the intent to be clear. Type intersection (Foo = Bar & { extra: string }) is more flexible because it works with any type, not just interfaces. In test code, both are fine. Pick the one that reads more naturally.
Yes. The test suite is often the best place to introduce TypeScript on a JavaScript team, because the scope is bounded and the benefits are immediate. Playwright supports TypeScript natively, test files are standalone, and the typed Page Objects and fixture definitions serve as living documentation of the application's data shapes. Teams frequently start with typed tests and later expand TypeScript to the application code.
→ See also: TypeScript Interfaces and Types for Page Object Model | TypeScript Best Practices in Playwright Test Code | Page Object Model in Playwright: From Messy to Maintainable