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@latestThe 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 asany— 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
testfrom@playwright/testafter extending it. Once you've created an extendedtestwith 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.
- In a new folder, run
npm init playwright@latest. Select TypeScript. - Open
tests/example.spec.tsand read the auto-generated test. Run it withnpx playwright test— confirm it passes. - Create
fixtures.tsat the project root. Declareinterface UserCredentialsandinterface TestFixturesexactly as in the lesson. Export the extendedtestand re-exportexpect. - Write a
tests/admin.spec.tsthat imports from../fixturesand uses theloggedInAsAdminfixture. (Mock the target with a free demo site or a stub if no app is available.) - Trigger every fixture-typing check. After each, read the error and revert:
- Add
loggedInUser: PagetoTestFixturesbut forget to implement it inextend(...). - Use
loggedInAsAdmim(typo) in a test parameter. - Set
await use(page.title())(string, not Page) insideloggedInAsAdmin's implementation.
- Add
- Stretch: add a
apiClientfixture that constructs a typed wrapper aroundrequestand exposes typed methods likeapiClient.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.