TypeScript Basics

A comprehensive guide to TypeScript for test automation engineers. Learn the type system, interfaces, classes, and how TypeScript integrates with Playwright to produce reliable, maintainable tests.

Introduction

What Is TypeScript?

TypeScript is an open-source programming language developed and maintained by Microsoft. It is a strict syntactical superset of JavaScript, which means every valid JavaScript file is also valid TypeScript. TypeScript adds optional static type annotations that are checked at compile time and then stripped away, producing plain JavaScript that runs anywhere JavaScript runs: in browsers, in Node.js, and in test runners like Playwright.

Why TypeScript for Test Automation?

When you write test automation code, you deal with complex objects like pages, locators, API responses, and test data. Without types, bugs slip through silently until your tests fail at runtime, often with cryptic error messages. TypeScript catches these problems before you even run the code:

  • Compile-time error detection — misspelled property names, wrong argument types, and missing fields are flagged instantly in your editor.
  • Intelligent autocomplete — your editor knows every method on a Page, every property on a Locator, and every matcher on expect().
  • Self-documenting code — type annotations act as living documentation. A function signature tells you exactly what it accepts and returns.
  • Safer refactoring — rename a property or change a function signature, and TypeScript shows every file that needs updating.

TypeScript vs JavaScript

The following example shows the same function written in both languages. Notice how the TypeScript version catches potential bugs at compile time rather than at runtime:

comparison.tstypescript
1// JavaScript - no type safety
2function login(username, password) {
3  // username could be anything: number, object, undefined...
4  return api.post('/login', { username, password });
5}
6
7// TypeScript - catches mistakes at compile time
8function login(username: string, password: string): Promise<AuthResponse> {
9  // TypeScript ensures username and password are always strings.
10  // If you pass a number by accident, the compiler will tell you
11  // BEFORE you even run the code.
12  return api.post('/login', { username, password });
13}
FeatureJavaScriptTypeScript
Type SafetyNone (dynamic typing)Full static type checking
Error DetectionAt runtime onlyAt compile time + runtime
Editor SupportBasic autocompleteRich autocomplete, refactoring, hover info
Learning CurveLowerSlightly higher (worth it)
Community AdoptionUniversalRapidly growing, standard in large projects
Key TakeawayTypeScript does not run in the browser or Node.js directly. It is compiled (transpiled) to JavaScript first. This means there is zero runtime overhead — types exist only during development and compilation.

Installation & Setup

Installing TypeScript and Playwright

Getting started is straightforward. You need Node.js installed on your machine (version 16 or higher is recommended). Then run the following commands in your terminal:

terminalbash
1# Create a new project directory
2mkdir my-playwright-project
3cd my-playwright-project
4
5# Initialize a Node.js project
6npm init -y
7
8# Install TypeScript and Node type definitions
9npm install --save-dev typescript @types/node
10
11# Install Playwright with its test runner
12npm install --save-dev @playwright/test
13
14# Install browsers (Chromium, Firefox, WebKit)
15npx playwright install
TipIf you are starting a brand-new Playwright project, you can use npm init playwright@latest which scaffolds a complete project with TypeScript configured automatically.

Understanding tsconfig.json

The tsconfig.json file is the TypeScript configuration for your project. It tells the TypeScript compiler how to process your files. Here is a well-commented configuration suitable for a Playwright project:

tsconfig.jsonjson
1{
2  "compilerOptions": {
3    // Target modern JavaScript output
4    "target": "ES2020",
5    // Use Node-style module resolution
6    "module": "commonjs",
7    "moduleResolution": "node",
8    // Enable all strict type-checking options
9    "strict": true,
10    // Allow default imports from modules with no default export
11    "esModuleInterop": true,
12    // Output compiled files to a dist/ folder
13    "outDir": "./dist",
14    // Root of your TypeScript source files
15    "rootDir": "./src",
16    // Generate source maps for debugging
17    "sourceMap": true,
18    // Skip type-checking declaration files for faster builds
19    "skipLibCheck": true,
20    // Ensure file name casing is consistent across platforms
21    "forceConsistentCasingInFileNames": true,
22    // Resolve JSON modules as imports
23    "resolveJsonModule": true
24  },
25  "include": ["src/**/*", "tests/**/*"],
26  "exclude": ["node_modules", "dist"]
27}

