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.