TechByteByByte

Part 30: Advanced TypeScript for Playwright

Apply advanced TypeScript patterns to make large Playwright codebases safer.

Types check allowed shapes before running, but disappear at runtime; data from outside still needs validation.

type check โ†’ JavaScript runs โ†’ validate external data

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Part 3 covered TypeScriptโ€™s fundamentals. This part revisits generics and interfaces with real, framework-scale application โ€” the patterns that show up once youโ€™re building shared abstractions used across dozens of page objects and hundreds of tests, rather than in individual, standalone test files.


Generics in Real Fixture Design

Recall Part 15โ€™s base.extend<MyFixtures>({...}) โ€” that type parameter is generics, exactly as covered conceptually in Part 3, now doing real, load-bearing work. Consider a generic helper for API-driven test data creation, reusable across genuinely different data types:

async function createTestEntity<T>(
  request: APIRequestContext,
  endpoint: string,
  data: Partial<T>,
): Promise<T> {
  const response = await request.post(endpoint, { data });
  return response.json();
}

interface User {
  id: number;
  email: string;
  role: string;
}

interface Product {
  id: number;
  name: string;
  price: number;
}

const user = await createTestEntity<User>(request, "/api/users", {
  email: "test@example.com",
});
const product = await createTestEntity<Product>(request, "/api/products", {
  name: "Test Item",
});

One function, genuinely reused for creating entirely different kinds of test data, while TypeScript still knows precisely โ€” at each individual call site โ€” that user is a User (with .email and .role available, autocompleted, and type-checked) and product is a Product (with .name and .price), rather than some vague, generic, untyped blob. This is exactly Part 3โ€™s generics discussion, now solving a real, concrete framework problem: avoiding writing near-identical createTestUser, createTestProduct, createTestOrder functions, each duplicating the same underlying POST-and-parse logic.

Partial<T> and Other Utility Types

Notice Partial<T> in the signature above โ€” a utility type, one of several TypeScript provides out of the box for transforming existing types into useful variations, without redefining them from scratch.

interface User {
  id: number;
  email: string;
  role: string;
  createdAt: string;
}

// Partial<User> makes every property optional โ€” useful here because
// creating a new user doesn't require an id or createdAt; the server generates those
function createUser(data: Partial<User>) {
  /* ... */
}

createUser({ email: "test@example.com", role: "standard" }); // valid โ€” id and createdAt aren't required

A few other genuinely useful ones for framework code:

type UserPreview = Pick<User, "id" | "email">; // only these two properties, everything else stripped out
type UserWithoutId = Omit<User, "id">; // every property except id
type ReadonlyUser = Readonly<User>; // every property becomes immutable after creation

These matter for a real, practical reason worth being explicit about: without utility types, expressing โ€œa version of User without the id fieldโ€ would require manually writing out an entirely separate, near-duplicate interface, which then needs to be kept in sync by hand every time the original User interface changes. Omit<User, 'id'> stays automatically, permanently in sync with User, since itโ€™s derived from it rather than independently redefined.

Analogy: The Lego Universal Mold & Blueprint Modifiers

  • Generics (The Lego Universal Mold): Instead of building a separate, dedicated machine for red blocks, blue blocks, and yellow blocks (duplicate functions for createUser, createProduct), you build a single universal mold machine (createTestEntity<T>). You slide in the desired template T (like User or Product), inject the plastic, and out pops the exact type-safe block you requested.
  • Utility Types (The Blueprint Modifiers): Imagine a master blueprint of a complex house (User).
    • Partial<User> (Optional Room Builder): A modified blueprint that says: โ€œYou can choose to build or skip any room on this plan.โ€ You arenโ€™t forced to build the pool or garage (optional parameters) right now.
    • Omit<User, 'id'> (Delete the Chimney): A modified blueprint that says: โ€œBuild this exact house, but delete the chimney from the plans before construction startsโ€ (filtering out server-generated fields like id).

๐Ÿ“Š Visual Flowchart: Type Shape Transformations

Here is how TypeScriptโ€™s utility types derive new, isolated shapes from a single base interface dynamically:

graph TD
    Base["Base Interface: User<br>{ id: number, email: string, role: string, createdAt: string }"]

Base -->|Partial&lt;User&gt;| P["All Properties Optional:<br>{ id?: number, email?: string, role?: string, createdAt?: string }"]
    Base -->|Omit&lt;User, 'id' &brvbar; 'createdAt'&gt;| O["Removed Server Keys:<br>{ email: string, role: string }"]
    Base -->|Pick&lt;User, 'id' &brvbar; 'email'&gt;| PI["Isolated Keys:<br>{ id: number, email: string }"]

P --> CreateFunc["function createUser(data: Partial&lt;User&gt;)"]
    O --> FactoryFunc["function buildTestUser(): Omit&lt;User, ...&gt;"]

Type-Safe Page Object Constructors

Recall Part 20โ€™s LoginPage class. A common, slightly more advanced pattern worth knowing: a typed factory function that constructs the correct page object based on context, keeping construction logic centralized:

interface PageObjects {
  loginPage: LoginPage;
  inventoryPage: InventoryPage;
  cartPage: CartPage;
}

