Playwright was designed with TypeScript in mind. Its init wizard defaults to TypeScript, its documentation examples are in TypeScript, and its type definitions are bundled with the package — no @types/playwright install needed. Migrating a JavaScript Playwright project is the most straightforward of the three framework migrations in this chapter. The framework itself requires no configuration changes for TypeScript; you're mainly doing the renaming and typing work covered in Chapters 3 and 4.
What changes and what doesn't
Before starting, it's worth knowing what Playwright handles for you: compilation. The Playwright test runner uses esbuild internally and processes .ts files directly. You don't run tsc to build test files — Playwright does it. Your tsconfig.json is still read for type-checking and strict settings, but the build pipeline is internal.
What you control: the tsconfig.json settings that determine which checks are enforced, and the type annotations you add to test files and page objects.
Step 1: Install TypeScript
npm install --save-dev typescriptPlaywright's types come bundled with @playwright/test — you already have them.
Step 2: Create tsconfig.json
Unlike the migration-friendly config from Chapter 2, a Playwright project can often start with a stricter config because Playwright test files tend to be simpler than large utility codebases. Start with moderate strictness:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": false,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}"include": ["**/*.ts"] and no allowJs is fine here because you'll rename all files during migration. If the project is large and you want to rename incrementally, add "allowJs": true as covered in Chapter 2.
Step 3: Rename playwright.config.js
git mv playwright.config.js playwright.config.ts// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Run npx playwright test to confirm the config loads correctly before touching test files.
Step 4: Rename test files
git mv tests/login.spec.js tests/login.spec.tsMost Playwright tests require minimal changes after renaming because page, request, and browser are already typed by Playwright's declarations:
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('logs in with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('shows error with invalid password', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('alice@test.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid credentials');
});The page parameter is typed as Page from Playwright. Every method — goto, getByLabel, getByRole, fill, click — is typed with its full signature. Autocomplete works, and mistakes like calling .fill() without a string are compile errors.
Step 5: Migrate page objects
This is where TypeScript pays off most in Playwright. Typed page objects give every test that uses them full autocomplete and type safety.
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
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 goto(): 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 getErrorMessage(): Promise<string | null> {
return this.errorAlert.textContent();
}
async isVisible(): Promise<boolean> {
return this.submitButton.isVisible();
}
}The private readonly properties prevent accidental reassignment and communicate intent. Locator is the Playwright type for an element reference — typed methods like fill, click, and textContent are available with correct signatures.
Step 6: Add typed fixtures
Custom fixtures are Playwright's most TypeScript-native feature. Migrate the fixture file as a .ts file from the start:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type QAFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
loggedInPage: LoginPage;
};
export const test = base.extend<QAFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
loggedInPage: async ({ page }, use) => {
const login = new LoginPage(page);
await login.goto();
await login.login('alice@test.com', 'password123');
await use(login);
},
});
export { expect } from '@playwright/test';Tests that import from fixtures.ts get all three fixtures fully typed:
import { test, expect } from '../fixtures';
test('dashboard is visible after login', async ({ loggedInPage, dashboardPage }) => {
await expect(dashboardPage.welcomeHeading).toBeVisible();
// loggedInPage: LoginPage — typed
// dashboardPage: DashboardPage — typed
});Playwright migration — the progression of type safety
JavaScript Playwright
page methods inferred loosely or not at all
Page objects: no property type checking
Wrong fill() argument: runtime error in CI
Fixtures: untyped, any shape accepted
Refactoring page objects: grep-and-check
TypeScript Playwright
All page.* methods fully typed with signatures
Page objects: typed Locators, typed return values
Wrong fill() argument: compile error in editor
Fixtures: exact types via test.extend<Fixtures>
Refactoring page objects: compiler finds every caller
API testing with Playwright's request fixture
Playwright's built-in request fixture makes API testing possible without a separate library. It's typed out of the box:
import { test, expect } from '@playwright/test';
test('creates a user via API', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
email: 'new@test.com',
password: 'pass123',
role: 'member',
},
});
expect(response.status()).toBe(201);
const user = await response.json() as { id: string; email: string };
expect(user.email).toBe('new@test.com');
});The as { id: string; email: string } cast is necessary because response.json() returns unknown — Playwright can't know the shape of your API's response. This is appropriate: the response shape is external and unverified at compile time, so a runtime check or schema validation (zod) is the correct long-term approach.
⚠️ Common mistakes
- Using
page.locator()return value directly in test assertions without storing it in a typed variable. While it works, storing locators in page object properties typed asLocatormakes the intent explicit and enables refactoring. - Not exporting
expectfrom the fixtures file. Tests that importtestfromfixtures.tsoften also needexpect. Export it:export { expect } from '@playwright/test'so tests import both from one place. - Skipping
tsconfig.jsonbecause "Playwright doesn't need it." Playwright compiles TS withouttsc, but VS Code andnpm run type-checkdo usetsconfig.json. Without it, you'll have red squiggles in the editor even though tests run — and no CI type-checking gate.
🎯 Practice task
Migrate a complete Playwright project or the samples from the TypeScript with Playwright lesson.
- Install TypeScript and create
tsconfig.json. - Rename
playwright.config.js→playwright.config.ts. Runnpx playwright testto confirm it still works. - Rename one simple test file to
.spec.ts. Fix any type errors. Run that test. - Migrate one page object from JavaScript to TypeScript. Add
private readonlyLocator properties initialised in the constructor. Add explicitPromise<void>andPromise<string | null>return types to each method. - Create a
tests/fixtures.tsthat exposes at least one page object as a fixture. Update the test file to importtestfrom fixtures instead of@playwright/test. - Stretch: find one
await response.json()call in a test or API helper. Replace theas SomeTypecast with a Zod schema:const body = schema.parse(await response.json()). Confirm that if the API returns an unexpected shape, the Zod parse throws with a descriptive error.