Migrating a Playwright Project from JS to TS

9 min read

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 typescript

Playwright'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.ts

Most 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 as Locator makes the intent explicit and enables refactoring.
  • Not exporting expect from the fixtures file. Tests that import test from fixtures.ts often also need expect. Export it: export { expect } from '@playwright/test' so tests import both from one place.
  • Skipping tsconfig.json because "Playwright doesn't need it." Playwright compiles TS without tsc, but VS Code and npm run type-check do use tsconfig.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.

  1. Install TypeScript and create tsconfig.json.
  2. Rename playwright.config.jsplaywright.config.ts. Run npx playwright test to confirm it still works.
  3. Rename one simple test file to .spec.ts. Fix any type errors. Run that test.
  4. Migrate one page object from JavaScript to TypeScript. Add private readonly Locator properties initialised in the constructor. Add explicit Promise<void> and Promise<string | null> return types to each method.
  5. Create a tests/fixtures.ts that exposes at least one page object as a fixture. Update the test file to import test from fixtures instead of @playwright/test.
  6. Stretch: find one await response.json() call in a test or API helper. Replace the as SomeType cast 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.

// tip to track lessons you complete and pick up where you left off across devices.