Guided Walkthrough — Interfaces, Generics, and Utility Types in Action

12 min read

This is the build lesson. Six steps, each producing one component of the framework, each using techniques from earlier chapters. The code here is complete enough to run — copy it into your project, adapt the names to your application, and you'll have a working framework by the end. Read once end-to-end before you start typing; the components reference each other and the order matters.

Step 1 — Type definitions (src/types/)

Every framework starts with the data. Define the shapes of users, products, orders, and API responses up front; everything else types itself against them.

// src/types/entities.ts
 
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "customer" | "guest";
  createdAt: string;
}
 
export interface Product {
  id: number;
  sku: string;
  name: string;
  price: number;
  inStock: boolean;
}
 
export interface CartItem {
  productId: number;
  quantity: number;
  priceAtAdd: number;
}
 
export interface Order {
  id: number;
  customerId: number;
  items: CartItem[];
  total: number;
  status: "pending" | "paid" | "shipped" | "delivered";
}

Now derive the input shapes for create and update flows using utility types:

// src/types/inputs.ts
import { type User } from "./entities";
 
export type CreateUserInput = Omit<User, "id" | "createdAt">;
//   { name; email; role }   — no id, no createdAt; the server sets those
 
export type UpdateUserInput = Partial<Omit<User, "id">>;
//   { name?; email?; role?; createdAt? }   — id is fixed, everything else optional

And the generic API response wrapper:

// src/types/api.ts
export interface ApiResponse<T> {
  status: number;
  data: T;
  message: string;
}
 
export interface ApiError {
  status: number;
  error: string;
  code: string;
}

The User, CreateUserInput, and UpdateUserInput triplet is the canonical pattern — one source of truth, two derived input shapes. Add a field to User and the inputs follow automatically. The whole framework benefits from this single discipline.

Step 2 — Test data factories (src/factories/)

The generic factory pattern from chapter 7, reused for every entity:

// src/factories/factory.ts
export function createFactory<T>(defaults: T): (overrides?: Partial<T>) => T {
  return (overrides = {}) => ({ ...defaults, ...overrides });
}
 
export function createMany<T>(
  factory: (overrides?: Partial<T>) => T,
  count: number,
  customise?: (i: number) => Partial<T>,
): T[] {
  return Array.from({ length: count }, (_, i) =>
    factory(customise?.(i) ?? ({} as Partial<T>)),
  );
}

Now apply it to each entity:

// src/factories/index.ts
import { createFactory } from "./factory";
import { type User, type Product, type Order } from "../types/entities";
 
export const createUser = createFactory<User>({
  id:        0,
  name:      "Test User",
  email:     "user@test.com",
  role:      "customer",
  createdAt: new Date().toISOString(),
});
 
export const createProduct = createFactory<Product>({
  id:      0,
  sku:     "TEST-PROD",
  name:    "Test Product",
  price:   9.99,
  inStock: true,
});
 
export const createOrder = createFactory<Order>({
  id:         0,
  customerId: 1,
  items:      [],
  total:      0,
  status:     "pending",
});

Three factories, six lines of customisation each, every override checked by Partial<T>. Tests can now write createUser({ role: "admin" }) and get a fully formed user — no mental overhead, no missed fields.

Step 3 — Page objects (src/pages/)

Start with a base class for shared behaviour, then extend it for each page:

// src/pages/BasePage.ts
import { type Page, expect } from "@playwright/test";
 
export abstract class BasePage {
  constructor(protected readonly page: Page) {}
 
  async goto(path: string): Promise<void> {
    await this.page.goto(path);
  }
 
  async expectUrl(pattern: RegExp): Promise<void> {
    await expect(this.page).toHaveURL(pattern);
  }
 
  async title(): Promise<string> {
    return this.page.title();
  }
}

Then the login page extends it:

// src/pages/LoginPage.ts
import { type Page, type Locator, expect } from "@playwright/test";
import { BasePage } from "./BasePage";
 
export class LoginPage extends BasePage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;
 
  constructor(page: Page) {
    super(page);
    this.emailInput    = page.getByTestId("email");
    this.passwordInput = page.getByTestId("password");
    this.submitButton  = page.getByTestId("submit");
    this.errorMessage  = page.getByTestId("error-message");
  }
 
  async navigate(): Promise<void> {
    await this.goto("/login");
  }
 
  async login(email: string, password: string): Promise<void> {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
 
  async expectError(message: string): Promise<void> {
    await expect(this.errorMessage).toContainText(message);
  }
}

And the product list:

// src/pages/ProductListPage.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./BasePage";
 
export class ProductListPage extends BasePage {
  private readonly products: Locator;
  private readonly searchInput: Locator;
 
  constructor(page: Page) {
    super(page);
    this.products    = page.getByTestId("product-card");
    this.searchInput = page.getByTestId("search");
  }
 
  async navigate(): Promise<void> {
    await this.goto("/products");
  }
 
  async search(query: string): Promise<void> {
    await this.searchInput.fill(query);
    await this.searchInput.press("Enter");
  }
 
  async productCount(): Promise<number> {
    return this.products.count();
  }
 
  async openProduct(name: string): Promise<void> {
    await this.products.filter({ hasText: name }).first().click();
  }
}

Notice productCount returns Promise<number> — explicit return types on every public method, as the brief required.

Step 4 — API helpers (src/api/)

A typed apiRequest<T> wrapper, then specific functions on top of it:

// src/api/client.ts
import { type ApiResponse, type ApiError } from "../types/api";
 
export interface RequestOptions {
  method: "GET" | "POST" | "PUT" | "DELETE";
  body?: unknown;
  headers?: Record<string, string>;
}
 
export async function apiRequest<T>(
  endpoint: string,
  options: RequestOptions,
): Promise<ApiResponse<T>> {
  const res = await fetch(endpoint, {
    method:  options.method,
    headers: { "Content-Type": "application/json", ...options.headers },
    body:    options.body ? JSON.stringify(options.body) : undefined,
  });
 
  const json = await res.json();
  if (!res.ok) {
    throw new Error(`API error: ${(json as ApiError).error}`);
  }
  return json as ApiResponse<T>;
}

Specific endpoints layer on top:

// src/api/auth.ts
import { apiRequest } from "./client";
 
export interface UserCredentials { email: string; password: string }
export interface AuthToken { token: string; expiresAt: string }
 
export async function login(creds: UserCredentials): Promise<AuthToken> {
  const res = await apiRequest<AuthToken>("/api/login", {
    method: "POST",
    body:   creds,
  });
  return res.data;
}

The generic <T> flows from the call site into the response type. login returns Promise<AuthToken>, not Promise<ApiResponse<unknown>>. The caller gets autocomplete on result.token and result.expiresAt.

Step 5 — Configuration (src/config/)

A literal-union environment, a typed browser config, a timeout interface:

// src/config/env.ts
export type Environment = "local" | "staging" | "production";
 
const baseUrls: Record<Environment, string> = {
  local:      "http://localhost:3000",
  staging:    "https://staging.example.com",
  production: "https://example.com",
};
 
export function getBaseUrl(env: Environment): string {
  return baseUrls[env];
}
 
export interface TimeoutConfig {
  navigation: number;
  action: number;
  assertion: number;
}
 
export const defaultTimeouts: TimeoutConfig = {
  navigation: 30_000,
  action:     10_000,
  assertion:  5_000,
};
 
export type Browser = "chromium" | "firefox" | "webkit";

Record<Environment, string> guarantees every environment has a base URL — add "qa" to the union and the compiler points at baseUrls until you fill in the new entry. That's the literal-union dividend from chapter 2.

Step 6 — Sample tests

Five tests that exercise the whole framework:

// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../src/pages/LoginPage";
import { createUser } from "../src/factories";
 
test("rejects invalid credentials", async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.navigate();
  await loginPage.login("alice@test.com", "wrong");
  await loginPage.expectError("Invalid credentials");
});
 
test("admin user logs in successfully", async ({ page }) => {
  const admin = createUser({ role: "admin", email: "admin@test.com" });
  const loginPage = new LoginPage(page);
  await loginPage.navigate();
  await loginPage.login(admin.email, "AdminPass");
  await loginPage.expectUrl(/\/products/);
});
// tests/product-list.spec.ts
import { test, expect } from "@playwright/test";
import { ProductListPage } from "../src/pages/ProductListPage";
 
test("search narrows the product list", async ({ page }) => {
  const list = new ProductListPage(page);
  await list.navigate();
  const before = await list.productCount();
  await list.search("widget");
  const after = await list.productCount();
  expect(after).toBeLessThan(before);
});

Reads like prose. Every method call typed. Every test data value typed. No raw selectors, no untyped fetches, no any.

How everything connects

The arrow direction is the dependency direction. Tests depend on pages and factories. Pages and factories depend on types and config. Types depend on nothing. That layering is what makes the framework refactor-safe — change a leaf and only the layers above it need to follow.

What this exercises, end to end

Re-read your own code at the end and you'll spot every chapter:

Build it, run it under strict: true, watch the compiler refuse every typo before the test runs. The next lesson is the self-review — what to check, where to push further.

// tip to track lessons you complete and pick up where you left off across devices.