Passing { productId: 123 } to a method that expects { id: number; name: string; price: number } fails silently at runtime; with an interface, TypeScript catches the wrong field name in the editor before the test runs. Change the interface, and every call site that doesn't match shows an error immediately. This article covers interfaces for test data, Page Object method signatures, API responses, and typed fixtures, plus the type vs interface decision and generic patterns for reusable shapes like paginated responses.

Why TypeScript in POM

Consider this without types:

class ProductPage {
  async addToCart(product) {  // product could be anything
    await this.page.click(`[data-id="${product.id}"]`);
  }
}

// Call site — no editor help, easy to pass wrong thing
await productPage.addToCart({ productId: 123 }); // oops, wrong field name

With an interface:

interface Product {
  id: number;
  name: string;
  price: number;
}

class ProductPage {
  async addToCart(product: Product) {
    await this.page.click(`[data-id="${product.id}"]`);
  }
}

// Editor catches this immediately
await productPage.addToCart({ productId: 123 }); // Error: productId doesn't exist on Product
await productPage.addToCart({ id: 123, name: 'Laptop', price: 999 }); // ✅

Basic Interface Syntax

An interface defines the shape of an object:

interface User {
  id: number;
  email: string;
  role: 'admin' | 'member' | 'viewer';
  isActive: boolean;
  createdAt?: string;  // Optional field (the ?)
}

  • Required fields: must always be present
  • Optional fields (?): may or may not be present
  • Union types ('admin' | 'member'): only those exact values are valid

Interfaces for Test Data

The most common use: typed test data objects.

// data/users.ts
export interface UserCredentials {
  email: string;
  password: string;
}

export interface UserProfile extends UserCredentials {
  name: string;
  role: 'admin' | 'member';
}

export const TEST_USERS = {
  admin: {
    email: 'admin@test.com',
    password: 'AdminPass1',
    name: 'Test Admin',
    role: 'admin' as const,
  } satisfies UserProfile,
  
  member: {
    email: 'member@test.com',
    password: 'MemberPass1',
    name: 'Test Member',
    role: 'member' as const,
  } satisfies UserProfile,
};

The satisfies keyword (TypeScript 4.9+) checks that the object matches the interface but preserves the literal types.

Interfaces for Page Objects

// pages/types.ts
import { Page, Locator } from '@playwright/test';

export interface PageObject {
  page: Page;
  navigate(): Promise<void>;
}

export interface LoginPageInterface extends PageObject {
  emailInput: Locator;
  passwordInput: Locator;
  submitButton: Locator;
  errorMessage: Locator;
  login(email: string, password: string): Promise<void>;
}

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { LoginPageInterface } from './types';

export class LoginPage implements LoginPageInterface {
  page: Page;
  emailInput: Locator;
  passwordInput: Locator;
  submitButton: Locator;
  errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput    = page.getByTestId('email-input');
    this.passwordInput = page.getByTestId('password-input');
    this.submitButton  = page.getByTestId('submit-btn');
    this.errorMessage  = page.getByTestId('error-message');
  }

  async navigate(): Promise<void> {
    await this.page.goto('/login');
  }

  async login(email: string, password: string): Promise<void> {
    await this.navigate();
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

The implements keyword tells TypeScript: "this class must have everything the interface requires." If you forget a method, you get a compile error.

type vs interface

Both define object shapes. The practical differences:

| | interface | type |

|-|-------------|--------|

| Extending | extends keyword | & intersection |

| Declaration merging | Yes (can add fields in multiple places) | No |

| Union types | No | Yes |

| Use for objects | ✅ Preferred | ✅ Also works |

Rule of thumb: Use interface for object shapes (especially page objects and data models). Use type for unions, primitives, and complex combinations.

// interface: great for object shapes
interface ProductFilter {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  inStock?: boolean;
}

// type: necessary for unions
type TestEnvironment = 'local' | 'staging' | 'production';
type Callback = () => void | Promise<void>;
type UserOrAdmin = User | Admin;

Generic Interfaces

Generics let you write flexible interfaces:

// A paginated API response, works for any data type
interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

// Use with specific types
type UsersResponse    = PaginatedResponse<User>;
type ProductsResponse = PaginatedResponse<Product>;
type OrdersResponse   = PaginatedResponse<Order>;

// In a test
const response = await request.get('/api/users?page=1&limit=10');
const body: UsersResponse = await response.json();

expect(body.data).toHaveLength(10);
expect(body.totalPages).toBeGreaterThan(0);
// TypeScript knows body.data[0] is a User
expect(body.data[0].email).toBeTruthy();

Interface for API Responses

Always type your API responses to get auto-complete and catch field name mistakes:

// types/api.ts
export interface LoginResponse {
  token: string;
  expiresAt: string;
  user: {
    id: number;
    email: string;
    role: string;
  };
}

export interface ErrorResponse {
  error: string;
  message: string;
  field?: string;  // Present for validation errors
}

export interface CreateUserRequest {
  email: string;
  password: string;
  name?: string;
  role?: 'admin' | 'member';
}

// In tests
test('login returns correct response', async ({ request }) => {
  const response = await request.post('/api/auth/login', {
    data: { email: 'user@test.com', password: 'ValidPass1' },
  });
  
  const body: LoginResponse = await response.json();
  
  // TypeScript knows exactly what's on body
  expect(body.token).toBeTruthy();
  expect(body.user.id).toBeGreaterThan(0);
  expect(body.user.role).toBe('member');
});

Extending Interfaces

Use extends to build on existing interfaces:

interface BaseEntity {
  id: number;
  createdAt: string;
  updatedAt: string;
}

interface User extends BaseEntity {
  email: string;
  name: string;
  role: 'admin' | 'member';
}

interface Product extends BaseEntity {
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

User and Product both have id, createdAt, updatedAt from BaseEntity, plus their own fields.

Interfaces for Fixtures

Type your custom Playwright fixtures:

// fixtures/types.ts
import { Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

export interface AppFixtures {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: Page;
}

export interface TestUser {
  id: number;
  email: string;
  token: string;
  role: 'admin' | 'member';
}

export interface ApiFixtures {
  testUser: TestUser;
  adminToken: string;
}

// fixtures/index.ts
import { test as base } from '@playwright/test';
import { AppFixtures, ApiFixtures } from './types';

export const test = base.extend<AppFixtures & ApiFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  // ...
});

Practical tsconfig for Playwright

Make sure your tsconfig.json has strict mode for better type checking:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "strict": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@fixtures": ["./fixtures/index.ts"],
      "@pages/*": ["./pages/*"],
      "@data/*": ["./data/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

The paths config lets you import with aliases:

import { test } from '@fixtures';           // instead of '../../fixtures/index'
import { LoginPage } from '@pages/LoginPage'; // instead of '../../pages/LoginPage'

Summary

  • Use interface for page objects, test data, and API response shapes
  • Use type for union types and complex type combinations
  • extends to build on existing interfaces (DRY principle)
  • Generic interfaces (PaginatedResponse) for reusable shapes
  • Type your API responses so TypeScript helps you verify response fields
  • implements in page object classes enforces the contract

TypeScript in test automation isn't about ceremony — it's about the editor catching product.productId when you meant product.id before you spend 10 minutes debugging why the click isn't working.

→ See also: TypeScript for QA: Why Static Types Make Your Tests Better | Page Object Model in Playwright: From Messy to Maintainable | Types, Interfaces, and Generics in TypeScript for Test Fixtures