QA Interview Hub · Knowledge base · Quizzes

TypeScript

Modules and tsconfig

ES Modules and CommonJS

Two module systems coexist in Node:

  • CommonJS: require() and module.exports. Historically the Node standard.
  • ES Modules: import and export. The language standard.

How Node decides which system: .mjs files are always ESM, .cjs always CommonJS, and .js depends on the "type" field in the nearest package.json ("module" or "commonjs", CommonJS by default).

In TS code we almost always write import/export syntax, and the compiler or runner turns it into the needed format. So confusion arises not in test code but in configuration: errors like "require is not defined in ES module scope" or "Cannot use import statement outside a module".

Useful things:

import type { Page, Locator } from '@playwright/test'; // type only, erased
import { test, expect } from '@playwright/test';

export { LoginPage } from './login-page';      // re-export, a barrel file
export type { Account } from './models';

import type guarantees that the import will be completely removed at compile time. This matters with the verbatimModuleSyntax option, which requires type imports to be marked explicitly, and helps avoid runtime circular dependencies.

I prefer named exports over default: the name is fixed, and auto-import and refactoring work better.

Playwright transpiles TS test files itself, so a separate build step isn't needed. But it doesn't check types: a test with a type error will run. So on CI I add a separate step npx tsc --noEmit.

In an interview: Node has CommonJS with require and ES Modules with import, and for .js the choice is determined by the type field in package.json. Playwright transpiles TS itself, but doesn't check types, so CI needs a separate tsc --noEmit step.

tsconfig for a test project

tsconfig.json sets how the compiler checks and builds code. For a Playwright test project, a minimal sensible config:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "baseUrl": ".",
    "paths": { "@pages/*": ["src/pages/*"], "@api/*": ["src/api/*"] }
  },
  "include": ["tests", "src", "playwright.config.ts"]
}

What the key options mean:

  • strict turns on a group of checks: strictNullChecks, noImplicitAny, strictFunctionTypes, useUnknownInCatchVariables and others. Without it TS gives little benefit.
  • target is which JS version to compile the syntax down to. For Node 20+ ES2022 fits.
  • module and moduleResolution are the module format and the rules for resolving imports. NodeNext matches Node's behaviour, Bundler is for code that goes through a bundler.
  • noEmit is type checking only, without generating JS. Playwright does the running.
  • skipLibCheck doesn't check the dependencies' .d.ts files, speeding up compilation.
  • paths are import aliases instead of ../../../pages. Playwright supports paths from tsconfig.
  • resolveJsonModule lets you import JSON with test data, typed.

Important: with module: NodeNext in an ESM project, relative imports require the .js extension in the path, even if the file is .ts. This often surprises newcomers.

In an interview: In a test project I always turn on strict and noUncheckedIndexedAccess, set noEmit, because Playwright runs the tests, and configure paths for short imports. I run type checking separately on CI with tsc --noEmit.

Environment types, .d.ts and environment variables

.d.ts files contain only type declarations, without code. They are used for JS libraries and for extending existing types.

A typical task is typing process.env. By default every variable has the type string | undefined, and that is right: the variable may be missing.

// src/env.ts
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing env variable ${name}`);
  return value;
}

export const env = {
  baseUrl: required('BASE_URL'),
  apiToken: required('API_TOKEN'),
  timeoutMs: Number(process.env.TIMEOUT_MS ?? '30000'),
} as const;

I prefer such a module to a global extension of the ProcessEnv type through declaration merging. A type extension only tells the compiler "the variable exists", but doesn't check that at runtime. A module with a check fails immediately with a clear error, instead of in the middle of a test with undefined in a URL. Even better is to validate the variables with a schema.

The @types/... packages contain types for JS libraries, for example @types/node. The types option in tsconfig limits which global types are picked up automatically. If one repository has both Playwright and Jest, the global expect may conflict, and then separate tsconfigs are set up for different parts.

Another tool is project references and several tsconfigs: a base tsconfig.base.json and separate configs for e2e, API tests and utilities through extends.

In an interview: .d.ts are files with types only. I don't just type environment variables, I read them through a module with a check, so a test fails immediately with a clear error if a variable is missing. For different parts of the repository I use separate tsconfigs through extends.