QA Interview Hub · Knowledge base · Quizzes

TypeScript

Generics

Why generics: a typed API client

A generic is a type parameter. A function or class works with different types but keeps the link between input and output. Without generics you have to choose between duplicating code and any.

class ApiClient {
  constructor(private readonly baseUrl: string) {}

  async get<T>(path: string): Promise<T> {
    const res = await fetch(`${this.baseUrl}${path}`);
    if (!res.ok) throw new Error(`GET ${path} -> ${res.status}`);
    return (await res.json()) as T;
  }
}

const account = await client.get<Account>('/api/v1/accounts/42');
account.balance; // number, with autocompletion

It is important to be honest here: as T is an assertion, not a check. If the server returned a different object, the type will lie. So in a mature framework the generic takes a schema, and the type is derived from it:

import { z } from 'zod';

async function getParsed<S extends z.ZodTypeAny>(path: string, schema: S): Promise<z.infer<S>> {
  const res = await fetch(path);
  return schema.parse(await res.json());
}

Now T isn't passed by hand but is inferred from the argument. This is a general rule: the best generics are the ones TS infers itself from the parameters, not the ones you have to state in angle brackets.

Other typical places for generics in tests: ApiResult<T>, a paginated response Page<T>, a helper retry<T>(fn: () => Promise<T>): Promise<T>, a test data factory build<T>(defaults: T, overrides?: Partial<T>): T.

In an interview: Generics let you write one method get<T> that returns the right type for any endpoint. But as T checks nothing, so I pass a schema into the client and derive the type from it, so the type and the real data don't drift apart.

Constraints, keyof and defaults

The extends constraint says that a type parameter must have a certain shape. Without it nothing is known about T inside the function.

interface HasId { id: string }

function findById<T extends HasId>(items: readonly T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

The combination with keyof gives type-safe access to fields. A classic example is a helper that extracts a column from results:

function pluck<T, K extends keyof T>(rows: readonly T[], key: K): T[K][] {
  return rows.map(r => r[key]);
}

const amounts = pluck(transactions, 'amount'); // number[]
// pluck(transactions, 'amout'); // error: a typo in the field name

T[K] is an indexed access type, the type of field K in object T. The returned type exactly matches the chosen field.

A type parameter can have a default value:

interface Page<T, Meta = { total: number }> {
  items: T[];
  meta: Meta;
}
type AccountsPage = Page<Account>;

Typical mistakes with generics:

  • A type parameter is used only once. function log<T>(x: T): void is no better than x: unknown. A generic makes sense when it links several places.
  • Stating the type by hand where it can be inferred.
  • Overly complex generics in test code. A test should read quickly, and complex typing belongs in the framework's library layer.
In an interview: With extends I constrain a type parameter, for example T extends HasId, and K extends keyof T gives safe access to fields without typos. If a type parameter appears only once, a generic isn't needed.

Generics in Playwright: fixtures

In Playwright generics most often appear in fixtures. test.extend<T>() takes a type that describes the new fixtures, and after that they are typed in every test.

import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { ApiClient } from './api/api-client';

type Fixtures = {
  loginPage: LoginPage;
  api: ApiClient;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  api: async ({ request }, use) => {
    await use(new ApiClient(request));
  },
});

test('user sees balance', async ({ loginPage, api }) => {
  // loginPage: LoginPage, api: ApiClient
});

For worker-scoped fixtures there is a second parameter: base.extend<TestFixtures, WorkerFixtures>(). For example, a test account that is created once per worker.

Another place is typing project options. A fixture can be declared as an option with { option: true } and its value set in playwright.config.ts for different projects, for example different currencies or markets.

Generics are also useful in wait helpers:

async function pollUntil<T>(fn: () => Promise<T>, done: (v: T) => boolean, timeoutMs = 10_000): Promise<T> {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const value = await fn();
    if (done(value)) return value;
    await new Promise(r => setTimeout(r, 500));
  }
  throw new Error('pollUntil timeout');
}

Although in Playwright there is already expect.poll for this, and I use it first.

In an interview: In Playwright I type fixtures through test.extend<Fixtures>(), and then the Page Object and API client are typed in every test. For worker-level fixtures there is a second type parameter.