Playwright supports JavaScript, TypeScript, Python, Java, and .NET. But in 2026, TypeScript is the default. The Playwright team writes Playwright itself in TypeScript, the official docs show TypeScript examples first, and the Claude AI MCP Server integration requires TypeScript. If you want the best developer experience, the strongest tooling, and the most maintainable test suites, this is the language to use.
This Playwright TypeScript tutorial covers everything from zero to production-ready CI/CD. Whether you are learning Playwright for the first time or migrating existing JavaScript tests to TypeScript, this guide gives you the patterns and code you need.
Why TypeScript for Playwright?
Before writing a single test, it is worth understanding why TypeScript matters for test automation specifically — not just for application development.
Type Safety Catches Bugs Before They Run
In JavaScript, you can pass a number where a string is expected, misspell a method name, or forget to await an async call — and the error only appears at runtime, often as a confusing failure in CI. TypeScript catches all of these at compile time, in your editor, before you run the test.
// No error in editor — fails at runtime await page.getByRole('buton', { name: 'Submit' }); // typo: 'buton' await page.goto(123); // wrong type: number instead of string
// TypeScript error: Argument of type '"buton"' is not assignable // to parameter of type AriaRole await page.getByRole('buton', { name: 'Submit' }); // red squiggly line // TypeScript error: Argument of type 'number' is not assignable // to parameter of type 'string' await page.goto(123); // caught before you save
Autocomplete for Every Playwright API
TypeScript enables your IDE (VS Code, WebStorm, Cursor) to show every available method, parameter, and option as you type. When you type page., you see the full list of 80+ methods with descriptions. When you type getByRole(', you get a dropdown of valid ARIA roles. This eliminates documentation lookups and reduces typos to near zero.
Safe Refactoring
When you rename a Page Object method or change a test data interface, TypeScript updates every reference across your entire test suite — or shows you exactly where the breaking changes are. In a JavaScript codebase with 500 tests, a renamed method might silently break 30 tests that only fail when run. In TypeScript, you see all 30 errors before you commit.
Industry Standard in 2026
TypeScript adoption in the testing community has grown from roughly 40% in 2023 to over 75% in 2026. Job postings for QA Automation Engineers now overwhelmingly list TypeScript as a required skill. Learning Playwright with TypeScript positions you for the market as it exists today.
Project Setup
Setting up a Playwright TypeScript project takes one command. Playwright's initializer generates everything: config file, example tests, TypeScript configuration, and GitHub Actions workflow.
Step 1: Initialize the Project
# Create a new directory and initialize Playwright
mkdir my-playwright-project
cd my-playwright-project
npm init playwright@latest
The initializer asks you a few questions. Choose these options for a TypeScript setup:
- TypeScript or JavaScript? — Select TypeScript
- Where to put end-to-end tests? —
tests(default) - Add a GitHub Actions workflow? — Yes
- Install Playwright browsers? — Yes
Step 2: Generated Folder Structure
After initialization, your project looks like this:
my-playwright-project/ playwright.config.ts # Playwright configuration (TypeScript) tsconfig.json # TypeScript compiler options package.json # Dependencies tests/ example.spec.ts # Example test file tests-examples/ demo-todo-app.spec.ts # Full example: TodoMVC tests .github/ workflows/ playwright.yml # GitHub Actions CI workflow
Step 3: tsconfig.json Configuration
The generated tsconfig.json works out of the box. Here is the recommended configuration with explanations:
{
"compilerOptions": {
"target": "ES2020", // top-level await, modern syntax
"module": "NodeNext", // ESM-compatible module resolution
"moduleResolution": "NodeNext",
"strict": true, // enable all strict type checks
"esModuleInterop": true, // allow default imports from CJS modules
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true, // faster compilation, skip .d.ts checks
"baseUrl": ".", // enables clean imports: 'pages/login'
"paths": {
"@pages/*": ["pages/*"], // path alias for Page Objects
"@fixtures/*": ["fixtures/*"] // path alias for custom fixtures
}
},
"include": ["tests/**/*.ts", "pages/**/*.ts", "fixtures/**/*.ts", "playwright.config.ts"]
}
VS Code Extensions
Install these two extensions for the best Playwright TypeScript developer experience:
- Playwright Test for VS Code (ms-playwright.playwright) — run, debug, and record tests directly from the editor. Includes the Test Explorer sidebar and pick-locator tool.
- TypeScript Nightly (ms-vscode.vscode-typescript-next) — latest TypeScript language features and faster IntelliSense.
Tip: Use the VS Code command palette (Cmd+Shift+P) and run "Playwright: Install Browsers" to install browser binaries directly from the editor.
Writing Your First TypeScript Test
Let us write a complete Playwright TypeScript example test. This test navigates to a page, fills a form, submits it, and verifies the result.
import { test, expect } from '@playwright/test'; test.describe('Login Page', () => { test('successful login redirects to dashboard', async ({ page }) => { // Navigate to the login page await page.goto('https://app.example.com/login'); // Fill in credentials using semantic locators await page.getByLabel('Email address').fill('user@example.com'); await page.getByLabel('Password').fill('SecurePass123!'); // Click the submit button await page.getByRole('button', { name: 'Sign In' }).click(); // Assert: URL changed to dashboard await expect(page).toHaveURL(/dashboard/); // Assert: welcome heading is visible await expect( page.getByRole('heading', { name: 'Welcome back' }) ).toBeVisible(); }); test('invalid credentials show error message', async ({ page }) => { await page.goto('https://app.example.com/login'); await page.getByLabel('Email address').fill('wrong@example.com'); await page.getByLabel('Password').fill('WrongPassword'); await page.getByRole('button', { name: 'Sign In' }).click(); // Assert: error message appears await expect( page.getByText('Invalid email or password') ).toBeVisible(); // Assert: still on login page await expect(page).toHaveURL(/login/); }); });
Run this test with:
npx playwright test tests/login.spec.ts
Notice that you did not need to compile TypeScript first. Playwright's test runner handles TypeScript transpilation internally using SWC — it is fast and requires zero configuration.
TypeScript-Specific Syntax Explained
Several elements in the test above are TypeScript-specific:
async ({ page })— Destructured parameter with type inference. TypeScript knowspageis of typePagebecause it comes from the test fixture.await— TypeScript enforces that you await Playwright's promises. If you forget, TypeScript shows a warning (withstrict: true).getByRole('button', ...)— The first argument is typed asAriaRole, so TypeScript only accepts valid ARIA role strings.
TypeScript-Specific Features for Playwright
TypeScript brings several powerful features that make Playwright tests more robust and maintainable.
Interfaces for Test Data
Define interfaces for your test data to ensure consistency across tests:
export interface UserCredentials { email: string; password: string; } export interface Product { id: number; name: string; price: number; category: ProductCategory; } export enum ProductCategory { Electronics = 'electronics', Clothing = 'clothing', Books = 'books', Home = 'home', } export interface CheckoutData { user: UserCredentials; products: Product[]; shippingAddress: { street: string; city: string; zipCode: string; country: string; }; }
Now your tests get autocomplete for every field, and TypeScript prevents you from passing invalid data:
import { UserCredentials } from '../types/test-data'; const validUser: UserCredentials = { email: 'test@example.com', password: 'SecurePass123!', }; const invalidUser: UserCredentials = { email: 'test@example.com', pasword: 'typo', // TypeScript error: 'pasword' does not exist };
Enums for Test States
Use enums to represent states and avoid magic strings scattered across tests:
export enum OrderStatus { Pending = 'pending', Processing = 'processing', Shipped = 'shipped', Delivered = 'delivered', Cancelled = 'cancelled', } // In your test — type-safe, no typos possible await expect(page.getByTestId('order-status')) .toHaveText(OrderStatus.Shipped);
Type Inference with Locators
Playwright's locator methods return typed Locator objects. TypeScript infers the type automatically, giving you autocomplete for every subsequent method call:
// TypeScript infers: submitButton is of type Locator const submitButton = page.getByRole('button', { name: 'Submit' }); // Full autocomplete: .click(), .fill(), .isVisible(), etc. await submitButton.click(); // TypeScript error: Property 'clikc' does not exist on type 'Locator' await submitButton.clikc(); // caught immediately
Page Object Model in TypeScript
The Page Object Model (POM) is the most important design pattern for maintainable Playwright test suites. TypeScript makes POM significantly better than JavaScript by enforcing typed method signatures, constructor parameters, and return types.
Defining a Page Object Class
import { type Page, type Locator } from '@playwright/test'; export class LoginPage { // Typed locator properties private readonly emailInput: Locator; private readonly passwordInput: Locator; private readonly submitButton: Locator; private readonly errorMessage: Locator; // Constructor injection — receives typed Page object constructor(private readonly page: Page) { this.emailInput = page.getByLabel('Email address'); this.passwordInput = page.getByLabel('Password'); this.submitButton = page.getByRole('button', { name: 'Sign In' }); this.errorMessage = page.getByTestId('login-error'); } // Type-safe navigation method async goto(): Promise<void> { await this.page.goto('/login'); } // Accepts typed credentials async login(email: string, password: string): Promise<void> { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); } // Returns typed string async getErrorMessage(): Promise<string> { return await this.errorMessage.textContent() ?? ''; } }
Using the Page Object in Tests
import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; test('successful login', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'SecurePass123!'); await expect(page).toHaveURL(/dashboard/); }); test('shows error for invalid credentials', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('wrong@example.com', 'WrongPassword'); const error = await loginPage.getErrorMessage(); expect(error).toContain('Invalid email or password'); });
Advanced POM: Composing Page Objects
For larger applications, page objects can reference each other. TypeScript ensures the composition is type-safe:
import { type Page, type Locator } from '@playwright/test'; export interface DashboardStats { totalOrders: number; revenue: number; activeUsers: number; } export class DashboardPage { private readonly heading: Locator; constructor(private readonly page: Page) { this.heading = page.getByRole('heading', { name: 'Dashboard' }); } // Returns a typed object — tests get autocomplete for stats.totalOrders async getStats(): Promise<DashboardStats> { return { totalOrders: Number(await this.page.getByTestId('total-orders').textContent()), revenue: Number(await this.page.getByTestId('revenue').textContent()), activeUsers: Number(await this.page.getByTestId('active-users').textContent()), }; } async isLoaded(): Promise<boolean> { return await this.heading.isVisible(); } }
Fixtures with TypeScript
Playwright fixtures are the mechanism for injecting dependencies into your tests — authenticated pages, API clients, test data, or custom utilities. TypeScript makes fixtures type-safe through generics.
Defining Custom Fixtures
import { test as base, type Page } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { DashboardPage } from '../pages/dashboard.page'; // Define the shape of your custom fixtures interface MyFixtures { loginPage: LoginPage; dashboardPage: DashboardPage; authenticatedPage: Page; } // Extend the base test with typed fixtures export const test = base.extend<MyFixtures>({ loginPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await use(loginPage); }, dashboardPage: async ({ page }, use) => { const dashboardPage = new DashboardPage(page); await use(dashboardPage); }, authenticatedPage: async ({ page }, use) => { // Login before the test await page.goto('/login'); await page.getByLabel('Email address').fill('admin@example.com'); await page.getByLabel('Password').fill('AdminPass123!'); await page.getByRole('button', { name: 'Sign In' }).click(); await page.waitForURL(/dashboard/); // Provide the authenticated page to the test await use(page); }, }); export { expect } from '@playwright/test';
Using Custom Fixtures in Tests
// Import from your fixtures file, not from @playwright/test import { test, expect } from '../fixtures/my-fixtures'; test('dashboard shows correct stats', async ({ authenticatedPage, dashboardPage }) => { // authenticatedPage is already logged in // dashboardPage has full autocomplete for all methods const stats = await dashboardPage.getStats(); // TypeScript knows stats has: totalOrders, revenue, activeUsers expect(stats.totalOrders).toBeGreaterThan(0); expect(stats.revenue).toBeGreaterThan(0); });
Key insight: The test.extend<MyFixtures>() generic parameter is what gives TypeScript the type information. Without it, destructured fixtures like { loginPage } would be typed as any and you'd lose all autocomplete and type checking.
API Testing with TypeScript
Playwright includes a built-in API testing client. TypeScript makes API testing significantly more reliable by typing request bodies and response objects.
Defining Response Interfaces
export interface ApiUser { id: number; name: string; email: string; role: 'admin' | 'user' | 'viewer'; createdAt: string; } export interface ApiResponse<T> { data: T; status: number; message: string; } export interface CreateUserPayload { name: string; email: string; role: 'admin' | 'user' | 'viewer'; }
Writing Typed API Tests
import { test, expect } from '@playwright/test'; import type { ApiUser, ApiResponse, CreateUserPayload } from '../../types/api'; test.describe('Users API', () => { test('GET /api/users returns user list', async ({ request }) => { const response = await request.get('/api/users'); expect(response.status()).toBe(200); // Type the JSON response const body = await response.json() as ApiResponse<ApiUser[]>; // TypeScript knows body.data is ApiUser[] expect(body.data.length).toBeGreaterThan(0); expect(body.data[0].email).toContain('@'); expect(['admin', 'user', 'viewer']).toContain(body.data[0].role); }); test('POST /api/users creates a new user', async ({ request }) => { // Typed payload — TypeScript enforces required fields const payload: CreateUserPayload = { name: 'Jane Doe', email: 'jane@example.com', role: 'user', }; const response = await request.post('/api/users', { data: payload, }); expect(response.status()).toBe(201); const body = await response.json() as ApiResponse<ApiUser>; expect(body.data.name).toBe('Jane Doe'); expect(body.data.id).toBeDefined(); }); });
Tip: Use as type assertions for API responses carefully. For production test suites, consider using a runtime validation library like Zod to validate response shapes at runtime and get TypeScript types from the same schema definition.
Configuration Deep Dive: playwright.config.ts
The Playwright TypeScript config file is where you define browsers, timeouts, base URLs, reporters, and project-level settings. TypeScript ensures every option is valid.
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ // Test directory testDir: './tests', // Run tests in parallel fullyParallel: true, // Fail the build on CI if test.only is left in source forbidOnly: !!process.env.CI, // Retry failed tests in CI only retries: process.env.CI ? 2 : 0, // Limit parallel workers in CI workers: process.env.CI ? 1 : undefined, // Reporter configuration reporter: [ ['list'], ['html', { open: 'never' }], ['json', { outputFile: 'test-results/results.json' }], ], // Shared settings for all projects use: { // Base URL for page.goto('/login') → 'https://staging.example.com/login' baseURL: 'https://staging.example.com', // Capture trace on first retry of failed test trace: 'on-first-retry', // Screenshot on failure screenshot: 'only-on-failure', // Video recording video: 'retain-on-failure', // Global timeout per action (click, fill, etc.) actionTimeout: 10_000, // Navigation timeout navigationTimeout: 30_000, }, // Browser projects projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, // Mobile viewports { name: 'mobile-chrome', use: { ...devices['Pixel 7'] }, }, { name: 'mobile-safari', use: { ...devices['iPhone 15'] }, }, ], });
The defineConfig() function is the key to TypeScript support in the config file. It provides full type checking and autocomplete for every option. If you mistype an option name or pass an invalid value, TypeScript catches it immediately.
| Config Option | Purpose | Recommended Value |
|---|---|---|
| fullyParallel | Run tests in parallel within each file | true |
| retries | Retry failed tests | 2 in CI, 0 locally |
| trace | Record step-by-step DOM snapshots | on-first-retry |
| screenshot | Capture page on failure | only-on-failure |
| video | Record test execution video | retain-on-failure |
| forbidOnly | Prevent .only from reaching CI | !!process.env.CI |
CI/CD with TypeScript
Running Playwright TypeScript tests in CI/CD is straightforward — see our dedicated Playwright GitHub Actions CI/CD guide for the full setup. Playwright's test runner handles TypeScript transpilation, so you do not need a separate build step. However, adding a type-checking step ensures you catch type errors before tests run.
GitHub Actions Workflow
name: Playwright Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps - name: Type check (catch TS errors before running tests) run: npx tsc --noEmit - name: Run Playwright tests run: npx playwright test - name: Upload test report uses: actions/upload-artifact@v4 if: '!cancelled()' with: name: playwright-report path: playwright-report/ retention-days: 14
The tsc --noEmit Step
The tsc --noEmit step is critical. Playwright's test runner uses SWC to transpile TypeScript — it strips types but does not perform type checking. This means a test with a type error will still run (and probably fail with a confusing runtime error). Adding tsc --noEmit as a separate step catches type errors with clear messages before any tests execute.
Warning: Without the tsc --noEmit step, TypeScript type errors in your tests will not be caught in CI. Playwright will transpile and run the tests regardless — you'll get cryptic runtime errors instead of clean type errors.
Parallel Execution in CI
For faster CI runs, you can shard tests across multiple jobs:
strategy: matrix: shard: [1/4, 2/4, 3/4, 4/4] steps: - name: Run Playwright tests (shard ${{ matrix.shard }}) run: npx playwright test --shard=${{ matrix.shard }}
This splits your test suite across 4 parallel jobs, reducing total CI time by up to 75%.
Common TypeScript Mistakes in Playwright
Even experienced TypeScript developers make these mistakes when writing Playwright tests. Here are the most common ones and how to avoid them.
1. Forgetting to await
Every Playwright interaction is async. Forgetting await means the action fires but your test continues without waiting for it to complete.
// BAD: missing await — click fires but test doesn't wait for it page.getByRole('button', { name: 'Submit' }).click(); // no await! await expect(page).toHaveURL(/success/); // may fail — click hasn't completed // GOOD: always await Playwright actions await page.getByRole('button', { name: 'Submit' }).click(); // awaited await expect(page).toHaveURL(/success/); // works reliably
Fix: Enable the @typescript-eslint/no-floating-promises ESLint rule. It flags any un-awaited promise as an error, catching this mistake in your editor.
2. Overusing Type Assertions (as)
Type assertions (as) tell TypeScript to trust you. Overusing them defeats the purpose of type safety.
// BAD: asserting a type without validation const user = await response.json() as ApiUser; // what if the API returns something else? // BETTER: validate the shape at runtime const body = await response.json(); expect(body).toHaveProperty('id'); expect(body).toHaveProperty('email'); const user = body as ApiUser; // now the assertion is backed by runtime checks
3. Using any to Silence Errors
When TypeScript shows an error, reaching for any is tempting — but it turns off type checking for that entire value chain.
// BAD: 'any' kills all type checking downstream const data: any = await response.json(); data.naem; // no error — 'any' allows anything, typo goes undetected // GOOD: use 'unknown' and narrow the type const data: unknown = await response.json(); if (isApiUser(data)) { data.name; // TypeScript knows this is ApiUser now }
4. Incorrect Generic Usage with Fixtures
// BAD: missing generic — fixtures are typed as 'any' export const test = base.extend({ myPage: async ({ page }, use) => { ... }, // myPage is 'any' in tests }); // GOOD: generic parameter provides type information export const test = base.extend<{ myPage: MyPage }>({ myPage: async ({ page }, use) => { ... }, // myPage is MyPage in tests });
5. Not Using strict Mode
Without "strict": true in your tsconfig.json, TypeScript is permissive — it allows implicit any, doesn't check for null, and misses many potential bugs. Always enable strict mode for Playwright projects.
Frequently Asked Questions
Do I need to know TypeScript before learning Playwright?
No. If you know JavaScript, you can start writing Playwright tests in TypeScript immediately. TypeScript adds type annotations on top of JavaScript, and Playwright's types are inferred automatically in most cases. You will learn TypeScript patterns naturally as you write tests. The Playwright + Claude AI course covers TypeScript fundamentals alongside Playwright so you can learn both together.
Is TypeScript better than JavaScript for Playwright tests?
Yes, for any project beyond a handful of tests. TypeScript catches type errors at compile time, provides autocomplete for every Playwright API, makes refactoring safe, and serves as living documentation for your test data structures. The Playwright team writes Playwright itself in TypeScript, and the official documentation defaults to TypeScript examples.
How do I configure tsconfig.json for Playwright?
Running npm init playwright@latest generates a tsconfig.json automatically. The key settings are: target ES2020 or later for top-level await support, module NodeNext for ESM compatibility, strict mode enabled for maximum type safety, and baseUrl set to your project root for clean imports.
Can I use Playwright TypeScript tests in CI/CD pipelines?
Absolutely. Playwright TypeScript tests run directly without a separate build step — the test runner handles TypeScript compilation internally. For CI/CD, add a tsc --noEmit step to catch type errors before running tests. GitHub Actions, GitLab CI, Jenkins, and Azure DevOps all support this workflow natively.
What is the Page Object Model in Playwright TypeScript?
The Page Object Model (POM) is a design pattern where each page or component gets its own TypeScript class. The class encapsulates locators and actions as typed methods. POM classes accept a Page object via constructor injection, expose async methods for user interactions, and return typed data. If a selector changes, you update one class instead of dozens of tests.
How do I create custom fixtures in Playwright with TypeScript?
Use test.extend<MyFixtures>() where MyFixtures is a TypeScript interface defining your fixture types. Each fixture is a function that receives existing fixtures and a use callback. TypeScript ensures every test that destructures your custom fixtures gets full type safety and autocomplete.
Does Playwright compile TypeScript before running tests?
Playwright uses a built-in TypeScript transformer (based on SWC) that transpiles TypeScript to JavaScript on the fly during test execution. This means you do not need a separate tsc build step to run tests. However, the transformer only strips types — it does not perform type checking. Run tsc --noEmit separately to catch type errors, ideally as a CI step before test execution.
Asim Noaman
Senior QA Automation Engineer & AI Testing Specialist
With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.