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 aLocator, and every matcher onexpect(). - 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:
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}| Feature | JavaScript | TypeScript |
|---|---|---|
| Type Safety | None (dynamic typing) | Full static type checking |
| Error Detection | At runtime only | At compile time + runtime |
| Editor Support | Basic autocomplete | Rich autocomplete, refactoring, hover info |
| Learning Curve | Lower | Slightly higher (worth it) |
| Community Adoption | Universal | Rapidly growing, standard in large projects |
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:
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 installnpm 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:
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
.mapfiles that map compiled JavaScript back to your TypeScript, making debugging much easier.
.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:
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
| Type | Description | Example |
|---|---|---|
string | Text values | let name: string = 'Alice' |
number | All numbers (integer and float) | let age: number = 30 |
boolean | True or false | let active: boolean = true |
string[] | Array of strings | let tags: string[] = ['a', 'b'] |
[string, number] | Tuple (fixed-length typed array) | let pair: [string, number] = ['age', 30] |
any | Disables type checking (avoid) | let x: any = 'anything' |
unknown | Type-safe alternative to any | let x: unknown = getData() |
void | Function returns nothing | function log(): void { } |
never | Function never returns (throws) | function fail(): never { } |
null / undefined | Absence of value | let 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:
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 clarityInterfaces & 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:
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 submitButtonType Aliases
Type aliases give a name to any type, not just objects. They are especially useful for union types, literal types, and utility types:
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 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.
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');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.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
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 propertyConstructor 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:
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! PrivateInheritance 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:
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}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.
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:
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' });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.
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:
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});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:
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:
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
nullorundefined
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.
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:
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:
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});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.