Key settings explained:

  • target — which version of JavaScript to output. ES2020 is safe for modern Node.js and supports optional chaining, nullish coalescing, etc.
  • strict — enables all strict type-checking options. This is the single most important setting. Always keep it true.
  • moduleResolution — set to "node" so TypeScript resolves imports the same way Node.js does.
  • outDir / rootDir — keep compiled output separate from your source files. Playwright usually runs TypeScript directly via its own loader, but these are useful for non-test code.
  • sourceMap — generates .map files that map compiled JavaScript back to your TypeScript, making debugging much easier.
ImportantPlaywright's test runner has its own TypeScript loader built in, so you do not need to manually compile .ts test files. However, you still need a tsconfig.json for your editor to provide proper IntelliSense and type checking.

Basic Types

TypeScript provides a rich set of built-in types. Understanding these is the foundation for everything else. Let us walk through each one:

basic-types.tstypescript
1// ─── Primitive Types ──────────────────────────────
2let username: string = 'admin';
3let age: number = 30;
4let isLoggedIn: boolean = true;
5
6// ─── Arrays ──────────────────────────────────────
7let scores: number[] = [95, 87, 92];
8let names: Array<string> = ['Alice', 'Bob', 'Charlie'];
9
10// ─── Tuple ────────────────────────────────────────
11// Fixed-length array where each position has a specific type
12let user: [string, number] = ['admin', 1];
13// user[0] is always string, user[1] is always number
14
15// ─── Enum ─────────────────────────────────────────
16enum TestStatus {
17  Passed = 'PASSED',
18  Failed = 'FAILED',
19  Skipped = 'SKIPPED',
20}
21let result: TestStatus = TestStatus.Passed;
22
23// ─── any vs unknown ──────────────────────────────
24// "any" disables type checking entirely (avoid!)
25let riskyValue: any = 42;
26riskyValue.nonExistentMethod(); // No error - dangerous!
27
28// "unknown" is the type-safe version of any
29let safeValue: unknown = 42;
30// safeValue.nonExistentMethod(); // Error! Must narrow first
31if (typeof safeValue === 'number') {
32  console.log(safeValue.toFixed(2)); // OK after type check
33}
34
35// ─── void and never ──────────────────────────────
36// "void" means a function returns nothing
37function logMessage(msg: string): void {
38  console.log(msg);
39}
40
41// "never" means a function never returns (throws or infinite loop)
42function throwError(message: string): never {
43  throw new Error(message);
44}

Type Reference Table

TypeDescriptionExample
stringText valueslet name: string = 'Alice'
numberAll numbers (integer and float)let age: number = 30
booleanTrue or falselet active: boolean = true
string[]Array of stringslet tags: string[] = ['a', 'b']
[string, number]Tuple (fixed-length typed array)let pair: [string, number] = ['age', 30]
anyDisables type checking (avoid)let x: any = 'anything'
unknownType-safe alternative to anylet x: unknown = getData()
voidFunction returns nothingfunction log(): void { }
neverFunction never returns (throws)function fail(): never { }
null / undefinedAbsence of valuelet x: string | null = null

Type Inference

One of TypeScript's best features is type inference. You do not need to annotate every single variable. When TypeScript can determine the type from context, it does so automatically:

inference.tstypescript
1// TypeScript infers the type from the assigned value.
2// You do NOT always need explicit annotations.
3
4let count = 10;             // inferred as number
5let greeting = 'hello';     // inferred as string
6let isActive = true;        // inferred as boolean
7
8// Arrays are also inferred
9let items = [1, 2, 3];     // inferred as number[]
10
11// Function return types are inferred from the return statement
12function double(n: number) {
13  return n * 2;             // return type inferred as number
14}
15
16// When should you add explicit types?
17// 1. Function parameters (always)
18// 2. Complex objects or API responses
19// 3. When the inferred type is too broad (e.g., "any")
20// 4. Public interfaces for clarity
TipHover over any variable in VS Code to see what type TypeScript has inferred. This is a great way to learn how inference works. If the inferred type looks correct, you can safely omit the explicit annotation.

Interfaces & Types

Interfaces and type aliases are the two main ways to describe the shape of objects in TypeScript. They are essential for test automation because you constantly work with structured data: page configurations, test credentials, API responses, and more.

Interfaces

An interface defines a contract for the structure of an object. It specifies what properties an object must have and what types those properties must be:

interfaces.tstypescript
1// ─── Interfaces ──────────────────────────────────
2// Interfaces describe the shape of an object.
3interface User {
4  id: number;
5  name: string;
6  email: string;
7  isActive: boolean;
8}
9
10// Now TypeScript enforces this shape everywhere:
11const admin: User = {
12  id: 1,
13  name: 'Admin',
14  email: 'admin@example.com',
15  isActive: true,
16};
17
18// ─── Optional Properties ─────────────────────────
19interface TestConfig {
20  baseURL: string;
21  timeout?: number;       // Optional - may or may not exist
22  retries?: number;       // Optional
23  headless: boolean;
24}
25
26const config: TestConfig = {
27  baseURL: 'https://example.com',
28  headless: true,
29  // timeout and retries are optional, so we can omit them
30};
31
32// ─── Readonly Properties ─────────────────────────
33interface Credentials {
34  readonly username: string;
35  readonly password: string;
36}
37
38const creds: Credentials = {
39  username: 'testuser',
40  password: 'secret123',
41};
42// creds.username = 'other'; // Error! Cannot reassign readonly
43
44// ─── Extending Interfaces ────────────────────────
45interface BasePage {
46  url: string;
47  title: string;
48}
49
50interface LoginPage extends BasePage {
51  usernameField: string;
52  passwordField: string;
53  submitButton: string;
54}
55
56// LoginPage now has url, title, usernameField,
57// passwordField, and submitButton

Type Aliases

Type aliases give a name to any type, not just objects. They are especially useful for union types, literal types, and utility types:

type-aliases.tstypescript
1// ─── Type Aliases ────────────────────────────────
2// Type aliases create a name for any type, not just objects.
3
4type ID = string | number;        // Union type
5type Status = 'pass' | 'fail' | 'skip';  // Literal type
6type Coordinates = [number, number];      // Tuple alias
7
8let userId: ID = 'abc-123';       // OK: string
9userId = 42;                      // OK: number
10// userId = true;                 // Error! boolean not allowed
11
12let testResult: Status = 'pass';  // OK
13// testResult = 'error';          // Error! Not in the union
14
15// ─── Interface vs Type ───────────────────────────
16// Interfaces can be extended and merged (declaration merging).
17// Types can represent unions, intersections, and primitives.
18
19// Use interface for objects you expect others to extend.
20// Use type for unions, primitives, and utility types.
21
22// ─── Intersection Types ──────────────────────────
23type HasName = { name: string };
24type HasAge = { age: number };
25type Person = HasName & HasAge;
26
27const person: Person = {
28  name: 'Alice',
29  age: 30,
30};
31
32// ─── Mapped / Utility Types ─────────────────────
33interface FormFields {
34  email: string;
35  password: string;
36  remember: boolean;
37}
38
39// Make all fields optional
40type PartialForm = Partial<FormFields>;
41
42// Make all fields required
43type RequiredForm = Required<FormFields>;
44
45// Pick specific fields
46type LoginFields = Pick<FormFields, 'email' | 'password'>;
47
48// Omit specific fields
49type QuickLogin = Omit<FormFields, 'remember'>;
Interface vs Type — When to Use Which?Use interface when defining the shape of objects, especially those that may be extended or implemented by classes. Use type when you need union types, intersection types, mapped types, or when naming primitive type combinations. In practice, both work for object shapes, so pick one convention and stay consistent within your project.

Functions

Functions are the building blocks of any test automation project. TypeScript allows you to type function parameters, return values, and even the function itself as a type. This means the compiler can verify that every function is called correctly and returns the expected data.

