On this page12 sections
CommandsIntermediate8-10 min reference

TypeScript for Testers

The TypeScript features you'll use across Cypress, Playwright, and Jest/Vitest projects — type-safe selectors, page objects, fixtures, and intercepts.

Basic Types

Primitive and special types

let name: string = 'QA';
let count: number = 42;
let isActive: boolean = true;
let value: null = null;
let notSet: undefined = undefined;
let nothing: void;                    // for functions that return nothing
let throwsForever: never;             // function never returns (throws / infinite loop)
let unsafe: any;                      // opt out of type checking — avoid
let unknown_: unknown;                // safer any — must narrow before use

any disables type checking entirely. Prefer unknown and narrow it with type guards.

Annotations and inference

let count: number = 5;        // explicit
let inferred = 5;             // inferred as number
const exact = 5;              // inferred as 5 (literal narrowing on const)
 
// Function signatures
function greet(name: string, greeting = 'Hello'): string {
  return `${greeting}, ${name}`;
}
 
// Arrow with full annotations
const add = (a: number, b: number): number => a + b;

Union types

type Id = string | number;
 
function loadUser(id: Id) {
  if (typeof id === 'string') {
    return repo.findByName(id);   // id is string here
  }
  return repo.findById(id);       // id is number here
}

Literal types

type Direction = 'up' | 'down' | 'left' | 'right';
type Status = 200 | 201 | 400 | 401 | 404 | 500;
 
function move(dir: Direction) { /* ... */ }
move('up');                       // ✓
move('forward');                  // ✗ compile error

Type assertions

const el = document.querySelector('input') as HTMLInputElement;
el.value = 'test@example.com';
 
// Older syntax (avoid in JSX/TSX files)
const el2 = <HTMLInputElement>document.querySelector('input');
 
// Non-null assertion — promise the value isn't null/undefined
const button = document.querySelector('button')!;
button.click();

Use ! only when you're certain — it suppresses the type, it doesn't validate at runtime.

Arrays & Tuples

Typed arrays

const ids: number[] = [1, 2, 3];
const names: Array<string> = ['ada', 'bob'];
 
const mixed: (string | number)[] = ['ada', 42];
 
const readonly_: readonly string[] = ['locked'];
const readonly2: ReadonlyArray<string> = ['locked'];
// readonly_.push('x');  // ✗

Tuples

const point: [number, number] = [10, 20];
const pair: [string, number] = ['count', 42];
 
// Optional and rest tuple elements
type LogEntry = [Date, 'info' | 'warn' | 'error', string, ...string[]];
const e: LogEntry = [new Date(), 'info', 'started', 'detail-a'];
 
// Destructuring with types preserved
const [x, y]: [number, number] = point;

Array destructuring

const users: { name: string; age: number }[] = [
  { name: 'Ada', age: 36 },
  { name: 'Bob', age: 42 },
];
 
const [first, ...rest] = users;
// first: { name: string; age: number }
// rest:  { name: string; age: number }[]

Interfaces & Type Aliases

interface vs type

Use interface for object shapes that may be extended/augmented (especially when integrating with libraries — declaration merging works on interfaces, not type aliases).

Use type for unions, intersections, primitives, mapped types, and conditionals.

interface User {
  id: number;
  name: string;
  email?: string;            // optional
  readonly createdAt: Date;  // readonly
}
 
type Id = number | string;
type Coordinates = [number, number];

Optional and readonly

interface Config {
  baseUrl: string;
  timeout?: number;             // optional
  readonly apiKey: string;      // can't be reassigned after init
}
 
const c: Config = { baseUrl: 'https://api.example.com', apiKey: 'k' };
// c.apiKey = 'new';   ✗

Extending and merging

interface User {
  id: number;
  name: string;
}
 
interface Admin extends User {
  role: 'admin' | 'super-admin';
}
 
// Multiple inheritance
interface SuperAdmin extends Admin, Auditable { }
 
// Declaration merging (same name across files)
interface User { phone?: string; }   // adds field to existing User

Index signatures

interface StringMap {
  [key: string]: string;
}
 
const env: StringMap = { BASE_URL: 'https://...', NODE_ENV: 'test' };

Type aliases and intersection types

