Skip to content
SDETRole
SDETRole

TypeScript interview question

How would you use generics to build a typed API client for tests?

intermediate 2–5 yrs #generics#api-testing#type-safety#playwright-request

Short answer

Write request helpers like get<T>(path): Promise<T> so callers state the expected response type, or better, map endpoints to request and response types in one interface and use keyof plus indexed access types so the path determines the types. Remember generics are compile-time only: validate at runtime too.

Answer

The basic idea is a request function whose return type is a type parameter: the caller says what shape they expect, and every test that uses the result gets autocompletion and compile errors on typos. A better design makes the endpoint itself determine the types, so callers cannot ask for the wrong one. Either way, generics are erased at compile time, so the client must still validate the response at runtime if you want the type to be true.

Step 1: a generic wrapper

Using Playwright’s APIRequestContext (works the same with fetch):

import type { APIRequestContext } from '@playwright/test';

export class ApiClient {
  constructor(private request: APIRequestContext) {}

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

  async post<TBody, TResponse>(path: string, body: TBody): Promise<TResponse> {
    const res = await this.request.post(path, { data: body });
    if (!res.ok()) throw new Error(`POST ${path} -> ${res.status()}`);
    return (await res.json()) as TResponse;
  }
}

// usage
type User = { id: string; email: string };
const user = await api.get<User>('/users/42');
user.email; // typed

This is already useful, but the as T is a promise from the caller, not a check, and nothing stops api.get<Order>('/users/42').

Step 2: let the endpoint drive the types

Describe the API once, then use keyof and indexed access types:

type User = { id: string; email: string; role: 'admin' | 'member' };
type Order = { id: string; total: number; status: 'paid' | 'pending' };

interface Endpoints {
  'GET /users/:id':  { params: { id: string }; response: User };
  'POST /users':     { body: Omit<User, 'id'>; response: User };
  'GET /orders/:id': { params: { id: string }; response: Order };
}

type Route = keyof Endpoints;
type ResponseOf<R extends Route> = Endpoints[R]['response'];
type BodyOf<R extends Route> = Endpoints[R] extends { body: infer B } ? B : never;

export class TypedClient {
  constructor(private request: APIRequestContext) {}

  async call<R extends Route>(
    route: R,
    opts: { params?: Record<string, string>; body?: BodyOf<R> } = {},
  ): Promise<ResponseOf<R>> {
    const [method, template] = route.split(' ') as [string, string];
    const path = template.replace(/:(\w+)/g, (_, k: string) => {
      const v = opts.params?.[k];
      if (v === undefined) throw new Error(`Missing param ${k} for ${route}`);
      return encodeURIComponent(v);
    });
    const res = await this.request.fetch(path, { method, data: opts.body });
    if (!res.ok()) throw new Error(`${route} -> ${res.status()}`);
    return (await res.json()) as ResponseOf<R>;
  }
}

const order = await client.call('GET /orders/:id', { params: { id: '7' } });
order.status;           // 'paid' | 'pending'
// client.call('GET /orderz/:id') -> compile error: not a known route

Now the route string is checked, and the response type follows from it. Teams with an OpenAPI spec usually generate this map (for example with openapi-typescript) instead of writing it by hand, so the types track the real contract.

Step 3: keep the types honest at runtime

as ResponseOf<R> is still a cast. If the backend returns total as a string, the test types are lying. Pair the client with a runtime schema (Zod, Ajv with JSON Schema) for responses, or at least for the endpoints under test. That turns a type assumption into an assertion, which is exactly what an API test should do.

What interviewers look for

  • Correct generic syntax and constraints (R extends keyof ...).
  • Moving from caller-chosen types to endpoint-derived types.
  • Awareness that generics vanish at runtime and casts are not validation.
  • Mention of generating types from OpenAPI in real projects.

A strong answer covers

  • Uses a type parameter for the response type, e.g. get<T>
  • Constrains generics with extends or keyof
  • Maps endpoints to request/response types so the path drives the types
  • Notes generics are erased at runtime and an assertion of shape is needed
Practise answering under time pressure

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →