Skip to content
SDETRole
SDETRole

Lesson 3 of 9 · Programming for Testers

TypeScript for Testers

Types, interfaces, unions, generics and narrowing for test code: type your API responses, page objects and test data so the compiler catches mistakes first.

intermediate 8 min read Updated

You will learn to

  • Annotate variables and functions and read TypeScript compiler errors
  • Model API payloads and test data with interfaces, type aliases and unions
  • Use generics and utility types to build reusable, type-safe helpers
  • Narrow unknown data safely instead of reaching for any

Prerequisites

  • JavaScript Essentials

TypeScript is JavaScript with a static type system. You write the same code you already know, add type information, and the TypeScript compiler checks it before anything runs. Playwright generates TypeScript projects by default, and most modern Cypress and WebdriverIO projects use it too.

For testers the payoff is concrete: misspelled property names, wrong argument types, possibly-undefined values and calls to page object methods that no longer exist become red squiggles in your editor instead of failures halfway through a CI run.

What TypeScript is (and is not)

  • TypeScript is a superset of JavaScript: every valid JavaScript program is valid TypeScript syntax, though the type checker may report errors on it.
  • Types exist only at compile time. They are erased when TypeScript is converted to JavaScript, so they cannot validate data at runtime by themselves.
  • Tools such as Playwright’s test runner and Node’s newer type-stripping support can run .ts files directly; type checking is done by tsc or your editor.
npm install --save-dev typescript
npx tsc --init          # creates tsconfig.json
npx tsc --noEmit        # type-check the project without writing output files

Make sure "strict": true is set in tsconfig.json. It enables the checks that make TypeScript worth using, including strict null checks.

Basic type annotations

Annotate with a colon after the name. Often TypeScript infers the type, so you do not need to write it.

const baseUrl = 'https://staging.example.com'; // inferred: string
let retries: number = 0;
const browsers: string[] = ['chromium', 'firefox', 'webkit'];

function buildUrl(path: string, query?: Record<string, string>): string {
  const params = query ? `?${new URLSearchParams(query)}` : '';
  return `${baseUrl}${path}${params}`;
}

buildUrl('/search', { q: 'shoes' }); // ok
buildUrl(42);
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.

The ? after query makes the parameter optional; its type becomes Record<string, string> | undefined.

Annotate function parameters and public return types explicitly. Let TypeScript infer local variables.

Modelling data: interfaces and type aliases

Most of your types will describe shapes of objects: API responses, request payloads, test users.

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: string;      // ISO 8601 date string in JSON
  phone?: string;         // optional
  readonly tenantId: string;
}

const ana: User = {
  id: 'u_1',
  email: 'ana@example.com',
  name: 'Ana',
  role: 'admin',
  createdAt: '2026-11-01T10:00:00Z',
  tenantId: 't_9',
};

ana.role = 'owner';
// Error: Type '"owner"' is not assignable to type '"admin" | "editor" | "viewer"'.

You can write the same thing with a type alias: type User = { ... }. Both work for object shapes. A practical rule: use interface for object shapes you may extend, and type for unions, function types and anything that is not a plain object.

Literal and union types

'admin' | 'editor' | 'viewer' is a union of literal types: the value must be exactly one of those strings. Unions are ideal for things like roles, environments and order states:

type Env = 'local' | 'staging' | 'prod';
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';

const urls: Record<Env, string> = {
  local: 'http://localhost:3000',
  staging: 'https://staging.example.com',
  prod: 'https://example.com',
};

If someone adds 'qa' to Env later, every Record<Env, string> that is missing a qa key becomes a compile error, which is exactly the reminder you want.

Typing async functions

An async function returns a Promise of something. Say what:

async function getUser(api: string, id: string): Promise<User> {
  const res = await fetch(`${api}/users/${id}`);
  if (!res.ok) throw new Error(`GET /users/${id} returned ${res.status}`);
  return (await res.json()) as User;
}

Note the as User. res.json() returns Promise<any>, and the assertion tells the compiler to trust you. It does not check the data. If the API returns { "user_id": ... } instead of { "id": ... }, your code compiles happily and fails at runtime.

unknown and narrowing

Prefer unknown over any for data you have not checked. unknown forces you to narrow the type before using it:

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof value.id === 'string' &&
    'email' in value &&
    typeof value.email === 'string'
  );
}

const data: unknown = await res.json();
if (!isUser(data)) {
  throw new Error(`Response is not a User: ${JSON.stringify(data)}`);
}
data.email; // ok: TypeScript now knows data is a User

value is User makes this a type guard: after it returns true, TypeScript treats the value as User. Narrowing also works with typeof, instanceof, in and checks on a shared literal field:

type ApiResult =
  | { ok: true; data: User }
  | { ok: false; error: string; status: number };

function describe(result: ApiResult): string {
  if (result.ok) {
    return `Got ${result.data.email}`;     // data is available here
  }
  return `Failed ${result.status}: ${result.error}`; // error and status here
}

This pattern is called a discriminated union, and it models success and error responses cleanly.

Generics

A generic is a type parameter, like a function parameter but for types. It lets one helper work with many shapes while staying type-safe.

async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`GET ${url} returned ${res.status}`);
  return (await res.json()) as T;
}

const user = await getJson<User>(`${api}/users/u_1`);
user.email;  // string
user.emial;  // Error: Property 'emial' does not exist on type 'User'. Did you mean 'email'?

You use generics constantly without writing them: Array<string>, Promise<User>, Record<Env, string> and Playwright’s Locator and Page methods all rely on them.

Utility types for test data

TypeScript ships with utility types that transform existing types. These four cover most test-data needs:

Utility What it does Testing use
Partial<T> All properties optional Overrides for a data builder
Pick<T, K> Keep only listed keys Request payload from a full model
Omit<T, K> Remove listed keys Create payload without server-generated fields
Required<T> All properties required Assert a fully populated object
type CreateUserPayload = Omit<User, 'id' | 'createdAt' | 'tenantId'>;

function buildUser(overrides: Partial<CreateUserPayload> = {}): CreateUserPayload {
  return {
    email: `user.${Date.now()}@example.com`,
    name: 'Test User',
    role: 'viewer',
    ...overrides,
  };
}

const admin = buildUser({ role: 'admin' });  // ok
const bad = buildUser({ role: 'superuser' }); // compile error: not a valid role

A data builder like this gives every test valid defaults, lets each test override only what it cares about, and stays in sync with the model automatically.

TypeScript in a Playwright test

Here is how the pieces show up in real test code. Playwright exports its own types, so your helpers and page objects get full autocomplete:

import { test, expect, type Page } from '@playwright/test';

async function loginAs(page: Page, email: string, password: string): Promise<void> {
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();
}

test('admin sees the settings link', async ({ page }) => {
  await loginAs(page, 'admin@example.com', process.env.ADMIN_PASSWORD ?? '');
  await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
});

If you pass a number where a string is expected or misspell getByRole, the editor tells you immediately. You will build on this in Playwright: Getting Started.

Practice exercise

Model an e-commerce order API in TypeScript:

  1. Define OrderStatus as a union of 'pending' | 'paid' | 'shipped' | 'cancelled'.
  2. Define an Order interface with id, status, items (an array of { sku: string; qty: number; price: number }) and an optional couponCode.
  3. Write a function orderTotal(order: Order): number.
  4. Write a type guard isOrder(value: unknown): value is Order that checks at least id, status and that items is an array.
  5. Write buildOrder(overrides?: Partial<Order>): Order with sensible defaults.

A good solution compiles under "strict": true with no any, rejects an invalid status at compile time, and uses the type guard before trusting parsed JSON.

Show a model answer
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';

interface OrderItem {
  sku: string;
  qty: number;
  price: number;
}

interface Order {
  id: string;
  status: OrderStatus;
  items: OrderItem[];
  couponCode?: string;
}

const STATUSES: readonly OrderStatus[] = ['pending', 'paid', 'shipped', 'cancelled'];

function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.qty * item.price, 0);
}

function isOrder(value: unknown): value is Order {
  if (typeof value !== 'object' || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === 'string' &&
    typeof v.status === 'string' &&
    (STATUSES as readonly string[]).includes(v.status) &&
    Array.isArray(v.items)
  );
}

function buildOrder(overrides: Partial<Order> = {}): Order {
  return {
    id: `ord_${Date.now()}`,
    status: 'pending',
    items: [{ sku: 'SKU-1', qty: 1, price: 10 }],
    ...overrides,
  };
}

const paid = buildOrder({ status: 'paid' });
console.log(orderTotal(paid)); // 10

Why it is good: the union makes invalid statuses a compile error, the guard narrows unknown safely (and is honest that it checks only top-level shape), and the builder gives valid defaults while allowing targeted overrides. In real code, prefer integer cents over floating-point prices for money.

Key takeaways

  • TypeScript adds compile-time type checking to JavaScript; types are erased at runtime.
  • Turn on "strict": true and run tsc --noEmit in CI, because test runners do not fail on type errors.
  • Model payloads with interfaces and unions of literals; use utility types like Partial and Omit for data builders.
  • as and any switch checking off. Use unknown plus type guards or runtime schema validation for API data.
  • Generics let one helper, such as getJson<T>, stay type-safe across many response shapes.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →