TypeScript's strictNullChecks forces you to handle the case where an element's text returns null, instead of letting text.length crash mid-test with Cannot read properties of null. Page object methods with typed return types catch the other common mistake: loginWith() returning Page instead of DashboardPage means tests can't tell which page they landed on. This article covers the tsconfig settings with the highest impact on test quality, typed fixtures, unknown over any for API response data, and utility types for separating what you send to the server from what you get back.
Strict tsconfig settings for test code
Start with a strict TypeScript configuration:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true
},
"include": ["tests/**/*", "fixtures/**/*", "pages/**/*"]
}strictNullChecks is the most valuable setting for test code. It forces you to handle null and undefined explicitly. This prevents Cannot read properties of null errors at runtime.
noUnusedLocals and noUnusedParameters eliminate dead code silently accumulating in test files.
Type test data
Untyped test data is a common mistake — you rely on object shape being correct at write time, then a field name changes and tests silently pass on stale data.
// Bad
const user = {
email: 'test@example.com',
password: 'pass123',
};
// Good — type ensures the shape stays correct
interface TestUser {
email: string;
password: string;
role: 'admin' | 'user' | 'viewer';
}
const testUser: TestUser = {
email: 'test@example.com',
password: 'pass123',
role: 'user',
};When you add a new required field to TestUser, TypeScript immediately flags every test data object that doesn't include it.
Type page object methods
Page object methods that navigate to a new page should return the new page object type:
export class LoginPage {
// Return type makes navigation explicit
async loginWith(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForURL('/dashboard');
return new DashboardPage(this.page);
}
// Void for actions that stay on the same page
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
// Return type for data extraction
async getErrorMessage(): Promise<string | null> {
if (await this.errorMessage.isVisible()) {
return this.errorMessage.textContent();
}
return null;
}
}The return type Promise forces callers to handle the null case — no more const text = await page.getErrorMessage(); expect(text.length).toBeGreaterThan(0) crashing on null.
Generic fixtures
Typed fixtures prevent fixture misuse:
// fixtures/auth.ts
import { test as base } from '@playwright/test';
interface AuthFixtures {
userPage: Page; // Pre-authenticated as regular user
adminPage: Page; // Pre-authenticated as admin
authToken: string;
}
export const test = base.extend<AuthFixtures>({
authToken: async ({ request }, use) => {
const response = await request.post('/api/auth/login', {
data: { email: process.env.TEST_USER_EMAIL!, password: process.env.TEST_USER_PASSWORD! },
});
expect(response.ok()).toBeTruthy();
const { token } = await response.json() as { token: string };
await use(token);
},
userPage: async ({ page, authToken }, use) => {
await page.context().addCookies([{
name: 'auth_token',
value: authToken,
domain: 'localhost',
path: '/',
}]);
await use(page);
},
});TypeScript ensures you can only access userPage and adminPage in tests that use this fixture extension, not in tests using the base test import.
Const assertions for selectors
Avoid duplicating selector strings across tests:
// selectors.ts
export const Selectors = {
login: {
emailInput: 'label:has-text("Email") >> input',
passwordInput: 'label:has-text("Password") >> input',
submitButton: 'button[type="submit"]',
},
checkout: {
cartTotal: '[data-testid="cart-total"]',
placeOrderButton: 'button:has-text("Place order")',
},
} as const; // 'as const' makes strings literal types — no accidental mutationBetter: use getByRole, getByLabel, etc. in page objects instead of string selectors at all. But when string selectors are necessary, as const prevents accidental reassignment.
Discriminated unions for API responses
When testing APIs that return different shapes based on success/failure:
type ApiSuccess<T> = {
success: true;
data: T;
};
type ApiError = {
success: false;
error: string;
code: number;
};
type ApiResponse<T> = ApiSuccess<T> | ApiError;
// Type narrowing in tests
const body = await response.json() as ApiResponse<{ orderId: string }>;
if (body.success) {
expect(body.data.orderId).toBeTruthy(); // TypeScript knows data exists here
} else {
expect(body.code).toBe(422); // TypeScript knows error and code exist here
}Avoid any — use unknown for untyped data
When you receive data from an API and don't have a type yet, use unknown instead of any:
// Bad — any disables all type checking
const body: any = await response.json();
body.nonExistentField.deeply.nested; // No error, silently wrong
// Good — unknown forces you to validate before using
const body: unknown = await response.json();
// Must validate before accessing
if (typeof body === 'object' && body !== null && 'orderId' in body) {
console.log((body as { orderId: string }).orderId);
}
// Better: use a type guard
function isOrderResponse(data: unknown): data is { orderId: string; status: string } {
return typeof data === 'object' && data !== null && 'orderId' in data;
}
if (isOrderResponse(body)) {
expect(body.orderId).toBeTruthy(); // Typed
}Utility types for test data
TypeScript's built-in utility types reduce duplication in test data types:
interface User {
id: string;
email: string;
password: string;
role: 'admin' | 'user';
createdAt: Date;
}
// Test creation input — no id or createdAt (generated by server)
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// Test update input — all fields optional
type UpdateUserInput = Partial<Pick<User, 'email' | 'role'>>;
// Test assertion object — only verifiable fields
type UserAssertion = Pick<User, 'email' | 'role'>;These types make test data handling explicit: what you send to the server and what you get back are different shapes, and TypeScript enforces that difference.
→ See also: TypeScript Interfaces and Types for Page Object Model | Types, Interfaces, and Generics in TypeScript for Test Fixtures | Page Object Model in Playwright: From Messy to Maintainable