type Id = number | string;
type Point = { x: number; y: number };
type AdminUser = User & { role: string };   // intersection

Generics

Generic functions

function identity<T>(value: T): T {
  return value;
}
 
const n = identity(42);              // T = number, return type number
const s = identity<string>('qa');    // explicit type argument

Generic interfaces

interface ApiResponse<T> {
  data: T;
  status: number;
  meta?: { page: number; total: number };
}
 
const userResp: ApiResponse<User> = await getUser(42);
const listResp: ApiResponse<User[]> = await getUsers();

Constraints (extends)

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
 
longest('hello', 'world');           // ✓ strings have length
longest([1, 2, 3], [4]);             // ✓ arrays have length
longest(42, 100);                    // ✗ numbers don't

Multiple type parameters and defaults

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
 
interface Box<T = string> { value: T; }
const a: Box = { value: 'default' };           // T = string (default)
const b: Box<number> = { value: 42 };

Common built-ins

Array<T>        — typed array
Promise<T>      — async result
Record<K, V>    — object map
Partial<T>      — all optional
Map<K, V>       — typed Map
Set<T>          — typed Set

Utility Types

interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

Partial<T> — all properties optional

function updateUser(id: number, patch: Partial<User>) { /* ... */ }
updateUser(1, { name: 'New' });       // ✓

Required<T> — all properties required

type StrictUser = Required<User>;

Pick<T, K> — choose properties

type UserSummary = Pick<User, 'id' | 'name'>;
// { id: number; name: string }

Omit<T, K> — exclude properties

type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
// { name: string; email: string }

Record<K, V> — typed map

type RoleCounts = Record<'admin' | 'editor' | 'viewer', number>;
const counts: RoleCounts = { admin: 1, editor: 5, viewer: 100 };
 
type AnyMap = Record<string, unknown>;

Readonly<T>

type FrozenUser = Readonly<User>;
const u: FrozenUser = await loadUser();
// u.name = 'x';   ✗

ReturnType<T> and Parameters<T>

function loadUser(id: number, opts?: LoadOpts): Promise<User> { /* ... */ }
 
type LoadResult = ReturnType<typeof loadUser>;       // Promise<User>
type LoadParams = Parameters<typeof loadUser>;        // [number, LoadOpts?]

Exclude<T, U> and Extract<T, U>

type Direction = 'up' | 'down' | 'left' | 'right';
type Vertical = Extract<Direction, 'up' | 'down'>;     // 'up' | 'down'
type NotDown = Exclude<Direction, 'down'>;             // 'up' | 'left' | 'right'

NonNullable<T>

type T1 = string | number | null | undefined;
type T2 = NonNullable<T1>;   // string | number

Enums

Numeric enums

enum Status {
  Active,         // 0
  Inactive,       // 1
  Pending,        // 2
}
 
const s: Status = Status.Active;
console.log(Status[0]);          // 'Active' (reverse mapping)

String enums

enum Status {
  Active   = 'ACTIVE',
  Inactive = 'INACTIVE',
  Pending  = 'PENDING',
}
 
const s: Status = Status.Active;     // 'ACTIVE'

const enum (inlined at compile time)

const enum HttpMethod {
  Get  = 'GET',
  Post = 'POST',
}
 
fetch(url, { method: HttpMethod.Get });
// Compiles to:  fetch(url, { method: 'GET' });

Enum as parameter type

function setStatus(status: Status) { /* ... */ }
setStatus(Status.Active);

Many teams now prefer literal-union types over enums — they generate no runtime code:

type Status = 'ACTIVE' | 'INACTIVE' | 'PENDING';

Type Guards

typeof narrowing

function format(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase();      // value is string
  }
  return value.toFixed(2);           // value is number
}

instanceof

function handle(err: Error | ApiError) {
  if (err instanceof ApiError) {
    console.log(err.status);         // ApiError-only field
  } else {
    console.log(err.message);
  }
}

in operator

type Cat = { meow: () => void };
type Dog = { bark: () => void };
 
function speak(pet: Cat | Dog) {
  if ('meow' in pet) pet.meow();
  else pet.bark();
}

Custom type guards

interface User { id: number; email: string }
 
