A function isn't the only thing that can take a type parameter. Interfaces, type aliases, and classes can be generic too — and they are everywhere in real test frameworks. Promise<T>, Array<T>, Cypress.Chainable<T>, Playwright.Locator — every one is a generic type. This lesson shows how to declare them yourself, why one ApiResponse<T> interface fits every endpoint in your test suite, and how generic classes make page-object data stores type-safe.
A generic interface
The syntax mirrors a generic function — angle brackets after the name, then the parameter used inside the body:
interface ApiResponse<T> {
status: number;
data: T;
message: string;
timestamp: string;
}That's it. ApiResponse<T> is now a template — fill in T with a real type and you get a real interface:
interface User { id: number; name: string }
interface Product { sku: string; price: number }
type UserResponse = ApiResponse<User>;
type ProductListResponse = ApiResponse<Product[]>;
type LoginResponse = ApiResponse<{ token: string; expiresIn: number }>;Three different responses, one shared shape. When the API team adds a correlationId to every response, you update ApiResponse once and every consumer picks up the change at compile time. Without the generic, you'd have hand-maintained dozens of slightly-different response interfaces.
Using a generic interface in test code
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const response = await fetchUser(1);
console.log(response.status); // ✅ number
console.log(response.data.name); // ✅ User → autocomplete works
console.log(response.data.banana);
// ❌ Property 'banana' does not exist on type 'User'.The whole pipeline — from the function signature down to the property access on response.data — is type-checked. The <T> flows from declaration to call site to consumer without anyone having to re-state what data looks like.
Generic type aliases
The type keyword takes parameters too. A canonical example — a Result that's either success-with-data or failure-with-error:
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function parseUser(raw: unknown): Result<User> {
// returns either { success: true, data: User } or { success: false, error: string }
/* ... */
return { success: false, error: "stub" };
}
const r = parseUser(rawJson);
if (r.success) {
console.log(r.data.name); // ✅ narrowed to { success: true; data: User }
} else {
console.log(r.error); // ✅ narrowed to the failure shape
}Result<T> is a discriminated union parameterised by T. The success: true | false boolean is the discriminator the type guards from chapter 2 narrow on. One alias, every test that fetches data gets a consistent error contract.
Default type parameters
A generic can have a default — the type used when the caller doesn't specify one. Useful when one shape is overwhelmingly common but you still want to allow overrides:
interface PaginationMeta {
page: number;
totalPages: number;
}
interface PaginatedResponse<T, TMeta = PaginationMeta> {
items: T[];
meta: TMeta;
}
type UserPage = PaginatedResponse<User>;
// meta defaults to PaginationMeta — no need to spell it out
interface CursorMeta { nextCursor: string | null }
type ProductPage = PaginatedResponse<Product, CursorMeta>;
// override meta when neededDefault parameters are how Cypress.Chainable defaults to Chainable<JQuery> when you don't specify the inner element type. Reach for them whenever 80% of callers use the same secondary type.
Generic classes — page object stores
Classes are generic the same way interfaces are. The most useful pattern in test code is a typed data store that holds any kind of test entity:
interface HasId { id: string | number }
class TestDataStore<T extends HasId> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string | number): T | undefined {
return this.items.find((item) => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
}
const userStore = new TestDataStore<User>();
const productStore = new TestDataStore<Product>();
userStore.add({ id: 1, name: "Alice" });
userStore.add({ id: 2, sku: "P-1", price: 99 });
// ❌ Object literal may only specify known properties — 'sku' does not exist on User.new TestDataStore<User>() produces a store that only accepts User values. The same class definition produces a Product-only store on the next line. You'll see this pattern in chapter 7's page object framework, where a base page class is parameterised over the locator shape each subclass exposes.
One generic, many concrete shapes
- – data: User
- – for GET /users/:id
- – data: Product[]
- – for GET /products
- – data: login response
- – for POST /login
- data: void –
- for DELETE /users/:id –
Every endpoint gets its own concrete response type by filling in T. The shape is shared; the data is specific. When the response envelope changes (a new field, a renamed property), you change one interface and every endpoint's typing updates.
When to reach for a generic interface
Three signals that an interface should be generic:
- The same shape wraps different payloads. API responses, paginated lists, event envelopes, retry results — anything where 80% of the structure is fixed and one slot is variable.
- Consumers care about the inner type. If callers always destructure
.dataand use it as a specific shape, parameterising overTkeeps them honest. - You'd otherwise duplicate the wrapper. Hand-maintained
UserResponse,ProductResponse,OrderResponseinterfaces with the same status/data/message fields are a refactor accident waiting to happen.
If the interface only ever wraps one type, don't bother with a generic — just write the concrete interface. Generics earn their complexity through reuse.
A QA-shaped example
Putting interface, type alias, and class together — a small typed test data layer:
interface HasId { id: string | number }
interface ApiResponse<T> {
status: number;
data: T;
message: string;
}
type Result<T> =
| { success: true; value: T }
| { success: false; error: string };
class FixtureStore<T extends HasId> {
private items = new Map<string | number, T>();
add(item: T): void { this.items.set(item.id, item); }
get(id: string | number): Result<T> {
const item = this.items.get(id);
return item
? { success: true, value: item }
: { success: false, error: `Not found: ${id}` };
}
}
const userStore = new FixtureStore<User>();
userStore.add({ id: 1, name: "Alice" } as User);
const result = userStore.get(1);
if (result.success) console.log(result.value.name); // ✅ narrowed to User
else console.log(result.error);Three generics, one tiny module, end-to-end type safety from test fixture to assertion. Add a Product type tomorrow and the same store — new FixtureStore<Product>() — works without a single line of new code.
⚠️ Common mistakes
- Forgetting that the type parameter has to be filled in. Writing
const response: ApiResponse;(without<User>) is a compile error — generic interfaces must be applied. This is the number-one paper cut for beginners; the fix is always to spell out the inner type. - Adding a default just to silence callers. A default like
<T = any>"works" — every caller can omit the type — but you've quietly fallen back toanyeverywhere. Defaults are useful when there's a real common case (<TMeta = PaginationMeta>); they're harmful when used to dodge the type system. - Making every interface generic "just in case." A generic interface is more complex than a concrete one — every reader has to understand the parameter. Reach for a generic only when you have at least two concrete uses or a clear plan for them. Speculative generics are a tax with no payoff.
🎯 Practice task
Build the API response layer for a small test suite. 25-35 minutes.
- In your
ts-for-qa/srcfolder, createapi-types.ts. - Define
interface ApiResponse<T> { status: number; data: T; message: string; timestamp: string }. - Define
interface User { id: number; name: string; email: string }andinterface Product { id: number; sku: string; price: number }. - Write
async function fetchUser(id: number): Promise<ApiResponse<User>>andasync function fetchProducts(): Promise<ApiResponse<Product[]>>. Mock the bodies to return hardcoded values. - Call each, log
response.data.nameandresponse.data[0].sku. Confirm autocomplete works on each. - Add
type Result<T> = { success: true; value: T } | { success: false; error: string }. Writefunction unwrap<T>(r: Result<T>): Tthat throws on failure and returns the value on success — confirm the return type flows through. - Trigger the generic-application errors. After each, read the error and revert:
- Type a variable as
const r: ApiResponse;(no<T>). - Pass an
ApiResponse<User>to a function expectingApiResponse<Product>.
- Type a variable as
- Stretch: write
class FixtureStore<T extends HasId>exactly as in the lesson. Create stores for User and Product, add a few entries, look one up, and walk through how theResult<T>narrowing keeps the success/failure branches typed correctly.
The chapter wraps up next with utility types — the built-in helpers (Partial, Pick, Omit, Record) that turn one interface into a whole family of related types.