Setting Up TypeScript with Playwright

8 min read

Playwright is TypeScript-first. When you create a new project, the wizard asks "JavaScript or TypeScript?" and most teams pick TypeScript without a second thought — the type definitions, the API design, the documentation are all built around it. This lesson sets up a fresh Playwright project, walks through the typed test API, and shows the test.extend<Fixtures> pattern that's unique to Playwright and one of the strongest reasons to write tests in TypeScript.

Setting up a fresh Playwright project

npm init playwright@latest

The wizard prompts:

  • TypeScript or JavaScript? → TypeScript.
  • Tests folder?tests (the default).
  • Add a GitHub Actions workflow? → optional.
  • Install browsers? → yes.

What you get:

tests/
└── example.spec.ts
playwright.config.ts          ← typed config
package.json                  ← @playwright/test in devDependencies
tsconfig.json                 ← optional; Playwright works without one

playwright.config.ts is already TypeScript. The default looks like this:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  reporter: "html",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox",  use: { ...devices["Desktop Firefox"] } },
  ],
});

defineConfig is generic over the project's options — pass an object that violates the contract (a misspelled key, a wrong type) and the compiler flags it. devices is a typed map of preset browser configurations. Every part of the config is checked.

Your first typed Playwright test

import { test, expect } from "@playwright/test";
 
test("logs in with valid credentials", async ({ page }) => {
  await page.goto("/login");
  await page.getByTestId("email").fill("alice@test.com");
  await page.getByTestId("password").fill("SecurePass123");
  await page.getByTestId("submit").click();
  await expect(page).toHaveURL(/dashboard/);
});

Hover over page in VS Code — it's typed as Page. Hover over expect(page) — it's typed as PageAssertions with a long list of to... methods, every one expecting the right argument type. Forget an await? The compiler can't catch that directly, but linters paired with Playwright's type definitions usually do.

The typed locator API (getByTestId, getByRole, getByLabel) is one of the most pleasant places to feel TypeScript pay off. Chaining methods autocomplete the next call. Wrong argument types are rejected at compile time. The Playwright commands cheat sheet has the full set — every one is fully typed.

Custom fixtures with test.extend<Fixtures>

Cypress augments the global cy chain with custom commands. Playwright takes a different route: fixtures. A fixture is a value (or a setup-and-teardown lifecycle) that's injected into your test by name. The killer combo is the typed extension that defines what fixtures are available:

import { test as base, type Page } from "@playwright/test";
 
interface UserCredentials {
  email: string;
  password: string;
}
 
interface TestFixtures {
  adminUser: UserCredentials;
  loggedInPage: Page;
}
 
export const test = base.extend<TestFixtures>({
  adminUser: async ({}, use) => {
    await use({ email: "admin@test.com", password: "AdminPass123" });
  },
 
  loggedInPage: async ({ page, adminUser }, use) => {
    await page.goto("/login");
    await page.getByTestId("email").fill(adminUser.email);
    await page.getByTestId("password").fill(adminUser.password);
    await page.getByTestId("submit").click();
    await use(page);
  },
});
 
export { expect } from "@playwright/test";

The <TestFixtures> generic argument is what makes this feel magical: every fixture you declare in the interface is now available to tests that import this test, with full type safety.

import { test, expect } from "./fixtures";
 
test("admin sees the admin nav", async ({ loggedInPage }) => {
  await expect(loggedInPage.getByText("Admin Tools")).toBeVisible();
});

loggedInPage is typed as Page. The compiler enforces that the fixture exists and has the declared type. Add loggedInUser to the interface but forget to implement it in extend(...) and you get a type error pointing at the missing key. Misspell loggedInPage at the call site and the compiler suggests the right name.

This pattern is the one most TypeScript-first Playwright projects converge on. You'll see it in every mature Playwright codebase.

Cypress vs Playwright — TypeScript edition

Cypress + TypeScript vs Playwright + TypeScript

Cypress

  • TypeScript opt-in — install separately, edit cypress/tsconfig.json

  • Augment cy.* via declare global { namespace Cypress { ... } }

  • Custom commands registered via Cypress.Commands.add

  • Fixtures via cy.fixture<T>('name.json').then(...)

  • One global cy chainable — declaration merging is the only extension point

Playwright

  • TypeScript-first — wizard defaults to it, config is .ts

  • Augment via test.extend<Fixtures>({ ... })

  • Fixtures injected by name into each test's parameters

  • Generic argument on extend gives type safety per project

  • Multiple test objects — easy to compose project-wide and feature-specific extensions

Both produce equally type-safe test code; they just take different routes. Cypress's Chainable augmentation feels more like patching a global; Playwright's test.extend feels more like dependency injection. Use whichever you're already standardised on — the TypeScript ergonomics are first-class in both.

tsconfig.json for Playwright

Playwright projects often work without an explicit tsconfig.json@playwright/test ships its own internal compilation pipeline. If you add one (because your project also has utility code, page objects, or a shared library), the same minimal config from chapter 1 is a good starting point:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["tests/**/*", "playwright.config.ts"]
}

"strict": true is the line that earns its keep — it forces every helper, fixture, and page object you write to be properly typed.

A small typed Playwright project, complete

// fixtures.ts
import { test as base, type Page } from "@playwright/test";
 
interface UserCredentials { email: string; password: string }
 
interface TestFixtures {
  adminUser: UserCredentials;
  testerUser: UserCredentials;
  loggedInAsAdmin: Page;
}
 
export const test = base.extend<TestFixtures>({
  adminUser: async ({}, use) => {
    await use({ email: "admin@test.com", password: "AdminPass123" });
  },
  testerUser: async ({}, use) => {
    await use({ email: "tester@test.com", password: "TesterPass123" });
  },
  loggedInAsAdmin: async ({ page, adminUser }, use) => {
    await page.goto("/login");
    await page.getByTestId("email").fill(adminUser.email);
    await page.getByTestId("password").fill(adminUser.password);
    await page.getByTestId("submit").click();
    await use(page);
  },
});
 
export { expect } from "@playwright/test";
// tests/admin.spec.ts
import { test, expect } from "../fixtures";
 
test("admin can open the user management screen", async ({ loggedInAsAdmin }) => {
  await loggedInAsAdmin.getByRole("link", { name: "Users" }).click();
  await expect(loggedInAsAdmin).toHaveURL(/\/admin\/users/);
});

Two files, every fixture typed, every locator typed, every assertion typed. Adding a third fixture is a one-line addition to the interface plus its implementation.

⚠️ Common mistakes

  • Forgetting the generic on test.extend<Fixtures>. Without it, every fixture you declare is typed as any — and your tests lose all of TypeScript's protection against typos and wrong shapes. The <Fixtures> generic is what makes the typing real.
  • Re-importing test from @playwright/test after extending it. Once you've created an extended test with custom fixtures, every test file in that suite should import from your fixtures file (./fixtures), not from @playwright/test. Mixing the two means some tests have access to the custom fixtures and others don't.
  • Treating Playwright's TypeScript types as runtime validation. Like all of TypeScript, Playwright's types disappear at compile time. They protect your test code from typos in fixture names and locator chains; they don't validate that the page matches what your test assumes. Use Playwright's locators (which fail clearly when an element is missing) and expect(...) matchers to catch runtime mismatches.

🎯 Practice task

Build a typed Playwright project from scratch. 30-45 minutes.

  1. In a new folder, run npm init playwright@latest. Select TypeScript.
  2. Open tests/example.spec.ts and read the auto-generated test. Run it with npx playwright test — confirm it passes.
  3. Create fixtures.ts at the project root. Declare interface UserCredentials and interface TestFixtures exactly as in the lesson. Export the extended test and re-export expect.
  4. Write a tests/admin.spec.ts that imports from ../fixtures and uses the loggedInAsAdmin fixture. (Mock the target with a free demo site or a stub if no app is available.)
  5. Trigger every fixture-typing check. After each, read the error and revert:
    • Add loggedInUser: Page to TestFixtures but forget to implement it in extend(...).
    • Use loggedInAsAdmim (typo) in a test parameter.
    • Set await use(page.title()) (string, not Page) inside loggedInAsAdmin's implementation.
  6. Stretch: add a apiClient fixture that constructs a typed wrapper around request and exposes typed methods like apiClient.getUser(id: number): Promise<User>. Use it in a test that calls the API and the UI side by side.

The next lesson formalises the cleanest patterns for page objects in both frameworks — the typed building blocks tests will compose on top of fixtures and custom commands.

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