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 templateT(likeUserorProduct), 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 likeid).
๐ 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<User>| P["All Properties Optional:<br>{ id?: number, email?: string, role?: string, createdAt?: string }"]
Base -->|Omit<User, 'id' ¦ 'createdAt'>| O["Removed Server Keys:<br>{ email: string, role: string }"]
Base -->|Pick<User, 'id' ¦ 'email'>| PI["Isolated Keys:<br>{ id: number, email: string }"]
P --> CreateFunc["function createUser(data: Partial<User>)"]
O --> FactoryFunc["function buildTestUser(): Omit<User, ...>"]
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