function isUser(value: unknown): value is User {
  return typeof value === 'object'
      && value !== null
      && 'id' in value
      && 'email' in value;
}
 
const data: unknown = JSON.parse(body);
if (isUser(data)) {
  console.log(data.email);           // narrowed to User
}

Discriminated unions

type Result<T> =
  | { type: 'success'; data: T }
  | { type: 'error'; message: string; status: number };
 
function handle(r: Result<User>) {
  switch (r.type) {
    case 'success': return r.data;       // T = User
    case 'error':   throw new Error(`${r.status}: ${r.message}`);
  }
}

Classes in TypeScript

Access modifiers

class LoginPage {
  public  readonly url = '/login';
  private driver: WebDriver;
  protected logger = console;
 
  constructor(driver: WebDriver) {
    this.driver = driver;
  }
}

Constructor parameter shorthand

class LoginPage {
  constructor(
    private readonly driver: WebDriver,   // declares + assigns the field
    private readonly logger: Logger,
  ) {}
 
  open() {
    this.logger.info('opening login');
    return this.driver.get('/login');
  }
}

Abstract classes

abstract class BasePage {
  constructor(protected driver: WebDriver) {}
 
  abstract get path(): string;          // subclasses must implement
 
  open() {
    return this.driver.get(this.path);
  }
}
 
class LoginPage extends BasePage {
  get path() { return '/login'; }
}

Implementing interfaces

interface Page {
  open(): Promise<void>;
  isLoaded(): Promise<boolean>;
}
 
class LoginPage implements Page {
  async open() { /* ... */ }
  async isLoaded() { return true; }
}

Static members

class TestConfig {
  static readonly BASE_URL = 'https://staging.example.com';
  private static instance: TestConfig;
 
  static get(): TestConfig {
    return this.instance ??= new TestConfig();
  }
}

Getters and setters

class User {
  #age = 0;                                  // private field
 
  get age() { return this.#age; }
  set age(v: number) {
    if (v < 0) throw new RangeError('age must be ≥ 0');
    this.#age = v;
  }
}

Async TypeScript

Promise return types

async function loadUser(id: number): Promise<User> {
  const res = await fetch(`/users/${id}`);
  return res.json() as Promise<User>;
}
 
async function maybeLoadUser(id: number): Promise<User | null> {
  const res = await fetch(`/users/${id}`);
  return res.ok ? (await res.json() as User) : null;
}

Typing API responses

interface ApiResponse<T> {
  data: T;
  status: 'ok' | 'error';
}
 
async function fetchJson<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
 
const users = await fetchJson<User[]>('/api/users');
console.log(users.data[0].email);

Typed catch

catch parameters default to unknown (with useUnknownInCatchVariables: true, which is on by default in strict mode). Narrow before use:

try {
  await call();
} catch (err) {
  if (err instanceof Error) {
    console.error(err.message);
  } else {
    console.error('non-Error thrown', err);
  }
}

Cypress + TypeScript Patterns

Custom command types

cypress/support/commands.ts:

declare global {
  namespace Cypress {
    interface Chainable<Subject> {
      login(email: string, password: string): Chainable<void>;
      getByTestId<E extends HTMLElement = HTMLElement>(
        testId: string,
      ): Chainable<JQuery<E>>;
    }
  }
}
 
Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('[data-testid=email]').type(email);
  cy.get('[data-testid=password]').type(password);
  cy.get('[data-testid=submit]').click();
});
 
Cypress.Commands.add('getByTestId', (testId) =>
  cy.get(`[data-testid="${testId}"]`),
);
 
export {};

Typed fixtures

interface UserFixture { email: string; password: string; }
 
cy.fixture<UserFixture>('users').then(user => {
  cy.login(user.email, user.password);
});

Typed intercepts

interface User { id: number; name: string; }
 
cy.intercept<undefined, User[]>('GET', '/api/users', (req) => {
  req.reply({ statusCode: 200, body: [{ id: 1, name: 'Ada' }] });
}).as('getUsers');
 
cy.wait('@getUsers').then(({ response }) => {
  expect(response?.body[0].name).to.eq('Ada');
});

Page Object Model

export class LoginPage {
  readonly url = '/login';
  private readonly emailInput = '[data-testid=email]';
  private readonly passwordInput = '[data-testid=password]';
  private readonly submitBtn = '[data-testid=submit]';
 
  open(): this {
    cy.visit(this.url);
    return this;
  }
 
  loginAs(email: string, password: string): this {
    cy.get(this.emailInput).type(email);
    cy.get(this.passwordInput).type(password);
    cy.get(this.submitBtn).click();
    return this;
  }
}
 
new LoginPage().open().loginAs('ada@example.com', 'secret');

Type-safe selectors

export const Selectors = {
  login: {
    email:    '[data-testid=login-email]',
    password: '[data-testid=login-password]',
    submit:   '[data-testid=login-submit]',
  },
  dashboard: {
    welcome: '[data-testid=dashboard-welcome]',
  },
} as const;
 
cy.get(Selectors.login.email).type('ada@example.com');

Typed env vars

const baseUrl = Cypress.env('API_URL') as string;
const timeout = Cypress.env('TIMEOUT') as number;

Playwright + TypeScript Patterns

Page Object class

import { Page, Locator } from '@playwright/test';
 
export class LoginPage {
  readonly page: Page;
  readonly email: Locator;
  readonly password: Locator;
  readonly submit: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.email = page.getByTestId('email');
    this.password = page.getByTestId('password');
    this.submit = page.getByRole('button', { name: 'Sign in' });
  }
 
  async open() {
    await this.page.goto('/login');
  }
 
  async loginAs(email: string, password: string) {
    await this.email.fill(email);
    await this.password.fill(password);
    await this.submit.click();
  }
}

Custom fixtures

import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
 
type Fixtures = {
  loginPage: LoginPage;
  authedRequest: APIRequestContext;
};
 
export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const lp = new LoginPage(page);
    await lp.open();
    await use(lp);
  },
  authedRequest: async ({ request }, use) => {
    const token = process.env.API_TOKEN!;
    await use(request);
  },
});
 
export { expect } from '@playwright/test';

Typed API testing

interface User { id: number; name: string; email: string; }
 
test('GET /users/42 returns Ada', async ({ request }) => {
  const res = await request.get('/api/users/42');
  expect(res.status()).toBe(200);
 
  const user = await res.json() as User;
  expect(user.email).toBe('ada@example.com');
});

Test data factories

function build<T extends object>(defaults: T) {
  return (overrides: Partial<T> = {}): T => ({ ...defaults, ...overrides });
}
 
const aUser = build<User>({
  id: 0,
  name: 'Default',
  email: 'default@example.com',
});
 
const ada = aUser({ name: 'Ada', email: 'ada@example.com' });

Configuration

tsconfig.json essentials

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "noEmit": true,
    "lib": ["ES2022", "DOM"],
    "types": ["node", "cypress"],
    "baseUrl": ".",
    "paths": {
      "@pages/*":     ["cypress/pages/*"],
      "@fixtures/*":  ["cypress/fixtures/*"],
      "@support/*":   ["cypress/support/*"]
    }
  },
  "include": ["cypress/**/*.ts"]
}

Strict mode flags

"strict": true enables all of these:

strictNullChecks               null/undefined are not assignable everywhere
noImplicitAny                  no untyped parameters/variables
strictFunctionTypes            function parameter contravariance
strictBindCallApply            checks bind/call/apply argument types
strictPropertyInitialization   class fields must be initialized
noImplicitThis                 this must have a known type
alwaysStrict                   emit "use strict" on every file
useUnknownInCatchVariables     catch err is unknown, not any

Recommended additional flags:

noUncheckedIndexedAccess       arr[i] is T | undefined
exactOptionalPropertyTypes     undefined ≠ missing field
noImplicitReturns              all paths must return
noFallthroughCasesInSwitch     case must break/return
noUnusedLocals                 ban unused vars
noUnusedParameters             ban unused params

Path aliases

In tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@pages/*":   ["pages/*"],
      "@helpers/*": ["helpers/*"]
    }
  }
}
import { LoginPage } from '@pages/LoginPage';
import { retry }     from '@helpers/retry';

Note: TypeScript only resolves these at compile time. For runtime resolution (Node, Vitest, Jest, Cypress), pair with the equivalent runner config (vite-tsconfig-paths, tsconfig-paths, etc.).