functions.tstypescript
1// ─── Typed Parameters and Return Types ───────────
2function add(a: number, b: number): number {
3  return a + b;
4}
5
6// ─── Optional Parameters ─────────────────────────
7function greet(name: string, greeting?: string): string {
8  return `${greeting ?? 'Hello'}, ${name}!`;
9}
10greet('Alice');            // "Hello, Alice!"
11greet('Alice', 'Hi');     // "Hi, Alice!"
12
13// ─── Default Parameters ─────────────────────────
14function createUser(
15  name: string,
16  role: string = 'viewer',
17  isActive: boolean = true
18): { name: string; role: string; isActive: boolean } {
19  return { name, role, isActive };
20}
21createUser('Bob');                // { name: 'Bob', role: 'viewer', isActive: true }
22createUser('Bob', 'admin');      // { name: 'Bob', role: 'admin', isActive: true }
23
24// ─── Arrow Functions ─────────────────────────────
25const multiply = (a: number, b: number): number => a * b;
26
27// With an object return type
28const buildUser = (name: string): User => ({
29  id: Date.now(),
30  name,
31  email: `${name.toLowerCase()}@test.com`,
32  isActive: true,
33});
34
35// ─── Function Types (callbacks) ──────────────────
36type ClickHandler = (element: string) => Promise<void>;
37
38const handleClick: ClickHandler = async (element) => {
39  console.log(`Clicking ${element}...`);
40};
41
42// ─── Async / Await ───────────────────────────────
43async function fetchUserData(userId: string): Promise<User> {
44  const response = await fetch(`/api/users/${userId}`);
45  if (!response.ok) {
46    throw new Error(`HTTP error: ${response.status}`);
47  }
48  const data: User = await response.json();
49  return data;
50}
51
52// ─── Rest Parameters ─────────────────────────────
53function logAll(prefix: string, ...messages: string[]): void {
54  messages.forEach(msg => console.log(`[${prefix}] ${msg}`));
55}
56logAll('TEST', 'Started', 'Running assertions', 'Done');
TipIn Playwright tests, almost every interaction with the page is asynchronous. Always use async/await and type your return values as Promise<T>. This makes it clear to both TypeScript and other developers that the function is asynchronous.
Common MistakeForgetting to await an asynchronous function is one of the most common bugs in Playwright tests. TypeScript helps here: if you accidentally assign a Promise<string> to a string variable, the compiler will flag the type mismatch.

Classes

Classes are fundamental to the Page Object Model pattern, which is the standard approach for organizing Playwright test code. TypeScript enhances JavaScript classes with access modifiers, abstract classes, and richer type checking.

Class Definition and Access Modifiers

TypeScript provides three access modifiers that control where a property or method can be used:

  • public — accessible from anywhere (default if omitted)
  • private — accessible only inside the class itself
  • protected — accessible inside the class and its subclasses
classes.tstypescript
1// ─── Basic Class ─────────────────────────────────
2class Animal {
3  // Properties with access modifiers
4  public name: string;
5  private sound: string;
6  protected speed: number;
7
8  constructor(name: string, sound: string, speed: number) {
9    this.name = name;
10    this.sound = sound;
11    this.speed = speed;
12  }
13
14  // Public method - accessible from anywhere
15  public speak(): string {
16    return `${this.name} says ${this.sound}`;
17  }
18
19  // Private method - only accessible inside this class
20  private getInternalId(): number {
21    return Math.random();
22  }
23
24  // Protected method - accessible in this class and subclasses
25  protected move(): string {
26    return `${this.name} moves at ${this.speed} km/h`;
27  }
28}
29
30const dog = new Animal('Rex', 'Woof', 40);
31dog.speak();       // OK
32// dog.sound;      // Error! Private property
33// dog.speed;      // Error! Protected property

Constructor Shorthand

TypeScript has a convenient shorthand for declaring and initializing class properties directly in the constructor parameters. This is widely used in Playwright Page Object classes:

constructor-shorthand.tstypescript
1// ─── Constructor Shorthand ────────────────────────
2// TypeScript allows you to declare and initialize properties
3// directly in the constructor parameters:
4
5class UserAccount {
6  constructor(
7    public readonly id: number,
8    public name: string,
9    private password: string,
10    public isActive: boolean = true
11  ) {}
12
13  // This is equivalent to declaring properties above
14  // the constructor and assigning them manually.
15
16  public validatePassword(input: string): boolean {
17    return this.password === input;
18  }
19}
20
21const account = new UserAccount(1, 'Alice', 'secret');
22console.log(account.name);       // 'Alice'
23console.log(account.isActive);   // true
24// account.password              // Error! Private

Inheritance and Abstract Classes

Inheritance lets you create a base class with shared functionality, then extend it for specific pages or components. Abstract classes go one step further by defining a contract that all subclasses must follow:

inheritance.tstypescript
1// ─── Inheritance ─────────────────────────────────
2class BasePage {
3  constructor(protected page: any) {}
4
5  async navigate(url: string): Promise<void> {
6    await this.page.goto(url);
7  }
8
9  async getTitle(): Promise<string> {
10    return await this.page.title();
11  }
12}
13
14class LoginPage extends BasePage {
15  private selectors = {
16    username: '#username',
17    password: '#password',
18    submit: 'button[type="submit"]',
19    error: '.error-message',
20  };
21
22  async login(user: string, pass: string): Promise<void> {
23    await this.page.fill(this.selectors.username, user);
24    await this.page.fill(this.selectors.password, pass);
25    await this.page.click(this.selectors.submit);
26  }
27
28  async getErrorMessage(): Promise<string> {
29    return await this.page.textContent(this.selectors.error);
30  }
31}
32
33// ─── Abstract Classes ────────────────────────────
34// Abstract classes CANNOT be instantiated directly.
35// They define a contract that subclasses must follow.
36
37abstract class BaseApiClient {
38  constructor(protected baseURL: string) {}
39
40  // Concrete method - shared logic
41  protected buildUrl(path: string): string {
42    return `${this.baseURL}${path}`;
43  }
44
45  // Abstract methods - MUST be implemented by subclasses
46  abstract get(path: string): Promise<any>;
47  abstract post(path: string, body: object): Promise<any>;
48}
49
50class RestApiClient extends BaseApiClient {
51  async get(path: string): Promise<any> {
52    const response = await fetch(this.buildUrl(path));
53    return response.json();
54  }
55
56  async post(path: string, body: object): Promise<any> {
57    const response = await fetch(this.buildUrl(path), {
58      method: 'POST',
59      headers: { 'Content-Type': 'application/json' },
60      body: JSON.stringify(body),
61    });
62    return response.json();
63  }
64}
Why This Matters for PlaywrightIn the Page Object Model pattern, you typically create a BasePage class with common methods like navigate(), waitForPageLoad(), and getTitle(). Then each specific page (LoginPage, DashboardPage, etc.) extends BasePage and adds its own locators and methods. This is inheritance in action.

Generics

Generics allow you to write flexible, reusable code that works with multiple types while still maintaining type safety. Think of a generic as a "type variable" — a placeholder for a type that is filled in when the function or class is used.

generics.tstypescript
1// ─── Generic Functions ───────────────────────────
2// Generics let you write code that works with ANY type
3// while still maintaining type safety.
4
5function getFirst<T>(items: T[]): T | undefined {
6  return items[0];
7}
8
9// TypeScript infers the type from the argument:
10const firstNum = getFirst([10, 20, 30]);      // type: number | undefined
11const firstStr = getFirst(['a', 'b', 'c']);   // type: string | undefined
12
13// ─── Generic Interfaces ─────────────────────────
14interface ApiResponse<T> {
15  data: T;
16  status: number;
17  message: string;
18  timestamp: Date;
19}
20
21interface User {
22  id: number;
23  name: string;
24  email: string;
25}
26
27// Usage with specific types
28type UserResponse = ApiResponse<User>;
29type UserListResponse = ApiResponse<User[]>;
30
31async function fetchUser(id: number): Promise<ApiResponse<User>> {
32  const response = await fetch(`/api/users/${id}`);
33  return response.json();
34}
35
36// ─── Generic Constraints ─────────────────────────
37// You can restrict what types a generic accepts.
38
39interface HasLength {
40  length: number;
41}
42
43function logLength<T extends HasLength>(item: T): void {
44  console.log(`Length: ${item.length}`);
45}
46
47logLength('hello');              // OK: strings have length
48logLength([1, 2, 3]);           // OK: arrays have length
49logLength({ length: 10 });      // OK: object has length
50// logLength(42);               // Error! numbers have no length
51
52// ─── Multiple Generic Parameters ────────────────
53function createPair<K, V>(key: K, value: V): [K, V] {
54  return [key, value];
55}
56
57const pair = createPair('name', 'Alice');  // [string, string]
58const pair2 = createPair(1, true);         // [number, boolean]

Real-World Example: Test Data Factory

Generics are especially useful in test automation for creating reusable patterns like data factories, API clients, and response wrappers:

test-data-factory.tstypescript
1// ─── Real-World Example: Test Data Factory ──────
2interface TestDataFactory<T> {
3  create(overrides?: Partial<T>): T;
4  createMany(count: number, overrides?: Partial<T>): T[];
5}
6
7interface UserData {
8  username: string;
9  email: string;
10  role: 'admin' | 'editor' | 'viewer';
11}
12
13class UserFactory implements TestDataFactory<UserData> {
14  private counter = 0;
15
16  create(overrides?: Partial<UserData>): UserData {
17    this.counter++;
18    return {
19      username: `user_${this.counter}`,
20      email: `user${this.counter}@test.com`,
21      role: 'viewer',
22      ...overrides,  // Override any default values
23    };
24  }
25
26  createMany(count: number, overrides?: Partial<UserData>): UserData[] {
27    return Array.from({ length: count }, () => this.create(overrides));
28  }
29}
30
31// Usage
32const factory = new UserFactory();
33const viewer = factory.create();
34const admin = factory.create({ role: 'admin', username: 'superadmin' });
35const bulkUsers = factory.createMany(5, { role: 'editor' });
TipYou do not need to master generics before starting with Playwright. Many Playwright APIs use generics internally (like page.evaluate<T>()), and TypeScript often infers the generic type automatically. Start by understanding the basics, and you will naturally encounter more advanced patterns as your projects grow.

TypeScript for Playwright

Playwright was built with first-class TypeScript support. Every API in Playwright ships with complete type definitions, so you get autocomplete, error checking, and documentation right in your editor. Let us look at how TypeScript and Playwright work together in practice.

Built-in Types: Page, Locator, Browser

Playwright exports TypeScript types for all its core objects. The three you will use most often are:

  • Page — represents a single browser tab. Has methods like goto(), fill(), click(), locator(), and many more.
  • Locator — represents a way to find elements on the page. Locators are lazy (they do not search until you perform an action) and auto-retry.
  • Browser — represents a browser instance (Chromium, Firefox, or WebKit). You rarely interact with it directly in tests.
playwright-types.tstypescript
1import { test, expect, type Page, type Locator, type Browser } from '@playwright/test';
2
3// ─── Page, Locator, and Browser Types ────────────
4// Playwright exports precise TypeScript types for every object.
5
6async function clickButton(page: Page, label: string): Promise<void> {
7  // page is fully typed - autocomplete shows all methods
8  const button: Locator = page.getByRole('button', { name: label });
9  await button.click();
10}
11
12async function getInputValue(page: Page, selector: string): Promise<string> {
13  const input: Locator = page.locator(selector);
14  const value = await input.inputValue(); // TypeScript knows this returns string
15  return value;
16}
17
18// ─── Test and Expect Typing ─────────────────────
19test('user can log in successfully', async ({ page }) => {
20  // "page" is automatically typed as Page by Playwright
21  await page.goto('https://example.com/login');
22
23  // Locators are typed - autocomplete shows fill, click, etc.
24  await page.getByLabel('Username').fill('testuser');
25  await page.getByLabel('Password').fill('password123');
26  await page.getByRole('button', { name: 'Sign In' }).click();
27
28  // expect() is fully typed - shows relevant matchers
29  await expect(page).toHaveURL(/dashboard/);
30  await expect(page.getByText('Welcome')).toBeVisible();
31});

Custom Types and Typed Fixtures

Beyond Playwright's built-in types, you should define your own types for test data, configurations, and fixtures. This ensures consistency across your entire test suite:

custom-types.tstypescript
1// ─── Custom Types for Test Data ──────────────────
2interface LoginCredentials {
3  username: string;
4  password: string;
5}
6
7interface Product {
8  name: string;
9  price: number;
10  category: string;
11  inStock: boolean;
12}
13
14interface CheckoutData {
15  shippingAddress: {
16    street: string;
17    city: string;
18    zip: string;
19    country: string;
20  };
21  paymentMethod: 'credit_card' | 'paypal' | 'bank_transfer';
22  items: Product[];
23}
24
25// ─── Using Custom Types in Tests ─────────────────
26const validUser: LoginCredentials = {
27  username: 'john_doe',
28  password: 'P@ssw0rd!',
29};
30
31const invalidUser: LoginCredentials = {
32  username: '',
33  password: 'short',
34};
35
36// ─── Typed Test Fixtures ─────────────────────────
37// Extend Playwright's test fixtures with your own types
38import { test as base } from '@playwright/test';
39
40interface TestFixtures {
41  validCredentials: LoginCredentials;
42  testProduct: Product;
43}
44
45const test = base.extend<TestFixtures>({
46  validCredentials: async ({}, use) => {
47    await use({
48      username: 'testuser',
49      password: 'Test1234!',
50    });
51  },
52  testProduct: async ({}, use) => {
53    await use({
54      name: 'Wireless Mouse',
55      price: 29.99,
56      category: 'Electronics',
57      inStock: true,
58    });
59  },
60});
61
62// Now your tests have access to typed fixtures
63test('add product to cart', async ({ page, testProduct }) => {
64  // testProduct is typed as Product - full autocomplete
65  await page.goto('/products');
66  await page.getByText(testProduct.name).click();
67  await expect(page.getByText(`$${testProduct.price}`)).toBeVisible();
68});
Why Typed Fixtures MatterPlaywright fixtures are a powerful dependency injection system. By adding TypeScript types to your custom fixtures, every test that uses them gets full autocomplete and type checking. If you change the shape of a fixture, the compiler tells you exactly which tests need updating.

