QA Interview Hub · Knowledge base · Quizzes
TypeScript
Basics: primitives, any, unknown, never, literals
Why TypeScript in a test framework
TypeScript is JavaScript plus a static type system. Types exist only at compile time: after transpilation to JS they are completely erased and check nothing at runtime. This is a key idea to state honestly in an interview: TS catches errors in code, but doesn't guarantee that an API response really has the shape you described.
What TS gives an automation engineer in practice:
- Autocompletion for Page Objects, fixtures, API clients. A new engineer sees which methods exist and which arguments they take.
- Safe refactoring. Renamed the
accountId field in a model, and the compiler shows all the tests that broke. - Documentation in code. The type
TransferRequest explains the contract better than a comment. - Early errors: a typo in a field name,
undefined where a string is expected, a forgotten await.
Basic primitives: string, number, boolean, null, undefined, bigint, symbol. For arrays, string[] or Array<string>, for tuples [string, number].
const baseUrl: string = process.env.BASE_URL ?? 'http://localhost:3000';
const retries: number = 2;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const pair: [currency: string, amount: number] = ['UAH', 10050];
You don't need to annotate every variable explicitly: TS infers the type itself (type inference). I put annotations at the boundaries: function parameters, return values of public methods, data models.
In an interview: For me TypeScript is a tool that catches errors before tests run and makes the framework self-documenting. But types are erased at runtime, so I additionally validate API data with a schema, rather than just trusting the type.
any, unknown and never
any turns off type checking. With a variable of type any you can do anything, and the compiler stays silent. Worse, any "infects" everything it touches: the result of anyValue.foo.bar is also any. In test code this is a typical source of errors that show up only at runtime.
unknown is a safe alternative. You can put anything into an unknown variable, but you can't use it without a check. First you must narrow the type: typeof, instanceof, a type guard or schema validation.
async function readBody(res: Response): Promise<unknown> {
return res.json();
}
const body = await readBody(res);
// body.id; // error: 'body' is of type 'unknown'
if (typeof body === 'object' && body !== null && 'id' in body) {
console.log(body.id); // now it is allowed
}
never is a type with no values. It is returned by a function that always throws or never finishes. The most useful application is an exhaustive check: if you forgot to handle a variant in a switch over a union type, the compiler shows an error.
type Status = 'PENDING' | 'COMPLETED' | 'FAILED';
function label(s: Status): string {
switch (s) {
case 'PENDING': return 'In progress';
case 'COMPLETED': return 'Success';
case 'FAILED': return 'Error';
default: {
const unreachable: never = s;
throw new Error(`Unknown status: ${unreachable}`);
}
}
}
If you add the status REVERSED, the code stops compiling until you handle it.
In an interview: any turns off checks, so in our framework it is forbidden by an ESLint rule. For external data I use unknown and narrow the type with a check, and never for an exhaustive switch, so that a new status doesn't go unnoticed.
Literal types, as const and satisfies
A literal type is a type with one specific value: 'GET', 200, true. A union of literals gives very precise contracts: type Currency = 'UAH' | 'USD' | 'EUR'. You can no longer pass 'usd' in lowercase.
When you write const method = 'GET', TS infers the literal type 'GET'. But for let or object properties the type is widened to string. To keep literals, as const is used: it makes all fields readonly and fixes the exact values.
const ENDPOINTS = {
accounts: '/api/v1/accounts',
transfers: '/api/v1/transfers',
} as const;
type Endpoint = typeof ENDPOINTS[keyof typeof ENDPOINTS];
// '/api/v1/accounts' | '/api/v1/transfers'
const CURRENCIES = ['UAH', 'USD', 'EUR'] as const;
type Currency = typeof CURRENCIES[number]; // 'UAH' | 'USD' | 'EUR'
The second technique is an array of values plus a type derived from it. That gives you one source of truth: the array can be used in parameterised tests and the type in signatures.
The satisfies operator (since TS 4.9) checks that a value conforms to a type, but doesn't "widen" it to that type. This is convenient for test data configs:
type UserRole = 'admin' | 'operator' | 'viewer';
const users = {
admin: { login: 'admin@test.io', role: 'admin' },
viewer: { login: 'viewer@test.io', role: 'viewer' },
} satisfies Record<string, { login: string; role: UserRole }>;
users.admin.login; // ok, the keys are known exactly
Instead of enum I more often take a union of literals: it doesn't generate runtime code and combines better with JSON from the API.
In an interview: I use a union of literals for statuses, currencies and HTTP methods, because it is a precise contract with no runtime code. as const fixes a config as immutable with exact types, and satisfies checks an object against a type without losing the specific keys.