Daily driver 3+ years

TypeScript & JavaScript

Strong typing, interfaces, generics, and clean architecture patterns for test frameworks.

TypeScriptJavaScriptNode.jsOOPGenerics

How I use it

TypeScript is the layer that makes a test framework maintainable at scale. Without it, a renamed API field silently breaks ten step definitions and you only find out in CI. With it, the compiler catches the breakage the moment the interface changes. I use strict mode, explicit return types, and utility types to build test helpers that are hard to misuse.

Typed API response wrapper

A generic response wrapper keeps every API call consistent — status, body, and latency in one typed shape. Step definitions and assertions work against this type rather than raw Axios responses, so if the wrapper changes, TypeScript flags every affected call site immediately.

export interface ApiResponse<T = unknown> {
  status: number;
  body: T;
  latencyMs: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}

export function isApiError(body: unknown): body is ApiError {
  return (
    typeof body === 'object' &&
    body !== null &&
    'code' in body &&
    'message' in body
  );
}

Generic request helper

A single typed wrapper around Axios gives every test consistent error handling and latency tracking without repeating the same boilerplate in every step. The generic parameter T flows through to the return type so callers get full autocomplete on res.body.

import axios from 'axios';
import type { ApiResponse } from '../types/api';

export async function request<T = unknown>({
  method,
  url,
  body,
  headers = {},
}: {
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  url: string;
  body?: unknown;
  headers?: Record<string, string>;
}): Promise<ApiResponse<T>> {
  const start = Date.now();

  const res = await axios.request<T>({
    method,
    url,
    data: body,
    headers,
    validateStatus: () => true,
  });

  return {
    status: res.status,
    body: res.data,
    latencyMs: Date.now() - start,
  };
}

Utility types in test factories

Utility types eliminate copy-paste interfaces. CreateBookingPayload is derived from the full Booking type — so if a required field is added to the domain model, the factory breaks at compile time rather than silently producing an invalid request.

interface Booking {
  id: string;
  bookingRef: string;
  customerId: string;
  date: string;
  time: string;
  partySize: number;
  tableId: string;
  status: 'pending' | 'confirmed' | 'cancelled';
  createdAt: string;
}

type CreateBookingPayload = Pick<Booking,
  | 'customerId'
  | 'date'
  | 'time'
  | 'partySize'
>;

export function buildBookingPayload(
  overrides: Partial<CreateBookingPayload> = {}
): CreateBookingPayload {
  return {
    customerId: 'cust-test-001',
    date: '2026-06-15',
    time: '19:30',
    partySize: 2,
    ...overrides,
  };
}

Typed Page Object with strict locators

Page Object classes declare locators as readonly so nothing outside the class can reassign them. The constructor is the only place selectors live — fixing a broken selector is a one-line change with no risk of missing an occurrence elsewhere.

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

export class BookingPage {
  readonly page: Page;
  readonly dateInput: Locator;
  readonly timeSelect: Locator;
  readonly partySizeInput: Locator;
  readonly submitButton: Locator;
  readonly confirmationBanner: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page             = page;
    this.dateInput        = page.getByLabel('Date');
    this.timeSelect       = page.getByLabel('Time');
    this.partySizeInput   = page.getByLabel('Party size');
    this.submitButton     = page.getByRole('button', { name: 'Book table' });
    this.confirmationBanner = page.getByRole('alert', { name: /confirmed/i });
    this.errorMessage     = page.getByRole('alert', { name: /error/i });
  }

  async fillBookingForm({
    date,
    time,
    partySize,
  }: {
    date: string;
    time: string;
    partySize: number;
  }): Promise<void> {
    await this.dateInput.fill(date);
    await this.timeSelect.selectOption(time);
    await this.partySizeInput.fill(String(partySize));
  }

  async submit(): Promise<void> {
    await this.submitButton.click();
  }
}

What I focus on

  • Strict mode everywhere"strict": true in tsconfig.json catches null dereferences and implicit any before they become runtime failures
  • Derive types, don't duplicate themPick, Partial, Omit, and Record keep payload types in sync with domain interfaces automatically
  • Explicit return types on helpers — callers see the contract without reading the implementation; TypeScript enforces it doesn't drift
  • Type guards over castingisApiError(body) narrows safely; body as ApiError just suppresses the error and hides the bug
  • Readonly on Page Object locators — prevents accidental reassignment and signals these are stable references, not mutable state