function createPageObjects(page: Page): PageObjects {
  return {
    loginPage: new LoginPage(page),
    inventoryPage: new InventoryPage(page),
    cartPage: new CartPage(page),
  };
}
test("full purchase flow", async ({ page }) => {
  const { loginPage, inventoryPage, cartPage } = createPageObjects(page);

  await loginPage.goto();
  await loginPage.login("standard_user", "secret_sauce");
  await inventoryPage.addToCart("Sauce Labs Backpack");
  await cartPage.checkout();
});

This is directly Part 2โ€™s destructuring, applied at framework scale โ€” one function call, one destructuring statement, and every page object a test might need is immediately available, fully typed, with TypeScriptโ€™s own autocomplete guiding exactly which page objects actually exist and what methods each one offers.

Reusable, Typed Test Data Factories

Combining generics, utility types, and Part 21โ€™s Faker.js:

import { faker } from "@faker-js/faker";

function buildTestUser(
  overrides: Partial<User> = {},
): Omit<User, "id" | "createdAt"> {
  return {
    email: faker.internet.email(),
    role: "standard",
    ...overrides, // Part 2's spread operator โ€” lets specific tests override just what they need
  };
}

const defaultUser = buildTestUser();
const adminUser = buildTestUser({ role: "admin" }); // same generator, one field deliberately overridden

This is genuinely worth recognizing as the convergence of nearly everything TypeScript-specific covered across this series โ€” Part 2โ€™s spread operator, Part 3โ€™s interfaces and utility types, and Part 21โ€™s dynamic data generation โ€” combined into a single, small, reusable, fully type-checked building block that a real framework would use constantly, across many different test files, each needing slightly different but related test data.


How It Works in a Real Test Run

Advanced TypeScript makes invalid framework combinations harder to express. A generic fixture preserves the type of what it creates, utility types describe controlled variations, and factories ensure required fields exist before a test starts.

These guarantees stop at runtime. Validate untrusted JSON, API responses, and environment variables before casting them; a type assertion tells the compiler to trust you and does not inspect the actual value.

Interview Questions

Q: In createTestEntity<T>, what does the generic type parameter T actually let this one function do that a non-generic version couldnโ€™t?

Ans: It lets a single function be reused for creating genuinely different kinds of data โ€” users, products, orders โ€” while TypeScript still knows the specific, correct type being returned at each individual call site, rather than needing separate, nearly duplicate functions written for each specific entity type, or losing type-safety entirely by using a generic any return type instead.

Q: What does Partial<User> mean, and why is it a useful type for a function that creates a new user?

Ans: Partial<User> produces a version of the User type where every property becomes optional rather than required. Itโ€™s useful for a creation function because a new user typically doesnโ€™t yet have server-generated fields like an id or createdAt timestamp โ€” Partial<User> allows those fields to be omitted at the point of creation, while still enforcing the correct types for whichever fields genuinely are provided.

Q: Why is Omit<User, 'id'> generally preferable to manually writing a separate UserWithoutId interface from scratch?

Ans: Omit<User, 'id'> is derived directly from the original User interface, so it automatically stays in sync whenever User itself changes โ€” add a new field to User, and Omit<User, 'id'> reflects it automatically. A manually written, separate interface would need to be updated by hand every time User changes, creating a real, ongoing risk of the two definitions silently drifting out of sync over time.

Q: How does the buildTestUser factory function combine several TypeScript and JavaScript concepts covered earlier in this series?

Ans: It uses Part 2โ€™s spread operator to let specific calls override just the fields they care about while keeping sensible defaults for the rest, Part 3โ€™s interfaces and utility types (Omit) to precisely describe the shape of data being returned, and Part 21โ€™s dynamic data generation via Faker.js to ensure each generated user has genuinely unique, non-colliding data by default.


Exercises โ€” Part 30

Understand: Explain, in your own words, why a generic createTestEntity<T> function is preferable to writing three separate, nearly identical functions (createUser, createProduct, createOrder) that each do essentially the same underlying work.

Simple Practice: Write your own Partial<T>-based data creation helper function for a Product interface (with name, price, and id fields), and call it twice โ€” once providing only a name, and once overriding both name and price โ€” confirming both calls type-check correctly.

Real-World Scenario: Build a small buildTestUser-style factory function, using Faker.js and the spread-operator override pattern from this part, for a hypothetical checkout address (street, city, postal code, country). Write two tests that each call it โ€” one using entirely default, randomly generated values, and one deliberately overriding just the country field to test a specific country-dependent behavior.

Challenge: Research TypeScriptโ€™s Record<K, V> utility type, and design a genuinely useful application of it for a Playwright framework โ€” for instance, a strongly typed mapping of environment names ('dev' | 'staging' | 'production') to their corresponding base URLs, ensuring every environment has a required, correctly typed URL and that a typo in an environment name is caught by TypeScript at compile time.


Next: Part 31 โ€” Production Framework Architecture

โ€” the full folder structure, separation of concerns, and the real architectural decisions behind a scalable, maintainable Playwright framework.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed