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 useany 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 errorType 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 UserIndex 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 }; // intersectionGenerics
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 argumentGeneric 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'tMultiple 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 | numberEnums
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.).