Best Practices

1. Always Enable Strict Mode

The single most impactful thing you can do is set "strict": true in your tsconfig.json. This enables all strict type-checking options, catching entire categories of bugs:

tsconfig.jsonjson
1// tsconfig.json - always enable strict mode
2{
3  "compilerOptions": {
4    "strict": true,
5    // "strict" is shorthand for ALL of these:
6    // "noImplicitAny": true,        - error on implicit any
7    // "strictNullChecks": true,     - null/undefined must be handled
8    // "strictFunctionTypes": true,  - stricter function type checking
9    // "strictBindCallApply": true,  - check bind/call/apply arguments
10    // "noImplicitThis": true,       - error on implicit this
11    // "alwaysStrict": true          - emit "use strict" in output
12  }
13}

2. Avoid any at All Costs

Using any tells TypeScript to stop checking a value entirely. It defeats the purpose of using TypeScript in the first place. If you do not know the type, use unknown and narrow it:

avoid-any.tstypescript
1// BAD: Using "any" defeats the purpose of TypeScript
2function processData(data: any) {
3  return data.results.map((r: any) => r.name); // No safety at all
4}
5
6// GOOD: Define the shape of your data
7interface ApiResult {
8  name: string;
9  score: number;
10}
11
12interface ApiData {
13  results: ApiResult[];
14  total: number;
15}
16
17function processData(data: ApiData): string[] {
18  return data.results.map(r => r.name); // Full type safety
19}
20
21// GOOD: Use "unknown" when type is truly uncertain
22function parseJson(raw: string): unknown {
23  return JSON.parse(raw);
24}
25
26// Then narrow the type before using it
27const parsed = parseJson('{"name": "test"}');
28if (typeof parsed === 'object' && parsed !== null && 'name' in parsed) {
29  console.log((parsed as { name: string }).name);
30}

3. Let TypeScript Infer When Possible

You do not need to annotate every variable. TypeScript's type inference is powerful and correct in most cases. Explicit annotations are most valuable on:

  • Function parameters (TypeScript cannot infer these from usage)
  • Function return types on public APIs (for documentation and safety)
  • Complex object literals where the shape is not obvious
  • Variables initialized to null or undefined

4. Document Complex Types

For interfaces and types that represent complex domain concepts, add JSDoc comments. These appear in editor tooltips and make your codebase easier to navigate:

5. Use Readonly Where Appropriate

Mark properties as readonly when they should not change after initialization. This is especially useful for Page Object locators, which are typically set once in the constructor and never reassigned.

6. Prefer Specific Types Over Broad Ones

Instead of string, use literal unions like 'admin' | 'editor' | 'viewer'. Instead of number, consider a branded type or enum if the value has a specific meaning. The more specific your types, the more bugs the compiler catches.

SummaryThe best TypeScript code reads almost like documentation. When someone opens your Page Object or test file, the types tell them exactly what data flows in, what comes out, and what can go wrong. Invest time in good types early, and your test suite will be dramatically easier to maintain.

Full Example

Let us put everything together in a complete, real-world example. We will create a typed Page Object class for a login page, a shared types file, and a test file that uses both. This is the pattern you will use in every Playwright project.

Page Object + Types

The Page Object encapsulates all locators and interactions for a single page. Notice how every property is typed, every method has typed parameters and return values, and the class uses readonly for locators that never change:

