QA Interview Hub · Knowledge base · Quizzes

TypeScript

Advanced types: keyof, typeof, mapped, conditional, template literal

keyof, typeof and indexed access

keyof T gives a union of a type's keys. typeof value in a type position takes the type from a value. T[K] is the type of the field with key K. Together they let you derive types from one source of truth.

const ROUTES = {
  login: '/login',
  accounts: '/accounts',
  transfers: '/transfers/new',
} as const;

type RouteName = keyof typeof ROUTES;            // 'login' | 'accounts' | 'transfers'
type RoutePath = (typeof ROUTES)[RouteName];      // '/login' | '/accounts' | '/transfers/new'

async function goTo(page: Page, name: RouteName) {
  await page.goto(ROUTES[name]);
}

Now goTo(page, 'acounts') won't compile, and a new route is available automatically.

Indexed access also works with arrays: (typeof CURRENCIES)[number] gives a union of the tuple's elements. And with nested types: Account['owner']['address'].

The difference between the two typeof: in a JS expression typeof x === 'string' is a runtime operator that returns a string. In a type position type T = typeof x is a TS operator that takes the static type of a variable. The syntax is the same, the meaning is different.

A useful pattern for test data:

const testUsers = {
  admin: { email: 'admin@test.io', role: 'admin' },
  operator: { email: 'op@test.io', role: 'operator' },
} as const;

type TestUserKey = keyof typeof testUsers; // 'admin' | 'operator'
type Role = (typeof testUsers)[TestUserKey]['role'];

A fixture or helper takes TestUserKey, and typos in roles disappear.

In an interview: keyof typeof CONFIG gives a union of a constant object's keys, and T[K] the value's type. I type routes, roles and test users this way, so there is one object as the source of truth and no typos in strings.

Mapped types and conditional types

A mapped type iterates over keys and builds a new type. That is how Partial, Readonly, Record are implemented.

type Nullable<T> = { [K in keyof T]: T[K] | null };

// modifiers: -? removes optional, -readonly removes readonly
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

Since TS 4.1 keys can be renamed with as:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => Promise<T[K]>;
};

interface Balance { available: number; blocked: number }
type BalanceApi = Getters<Balance>;
// { getAvailable: () => Promise<number>; getBlocked: () => Promise<number> }

A conditional type is an "if" at the type level: T extends U ? X : Y. Together with infer it lets you extract part of a type.

type ItemOf<T> = T extends readonly (infer I)[] ? I : never;
type Tx = ItemOf<Transaction[]>; // Transaction

type UnwrapResult<T> = T extends { status: 'success'; data: infer D } ? D : never;

An important property is distributivity. If T is a "naked" type parameter and a union is passed in, the condition is applied to each member separately. That is how Exclude<T, U> = T extends U ? never : T works. To turn off distributivity, the parameter is wrapped in a tuple: [T] extends [U].

Honestly about practice: in tests I write such types rarely. They are needed in the framework layer: a typed API client, factory generators. If a type is hard for a colleague to read, it is better to simplify it.

In an interview: A mapped type iterates over the keys of keyof T and builds a new type, and that is how Partial and Readonly are built. A conditional type is a type-level condition, and with infer you can extract, for example, an array's element type. In tests I use this moderately, mostly in the library layer.

Template literal types

Template literal types build string types from a template, like template strings, only at the type level. When a union is substituted, a Cartesian product results.

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Version = 'v1' | 'v2';
type ApiPath = `/api/${Version}/${string}`;

type RouteKey = `${Method} ${ApiPath}`;
const key: RouteKey = 'POST /api/v1/transfers'; // ok
// const bad: RouteKey = 'POST /transfers';     // error

${string} means any string in that position, and ${number} a string that looks like a number.

Built-in utilities for string types: Uppercase, Lowercase, Capitalize, Uncapitalize.

Practical examples in automation:

  • Test identifiers for a helper over getByTestId, to follow a naming convention (the TestId type below).
  • Event or metric keys (the TransferEvent type below).
  • Data format (the IsoDate type below): a rough restriction that catches obvious mistakes but doesn't fully validate a date. The string '1-2-3' would also pass.
type TestId = `${'btn' | 'input' | 'row'}-${string}`;
type TransferEvent = `transfer.${'created' | 'completed' | 'failed'}`;
type IsoDate = `${number}-${number}-${number}`;
type Env = 'dev' | 'stage';
type BaseUrl = `https://${Env}.bank.example`;

function urlFor(env: Env): BaseUrl {
  return `https://${env}.bank.example`;
}

A limitation: template literal types check only literals known at compile time. A string from .env or from an API response has the type string, and it still has to be validated at runtime.

In an interview: Template literal types set a string template at the type level, for example a method plus a path that starts with /api/. I use them for conventions: route keys, test ids, environment URLs. But I understand this is compile-time only, and I validate external data separately.