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.