Generic Interfaces and Types

8 min read

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 needed

Default 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

interface ApiResponse<T>
  • – 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 .data and use it as a specific shape, parameterising over T keeps them honest.
  • You'd otherwise duplicate the wrapper. Hand-maintained UserResponse, ProductResponse, OrderResponse interfaces 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 to any everywhere. 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.

  1. In your ts-for-qa/src folder, create api-types.ts.
  2. Define interface ApiResponse<T> { status: number; data: T; message: string; timestamp: string }.
  3. Define interface User { id: number; name: string; email: string } and interface Product { id: number; sku: string; price: number }.
  4. Write async function fetchUser(id: number): Promise<ApiResponse<User>> and async function fetchProducts(): Promise<ApiResponse<Product[]>>. Mock the bodies to return hardcoded values.
  5. Call each, log response.data.name and response.data[0].sku. Confirm autocomplete works on each.
  6. Add type Result<T> = { success: true; value: T } | { success: false; error: string }. Write function unwrap<T>(r: Result<T>): T that throws on failure and returns the value on success — confirm the return type flows through.
  7. 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 expecting ApiResponse<Product>.
  8. 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 the Result<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.

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