LoginPage.ts + test-data.tstypescript
1// ─── src/pages/LoginPage.ts ──────────────────────
2import { type Page, type Locator, expect } from '@playwright/test';
3
4/** Represents the login page of the application. */
5export class LoginPage {
6  // Locators defined as readonly class properties
7  readonly page: Page;
8  readonly usernameInput: Locator;
9  readonly passwordInput: Locator;
10  readonly submitButton: Locator;
11  readonly errorMessage: Locator;
12  readonly welcomeMessage: Locator;
13
14  constructor(page: Page) {
15    this.page = page;
16    this.usernameInput = page.getByLabel('Username');
17    this.passwordInput = page.getByLabel('Password');
18    this.submitButton = page.getByRole('button', { name: 'Sign In' });
19    this.errorMessage = page.locator('[data-testid="error-message"]');
20    this.welcomeMessage = page.getByRole('heading', { name: /welcome/i });
21  }
22
23  /** Navigate to the login page. */
24  async goto(): Promise<void> {
25    await this.page.goto('/login');
26    await expect(this.page).toHaveTitle(/Login/);
27  }
28
29  /** Fill in credentials and submit the login form. */
30  async login(credentials: LoginCredentials): Promise<void> {
31    await this.usernameInput.fill(credentials.username);
32    await this.passwordInput.fill(credentials.password);
33    await this.submitButton.click();
34  }
35
36  /** Assert that login succeeded and the dashboard is visible. */
37  async expectLoginSuccess(): Promise<void> {
38    await expect(this.page).toHaveURL(/\/dashboard/);
39    await expect(this.welcomeMessage).toBeVisible();
40  }
41
42  /** Assert that an error message is displayed. */
43  async expectLoginError(expectedText: string): Promise<void> {
44    await expect(this.errorMessage).toBeVisible();
45    await expect(this.errorMessage).toContainText(expectedText);
46  }
47}
48
49// ─── src/types/test-data.ts ─────────────────────
50export interface LoginCredentials {
51  username: string;
52  password: string;
53}
54
55export interface UserProfile {
56  id: number;
57  name: string;
58  email: string;
59  role: 'admin' | 'editor' | 'viewer';
60  createdAt: string;
61}

Test File

The test file imports the Page Object and typed test data. Each test is focused, readable, and fully type-safe. If you rename a property in LoginCredentials or change a method signature on LoginPage, TypeScript will immediately highlight every test that needs updating:

login.spec.tstypescript
1// ─── tests/login.spec.ts ─────────────────────────
2import { test, expect } from '@playwright/test';
3import { LoginPage } from '../src/pages/LoginPage';
4import type { LoginCredentials } from '../src/types/test-data';
5
6// ─── Test Data ───────────────────────────────────
7const validAdmin: LoginCredentials = {
8  username: 'admin@example.com',
9  password: 'Admin1234!',
10};
11
12const invalidUser: LoginCredentials = {
13  username: 'nobody@example.com',
14  password: 'wrongpassword',
15};
16
17const emptyCredentials: LoginCredentials = {
18  username: '',
19  password: '',
20};
21
22// ─── Test Suite ──────────────────────────────────
23test.describe('Login Page', () => {
24  let loginPage: LoginPage;
25
26  test.beforeEach(async ({ page }) => {
27    loginPage = new LoginPage(page);
28    await loginPage.goto();
29  });
30
31  test('should log in with valid admin credentials', async () => {
32    await loginPage.login(validAdmin);
33    await loginPage.expectLoginSuccess();
34  });
35
36  test('should show error for invalid credentials', async () => {
37    await loginPage.login(invalidUser);
38    await loginPage.expectLoginError('Invalid username or password');
39  });
40
41  test('should show error for empty credentials', async () => {
42    await loginPage.login(emptyCredentials);
43    await loginPage.expectLoginError('Username is required');
44  });
45
46  test('should have a visible submit button', async () => {
47    await expect(loginPage.submitButton).toBeEnabled();
48    await expect(loginPage.submitButton).toHaveText('Sign In');
49  });
50
51  test('password field should mask input', async ({ page }) => {
52    const passwordType = await loginPage.passwordInput.getAttribute('type');
53    expect(passwordType).toBe('password');
54  });
55});
What You Have LearnedThis example demonstrates every TypeScript concept covered in this guide: interfaces for data shapes, classes with access modifiers for page objects, async/await for Playwright interactions, readonly for immutable locators, and type imports to share types across files. This is production-ready code that you can adapt directly for your own projects.
Next StepsNow that you understand TypeScript fundamentals, head over to the Playwright Essentials chapter to learn about locators, assertions, and test configuration. Then bring it all together in the Page Object Model chapter where you will build a complete test architecture.