QAVeda Explore QAVeda →
Interview Prep · TypeScript for QA

TypeScript for QA
Interview Questions

Master TypeScript for test automation and QA engineering. Learn types, interfaces, generics, OOP principles and advanced patterns used in modern test frameworks. Real TypeScript interview questions for QA roles.

150
Questions
3
Levels
Expert
Curated

Junior (0–2 years)

1
Fundamentals

What is TypeScript, and how does it differ from JavaScript?

TypeScript is a superset of JavaScript — that means everything you can already write in JavaScript also works in TypeScript, and TypeScript adds extra features on top. The biggest addition is static types: you tell TypeScript what kind of data each variable, parameter, and function return should hold, and it checks that the rest of your code uses them correctly.

The problem it solves:

JavaScript only catches type mistakes when the code actually runs. So you write something, ship it, a user clicks a button, and *then* the browser blows up because you called .toUpperCase() on a number. TypeScript catches that same mistake the moment you type it — before the code has ever run.

The two languages side by side:

``ts
// JavaScript — the error only blows up when it runs:
function greet(name) {
return "Hi " + name.toUpperCase();
}
greet(42); // 💥 Runtime crash: "name.toUpperCase is not a function"

// TypeScript — the same mistake is caught while you type:
function greet(name: string) {
return "Hi " + name.toUpperCase();
}
greet(42); // ❌ Compile error: Argument of type 'number' is not assignable to type 'string'.
`

The
: string annotation tells TypeScript "this parameter must be a string." When you call greet(42), TypeScript matches that against the annotation and immediately flags it red in your editor.

Other things TypeScript gives you:
- Better autocompletion — your editor knows exactly what fields exist on an object and offers them as you type.
- Safer refactoring — rename a field on a type and TypeScript instantly shows every file that needs updating.
- Self-documenting code — function signatures show exactly what they expect and return, so the code reads like its own documentation.

What TypeScript does NOT do:
- It doesn't run in the browser directly. Before your code ships, the TypeScript compiler (
tsc`) strips out all the type annotations and produces plain JavaScript. The browser only ever sees the JavaScript.
- It doesn't check types at runtime. If your API sends back wrong data, TypeScript can't catch that — type annotations only exist while you're writing code. For data coming from outside, you still need to validate at runtime.
💡 Plain English: JavaScript is writing an essay in pen — any mistake you made only surfaces when someone reads it aloud at the front of the class (runtime). You're cringing as they stumble over the wrong word. TypeScript is writing the same essay with spell-check on. As you type each sentence, the editor underlines mistakes in red — wrong word, missing comma, sentence doesn't agree — *before* anyone else sees it. The reader never trips up because the mistakes were fixed while you were still writing.
2
Types

What are the basic types in TypeScript?

TypeScript adds type annotations on top of JavaScript values. You tell TypeScript what kind of data each variable holds, and it makes sure you never accidentally put the wrong kind in.

The everyday primitive types:

``ts
let name: string = "Asha"; // text in quotes
let age: number = 30; // integer or decimal
let active: boolean = true; // true or false
`

If you try to assign the wrong kind, TypeScript stops you immediately:
`ts
name = 42; // ❌ Type 'number' is not assignable to type 'string'.
`

Collection types:

`ts
let scores: number[] = [90, 85, 70]; // an array of numbers
let names: string[] = ["a", "b", "c"]; // an array of strings
`

number[] literally means "an array where every item is a number." Try pushing the wrong kind and TypeScript catches it:
`ts
scores.push("hello"); // ❌ 'string' is not assignable to 'number'.
`

Special "empty" types:

`ts
let nothing: null = null; // deliberately set to nothing
let notSet: undefined = undefined; // variable declared but never assigned
`

Escape-hatch types (use sparingly):

`ts
let anything: any = 5; // turns off type checking — anything goes
let mystery: unknown = 5; // "could be anything, but check before using"
`

any and unknown look similar but behave very differently:
`ts
let a: any = "hello";
a.toUpperCase(); // ✅ allowed (but risky — would crash if a were a number)

let u: unknown = "hello";
u.toUpperCase(); // ❌ TypeScript stops you — you must check the type first
`

Function-related types:

`ts
function log(msg: string): void { console.log(msg); } // returns nothing useful
function fail(): never { throw new Error("oops"); } // never returns at all
`

Object and tuple types:

`ts
let user: { name: string; age: number } = { name: "Asha", age: 30 };
let pair: [string, number] = ["Asha", 30]; // tuple — fixed length, ordered types per slot
`

Rule of thumb: start with the obvious one (
string, number, boolean) for everyday values, use arrays for collections, and only reach for any or unknown` when the type genuinely isn't known up front.
💡 Plain English: Types are like labelled containers in a kitchen. The jar marked "flour" only holds flour. The jar marked "sugar" only holds sugar. The labels stop you pouring salt into the sugar jar by mistake — and stop the next cook reaching for the wrong jar. `any` is an unlabelled jar. You can put anything in, but you also have no idea what's in there when you grab it next. Useful occasionally; risky as a daily habit.
3
Types

What is the difference between type inference and type annotation?

TypeScript figures out the type of a value in two ways: either you spell it out explicitly (annotation), or TypeScript works it out from the value itself (inference).

Annotation — you tell TypeScript the type explicitly:

``ts
let age: number = 30;
let name: string = "Asha";
let active: boolean = true;
`

The
: number, : string, : boolean are annotations — the explicit type labels you write yourself.

Inference — TypeScript figures it out from the value:

`ts
let age = 30; // TypeScript infers: number
let name = "Asha"; // TypeScript infers: string
let active = true; // TypeScript infers: boolean
`

You didn't write the type, but TypeScript knows it anyway. Hover over
age in your editor and it shows number.

Inference is smarter than it looks — it locks the type in:

`ts
let name = "Asha"; // inferred as string
name = 42; // ❌ 'number' is not assignable to 'string'.
`

Even though you didn't annotate, TypeScript locked in
string the moment you assigned "Asha". The later assignment to 42 is caught.

When to lean on inference vs annotate explicitly:

Lean on inference when:
- The value makes the type obvious:
let count = 0; is fine — annotating : number is just noise.
- Local variables inside a function — short scope, easy to trace.

Annotate explicitly when:
- Function parameters — TypeScript can't guess what callers will pass:
`ts
function greet(name: string) { /* ... */ } // annotation required
`
- Function return types — documents intent and catches mistakes if you change the body later:
`ts
function getAge(): number { return user.age; }
`
- Empty arrays or objects — TypeScript would infer
any[] or {}, which loses safety:
`ts
let scores: number[] = []; // explicit — otherwise inferred as any[]
``

Rule of thumb: let inference handle the obvious; annotate the things that cross a boundary (function parameters, return types, exports, empty containers).
💡 Plain English: Annotation is labelling a box yourself before putting things in — you decide what the box is for. Inference is the smart assistant who watches you put apples in and labels it "apples" automatically. For your own pantry shelves (local variables), the assistant is fine. For boxes being shipped out to others (function parameters, return types), you label them yourself — so the people receiving them know exactly what's inside, and can't put the wrong thing in by mistake later.
4
JavaScript Basics

What is the difference between let, const, and var?

All three declare a variable, but they behave very differently in terms of *scope*, *reassignment*, and old-JavaScript quirks.

const — a variable you cannot reassign:

``ts
const MAX = 100;
MAX = 200; // ❌ Cannot assign to 'MAX' because it is a constant.
`

Block-scoped (only visible inside the
{ ... } it was declared in). Use const by default — most variables don't actually need to be reassigned.

Important catch —
const prevents reassignment, not mutation:

The contents of a
const object or array can still change. The "constant" part is the *binding* (which value the variable points to), not the value itself:

`ts
const list = [1, 2, 3];
list.push(4); // ✅ allowed — the array contents changed, but 'list' still points to the same array
list = [9, 9, 9]; // ❌ not allowed — this would reassign 'list' to a different array
`

let — a variable you can reassign:

`ts
let total = 0;
total = total + 10; // ✅ allowed
total = "done"; // ❌ TypeScript still enforces the original inferred type (number)
`

Also block-scoped. Use
let when you genuinely need to change the value over time — counters, accumulators, loop trackers.

var — the old way, generally avoid:

`ts
var x = 5;
`

var has two quirks that cause bugs in older code:

1. Function-scoped, not block-scoped — a
var declared inside an if block leaks out of it:
`ts
if (true) { var leaked = 1; }
console.log(leaked); // prints 1 — leaked outside the if block!
`

2. Hoisting — a
var can be used before its declaration line is reached (it's silently moved to the top of the function), giving confusing undefined values instead of errors.

var exists only for backwards compatibility. In modern TypeScript code: never use it.

Rule of thumb:
- Default to
const.
- Use
let only when you genuinely need to reassign the variable.
- Don't use
var`.
💡 Plain English: **`const`** is a reserved parking spot for one specific car. You can't swap the car for a different one, but you can rearrange the things *inside* the car. Most parking spots in your codebase should be like this. **`let`** is a regular parking spot — you can park different cars in it over the course of the day. **`var`** is the old free-for-all car park with confusing rules — cars sometimes appear in spots you didn't park them in, and the boundaries between sections are blurry. Modern car parks abandoned the design.
5
Types

What is the difference between any, unknown, and never?

These three look similar at first glance but solve completely different problems. Getting them right is one of the biggest signals of how well you actually understand TypeScript.

any — turns off type checking entirely:

``ts
let value: any = 5;
value.foo.bar.baz(); // ✅ no error from TypeScript — anything goes
value = "now a string"; // ✅ allowed
value = { x: 1 }; // ✅ allowed
`

any says "stop checking — trust me." It defeats the whole point of TypeScript. Useful occasionally as an escape hatch (interfacing with truly dynamic data, or in a hurry while migrating old code), but every any is a hole in your type safety.

unknown — the safe version of any:

`ts
let value: unknown = 5;
value.foo; // ❌ TypeScript blocks you
value.toFixed(2); // ❌ TypeScript blocks you

// You MUST narrow it first:
if (typeof value === "number") {
value.toFixed(2); // ✅ now TypeScript knows it's a number
}
`

unknown says "I don't know the type yet — check before you use it." It forces you to prove the type before touching the value. Use unknown for data coming in from outside your code: API responses, JSON parses, caught errors.

never — a value that cannot exist:

never represents a value that *can't happen*. You see it in three places:

1 — A function that always throws (and never returns):
`ts
function fail(msg: string): never {
throw new Error(msg);
}
`

2 — An impossible branch (the most useful pattern):
`ts
type Status = "active" | "inactive";

function handle(s: Status) {
if (s === "active") return enable();
if (s === "inactive") return disable();
const impossible: never = s; // ❌ compile error if a new Status is added
}
`
If someone later adds
"banned" to Status, this code fails to compile until you handle the new case. That's the trick — never is your tripwire for "missing case" bugs.

3 — Filtering in advanced types
never removes a type from a union (you'll see this in mid/senior-level utility types).

The big distinction in one line:
-
any = "don't check anything." (dangerous)
-
unknown = "I don't know — make me check before I use it." (safe)
-
never = "this can't happen." (a tripwire/alarm)

Rule of thumb:
- Avoid
any in real code. If you need an escape hatch, prefer unknown.
- Use
unknown for any data coming from outside (APIs, JSON, error catches).
- Use
never` as a tripwire to make sure you've handled every case of a union.
💡 Plain English: **`any`** is signing a blank cheque — anyone can fill in any amount, no questions asked. Dangerous to hand out. **`unknown`** is a sealed package handed to you at the door — you can't use the contents until you've opened it and verified what's inside. Safe by default. **`never`** is the alarm on a door that's supposed to stay locked — if the door is ever opened, the alarm goes off. A clear signal that "something that was never supposed to happen, just happened."
6
Types

What is the difference between an interface and a type?

Both interface and type describe the *shape* of data — like a blueprint that says "an object of this kind has these properties." For most everyday object shapes, they're interchangeable. But each has things the other can't do.

Basic usage — they look almost identical:

``ts
interface User {
id: number;
name: string;
}

type User = {
id: number;
name: string;
};

// Both can be used the same way:
const u: User = { id: 1, name: "Asha" };
`

What
type can do that interface cannot:

1 — Union types ("this OR that"):
`ts
type ID = string | number; // can only be done with 'type'
type Status = "active" | "banned";
`

2 — Aliasing primitives, tuples, and function signatures:
`ts
type Age = number; // simple alias
type Coordinates = [number, number]; // tuple
type Logger = (msg: string) => void; // function signature
`

What
interface can do that type cannot (easily):

1 — Declaration merging — same interface can be re-opened to add properties:
`ts
interface User { name: string; }
interface User { age: number; } // merges automatically

// User now has both: { name: string; age: number; }
`
This is mostly used to extend types from third-party libraries.

2 — Cleaner
extends syntax for inheritance:
`ts
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
// Dog has: { name: string; breed: string; }
`

(
type can do this too with intersections — type Dog = Animal & { breed: string }; — but extends reads more naturally for object hierarchies.)

Rule of thumb:
- Use
interface for object and class shapes — especially when other developers might need to extend it.
- Use
type` for unions, primitives, tuples, function signatures, and complex computed types.
- For plain object shapes, pick one style and stick to it across your team for consistency.
💡 Plain English: Both are blueprints, but for different jobs. An **interface** is a building blueprint — it describes a structure that can be extended (add a balcony, add a floor), and other architects can submit additions that get merged in. A **type** is a more flexible label — it can describe a building, but it can also describe "either a house OR an apartment" (union), or "exactly two coordinates" (tuple), or even "any rule that takes a number and returns a string" (function signature). It's the all-purpose labelling tool when "object blueprint" isn't quite the right fit.
7
Types

What are union and intersection types?

Union and intersection are two ways to combine existing types into new ones. They sound similar but mean opposite things.

Union (|) — "one of these":

A value typed as a union can be *any one* of the listed types — but only one at a time.

``ts
let id: string | number;
id = "abc"; // ✅ allowed (it's a string)
id = 123; // ✅ allowed (it's a number)
id = true; // ❌ Type 'boolean' is not assignable to 'string | number'.
`

A common real-world example — a function that accepts either an ID format:
`ts
function findUser(id: string | number) {
// id is either a string OR a number — we need to handle both
if (typeof id === "number") {
return db.users.findById(id); // here TypeScript knows id is number
}
return db.users.findByUuid(id); // here TypeScript knows id is string
}
`

Note: inside an
if check, TypeScript narrows the union to just one of the types — this is called type narrowing.

Intersection (
&) — "all of these combined":

A value typed as an intersection has *all* the properties of every type combined into one.

`ts
interface Person { name: string; age: number; }
interface Employee { employeeId: string; role: string; }

type Staff = Person & Employee;

const s: Staff = {
name: "Asha",
age: 30,
employeeId: "E001",
role: "QA Lead",
};
// must have ALL fields from both Person AND Employee
`

A common real-world example — combining a base shape with an extension:
`ts
type WithTimestamps<T> = T & { createdAt: Date; updatedAt: Date; };
type TimestampedUser = WithTimestamps<User>;
// User + createdAt + updatedAt — all in one type
`

The key difference in one sentence:
- Union (
|) = "EITHER this OR that" — a value can be one of several types.
- Intersection (
&`) = "BOTH this AND that" — a value must have all properties from every type.

Rule of thumb:
- Use union when a value's type can vary (a function input that accepts either string or number, a state that can be loading/success/error).
- Use intersection when you want to combine multiple shapes into one (add fields, mix in capabilities).
💡 Plain English: A **union** is ordering off the drinks menu — "tea OR coffee OR juice." You pick *one* at a time. The waiter brings the one you chose; you don't get all three. An **intersection** is a combo meal — "burger AND fries AND a drink, all together." You don't choose one; the meal *includes* all of them by definition. If any item is missing, it's not the combo meal anymore.
8
Types

What are optional properties in TypeScript?

An optional property is a field that may or may not be present on an object. You mark it with a ? after the property name.

The syntax:

``ts
interface User {
name: string; // required — must be present
age?: number; // optional — may be missing
email?: string; // optional — may be missing
}

const u1: User = { name: "Asha" }; // ✅ optional fields omitted
const u2: User = { name: "Ben", age: 30 }; // ✅ some optional fields filled
const u3: User = { name: "Cat", age: 25, email: "c@x.com" }; // ✅ all filled
const u4: User = { age: 30 }; // ❌ 'name' is required
`

What the
? actually does — it implicitly adds | undefined to the type:

`ts
interface User {
age?: number;
}

// Reading the field gives you 'number | undefined' — you must handle the missing case:
function getAge(u: User): number {
return u.age; // ❌ Type 'number | undefined' is not assignable to 'number'.
}

// Fix — handle the missing case:
function getAge(u: User): number {
return u.age ?? 0; // use 0 if age is missing
}
`

A common real-world example — an API response where some fields aren't always present:

`ts
interface ApiUser {
id: number;
name: string;
phoneNumber?: string; // not every user has provided their phone
lastLogin?: string; // missing if they've never logged in
}

function showProfile(user: ApiUser) {
console.log(user.name);
if (user.phoneNumber) {
console.log("Phone:", user.phoneNumber);
}
if (user.lastLogin) {
console.log("Last login:", user.lastLogin);
}
}
`

Optional property (
age?: number) vs explicit age: number | undefined:

These look similar but are subtly different:

`ts
interface A { age?: number; } // age may be OMITTED entirely
interface B { age: number | undefined; } // age MUST be present, but can be 'undefined'

const a: A = { }; // ✅ allowed — omitted
const b: B = { }; // ❌ 'age' is required — must include 'age: undefined'
`

Rule of thumb: use
? when the field can be absent from the object entirely. Use | undefined` only when the field must always be set, but may explicitly hold no value.
💡 Plain English: Optional properties are like fields on a form — some required (name, email), some optional (middle name, alternate phone number). You can submit the form without filling in the optional fields, and that's fine. But the code processing the form has to *cope with* those fields being blank — it can't assume there's a value there. If your code tries to print "Middle name: X" without checking, it'll print "Middle name: undefined" — embarrassing.
9
OOP

What is a TypeScript class and how do you use one in a Page Object for Playwright?

A class in TypeScript is a blueprint for creating objects that combine data (properties) and behaviour (methods) in one place. TypeScript adds type annotations and access modifiers on top of JavaScript's class syntax.

In test automation, the most common use of a class is the Page Object Model (POM) — a design pattern where each page of the application gets its own class that encapsulates its locators and actions. Instead of scattering page.locator('#login-btn') calls throughout your test files, you put them in one place and reuse them.

Why it matters — tests without a Page Object:

``ts
// ❌ Without POM — locators and actions scattered everywhere:
test('login works', async ({ page }) => {
await page.locator('#email').fill('user@test.com');
await page.locator('#password').fill('secret');
await page.locator('#submit-btn').click();
await expect(page.locator('.dashboard-title')).toBeVisible();
});

// Now every other test file copies the same locators. Change the id? Update 20 files.
`

With a Page Object class:

`ts
import { Page, Locator } from '@playwright/test';

class LoginPage {
// Properties hold the locators — typed as Locator
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;

// Constructor receives the Playwright Page object
constructor(private page: Page) {
this.emailInput = page.locator('#email');
this.passwordInput = page.locator('#password');
this.submitButton = page.locator('#submit-btn');
}

// Methods encapsulate actions
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}

async goto(): Promise<void> {
await this.page.goto('/login');
}
}
`

Using the Page Object in tests — clean and readable:

`ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@test.com', 'secret');
await expect(page.locator('.dashboard-title')).toBeVisible();
});
`

The
#email selector exists in exactly one place. Change it? Update one file. TypeScript's types mean the editor autocompletes loginPage. and shows all available methods — you can't misspell an action name.

Access modifiers TypeScript adds:
-
private — only accessible inside the class (prevents tests directly accessing raw locators)
-
readonly — set once in the constructor, then never reassigned
-
public — default; accessible from anywhere

Rule of thumb:
- One class per page or major component.
- Put locators as
private readonly properties — tests should call methods, not access locators directly.
- Constructor takes
Page` from Playwright — the class never creates its own browser context.
💡 Plain English: A Page Object class is like a TV remote control. The TV has hundreds of internal circuits and wiring (the actual page HTML, locators, and DOM). The remote (the class) gives you a clean set of labelled buttons — Power, Volume Up, Channel Down — that hide all that complexity. When the TV manufacturer moves the power button internally (selector changes), they update the remote's wiring once. Everyone using the remote (the tests) keeps pressing the same button without knowing anything changed.
10
Types

What is an enum in TypeScript?

An enum (short for "enumeration") is a way to give friendly names to a fixed set of related values. Instead of scattering "magic numbers" or hardcoded strings through your code, you collect the allowed options into one named group.

Numeric enum — the default behavior:

``ts
enum Status {
Active, // 0
Inactive, // 1
Banned, // 2
}

let s: Status = Status.Active;
console.log(s); // prints 0
`

Without a value, TypeScript auto-assigns
0, 1, 2, ... behind the scenes. The names exist for *you* to read; the runtime sees numbers.

String enum — usually the better choice in real code:

`ts
enum Role {
Admin = "ADMIN",
User = "USER",
Guest = "GUEST",
}

let r: Role = Role.Admin;
console.log(r); // prints "ADMIN"
`

Each name maps to an explicit string. This is much more useful in practice because the value is meaningful in logs, network requests, and database records —
"ADMIN" tells you what it is; 0 doesn't.

Why enums exist — they prevent typos and magic values:

`ts
// ❌ Without an enum — easy to mistype:
function setRole(role: string) { /* ... */ }
setRole("admln"); // typo — no error, silent bug in production

// ✅ With an enum — TypeScript catches the mistake:
function setRole(role: Role) { /* ... */ }
setRole(Role.Admin); // ✅
setRole("admln"); // ❌ Argument of type 'string' is not assignable to 'Role'.
`

Using an enum value in a switch:

`ts
function describe(status: Status): string {
switch (status) {
case Status.Active: return "User is active";
case Status.Inactive: return "User is inactive";
case Status.Banned: return "User is banned";
}
}
`

Modern alternative — union of string literals:

These days, many teams prefer a simple union of strings over an enum because it has no runtime code (enums add a small JavaScript object to your build):

`ts
type Role = "ADMIN" | "USER" | "GUEST";
let r: Role = "ADMIN";
``

This achieves the same compile-time safety with no runtime overhead.

Rule of thumb:
- Use a string enum when you want a named group of values that's also iterable at runtime (e.g., to populate a dropdown).
- Use a union of string literals for simple fixed sets where you don't need runtime iteration.
- Avoid numeric enums in new code — string enums are almost always clearer.
💡 Plain English: An enum is like the labelled positions on a washing machine dial — "DELICATE / COLOURS / HOT WASH / SPIN." You don't need to remember that "DELICATE" is technically program number 3 internally; the label tells you what it does. Without the labels, you'd be reading the manual every time: "wait, was hot wash mode 5 or 6?" With the labels, the dial explains itself — and you can't accidentally select a mode that doesn't exist.
11
Functions

How do you type a function's parameters and return value?

Typing a function means saying two things: what kinds of values it accepts (parameters) and what kind of value it gives back (return type).

The basic syntax — annotate each parameter, then the return type:

``ts
function add(a: number, b: number): number {
return a + b;
}
`

-
a: number, b: number — both parameters must be numbers.
-
: number after the parentheses — the function returns a number.

If a caller passes the wrong kind, TypeScript catches it:
`ts
add(2, 3); // ✅ returns 5
add(2, "three"); // ❌ Argument of type 'string' is not assignable to 'number'.
`

Arrow functions — same idea, slightly different syntax:

`ts
const greet = (name: string): string =>
Hi ${name};

const isEven = (n: number): boolean => n % 2 === 0;
`

TypeScript can infer the return type — but annotating is usually better:

`ts
// Without annotation — TypeScript infers the return type from the body:
function double(n: number) {
return n * 2; // inferred return type: number
}

// With annotation — explicit return type:
function double(n: number): number {
return n * 2;
}
`

The annotated version is preferred because:
- It documents intent — anyone reading the signature knows what the function returns without scanning the body.
- It catches mistakes early — if you later change the body in a way that accidentally returns the wrong type, TypeScript flags the function declaration itself, not some confused caller far away.

Functions that return nothing — use
void:

`ts
function logMessage(msg: string): void {
console.log(msg);
// no return — does something useful, returns nothing
}
`

Functions with no parameters — empty parentheses, return type still annotated:

`ts
function getCurrentTime(): Date {
return new Date();
}
``

Rule of thumb:
- Always annotate function parameters (TypeScript can't guess what callers will pass).
- Annotate return types on exported/public functions for clarity.
- Inside short local helpers, inferred return types are fine.
💡 Plain English: A function signature is like a printed recipe card on a restaurant kitchen wall. The parameters say "to make this dish, you need: 200g flour, 2 eggs, 100ml milk" — exact ingredients, exact types. If a junior cook brings olive oil instead of milk, the head chef stops them at the door. The return type says "what comes out is a chocolate cake" — so the waiter who picks it up knows exactly what to put on the customer's plate. No one is surprised when they lift the cover.
12
Types

What is type assertion (`as`), and when do you use it?

A type assertion is you telling TypeScript "trust me, I know this value's type better than you do." You use the as keyword to override what TypeScript inferred.

The problem it solves:

Sometimes you know more about a value than TypeScript can work out on its own. The most common case is grabbing an HTML element from the DOM:

``ts
const el = document.getElementById("name");
// TypeScript infers: HTMLElement | null
// It doesn't know specifically that this element is an input

el.value; // ❌ Property 'value' does not exist on type 'HTMLElement'.
`

You can see in your HTML that the element with id
"name" is an <input>, but TypeScript can't read your HTML. You can assert it:

`ts
const el = document.getElementById("name") as HTMLInputElement;
el.value; // ✅ now TypeScript knows it's an input — .value is allowed
`

Another common use — narrowing an
unknown value from JSON:

`ts
const data: unknown = JSON.parse(response);
const user = data as User;
console.log(user.name);
`

Two syntaxes — both work the same:

`ts
const a = value as string; // 'as' syntax (preferred)
const b = <string>value; // angle-bracket syntax (older, doesn't work in .tsx)
`

The big warning —
as bypasses checking; it can lie:

`ts
const x = "hello" as unknown as number; // TypeScript allows it
x.toFixed(2); // 💥 Runtime crash — "hello" has no .toFixed
`

You promised TypeScript it was a number. TypeScript believed you. At runtime, the lie blows up.

as doesn't change the actual value — it only changes how TypeScript *thinks about* the value during compilation. The runtime data is whatever it actually was.

When to reach for
as:
- DOM elements where you know the specific type from your HTML.
- Narrowing data after you've already checked it (e.g. after Zod validation).
- Telling TypeScript that an
as const literal is broader (rare).

When NOT to use
as:
- To silence a "doesn't exist on type" error — fix the type definition instead.
- To force untyped API data into a typed shape without validating it — use a runtime check or Zod.
- To "make the error go away" without understanding why TypeScript objected.

Rule of thumb:
as` is a last resort. If you find yourself using it often, you're probably missing a type guard, a validation step, or a better type definition.
💡 Plain English: Type assertion is vouching for someone at a club door. The bouncer (the compiler) doesn't recognise the person, but you say "I know them, they're on the list — let them in." If you're right, no harm done. If you're wrong, you've just walked an impostor past the bouncer — and whatever they do inside the club (run your code) is now your problem, because you overrode the security check. The fewer times you have to vouch, the safer the door. Reserve it for cases where you genuinely have information the bouncer doesn't.
13
Types

How do you type an array in TypeScript?

TypeScript gives you two equivalent ways to declare the type of an array — they mean exactly the same thing, just written differently.

Syntax 1 — the shorthand (most common):

``ts
let nums: number[] = [1, 2, 3];
let names: string[] = ["Asha", "Ben"];
let flags: boolean[] = [true, false, true];
`

number[] literally reads as "array of numbers."

Syntax 2 — the generic form:

`ts
let nums: Array<number> = [1, 2, 3];
let names: Array<string> = ["Asha", "Ben"];
`

Array<number> is the long-hand version. They're identical at the type level — pick a style and stick to it. Most teams use the shorthand number[] for simple cases because it's shorter.

Arrays that hold multiple types — use a union:

`ts
let mixed: (string | number)[] = [1, "a", 2, "b"];
`

The parentheses around
(string | number) matter — without them, string | number[] would mean "either a string OR an array of numbers," which is different.

Arrays of objects:

`ts
interface User { id: number; name: string; }
let users: User[] = [
{ id: 1, name: "Asha" },
{ id: 2, name: "Ben" },
];
`

TypeScript catches wrong items immediately:

`ts
let scores: number[] = [90, 85];
scores.push(72); // ✅ allowed — 72 is a number
scores.push("hello"); // ❌ Argument of type 'string' is not assignable to 'number'.

let names: string[] = ["Asha"];
const first = names[0]; // TypeScript knows: first is a string
first.toUpperCase(); // ✅ available — strings have .toUpperCase
`

Empty arrays — always annotate, or you get
any[]:

`ts
let bad = []; // inferred as any[] — loses type safety!
bad.push("x");
bad.push(42); // no error, but probably a bug

let good: string[] = []; // ✅ explicit — TypeScript enforces strings only
good.push("x");
good.push(42); // ❌ caught
`

Read-only arrays — when you want to prevent mutation:

`ts
let frozen: readonly number[] = [1, 2, 3];
frozen.push(4); // ❌ Property 'push' does not exist on type 'readonly number[]'.
`

Rule of thumb:
- Use
T[] shorthand for simple cases — it's the most common style.
- Use
Array<T> if the inner type is complex (e.g. Array<{ name: string; age: number }>).
- Always annotate empty arrays, or TypeScript falls back to
any[]`.
💡 Plain English: Typing an array is like labelling a shelf in a warehouse — "this shelf holds books only." The label keeps the wrong items from being placed on the shelf. `number[]` and `Array<number>` are two ways of writing the same label. One is a quick sticker, the other a formal sign. Both result in the same rule: only numbers belong here. An empty shelf with no label, on the other hand, becomes a junk pile — anyone drops anything on it, and you've lost the structure.
14
Types

What does `readonly` do?

readonly marks a property as set-once — it can be assigned when the object is first created, but never reassigned afterwards. It's TypeScript's way of saying "this value is fixed once it exists."

On an interface or type — protects individual properties:

``ts
interface Point {
readonly x: number;
readonly y: number;
}

const p: Point = { x: 1, y: 2 }; // ✅ set at creation
p.x = 5; // ❌ Cannot assign to 'x' because it is a read-only property.
`

The values can be read forever —
p.x is fine — but they cannot be reassigned.

A common real-world example — config objects that should never change after startup:

`ts
interface AppConfig {
readonly apiUrl: string;
readonly maxRetries: number;
readonly version: string;
}

const config: AppConfig = {
apiUrl: "https://api.company.com",
maxRetries: 3,
version: "2.4.1",
};

config.apiUrl = "https://hacked.com"; // ❌ blocked at compile time
`

On arrays —
readonly blocks mutation methods:

`ts
const items: readonly number[] = [1, 2, 3];
items.push(4); // ❌ Property 'push' does not exist on type 'readonly number[]'.
items[0] = 99; // ❌ Index signature in type 'readonly number[]' only permits reading.
`

You can still read the array —
items[0] is fine, items.length is fine — but mutation methods like push, pop, splice are gone from the type entirely.

A common use — function parameters that should be read but not modified:

`ts
function sum(numbers: readonly number[]): number {
numbers.push(0); // ❌ blocked — protects the caller's array
return numbers.reduce((a, b) => a + b, 0);
}
`

This signature says "I will only read this array, I promise not to modify it." Callers can pass their data knowing it won't be mutated unexpectedly.

Important catch —
readonly is a compile-time check, not a runtime lock:

`ts
const p: Point = { x: 1, y: 2 };
(p as any).x = 5; // 💥 sneaks past TypeScript at runtime
`

If you need true runtime immutability, combine with
Object.freeze().

Rule of thumb:
- Use
readonly for config values, IDs, timestamps, and any "set once" data.
- Use
readonly T[] for function parameters where you only need to read, not modify.
- For deeply immutable objects, you'll need each nested property marked
readonly too (or use the Readonly<T>` utility type for a shallow pass).
💡 Plain English: `readonly` fields are like the engraved details on the back of a watch — the serial number, the year of manufacture, the model name. The watchmaker sets them once during production. After that, you can read them forever, but you can't rub them out and rewrite — they're permanent. A regular (non-readonly) property is more like the time displayed on the watch face — you can adjust it whenever you want. `readonly` is for the parts of an object that should never change once set.
15
Functions

What does the `void` type mean?

void is the return type for a function that does something but gives nothing back. The function runs, has some effect — prints to the console, updates the DOM, sends a request — but doesn't return a value the caller can use.

Basic usage:

``ts
function log(msg: string): void {
console.log(msg);
// no return statement — the function does its work and ends
}

const result = log("hello");
// result is type 'void' — there's nothing meaningful here
`

Functions that update state, log events, or trigger side effects almost always return
void:

`ts
function clearScreen(): void {
document.body.innerHTML = "";
}

function sendAnalytics(event: string): void {
analytics.track(event);
}
`

void is different from never — they sound similar but mean different things:

`ts
function logIt(msg: string): void {
console.log(msg);
// returns control to the caller — just with no value
}

function fail(msg: string): never {
throw new Error(msg);
// never returns to the caller at all — execution stops
}
`

| Type | Meaning |
|---|---|
|
void | Function returns, but with no useful value |
|
never | Function never returns at all (throws or loops forever) |

A subtle behavior —
void is more lenient for callbacks:

When
void is used as a callback's return type, TypeScript allows the callback to actually return something — it just ignores the value. This is intentional and useful:

`ts
function each(items: number[], callback: (n: number) => void): void {
for (const item of items) callback(item);
}

each([1, 2, 3], (n) => console.log(n)); // ✅ callback returns void
each([1, 2, 3], (n) => n * 2); // ✅ also allowed — return value is ignored
`

This is why
array.forEach works with callbacks that accidentally return a value — TypeScript is forgiving here on purpose.

Don't confuse
void with undefined:

`ts
function a(): void {} // ✅ returns nothing
function b(): undefined { return; } // returns undefined explicitly
`

For most cases,
void is the right choice when you mean "no meaningful return value."

Rule of thumb:
- Use
void as the return type whenever a function does its work for the *effect*, not the result.
- Don't try to use the return value of a
void` function — there isn't one.
💡 Plain English: `void` is a chore you do for the effect, not the outcome — like switching off the lights when you leave a room, taking out the bins, or saying "thank you" at a checkout. You did something useful. The room is dark, the bins are empty, the cashier feels appreciated. But there's no *result* to hand back and use elsewhere. You don't say "okay, now use the leftover gratitude" — there's nothing tangible to pass on. `never`, by contrast, is more like setting off a fire alarm — you do it and then *you don't come back* (the function throws). `void` returns to its caller; `never` doesn't.
16
Types

What are literal types?

A literal type is a type that allows only one *specific value*, not a whole category. Instead of "any string," it's "the exact string "left"." Combined with a union, this becomes one of TypeScript's most useful features for modelling fixed choices.

The contrast — broad type vs literal type:

``ts
let direction: string = "left"; // accepts ANY string — too loose
let direction: "left" = "left"; // accepts ONLY the string "left"
`

A literal type on its own (
"left") is rarely useful — but a *union* of literal types is extremely useful.

Union of literal types — "one of these specific values":

`ts
let direction: "left" | "right" | "up" | "down";

direction = "left"; // ✅
direction = "right"; // ✅
direction = "north"; // ❌ Type '"north"' is not assignable to type '"left" | "right" | "up" | "down"'.
`

This is the same idea as an enum, but lighter — there's no extra runtime object generated, and the editor shows you the allowed values as autocomplete suggestions.

A common real-world example — restricting a function's input:

`ts
type OrderStatus = "pending" | "shipped" | "delivered" | "cancelled";

function updateStatus(orderId: number, status: OrderStatus) {
// ...
}

updateStatus(1, "shipped"); // ✅
updateStatus(1, "delivred"); // ❌ typo caught at compile time!
`

Without the literal union, the parameter would just be
string, and the typo "delivred" would silently slip through to production.

Numeric and boolean literals also exist:

`ts
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;

let result: DiceRoll = 4; // ✅
let result: DiceRoll = 7; // ❌ not a valid dice roll

type IsAdmin = true; // literally only the value 'true'
`

Combined with other types in real APIs:

`ts
interface ApiResponse {
status: "success" | "error";
message: string;
code: 200 | 400 | 404 | 500;
}
`

The shape forces the caller into specific, valid combinations — no
"successful"` typos or invented status codes.

Rule of thumb:
- Reach for a literal union whenever a value has a small, fixed set of allowed options.
- Pair literal unions with type narrowing to safely handle each case in the body of a function.
- This pattern often replaces the need for enums in modern TypeScript code.
💡 Plain English: A plain `string` is a blank line on a form — write anything you like, the form will accept it. A literal union is a multiple-choice question — "Select your preferred contact method: PHONE / EMAIL / SMS." The form refuses any answer that isn't one of the three printed options. No typos, no surprise values — only the allowed answers count.
17
Types

What is a type alias?

A type alias is a custom name you give to an existing type. It doesn't create a new type — it just provides a shorter, more meaningful name for something you'll use repeatedly. You declare it with the type keyword.

The problem it solves:

Without aliases, you end up repeating the same complex type in many places:

``ts
function findById(id: string | number) { /* ... */ }
function deleteById(id: string | number) { /* ... */ }
function updateById(id: string | number, data: object) { /* ... */ }
`

The same union (
string | number) is spelled out three times. If you later decide IDs can also be UUID, you'd need to find and update every occurrence. A type alias gives you one place to define it:

`ts
type ID = string | number;

function findById(id: ID) { /* ... */ }
function deleteById(id: ID) { /* ... */ }
function updateById(id: ID, data: object) { /* ... */ }
`

Now
ID is defined once. Change it in one place and every usage updates automatically.

Type aliases can name any type, not just unions:

`ts
type Age = number; // simple alias
type Coordinates = [number, number]; // tuple
type User = { id: number; name: string; age: number; }; // object shape
type Logger = (msg: string) => void; // function signature
type Status = "active" | "inactive" | "banned"; // union of literals
`

A common real-world example — naming a complex callback shape:

`ts
type EventHandler = (event: { type: string; payload: unknown }) => void;

function onUserCreated(handler: EventHandler) { /* ... */ }
function onOrderPlaced(handler: EventHandler) { /* ... */ }
`

Without the alias, both functions would have to repeat the whole callback signature.

Aliases vs interfaces — when to use
type:

For object shapes, you can use either
type or interface (they're mostly interchangeable). Use type when:
- You need a union, primitive, tuple, or function-signature alias (interface can't do these).
- You want a computed type using utility types like
Pick, Omit, Partial.

`ts
type PublicUser = Omit<User, "passwordHash">; // ✅ derived type — only 'type' can do this cleanly
`

Aliases don't create new distinct types — they're just names:

`ts
type UserId = number;
type OrderId = number;

const u: UserId = 5;
const o: OrderId = u; // ✅ allowed — both are just 'number' to TypeScript
`

If you want to make these truly distinct so they can't be mixed up, you'd need "branded types" (a senior-level technique).

Rule of thumb:
- Create a type alias whenever a type is reused more than once, or when its meaning is non-obvious from its shape.
- Aim for descriptive names (
UserId, OrderStatus) rather than vague ones (MyType, Data`).
💡 Plain English: A type alias is a nickname for something complicated. If everyone in the office keeps referring to "the spreadsheet showing Q3 sales by region with the pivot table on tab 2," conversations get tedious. Someone proposes calling it "the Q3 sheet." Now everyone says "the Q3 sheet" and knows exactly what's meant. The spreadsheet hasn't changed — it's still the same complicated file. You just gave it a short, memorable label, and everyone refers to it the same way. If the spreadsheet ever moves or gets restructured, you update the definition once and everyone benefits.
18
JavaScript Basics

What is the difference between == and === ?

Both check equality between two values, but they do it very differently. Choosing the wrong one is one of the classic sources of silent JavaScript bugs.

=== — strict equality (checks value AND type):

The values must be the same *and* the types must match. No conversion happens.

``ts
0 === 0; // ✅ true (same number)
"0" === "0"; // ✅ true (same string)
0 === "0"; // ❌ false (one is number, one is string)
null === undefined; // ❌ false (different types)
`

== — loose equality (converts types before comparing):

JavaScript converts one or both values so they can be compared. This conversion has unexpected results:

`ts
0 == "0"; // ✅ true — "0" gets converted to number 0
"" == 0; // ✅ true — empty string converts to 0
null == undefined; // ✅ true — they're considered "equal-ish"
1 == true; // ✅ true — true becomes 1
[] == false; // ✅ true — empty array converts to 0, false converts to 0
`

These conversions are not intuitive. Someone reading your code can easily misread what's being compared.

A real bug
== causes:

`ts
function validate(input: string | null) {
if (input == "") {
return "Please enter a value";
}
}

validate(null); // returns "Please enter a value" 😬 — null == "" is FALSE actually
// but null == undefined is true, etc.
// The point: behaviour is unpredictable
`

The behaviour of
== depends on a table of conversion rules most developers don't have memorised. That's the bug — you have to look up the rules to know what your own code will do.

Useful pattern —
== null checks BOTH null and undefined:

The only common case where
== is genuinely useful:

`ts
if (value == null) {
// catches both null AND undefined in one check
}

// Equivalent strict version:
if (value === null || value === undefined) {
// more explicit, more typing
}
`

Some style guides allow this one exception; others insist on always using
===.

Rule of thumb:
- Always use
=== in modern code. It's predictable: same type, same value.
- Configure ESLint with
eqeqeq to enforce === across the codebase.
- The one exception some teams allow:
x == null` to check for null-or-undefined.
💡 Plain English: `===` is a strict bouncer at a club. They check both your name on the list *and* your photo ID. If anything doesn't match exactly, you're not getting in. Predictable, safe. `==` is a lenient bouncer who says "close enough." Wrong name? Maybe they think you meant a similar one. Photo doesn't look like you? They'll let you in if you look "kind of" like the person. The result: impostors get through, the club has problems, and nobody can predict who got in on what shift. For software, you want the strict bouncer every time.
19
JavaScript Basics

What is the difference between null and undefined?

Both null and undefined represent "no value," but they signal different things: one means "nothing was ever set," the other means "set to nothing, on purpose."

undefined — the default absence:

``ts
let a; // declared but not assigned — value is undefined

function getName() {
// function doesn't return anything → caller gets undefined
}
const x = getName(); // x is undefined

const obj = { name: "Asha" };
console.log(obj.age); // accessing a missing property → undefined
`

undefined shows up whenever something *hasn't been given a value*. It's the JavaScript runtime's way of saying "there's nothing here."

null — the deliberate absence:

`ts
let b = null; // explicit assignment — "this is intentionally empty"

let currentUser: User | null = null; // no user is logged in (yet)
`

null is something you assign on purpose. It signals "I know this should hold a value, but right now it doesn't."

The semantic difference in one sentence:
-
undefined = "no one has set this yet" (often the language's default).
-
null = "this was deliberately set to nothing" (intentional).

TypeScript helps when
strictNullChecks is on (and you should always turn it on):

`ts
function getUserName(u: User | null): string {
return u.name; // ❌ Object is possibly 'null'.
}

// Fix — handle the null case:
function getUserName(u: User | null): string {
if (u === null) return "Anonymous";
return u.name; // ✅ TypeScript now knows u is User here
}
`

Without
strictNullChecks, TypeScript would silently let you call .name on a null value and crash at runtime.

== null catches both — a useful shortcut:

`ts
if (value == null) {
// catches both null AND undefined in one check
}
`

This is the one case where
== (loose equality) is genuinely useful, because null == undefined is true.

Real-world conventions:
- DOM APIs (e.g.,
document.getElementById) usually return null when nothing is found.
- Optional properties and missing function parameters tend to give you
undefined.
- Databases often store
null for empty fields; JavaScript's undefined typically doesn't survive serialization.

Rule of thumb:
- Use
undefined for "not yet set" or "field missing" — let TypeScript's optional properties (age?: number) handle this naturally.
- Use
null` for "deliberately empty" — an explicit absence you're choosing to represent.
- Always handle both when validating data coming in from outside.
💡 Plain English: Think of a form on a clipboard. `undefined` is a field that nobody has touched yet — the line is blank because the form is fresh and the question hasn't been answered. `null` is a field where someone has deliberately written "N/A" — they read the question, decided no value applied, and put down an explicit "nothing here." Both fields are empty in a sense, but they communicate different things. The blank one means "we don't know." The N/A one means "we asked, and the answer is no value."
20
Fundamentals

Does the browser or Node run TypeScript directly? How does it run?

No — browsers and Node.js only run JavaScript. They have no idea what TypeScript is. Before your TypeScript code can run, the TypeScript compiler must convert it into plain JavaScript.

The flow:

``
your-code.ts → [ TypeScript compiler ] → your-code.js → browser / Node
`

The compiler is called
tsc (TypeScript Compiler). When you run tsc, it:

1. Reads your
.ts files.
2. Checks the types — if there are type errors, it reports them.
3. Strips out all the type annotations.
4. Outputs plain
.js files.

What the compiled output looks like:

`ts
// What you wrote (input.ts):
function greet(name: string): string {
return "Hi " + name;
}
const message: string = greet("Asha");
`

`js
// What ships to the browser (output.js):
function greet(name) {
return "Hi " + name;
}
const message = greet("Asha");
`

Notice: the type annotations (
: string) are completely gone. The runtime has no memory of them.

Two big consequences of "types are erased":

1 — Zero runtime overhead. Your TypeScript code runs exactly as fast as the equivalent JavaScript, because the runtime IS plain JavaScript. TypeScript adds nothing at runtime.

2 — No runtime type checking. TypeScript can't catch bad data at runtime — its protection ends at compile time:

`ts
interface User { id: number; name: string; }

async function loadUser(): Promise<User> {
const res = await fetch("/users/1");
return res.json(); // TypeScript trusts this is a User
}

const user = await loadUser();
console.log(user.name.toUpperCase()); // 💥 crashes if API returned { id: 1 } with no name
`

The TypeScript types say "this is a User." The runtime says "this is whatever the API actually sent." If the API lied, TypeScript can't save you.

This is why external data must be validated at runtime:

`ts
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string() });

async function loadUser() {
const res = await fetch("/users/1");
const data = await res.json();
return UserSchema.parse(data); // throws if shape is wrong — runtime check
}
`

Modern alternatives to
tsc` for running TypeScript:

- Vite, Webpack, esbuild, swc — bundlers that compile TypeScript as part of their build pipeline.
- ts-node — runs TypeScript directly in Node by compiling on the fly.
- Deno, Bun — newer runtimes that handle TypeScript natively (still compiling under the hood, just transparently).

Rule of thumb:
- TypeScript types help you *while writing code* — they're a development-time tool.
- All runtime safety must still come from runtime checks (validation libraries, defensive code at boundaries).
- Never trust external data based on its TypeScript type alone.
💡 Plain English: TypeScript types are like the scaffolding around a building under construction. While the building goes up, the scaffolding is essential — workers stand on it, materials hang off it, the design depends on it. It catches mistakes early and helps the whole structure come together correctly. But before anyone moves in, the scaffolding is taken down. The finished building (the JavaScript) stands on its own. No scaffolding remains in the walls. If you decide later to make changes inside the building, you can't shout up to the scaffolding for help — it's long gone. You have to do your own safety checks. Types are the scaffolding. The runtime is the building once people live in it. External data is whatever furniture they bring in — and the building can't refuse a wobbly chair unless you put a doorman (runtime validation) at the entrance.
21
Tooling

What is tsconfig.json, and what does strict mode do?

tsconfig.json is the configuration file that tells the TypeScript compiler (tsc) how to handle your project — which files to include, what version of JavaScript to output, where to put compiled files, and how strict the type checking should be.

A minimal example:

``json
{
"compilerOptions": {
"target": "ES2020", // what JavaScript version to compile to
"module": "ESNext", // module system (ES modules)
"strict": true, // turn on all strict type-checking
"outDir": "./dist", // where compiled .js files go
"esModuleInterop": true // smoother import compatibility
},
"include": ["src/**/*"], // which files to compile
"exclude": ["node_modules"] // which to skip
}
`

You don't write this by hand from scratch — run
npx tsc --init to generate a starter file, then adjust.

The most important setting —
"strict": true:

Strict mode is a single flag that turns on a *bundle* of stricter type-checking rules at once:

| Sub-flag | What it does |
|---|---|
|
noImplicitAny | Errors on parameters/variables that have no type and no inferable type. Forces you to be explicit instead of accidentally using any. |
|
strictNullChecks | Treats null and undefined as their own types — you must handle them explicitly. Catches a huge class of "Cannot read property of null" bugs. |
|
strictFunctionTypes | Stricter checking of function parameter compatibility. |
|
strictPropertyInitialization | Class properties must be assigned in the constructor. |
|
alwaysStrict | Emits "use strict" in all files. |
|
noImplicitThis | Errors when this has an implicit any type. |
|
useUnknownInCatchVariables | Catch clause variables are typed as unknown, not any. |

Without strict mode, many bugs slip through. With it, the compiler does much more of the work for you.

Comparing strict off vs on:

`ts
// strict: false
function getName(user) { // 'user' is silently typed as 'any' — no error
return user.name.toUpperCase();
}

// strict: true
function getName(user) { // ❌ Parameter 'user' implicitly has an 'any' type.
return user.name.toUpperCase();
}
// Forces you to write:
function getName(user: { name: string }) {
return user.name.toUpperCase();
}
`

Other commonly-tuned settings:

`json
{
"compilerOptions": {
"noUncheckedIndexedAccess": true, // array[0] becomes T | undefined — safer
"noUnusedLocals": true, // errors on unused variables
"skipLibCheck": true, // don't check types in node_modules — much faster builds
"paths": {
"@/*": ["src/*"] // import aliases — write '@/utils' instead of '../../utils'
}
}
}
`

Rule of thumb:
- For any new project: set
strict: true from day one.
- For an existing JavaScript project being migrated: turn on
strict flags one at a time, starting with noImplicitAny, to make the migration manageable.
-
skipLibCheck: true` is almost always worth turning on — it speeds up builds significantly with little real downside.
💡 Plain English: `tsconfig.json` is the settings panel for your spell-checker. You can configure: which document folders to spell-check, which language dictionary to use, where to save the corrected files, and — crucially — how strict the spell-checker should be. Strict mode is cranking the sensitivity all the way up. The spell-checker now catches subtle mistakes (missing apostrophes, agreement errors, mid-sentence capitalisation) — not just the glaring typos. More red underlines while you write, but far fewer embarrassing mistakes when someone else reads the final document.
22
Functions

What are optional and default function parameters?

TypeScript gives you two ways to handle function parameters that don't always need to be supplied: mark them optional, or give them a default value. Both let callers omit the argument, but they behave differently inside the function.

Optional parameter — mark with ?:

The caller may or may not pass it. If omitted, the parameter is undefined inside the function.

``ts
function greet(name: string, title?: string) {
console.log(
Hello, ${title ?? ""} ${name});
}

greet("Asha"); // ✅ "Hello, Asha"
greet("Asha", "Dr."); // ✅ "Hello, Dr. Asha"
`

Note the type of
title inside the function is actually string | undefined — you have to handle the missing case (here using ?? "" to fall back to an empty string).

Default parameter — provide a fallback value with
=:

If the caller omits the argument, TypeScript fills it in with the default. Inside the function, the parameter always has a real value.

`ts
function pour(size: string, sugar = 0) {
console.log(
Pouring ${size} with ${sugar} sugars);
}

pour("large"); // ✅ "Pouring large with 0 sugars" — uses default
pour("large", 2); // ✅ "Pouring large with 2 sugars" — overrides default
`

Notice we didn't write
sugar: number — TypeScript infers the type from the default value (0number). You can still be explicit if you want: sugar: number = 0.

The difference inside the function body:

`ts
function withOptional(x?: number) {
return x + 1; // ❌ Object is possibly 'undefined' — must handle missing case
}

function withDefault(x = 0) {
return x + 1; // ✅ x is always a number — TypeScript fills in 0 if omitted
}
`

Optional means "maybe missing, you handle it." Default means "always there, I'll fill it in if you don't."

Important rule — optional and default parameters must come AFTER required ones:

`ts
function bad(name?: string, age: number) {} // ❌ required can't follow optional

function good(age: number, name?: string) {} // ✅ required first, then optional
function ok(age: number, name = "Guest") {} // ✅ required first, then default
`

A common real-world example — an API helper:

`ts
function fetchUsers(
limit: number, // required
status: "active" | "all" = "active", // default
cursor?: string // optional (for pagination)
) {
// ...
}

fetchUsers(10); // ✅ uses defaults
fetchUsers(10, "all"); // ✅ override status
fetchUsers(10, "active", "abc123"); // ✅ pass cursor for next page
`

Rule of thumb:
- Use a default value when there's a sensible fallback you can supply (
page = 1, pageSize = 20).
- Use optional (
?`) when missing genuinely means "not applicable" and the function should branch on it.
- Required parameters always come first.
💡 Plain English: Ordering a coffee at a café. The **size** is required — the barista has to ask if you don't say, or they can't make anything ("Small, medium, or large?"). The **sugar** has a default — if you don't mention it, they pour none. You only speak up to change it from the default ("with two sugars, please"). The **extra shot** is optional — totally up to you, no default, no assumption. Mention it if you want it; otherwise it just doesn't happen. The barista (your function) handles each one differently: required = must ask, default = fall back if silent, optional = act only if asked.
23
JavaScript Basics

What is destructuring, and how does it work with types?

Destructuring is a JavaScript syntax that lets you pull values out of an object or an array into individual named variables, all in one line. TypeScript infers the type of each extracted variable from the source.

Object destructuring — extract properties by name:

``ts
const user = { name: "Asha", age: 30, role: "admin" };

// Without destructuring:
const name = user.name;
const age = user.age;
const role = user.role;

// With destructuring (one line):
const { name, age, role } = user;
// name is string, age is number, role is string
`

Array destructuring — extract items by position:

`ts
const coords = [10, 20];
const [x, y] = coords; // x is 10, y is 20

// Real-world example — React's useState returns a tuple:
const [count, setCount] = useState(0);
// count is number, setCount is a setter function
`

Rename while destructuring — use the
: syntax:

`ts
const { name: userName, age: userAge } = user;
// 'name' is extracted but stored in a variable called 'userName'
`

This is useful when there's a name clash, or when you want a more descriptive local name.

Default values for missing properties:

`ts
const { name, country = "Unknown" } = user;
// if user.country is undefined, country variable gets "Unknown"
`

A common real-world use — destructuring function parameters:

`ts
interface UserCard { name: string; age: number; email: string; }

// Without destructuring — repetitive 'user.' everywhere:
function showUser(user: UserCard) {
console.log(user.name);
console.log(user.age);
}

// With destructuring in the parameter:
function showUser({ name, age, email }: UserCard) {
console.log(name);
console.log(age);
console.log(email);
}
`

The function declares the type of the parameter (
: UserCard) and destructures its properties in one move. TypeScript still infers name as string, age as number, etc.

This pattern is everywhere in React components:

`ts
interface ButtonProps { label: string; onClick: () => void; disabled?: boolean; }

function Button({ label, onClick, disabled = false }: ButtonProps) {
return <button onClick={onClick} disabled={disabled}>{label}</button>;
}
`

The destructure pulls out exactly the props the function uses, with a sensible default for
disabled.

Nested destructuring — works too, but use sparingly for readability:

`ts
const user = { name: "Asha", address: { city: "Pune", country: "India" } };
const { address: { city } } = user; // city is "Pune"
`

Rule of thumb:
- Destructure when you'll use several properties of an object — it's cleaner than repeating the object name.
- Destructure function parameters in React components — it's the standard pattern.
- Don't over-nest — if a destructure becomes hard to read, just use
user.address.city` instead.
💡 Plain English: Destructuring is like unpacking a labelled grocery bag straight onto the kitchen counter. Without destructuring, you'd reach into the bag every single time you need something: "give me the milk... now the bread... now the eggs." Three trips into the bag. With destructuring, you tip the bag out once: "milk, bread, eggs — all on the counter, labelled." Now you can grab each one directly. Same items, less reaching. For the array version, the bag is sorted by position — "first thing out is the milk, second is the bread." You don't name them; you just take them in order.
24
Types

How do you describe the shape of an object in TypeScript?

In TypeScript, you describe an object's shape — what properties it has and what types those properties hold — using one of three approaches: an inline type, a type alias, or an interface.

1 — Inline type (good for one-off uses):

You write the shape directly where the object is used:

``ts
const user: { name: string; age: number } = {
name: "Asha",
age: 30,
};

function greet(user: { name: string; age: number }) {
return
Hello, ${user.name};
}
`

Quick for a single use, but repetitive if you use the same shape in multiple places.

2 — Type alias (reusable, flexible):

Give the shape a name with
type:

`ts
type User = {
name: string;
age: number;
};

const u: User = { name: "Asha", age: 30 };

function greet(user: User) {
return
Hello, ${user.name};
}
`

Same shape, defined once, reused everywhere. Change the definition and every usage updates.

3 — Interface (reusable, supports extension):

Same as a type alias for object shapes, but with the
interface keyword:

`ts
interface User {
name: string;
age: number;
}

const u: User = { name: "Asha", age: 30 };
`

Interfaces also support
extends cleanly for inheritance:

`ts
interface Admin extends User {
permissions: string[];
}

const a: Admin = { name: "Asha", age: 30, permissions: ["read", "write"] };
`

Important — don't use the bare
object type:

`ts
function badGreet(user: object) {
return
Hello, ${user.name}; // ❌ Property 'name' does not exist on type 'object'.
}
`

The bare
object type just means "some non-primitive value" — TypeScript has no idea what properties it has. You can't access anything on it. Always describe the actual shape.

Describing optional and read-only fields:

`ts
interface User {
readonly id: number; // set once, cannot be reassigned
name: string; // required
email?: string; // optional — may be undefined
age: number | null; // required field, but allowed to be null
}
`

Describing objects with unknown keys (dictionary-like):

`ts
interface ScoresByName {
[key: string]: number; // any string key maps to a number
}

const scores: ScoresByName = { asha: 90, ben: 85, chris: 72 };
`

Rule of thumb:
- Use inline types for one-off shapes used only in one place.
- Use
interface when the shape will be reused or might be extended.
- Use
type when you need a union, primitive alias, or a computed type using utility types.
- Never use the bare
object` type — always describe the fields.
💡 Plain English: Describing an object is like writing a packing list for a parcel. If you just write "a box" on the customs form, you've said nothing useful — customs doesn't know what to expect, the courier can't prioritise handling, and the recipient has no idea what's coming. Instead, you write a proper packing list: "1 phone (electronics, valued at €500), 2 books (paper, valued at €30 total)." Now everyone in the chain knows exactly what's inside, what type each item is, and how to handle it. `interface` and `type` are how you write the proper packing list for your objects. The bare `object` type is just writing "a box" on the form — technically true, useless in practice.
25
Types

What is structural typing (duck typing) in TypeScript?

Structural typing means TypeScript compares types by their *shape* — what properties and methods they have — not by their *name*. If an object has all the required properties of a type, it counts as that type, even if it was never explicitly declared as one.

This is also called "duck typing" — from the saying *"if it walks like a duck and quacks like a duck, it's a duck."* TypeScript doesn't look at the label on the box; it looks at what's inside.

The classic example — an object that just happens to fit:

``ts
interface User {
name: string;
age: number;
}

function save(u: User) {
console.log(
Saving ${u.name});
}

const person = { name: "Asha", age: 30, city: "Pune" };
save(person); // ✅ accepted — person has 'name' and 'age', so it fits User
`

Notice
person was never declared as a User. It's just a plain object literal — but because it *has* the properties User requires, TypeScript accepts it. The extra city field doesn't cause a problem.

Contrast with nominal typing (the opposite approach):

In languages like Java or C#, two classes are different types even if they have identical shapes — you have to explicitly say "this implements that interface." That's nominal typing (based on names).

TypeScript chose structural typing because JavaScript itself is duck-typed at runtime — objects are just bags of properties, no formal class hierarchy required.

The benefit — flexibility:

`ts
interface Lengthy {
length: number;
}

function describe(thing: Lengthy): string {
return
length is ${thing.length};
}

describe("hello"); // ✅ string has .length
describe([1, 2, 3]); // ✅ array has .length
describe({ length: 42 }); // ✅ plain object with .length
`

The function works on anything with a
length property — string, array, custom object. You didn't have to make each one explicitly implement Lengthy.

The catch — when structural typing is too lenient:

Because TypeScript only looks at shape, two unrelated concepts that *happen* to have the same shape are treated as identical:

`ts
type UserId = number;
type OrderId = number;

function getUser(id: UserId) { /* ... */ }

const orderId: OrderId = 42;
getUser(orderId); // ✅ allowed — both are just 'number' to TypeScript
`

Even though
UserId and OrderId represent different things conceptually, TypeScript can't distinguish them — they're both number. This is why senior code sometimes uses "branded types" to force a distinction.

One exception — excess property checks on fresh object literals:

If you pass a freshly written object literal directly (not via a variable), TypeScript is stricter and complains about extra properties:

`ts
save({ name: "Asha", age: 30, city: "Pune" });
// ❌ Object literal may only specify known properties, and 'city' does not exist in type 'User'.
`

This is a guardrail against typos. If you assign to a variable first, the check disappears (variables get the lenient structural rules).

Rule of thumb:
- Structural typing means *shape matters, names don't*. Anything with the required properties fits.
- This makes TypeScript flexible and friendly to existing JavaScript patterns.
- Be aware of the lookalike problem — types that should be distinct (
UserId, OrderId`) may need branded types if you want TypeScript to enforce the distinction.
💡 Plain English: Structural typing is the "if it walks like a duck and quacks like a duck, it's a duck" rule. Suppose you need a duck for a parade. Strict (nominal) typing would say: "I need an animal with a birth certificate saying 'Duck' on it — nothing else qualifies, even if it looks identical." Bureaucratic. Structural typing (TypeScript's approach) says: "Bring me anything that walks like a duck, quacks like a duck, has feathers and webbed feet. If it has all the duck features, I'll accept it as a duck for parade purposes." This is more flexible — a swan in a duck costume passes, a robot duck passes, a perfectly-trained chicken probably passes. The trade-off is that you can't distinguish "real duck" from "very convincing fake duck" by shape alone — for that, you'd need to add a hidden mark (the branded type trick).
26
JavaScript Basics

What are optional chaining (?.) and nullish coalescing (??)?

Optional chaining (?.) and nullish coalescing (??) are two modern JavaScript operators that handle missing or null values cleanly. Both also exist in TypeScript with full type inference.

Optional chaining (?.) — safely access a property that might not exist:

If any part of the chain is null or undefined, the whole expression returns undefined instead of crashing with "Cannot read property of null."

``ts
const user = { name: "Asha" }; // no address property at all

// ❌ Without optional chaining — crashes if address is missing:
const city = user.address.city;
// 💥 TypeError: Cannot read properties of undefined (reading 'city')

// ✅ With optional chaining — returns undefined safely:
const city = user?.address?.city;
// city is undefined — no crash
`

You can chain through as many levels as needed:

`ts
const phone = company?.headquarters?.contact?.phone;
// If any link in the chain is null/undefined, phone is undefined
`

Optional chaining works on method calls and arrays too:

`ts
user?.getName?.(); // call getName only if user AND getName exist
items?.[0]?.name; // safely index into a possibly-missing array
`

Nullish coalescing (
??) — provide a fallback when a value is null/undefined:

The fallback is used only when the left side is
null or undefined — *not* when it's 0 or "" or false.

`ts
const name = input ?? "Guest";
// name = input, unless input is null/undefined, then "Guest"
`

The important difference from
|| (logical OR):

`ts
const count = 0 || 5; // 5 — || treats 0 as falsy, falls back
const safe = 0 ?? 5; // 0 — ?? only replaces null/undefined

const name1 = "" || "Guest"; // "Guest" — empty string is falsy
const name2 = "" ?? "Guest"; // "" — empty string is a valid value
`

This matters when
0, "", or false are legitimate values you want to keep:

`ts
function showCount(count: number | undefined) {
return count ?? 0; // ✅ keeps 0; only replaces undefined
// return count || 0; // ❌ also replaces 0 (because || treats 0 as falsy)
}
`

Combining them — common in real-world code:

`ts
const username = user?.profile?.displayName ?? "Anonymous";
// Drill safely to displayName, fall back to "Anonymous" if anything is missing
`

Rule of thumb:
- Use
?. when reaching into a chain of objects that might not exist.
- Use
?? for fallbacks where you specifically want to preserve 0, "", or false.
- Prefer
?? over ||` for default values — it's almost always what you actually meant.
💡 Plain English: **`?.`** is tip-toeing down a hallway in the dark, checking each door exists before opening it. If a door's missing, you just stop — you don't faceplant into the wall. The hallway might end abruptly (you get `undefined`), but you don't crash. **`??`** is "use my backup plan only if there's *genuinely* nothing." If someone gave me an answer (even an unusual one like `0` or empty string), I keep their answer. I only fall back to my own default if they gave me literally nothing — `null` or `undefined`. This is different from the old `||` operator, which was overly suspicious — it would discard `0` and `""` as if they were "nothing," even when they were meant as real values.
27
Practical

You are getting "Object is possibly null" errors everywhere. How do you fix them?

This error means TypeScript found a code path where a value could be null (or undefined) and you tried to use it without checking. It's TypeScript doing its job — protecting you from runtime crashes.

Why you're seeing it — strictNullChecks is on:

``ts
const el = document.getElementById("name");
// TypeScript infers: HTMLElement | null
// getElementById returns null if nothing matches the ID

el.textContent = "Hi";
// ❌ Object is possibly 'null'.
`

TypeScript doesn't know whether the element exists in your HTML —
getElementById can fail. So it forces you to handle the null case.

You have four good ways to fix it, depending on the situation. Here they are, in order of how often you should reach for each:

Fix 1 — Guard with an
if check (most common, safest):

`ts
const el = document.getElementById("name");
if (el) {
el.textContent = "Hi"; // ✅ TypeScript knows el is HTMLElement here, not null
} else {
console.warn("Element 'name' not found");
}
`

The
if (el) check is a type guard — inside the block, TypeScript narrows the type to remove null. This is the safest approach because you're also choosing what to do when the value IS missing.

Fix 2 — Optional chaining (when missing is acceptable):

`ts
const text = el?.textContent;
// If el is null, text is undefined — no crash
`

Useful when you just want to read a value, and "missing" is a fine outcome.

Fix 3 — Nullish coalescing for a fallback:

`ts
const name = user?.name ?? "Guest";
// If user is null OR user.name is missing, use "Guest"
`

Combine
?. and ?? when you want a default value.

Fix 4 — Non-null assertion (
!) — only when you are absolutely certain:

`ts
const el = document.getElementById("name")!;
// You're promising TypeScript: "I know this isn't null."
el.textContent = "Hi"; // ✅ compiles
`

This silences the error without actually checking. If you're wrong, your code crashes at runtime:

`ts
const el = document.getElementById("doesnt-exist")!;
el.textContent = "Hi"; // 💥 Runtime crash: Cannot set property of null
`

When to use which:

| Situation | Fix |
|---|---|
| Value is genuinely optional, you should handle both cases |
if check |
| Reading a value where "missing" is acceptable |
?. (optional chaining) |
| Need a default when value is missing |
?? "fallback" |
| You truly know it exists (e.g., element you just created) |
! (sparingly) |

Rule of thumb:
- 90% of the time, an
if check is the right answer — it's safe and explicit.
- Reach for
! only when bypassing the check is genuinely justified, and add a comment explaining why.
- Don't disable
strictNullChecks` to silence these errors — you'd be hiding real bugs.
💡 Plain English: "Object is possibly null" is a "wet floor" warning sign across a corridor. You have four ways past it: - **Look first and choose a path (`if` check)** — you check the floor, and act based on what you see. Safest option. - **Tiptoe carefully (`?.`)** — you don't engage with the wet patch; if it's slippery you just skip the step. - **Bring your own dry mat (`??`)** — if the floor's wet, you lay your fallback down instead. - **Walk through confidently (`!`)** — you assert the floor is actually dry. Quick if you're right; faceplant if you're wrong. The warning is there because someone could genuinely slip. Ignoring it without good reason is how bugs slip through to production.
28
Practical

Your colleague used `any` everywhere in a TypeScript file. What is the problem and how do you start fixing it?

any turns off TypeScript's type checking entirely for that value. It's effectively writing JavaScript inside a TypeScript file. A few anys here and there are usually fine; a whole file full of them defeats the whole point of using TypeScript.

What the problems actually look like:

``ts
function processUser(user: any) {
return user.nmae.toUpperCase(); // typo 'nmae' — TypeScript should catch this
}

processUser({ name: "Asha" });
// 💥 Runtime: Cannot read properties of undefined (reading 'toUpperCase')
`

If
user had been typed properly, TypeScript would have flagged user.nmae at compile time. Because it's any, the bug shipped silently.

Three specific problems with
any:

1. No autocomplete or refactor safety — the editor doesn't know what
user has, so you get no suggestions and renaming a field doesn't update usages.
2. No compile-time errors — typos, wrong types, missing fields all slip through.
3. It spreads — an
any value assigned to a typed variable can poison the variable's type:
`ts
const data: any = getData();
const id: number = data.id; // ✅ accepted even if data.id is actually a string
`

How to fix it incrementally — don't try to clean the whole file in one go:

Step 1 — Replace
any with unknown as a first pass:

`ts
function processUser(user: unknown) {
return user.nmae.toUpperCase(); // ❌ TypeScript now stops you
}
`

unknown forces you to check the type before using the value. This surfaces every assumption being made in the file.

Step 2 — Define real types where the shape is known:

`ts
interface User {
name: string;
age: number;
}

function processUser(user: User) {
return user.nmae.toUpperCase();
// ^^^^ ❌ Property 'nmae' does not exist on type 'User'.
}
`

Now the typo is caught immediately.

Step 3 — For function parameters, annotate explicitly:

`ts
function add(a, b) { return a + b; } // implicit any for both
function add(a: number, b: number) { return a + b; } // ✅ explicit
`

Step 4 — Turn on
"noImplicitAny": true in tsconfig.json:

`json
{ "compilerOptions": { "noImplicitAny": true } }
`

This stops *new* implicit
anys from sneaking in while you clean up the old ones.

Practical migration tip — start with the most-used functions first:

A utility used in 50 places gives 50× the safety win. Hunt those down and type them properly before tackling private helpers used once.

When
any is genuinely OK:
- Migrating a large JavaScript codebase incrementally (temporary).
- Interfacing with truly dynamic data where you've validated the shape elsewhere.
- A function that genuinely accepts anything (rare).

In every case, prefer
unknown over any` if you can — it's the safer escape hatch.
💡 Plain English: `any` is duct tape holding parts of a house together. Each strip works for now, the structure stays standing — but the underlying joints are unchecked. If a beam cracks underneath, the tape gives no warning; you only find out when the ceiling falls in. Fixing it is removing the tape piece by piece and replacing each with proper joins (typed interfaces). More work up front, but you actually know which parts of the house are sound. And you put up a "no new tape allowed" sign (`noImplicitAny`) so future repairs use the right materials too.
29
Types

You have a function that searches a database and might find nothing. How do you type the return value?

When a function might *not* find a result, the type should make that absence visible. The standard pattern is to return a union of the success type with null (or undefined).

The wrong approach — returning just the success type:

``ts
async function findUser(id: number): Promise<User> {
const row = await db.users.findById(id);
return row;
}

const user = await findUser(42);
console.log(user.name); // 💥 crashes if user 42 doesn't exist
`

The return type says "always a User," but the implementation can secretly return undefined. Callers assume there's always a value, skip the null check, and crash at runtime.

The right approach — make absence part of the return type:

`ts
async function findUser(id: number): Promise<User | null> {
const row = await db.users.findById(id);
return row ?? null;
}
`

Now callers are *forced* by TypeScript to handle the missing case:

`ts
const user = await findUser(42);

console.log(user.name);
// ❌ Object is possibly 'null'.

if (user) {
console.log(user.name); // ✅ TypeScript narrows user to 'User' here
} else {
console.log("User not found");
}
`

null vs undefined — both work; pick one and be consistent:

`ts
async function findUser(id: number): Promise<User | null> { /* ... */ }
async function findUser(id: number): Promise<User | undefined> { /* ... */ }
`

Many teams pick
null for "deliberately not found" (it's an intentional outcome) and reserve undefined for "value was never set." But the more important thing is that *some* form of absence is visible in the type.

Alternative pattern — a Result type for "found vs error vs not found":

When the function can fail for multiple reasons, you can return a richer type:

`ts
type FindResult<T> =
| { status: "found"; data: T }
| { status: "not-found" }
| { status: "error"; message: string };

async function findUser(id: number): Promise<FindResult<User>> {
// ...
}

const result = await findUser(42);
switch (result.status) {
case "found": return result.data;
case "not-found": return null;
case "error": throw new Error(result.message);
}
`

This is a senior-level pattern, but worth knowing.

Rule of thumb:
- If a function *might* fail to produce its value, encode that in the return type — don't pretend it always succeeds.
- The simplest form is
Promise<T | null>`.
- Consumers will be forced by TypeScript to handle both outcomes — exactly what you want.
💡 Plain English: Asking a librarian to find a book. A bad system makes the librarian hand you a book object every single time — even if no matching book exists, you're handed an empty wrapper with no label. You don't realise it's empty until you open it at home and discover there's no title. A good system has the librarian say either "here's the book" or "we don't have it." Both outcomes are visible from the moment you receive the response. You know to plan for both — and you'll never get a surprise empty wrapper that crashes your day. `T | null` is the librarian's "we don't have it" option, written into the return type for everyone who asks.
30
Tooling

How would you add TypeScript to an existing JavaScript project?

The golden rule: do it incrementally. Forcing a big-bang conversion blocks the team for weeks and almost always introduces regressions. The right approach lets the project keep running while you tighten one file at a time.

Step 1 — Install TypeScript and create a config:

``bash
npm install -D typescript
npx tsc --init # generates tsconfig.json
`

Step 2 — Start with a permissive
tsconfig.json so nothing breaks:

`json
{
"compilerOptions": {
"allowJs": true, // accept existing .js files alongside .ts
"checkJs": false, // don't type-check JS files yet (too noisy)
"strict": false, // start lenient — we'll tighten later
"outDir": "dist", // where compiled .js files go
"target": "ES2020", // what JS version to compile to
"esModuleInterop": true,
"skipLibCheck": true // don't re-check node_modules — faster builds
},
"include": ["src/**/*"]
}
`

This config means TypeScript will compile your project as-is. No type errors yet — you've just set up the runway.

Step 3 — Rename files one at a time, starting with the most-shared:

`
utils/dates.js → utils/dates.ts
utils/format.js → utils/format.ts
`

Start with utilities used in many places — typing them gives the biggest payoff. Add type annotations as you rename. Fix the errors that surface.

Step 4 — Once a few files are typed, tighten the rules incrementally:

Turn on strict flags one at a time, fix the new errors, then turn on the next:

`json
{
"compilerOptions": {
"noImplicitAny": true, // first — forces explicit types on parameters
// ... fix errors, commit ...
"strictNullChecks": true, // second — handle null/undefined properly
// ... fix errors, commit ...
"strict": true // eventually — enables all strict flags at once
}
}
`

Each flag exposes a category of bugs. Tackle them in waves.

Step 5 — Install
@types/* for any libraries that need them:

Many libraries don't ship their own types, but the community publishes them on DefinitelyTyped:

`bash
npm install -D @types/node @types/lodash @types/jest
`

Practical migration tips:
- Never block development — new code can still be
.js during early migration if needed; just convert when convenient.
- Track progress with
npx tsc --noEmit 2>&1 | wc -l to count remaining errors over time.
- Don't try to type third-party libraries with bad types perfectly — use
any or @ts-expect-error to move on, fix later.
- CI should compile but not block on type errors at first — gradually tighten this once the team is on board.

What NOT to do:
- Don't try to convert every file in one PR — it's unreviewable and will cause merge conflicts for weeks.
- Don't turn on
"strict": true` from day one in a large codebase — you'll see thousands of errors and lose morale.
💡 Plain English: Adding TypeScript to a running JavaScript project is like renovating a house room by room while the family still lives in it. You don't demolish the whole building on day one — everyone needs somewhere to sleep tonight. Instead, you start with one room: clear it, fix the wiring properly, plaster the walls, paint, move the furniture back. The room is now solid; the family is fine; the rest of the house is still functional. Then you move to the next room. Then the next. By the time the last room is done, the whole house is rebuilt — and at no point was the family without a roof. Trying to gut the whole house at once (a big-bang migration) leaves everyone with nowhere to live until you're done, and you almost certainly find structural surprises that delay everything.
31
Practical

How do you import the `Page` type from Playwright and use it to type a helper function?

When you write helper functions for your Playwright tests — things like loginAs(), navigateToDashboard(), or fillCheckoutForm() — those functions need a Playwright Page object to interact with the browser. TypeScript requires you to declare what type that parameter is. Playwright ships its own TypeScript types, so you import Page directly from the @playwright/test package.

Why this matters:

Without typing the page parameter, TypeScript treats it as any — you lose autocomplete for all Playwright methods (page.locator(), page.goto(), page.waitForURL(), etc.) and lose the compile-time safety that catches misspelled method names.

Basic helper function — how to type it:

``ts
import { Page } from '@playwright/test';

// ❌ Without type — page is any, no autocomplete, no safety:
async function loginAs(page, email, password) {
await page.lokator('#email').fill(email); // typo — no error until runtime
}

// ✅ With types — editor catches the typo immediately:
async function loginAs(page: Page, email: string, password: string): Promise<void> {
await page.locator('#email').fill(email); // ✅ autocomplete works
await page.locator('#password').fill(password);
await page.locator('[data-testid="submit"]').click();
}
`

TypeScript catches
page.lokator before you ever run a test.

Using the typed helper in a test:

`ts
import { test, expect } from '@playwright/test';
import { loginAs } from './helpers/auth';

test('dashboard loads after login', async ({ page }) => {
await loginAs(page, 'admin@test.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
`

The
page fixture from { page } is already typed as Page by Playwright — TypeScript confirms it matches the helper's parameter type.

Returning something typed from a helper:

`ts
import { Page, Locator } from '@playwright/test';

// A helper that navigates and returns a locator for assertions:
async function openProductPage(page: Page, productId: string): Promise<Locator> {
await page.goto(
/products/${productId});
return page.locator('[data-testid="product-title"]');
}

// In the test:
const title = await openProductPage(page, 'abc123');
await expect(title).toHaveText('Running Shoes');
`

The return type
Promise<Locator> means TypeScript knows title is a Locator — so expect(title).toHaveText() autocompletes correctly.

Other Playwright types you'll commonly import:

`ts
import { Page, Locator, BrowserContext, APIRequestContext } from '@playwright/test';
`

Rule of thumb:
- Always type the
page parameter as Page — never leave it as implicit any.
- Import types from
'@playwright/test', not from 'playwright' — the test package has the fixture types.
- Type your helper's return value too (
Promise<void>, Promise<Locator>`) — it makes the caller's code cleaner and self-documenting.
💡 Plain English: Imagine giving a colleague a set of instructions for operating a piece of equipment. If you write "use the machine" with no further description, they have no idea which buttons exist or what they do. But if you write "use the Nespresso Vertuo machine" — suddenly they know: there's a button on top, the lever locks, the drip tray slides out. The typed `Page` parameter is the same: it tells TypeScript exactly which machine you're working with, so it can show you every button, warn you if you press a wrong one, and catch typos before you ruin the coffee.
32
Types

What is a type guard and when do you actually need one?

A type guard is any check in your code that lets TypeScript narrow a value from a broad type (like a union or unknown) to a more specific type *inside* a branch. After the check, TypeScript treats the value as the narrower type — and lets you use the properties and methods that type provides.

The problem it solves:

``ts
function format(x: string | number) {
return x.toUpperCase();
// ❌ Property 'toUpperCase' does not exist on type 'string | number'.
}
`

TypeScript won't let you call
.toUpperCase() because x could be a number — numbers don't have that method. You need to *prove* it's a string first.

Built-in type guards:

1 —
typeof (for primitives — string, number, boolean, etc.):

`ts
function format(x: string | number) {
if (typeof x === "string") {
return x.toUpperCase(); // ✅ TypeScript: x is string here
}
return x.toFixed(2); // ✅ TypeScript: x must be number here
}
`

2 —
instanceof (for classes and built-in objects):

`ts
function handleError(err: unknown) {
if (err instanceof Error) {
console.log(err.message); // ✅ err is Error here
console.log(err.stack);
} else {
console.log("Unknown error:", err);
}
}
`

3 —
in operator (for checking object properties):

`ts
type Dog = { bark: () => void };
type Cat = { meow: () => void };

function play(pet: Dog | Cat) {
if ("bark" in pet) {
pet.bark(); // ✅ TypeScript: pet is Dog here
} else {
pet.meow(); // ✅ TypeScript: pet must be Cat here
}
}
`

4 — Equality checks (for literal unions):

`ts
type Status = "loading" | "success" | "error";

function render(s: Status) {
if (s === "loading") return <Spinner />;
if (s === "success") return <Done />;
return <ErrorBox />;
}
`

Custom (user-defined) type guards — for complex object shapes:

When TypeScript can't narrow on its own, you write a function with the special return type
x is SomeType:

`ts
interface User { id: number; name: string; }

function isUser(x: unknown): x is User {
return (
typeof x === "object" &&
x !== null &&
"id" in x &&
"name" in x &&
typeof (x as any).id === "number" &&
typeof (x as any).name === "string"
);
}

const data: unknown = await fetch("/users/1").then(r => r.json());

if (isUser(data)) {
console.log(data.name); // ✅ TypeScript knows data is User here
}
`

The
x is User return type is what tells TypeScript "if this returns true, x is definitely a User."

When you need type guards:
- You receive
unknown (from API responses, JSON, caught errors).
- You have a union type and need to branch on the actual variant.
- You're working with class hierarchies and need to know which subclass.

Rule of thumb:
- Use
typeof for primitives, instanceof for classes, in for property checks.
- Write a custom guard (
x is T`) for object shapes that TypeScript can't narrow automatically.
- For external/untrusted data, prefer a runtime validation library like Zod — it gives you a guard *and* throws clear errors when the data doesn't match.
💡 Plain English: A type guard is like a bouncer at a club checking ID before letting someone through a specific door. Outside the club, you're just "a person" (the broad type — could be anyone). The bouncer checks your ID — that's the type guard. Once the check passes, the bouncer knows exactly who you are: an adult, a VIP, a staff member. Now you can be sent through the right door (and TypeScript knows what properties you have on the other side). Without the bouncer's check, sending you through the "over 18 only" door would be risky — they wouldn't know whether you actually qualify. That's exactly why TypeScript blocks you from using string methods on a `string | number` until you've checked.
33
Types

You have a function that needs to accept either a string ID or a numeric ID. How do you type it?

When a function needs to accept more than one type of input, use a union type (A | B) and narrow inside the function to handle each case.

Basic approach — union parameter with type narrowing:

``ts
function findUser(id: string | number): User {
if (typeof id === "number") {
// TypeScript knows: id is number here
return db.users.findByNumericId(id);
}
// Below the if, TypeScript knows: id is string
return db.users.findByUuid(id);
}

findUser(42); // ✅
findUser("abc-123"); // ✅
findUser(true); // ❌ Type 'boolean' is not assignable
`

The function signature says "I accept either a string or a number." Inside,
typeof narrows the union to the specific type before you use it.

Why this is better than
any:

`ts
function findUser(id: any): User { // ❌ no safety at all
return db.users.findByNumericId(id); // accepts anything — even booleans, objects
}
`

any accepts the right inputs but also accepts the wrong ones. The union gives you safety at the call site while still allowing flexibility.

A common real-world example:

`ts
type DateInput = string | Date | number; // ISO string, Date object, or timestamp

function formatDate(input: DateInput): string {
let date: Date;

if (typeof input === "string") {
date = new Date(input);
} else if (typeof input === "number") {
date = new Date(input);
} else {
date = input; // already a Date
}

return date.toLocaleDateString();
}

formatDate("2026-06-02");
formatDate(1717286400000);
formatDate(new Date());
`

When the return type also differs — use function overloads:

If different input types should return different output types, function overloads are cleaner than a union return:

`ts
// Overload signatures (callers see these):
function findUser(id: number): UserById;
function findUser(id: string): UserByUUID;

// Implementation signature (must accept all variants):
function findUser(id: number | string): UserById | UserByUUID {
if (typeof id === "number") {
return db.users.findByNumericId(id); // returns UserById
}
return db.users.findByUuid(id); // returns UserByUUID
}

const a = findUser(42); // type: UserById
const b = findUser("abc-123"); // type: UserByUUID
`

Rule of thumb:
- Use a union type when the function accepts multiple input types but the logic is similar — narrow inside with
typeof.
- Use overloads when different input types should produce genuinely different return types.
- Avoid
any` — it loses the safety the union gives you for free.
💡 Plain English: A reception desk that accepts either a staff badge (numeric ID) or a visitor QR code (string ID). The receptionist (your function) takes whatever the visitor presents at the desk — both formats are allowed. But before processing, they look at *what* they've been handed: "Oh, this is a badge — let me scan it against the staff database. Ah, this is a QR code — different system entirely." A union type models exactly that: the front door accepts either kind of credential, and the logic inside checks which kind arrived before deciding how to handle it. The desk doesn't accept passports or library cards (other types) — only the two it was designed to support.
34
Practical

How do you share a TypeScript interface across multiple test files without redefining it each time?

As your test suite grows, you'll define types for things like users, products, orders, and API responses. If you copy those interface definitions into each test file, you end up with multiple slightly-different versions that drift apart — one file adds a field, another doesn't. The fix is to define types once in a dedicated file and import them wherever they're needed.

Why copying types is a problem:

``ts
// tests/login.test.ts — copy #1
interface User { id: number; email: string; role: string; }

// tests/checkout.test.ts — copy #2 (someone added 'name' here, forgot in login)
interface User { id: number; email: string; role: string; name: string; }

// Now they're out of sync. TypeScript can't help because they're separate declarations.
`

The fix — a shared types file:

Create a dedicated file for your shared test types:

`ts
// types/index.ts (or types/models.ts — pick a convention and stick to it)

export interface User {
id: number;
email: string;
role: 'admin' | 'guest' | 'regular';
name: string;
}

export interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}

export interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
`

Import the type wherever you need it:

`ts
// tests/login.test.ts
import { User } from '../types';

test('login returns a user object', async ({ request }) => {
const res = await request.post('/api/login', {
data: { email: 'a@test.com', password: 'secret' }
});
const user: User = await res.json();
expect(user.role).toBe('admin');
});
`

`ts
// tests/checkout.test.ts
import { User, Product } from '../types';

test('checkout creates an order for a user', async () => {
const user: User = { id: 1, email: 'a@test.com', role: 'regular', name: 'Asha' };
// ...
});
`

One definition, used everywhere. Update the
User interface once — every file that imports it gets the update automatically.

Type-only imports (good practice in test files):

`ts
// The 'import type' syntax makes clear this is a type, not a runtime value.
// The bundler strips it out entirely — no extra bytes in output.
import type { User } from '../types';
`

Typical folder structure for a Playwright project:

`
tests/
login.test.ts
checkout.test.ts
types/
index.ts ← shared interfaces live here
helpers/
auth.ts
`

Rule of thumb:
- One shared
types/ folder — define every reusable interface there.
- Use
export interface or export type — both work; the team just needs to pick one style.
- Prefer
import type { X }` in test files — it signals intent and keeps builds clean.
- If you only use a type in one file, keep it local. Only move to shared when two or more files need it.
💡 Plain English: Think of your shared types file like a company HR system that stores everyone's official job title. Instead of each department keeping their own list of who is "Senior Engineer" vs "Lead Engineer" (which immediately goes stale and diverges), there's one system of record. When someone gets promoted, HR updates it once and every department's reports automatically reflect the change. Copying interface definitions into each test file is the "each department keeps their own spreadsheet" approach — it always goes wrong eventually.
35
Practical

An API returns an object with keys you don't know in advance. How do you type it?

When you don't know the specific keys ahead of time — for example, a dictionary of HTTP headers, a translation lookup table, or a user-defined config — you describe the *pattern* of keys and values rather than listing them all. Two ways to do this: index signature or Record<K, V>.

Option 1 — Index signature (interface or type with [key: string]):

``ts
interface StringMap {
[key: string]: string;
}

const headers: StringMap = {
"Content-Type": "application/json",
"X-Request-ID": "abc123",
"Authorization": "Bearer abc",
// ...any number of string-to-string pairs allowed
};
`

[key: string]: string reads as "any string key, mapped to a string value." TypeScript enforces that *every* value is a string:

`ts
headers["X-Retry"] = 3; // ❌ Type 'number' is not assignable to 'string'.
`

Option 2 —
Record<K, V> (same idea, shorter):

`ts
const headers: Record<string, string> = {
"Content-Type": "application/json",
};

const scores: Record<string, number> = {
asha: 90,
ben: 85,
};
`

Record<string, number> means exactly the same as { [key: string]: number } — pick whichever style your team prefers.

For mixed value types — use
unknown and narrow before use:

`ts
const config: Record<string, unknown> = {
apiUrl: "https://...",
timeoutMs: 5000,
retries: 3,
debug: false,
};

const port = config.port; // type: unknown
if (typeof port === "number") {
// safely use port as number here
}
`

unknown forces you to check the value type before using it — safer than any.

Mixing known fields with arbitrary extras:

If the object has some required known fields *and* allows arbitrary extras:

`ts
interface ApiResponse {
id: number; // always present
status: "ok" | "error"; // always present
[key: string]: unknown; // plus any number of extra fields
}

const res: ApiResponse = {
id: 1,
status: "ok",
payload: { foo: "bar" }, // extra field allowed
duration: 42,
};
`

Record with a literal-union key — for known keys:

If you actually know the exact set of keys, use a literal union:

`ts
type Role = "admin" | "user" | "guest";
const permissions: Record<Role, boolean> = {
admin: true,
user: false,
guest: false,
};
// TypeScript enforces that ALL three keys are present
`

This is much stricter — TypeScript checks you provided exactly the right keys.

Avoid typing it as
any:

`ts
const data: any = await response.json();
data.foo.bar.baz(); // 💥 no safety, no autocomplete
`

At minimum use
Record<string, unknown> — you keep the flexibility but force narrowing before use.

Rule of thumb:
- Use
Record<string, V> (or { [key: string]: V }) when keys are dynamic and all values share a type.
- Use
Record<LiteralUnion, V> when keys are from a known fixed set (stricter).
- Use
unknown` for value types when you don't know them precisely — it forces safe handling.
- For external API responses where you genuinely need runtime safety, reach for a validation library like Zod.
💡 Plain English: An index signature is like a hotel register that says: "every row has two columns — a guest name (any text) and a check-in time (any timestamp)." You don't list the actual guests in advance — they sign in as they arrive. But you've declared the *shape* of each row, so the register can't accept a row with three columns, or a number in the name column. `Record<string, string>` is the same idea, just a shorter way to write the register's rules. Either way, you've described what every entry will look like, even though you don't know who'll fill the rows yet.
36
Types

What does TypeScript do when you pass extra properties to a typed function?

This is one of TypeScript's most confusing behaviors at first — the answer depends on how you pass the object, not just what it contains.

Case 1 — Passing a fresh object literal directly:

TypeScript runs an excess property check and rejects extra fields:

``ts
interface User { name: string; }
function save(user: User) {}

save({ name: "Asha", age: 30 });
// ❌ Object literal may only specify known properties,
// and 'age' does not exist in type 'User'.
`

The reasoning: when you write the object right there at the call site, an extra property is almost certainly a typo or a misunderstanding. TypeScript wants to flag it.

Case 2 — Passing through a variable:

TypeScript only checks structural compatibility — the variable's type just needs to have all the required fields. Extras are allowed.

`ts
const person = { name: "Asha", age: 30 };
save(person); // ✅ allowed — 'person' has 'name', that's enough
`

The reasoning: a variable might be used in many places, with extra properties needed by other callers. TypeScript trusts that the variable was constructed deliberately.

Why the asymmetry?

TypeScript chose this trade-off:
- Fresh literals are usually authored *just for this call* — extras are suspicious (typo "name" as "nmae"; you don't catch unless excess properties are flagged).
- Variables are reusable values — extras might be intentional and needed elsewhere.

The excess property check is essentially a typo guard for inline objects.

A real-world example where this matters:

`ts
interface ButtonProps { label: string; onClick: () => void; }

function Button(props: ButtonProps) { /* ... */ }

// ❌ Caught — color isn't a valid prop:
Button({ label: "OK", onClick: handle, color: "red" });

// ✅ Sneaks through — color is allowed because it came via a variable:
const buttonProps = { label: "OK", onClick: handle, color: "red" };
Button(buttonProps);
`

How to avoid being surprised:

If you want strict checking even through variables, annotate the variable with the target type:

`ts
const buttonProps: ButtonProps = { label: "OK", onClick: handle, color: "red" };
// ❌ caught here — variable typed as ButtonProps doesn't allow color
``

Rule of thumb:
- Excess property checks fire only on fresh object literals at the call site.
- If you pass through a variable, extras are quietly accepted (structural typing).
- To get strict checking on a variable, annotate it with the destination type.
- This is a deliberate TypeScript design — useful to recognise so you don't waste time wondering why "the same object" sometimes errors and sometimes doesn't.
💡 Plain English: Imagine handing forms to a clerk at a front desk. If you fill in a brand-new blank form right there at the counter and slide it across, the clerk reads every line — and points out any extra fields you scribbled in the margins ("we don't have a field for 'pet's name' on this form"). If instead you pull a pre-filled card from your wallet — one you'd already prepared for some other purpose — the clerk just reads the fields they care about ("name and date of birth — got it") and ignores the rest. Your card might have all sorts of extra info on the back; not their concern. Same information, different handover style, different scrutiny. The fresh-literal check is the clerk being extra careful with forms written in front of them; the variable case is them trusting a pre-existing document.
37
Types

How do you make one interface extend another in TypeScript?

Use the extends keyword to build a new interface that inherits all the properties of a parent interface and adds (or overrides) its own. This is TypeScript's way of expressing "A is a B, plus more."

Basic syntax:

``ts
interface Animal {
name: string;
age: number;
}

interface Dog extends Animal {
breed: string;
}

const dog: Dog = {
name: "Rex", // inherited from Animal
age: 3, // inherited from Animal
breed: "Labrador", // added by Dog
};
`

A
Dog must have *every* property from Animal *plus* the new breed property. Missing any field is an error:

`ts
const bad: Dog = { name: "Rex", breed: "Labrador" };
// ❌ Property 'age' is missing in type ...
`

Multiple inheritance — extend several interfaces at once:

`ts
interface Person {
name: string;
age: number;
}

interface HasRole {
role: string;
permissions: string[];
}

interface Employee extends Person, HasRole {
employeeId: string;
salary: number;
}

// Must include fields from Person + HasRole + Employee
const e: Employee = {
name: "Asha",
age: 30,
role: "QA Lead",
permissions: ["read", "write"],
employeeId: "E001",
salary: 75000,
};
`

A common real-world example — building up a request type:

`ts
interface BaseRequest {
requestId: string;
timestamp: number;
}

interface AuthenticatedRequest extends BaseRequest {
userId: number;
token: string;
}

interface AdminRequest extends AuthenticatedRequest {
adminLevel: number;
}

// AdminRequest has 5 fields total — auto-tracked from the chain
`

Change
BaseRequest to add a new field, and AuthenticatedRequest and AdminRequest automatically include it too. No duplication, no manual updates.

Overriding a parent property — must be a compatible type:

`ts
interface Animal {
age: number;
}

interface Pet extends Animal {
age: 1 | 2 | 3 | 4 | 5; // ✅ narrower type — allowed
}

interface BadPet extends Animal {
age: string; // ❌ Error — incompatible with parent's 'number'
}
`

You can make a property *more specific* in the child, but you can't change its type entirely.

extends (with interfaces) vs & (intersection with types):

`ts
// Using interfaces:
interface Dog extends Animal { breed: string; }

// Using type aliases — same effect:
type Dog = Animal & { breed: string; };
`

Both produce the same result.
extends reads more naturally for class-like hierarchies; & is more flexible for ad-hoc combinations.

Rule of thumb:
- Use
extends to model "is a" relationships — Dog is an Animal, Admin is a User.
- Inheritance keeps types DRY — change the parent, all children update.
- For combining unrelated shapes ad hoc, prefer
type aliases with &` intersection.
💡 Plain English: Interface inheritance is like a job application form built on a base template. The base template (`Animal`) has the fields everyone needs to fill in — name, age, basic details. When you create a more specialised form (`Dog`), you don't redraw the whole form from scratch — you start with the base and add the role-specific fields (breed, vaccination history). If HR later updates the base template to add an emergency contact field, every form built on top of it automatically gets that field too. No need to update each form by hand. Multiple inheritance is like combining a "Personal Info" template and a "Job Role" template into one larger form — every applicant fills in both sections together.
38
Practical

How do you write a TypeScript function that creates test user data with defaults but lets the caller override specific fields?

One of the most common patterns in test automation is a test data factory — a function that produces a ready-to-use test object with sensible defaults, but lets each test override only the fields it specifically cares about. In TypeScript, you do this with a combination of an interface, default parameters, and the spread operator.

Why this pattern matters:

Without it, every test that needs a user has to spell out all fields — even the ones it doesn't care about:

``ts
// ❌ Every test repeats all fields, even the ones it doesn't care about:
test('admin can delete posts', async () => {
const user = { id: 1, name: 'Test User', email: 'test@x.com', role: 'admin', active: true };
// ... test only cares about role:'admin', but had to write everything
});
`

If the
User shape gains a new required field, every test breaks. And every test reads 80% boilerplate.

The factory pattern — with TypeScript types:

`ts
// types/index.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
active: boolean;
}

// helpers/factories.ts
import { User } from '../types';

export function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'viewer',
active: true,
...overrides, // spread the overrides LAST — they win over the defaults
};
}
`

Breaking this down:
-
overrides: Partial<User> — the caller can pass *any subset* of User fields, or none at all. Partial<T> makes all fields optional.
-
= {} — default value means the caller doesn't have to pass anything if all defaults are fine.
-
...overrides spread at the end — any field the caller provides replaces the default.

Using the factory in tests — each test overrides only what it needs:

`ts
import { makeUser } from '../helpers/factories';

test('admin sees delete button', async () => {
const user = makeUser({ role: 'admin' });
// user = { id:1, name:'Test User', email:'test@example.com', role:'admin', active:true }
});

test('inactive user is redirected to reactivation page', async () => {
const user = makeUser({ active: false });
// Only active:false changed — everything else is the default
});

test('uses default user when no overrides needed', async () => {
const user = makeUser();
// All defaults — no boilerplate in the test at all
});
`

TypeScript's type safety in action:

`ts
makeUser({ role: 'superuser' }); // ❌ compile error — 'superuser' is not a valid role
makeUser({ age: 30 }); // ❌ compile error — 'age' is not a field on User
makeUser({ role: 'admin' }); // ✅ valid override
`

The type system prevents typos in role names and unknown fields — errors you'd otherwise only catch when the test fails at runtime.

Rule of thumb:
- Put factories in a shared
helpers/factories.ts file — one factory per entity (user, product, order, etc.)
- Use
Partial<T> for the overrides parameter — never a plain object or any`.
- Spread overrides *after* the defaults — left-to-right, last write wins.
- Keep defaults realistic — use values that work for most tests so each test only overrides what's special about its scenario.
💡 Plain English: A test data factory is like a sandwich shop with a house special. The house special already has a sensible combination of bread, filling, and condiments — most customers just take it as-is. But if you want extra cheese or no onions, you say so and the shop applies just those changes to the standard recipe. You don't redesign the whole sandwich every time. The factory is the house special recipe; the overrides are your "extra cheese, no onions" request; and `Partial<User>` is the menu that lists what can actually be customised.
39
Functions

How do you type a function that accepts a callback in TypeScript?

A callback is a function passed as an argument to another function, to be called back later. To type one, you write its full signature — its parameters and return type — using TypeScript's arrow-function syntax.

Basic syntax — inline callback type:

``ts
function fetchData(
url: string,
onSuccess: (data: User[]) => void
) {
fetch(url)
.then(res => res.json())
.then(users => onSuccess(users));
}

// Caller passes a function matching that exact shape:
fetchData("/users", (users) => {
console.log(users.length); // ✅ TypeScript knows 'users' is User[]
});
`

The signature
(data: User[]) => void reads as: "a function that takes one argument of type User[] and returns nothing useful."

Named callback type for reuse:

If you use the same callback shape in multiple places, give it a name:

`ts
type SuccessHandler = (data: User[]) => void;
type ErrorHandler = (err: Error) => void;

function fetchData(url: string, onSuccess: SuccessHandler, onError: ErrorHandler) {
// ...
}
`

This is cleaner and lets you update the signature in one place.

Optional callbacks — mark with
?:

`ts
function retry(
fn: () => void,
onError?: (e: Error) => void // optional
) {
try {
fn();
} catch (e) {
onError?.(e instanceof Error ? e : new Error(String(e)));
}
}
`

Callback that returns a value:

`ts
function mapItems<T, U>(
items: T[],
transform: (item: T) => U // takes T, returns U
): U[] {
return items.map(transform);
}

const lengths = mapItems(["hello", "world"], (s) => s.length);
// TypeScript infers: lengths is number[]
`

The callback's return type
U flows through to the final result type.

Common real-world examples — array methods:

The signatures you've used countless times in JavaScript are typed exactly this way under the hood:

`ts
// Simplified versions of built-in array methods:
interface Array<T> {
forEach(cb: (item: T, index: number) => void): void;
map<U>(cb: (item: T) => U): U[];
filter(cb: (item: T) => boolean): T[];
}
`

When you write
users.map(u => u.name), TypeScript checks the callback's signature against (item: User) => string and infers the result as string[].

React event handlers — same idea:

`ts
interface ButtonProps {
label: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
}
`

Rule of thumb:
- Always type both the parameters AND the return type of a callback.
- Use a named type alias when the same callback shape appears in multiple functions.
- Use
? for optional callbacks; remember to call them with ?.() syntax inside the function.
- Don't type a callback as
Function or any` — you lose all the safety the signature provides.
💡 Plain English: Typing a callback is like giving someone a clear job description before they show up to do the work. Instead of just saying "I'll need help later" (untyped), you hand them a printed brief: "When the package arrives, you'll be given a list of User records — your job is to sort them by date, you don't need to return anything." Now they know exactly what to expect, what to do, and what to hand back. If they show up trying to sort Orders instead of Users, or trying to return a value when none is expected, the brief makes the mismatch obvious right at the door — not three steps into the job.
40
Practical

How do you avoid using `any` when you don't know the shape of data coming from an API?

TypeScript types exist only in your source code — they are erased completely at runtime. The JSON your API returns is whatever the server actually sends, regardless of what your TypeScript says it should be. This is why any is the wrong tool for API responses: it doesn't add safety, it removes the safety you already had.

The naive approach — any — and why it fails:

``ts
const data: any = await res.json();
data.user.name.toUpperCase();
// 💥 If the API sends { error: "Not found" }, this crashes at runtime
// TypeScript said nothing — it trusted you blindly
`

With
any, the type system goes completely silent. When the API changes shape or returns an error object, your code doesn't notice until it blows up in production.

Option 1 —
unknown + a type guard:

unknown is the safe alternative to any. TypeScript won't let you access any property on an unknown value until you have proven its shape:

`ts
interface User { id: number; name: string; email: string; }

async function fetchUser(id: number): Promise<User> {
const res = await fetch(
/users/${id});
const data: unknown = await res.json();

if (isUser(data)) {
return data; // ✅ TypeScript now knows data is User
}
throw new Error("API returned an unexpected shape");
}

function isUser(x: unknown): x is User {
return (
typeof x === "object" && x !== null &&
typeof (x as any).id === "number" &&
typeof (x as any).name === "string" &&
typeof (x as any).email === "string"
);
}
`

If the API sends garbage, the guard returns
false and you get a clear error immediately — not a confusing crash three lines later.

Option 2 (recommended for production) — runtime validation with Zod:

Writing type guards by hand scales poorly. A schema library like Zod does the validation AND generates the TypeScript type from the same definition:

`ts
import { z } from "zod";

const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;
// ↑ TypeScript infers: { id: number; name: string; email: string }
// No need to write the interface separately — one definition, two outputs.

async function fetchUser(id: number): Promise<User> {
const res = await fetch(
/users/${id});
const data = await res.json();
return UserSchema.parse(data);
// ↑ throws a descriptive ZodError if any field is missing or wrong type
}
`

Zod gives you: runtime validation that throws on bad data, TypeScript types inferred automatically, and detailed field-level error messages that tell you exactly what was wrong.

The core principle — types are labels, not checks:

TypeScript types only tell you what something *should* be. At the API boundary, you need to verify what it *actually* is. The contract:
- At the boundary (API, localStorage, user input): validate with runtime code.
- Inside your app (after validation): trust TypeScript types fully.

Rule of thumb:
- Never type an API response as
any — it defeats the entire point of TypeScript.
- Use
unknown` + a type guard for simple or one-off calls.
- Use Zod (or Valibot, io-ts) for production code — one schema = validation + TypeScript type.
💡 Plain English: Receiving a package from a courier where the label might be wrong. `any` is grabbing the package, trusting the label completely, and putting it straight on the shelf. If the label says "books" but the box is broken glass, you find out the hard way when someone reaches in. `unknown` forces you to open the box at the door and verify the contents match the label before you do anything with it. A validation library like Zod is hiring a customs officer at the door. They have a printed checklist for every package type, they open and inspect every one, and they refuse anything that doesn't match the manifest — with a written note explaining exactly which item was wrong. You only ever handle packages that have been verified and signed off.
41
Practical

You are seeing "Property 'x' does not exist on type 'Y'" — what does this mean and how do you fix it?

TypeScript maintains a precise map of every type's shape — every field name and its exact type. When you write obj.something, it looks up "does this type have a field called something?" If not, it refuses to compile. This is intentional: in JavaScript, accessing a missing property doesn't crash — it silently returns undefined, which causes confusing bugs two or three lines later. TypeScript catches this before your code ever runs.

The four most common causes — and how to fix each:

Cause 1 — Typo in the property name:

``ts
interface User { name: string; age: number; }
const user: User = getUser();

user.nane; // ❌ "Property 'nane' does not exist on type 'User'"
user.name; // ✅
`

TypeScript is your spell-checker for property names. Fix: correct the spelling. Your editor will autocomplete valid options.

Cause 2 — Property exists at runtime but isn't declared in your type:

`ts
interface User { name: string; } // email wasn't included
const user: User = getUser();

user.email; // ❌ "Property 'email' does not exist on type 'User'"
`

Fix: add the missing property to the interface:

`ts
interface User { name: string; email: string; }
`

Cause 3 — Property exists on only one branch of a union type:

`ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };

const shape: Shape = getShape();
shape.radius; // ❌ TypeScript can't assume which branch you have
`

Fix: narrow the union first, then access the property:

`ts
if (shape.kind === "circle") {
shape.radius; // ✅ TypeScript now knows it's a circle
}
`

Cause 4 — Value is typed as
unknown or object (no properties accessible):

`ts
const data: unknown = await res.json();
data.name; // ❌ TypeScript refuses all property access on 'unknown'
`

Fix: validate the shape first (type guard or Zod schema), then access properties inside the validated block.

The reflex to avoid — silencing with
as any:

`ts
(user as any).nane; // ❌ compiles fine — and still returns undefined at runtime
`

This removes the type error without fixing the underlying problem. TypeScript is almost always right when it reports this error — the right question is "what does TypeScript think this type is, and why doesn't it include the property I expect?"

Rule of thumb:
- Read the full error — it names both the property and the type TypeScript sees.
- Fix the type or narrow the union; don't silence the error with
as any`.
- When working with union types, always narrow first, then access narrow-only properties.
💡 Plain English: Asking a vending machine for a sandwich when the machine only sells drinks. The machine isn't being difficult — it's correctly telling you "that slot doesn't exist in my inventory." The fix is either to order something the machine actually has (use an existing property), check you're at the right machine (narrow the union to the right branch), or ask the supplier to add the sandwich slot (add the property to the interface). Slapping an "override — give me what I ask for" sticker on the machine (`as any`) doesn't help. The slot still isn't there; now you just get a confusing result instead of a clear refusal.
42
Practical

An API response returns a User object but without the password field. How do you type this cleanly without duplicating the interface?

This is a very common situation in test automation — your internal User model has a passwordHash or password field, but the GET /users API response deliberately strips it for security. You need a type for the response that matches the actual JSON, without copying the whole interface and maintaining two versions.

The wrong approach — two parallel interfaces that drift apart:

``ts
// ❌ Duplicating the type by hand:
interface User {
id: number;
name: string;
email: string;
role: string;
passwordHash: string; // server-side only
}

interface UserApiResponse {
id: number;
name: string;
email: string;
role: string;
// passwordHash removed — but now you have two interfaces to maintain
}
// Add 'createdAt' to User? You must remember to add it to UserApiResponse too.
// This silently goes stale every time User changes.
`

The right approach — derive the response type using
Omit:

`ts
// ✅ One source of truth:
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
passwordHash: string;
}

type UserApiResponse = Omit<User, 'passwordHash'>;
// { id: number; name: string; email: string; role: 'admin'|'editor'|'viewer' }
`

Omit<User, 'passwordHash'> produces a new type that is identical to User except passwordHash is removed. Add a new field to User? UserApiResponse automatically includes it. Nothing to sync manually.

Using the type in a Playwright API test:

`ts
import { test, expect } from '@playwright/test';
import type { UserApiResponse } from '../types';

test('GET /users/:id returns user without password', async ({ request }) => {
const res = await request.get('/api/users/1');
expect(res.status()).toBe(200);

const user: UserApiResponse = await res.json();

expect(user.id).toBe(1);
expect(user.email).toBeDefined();

// TypeScript won't even let you write this — 'passwordHash' is not on the type:
// expect(user.passwordHash).toBeUndefined(); ← ❌ compile error
});
`

TypeScript prevents you from accidentally asserting on a field that shouldn't exist — which is exactly the kind of security regression you want to catch.

Removing multiple fields — use a union:

`ts
// Strip both server-managed fields:
type CreateUserInput = Omit<User, 'id' | 'passwordHash'>;
// Caller provides name, email, role — server fills in id and passwordHash
`

Rule of thumb:
- Use
Omit<T, 'fieldName'> whenever the API returns a subset of your internal model.
- Prefer
Omit over a hand-written parallel interface — one source of truth, no drift.
- To remove multiple fields:
Omit<T, 'a' | 'b' | 'c'>.
- The opposite — keep only some fields — use
Pick<T, 'a' | 'b'>`. Choose whichever list is shorter.
💡 Plain English: Your internal `User` model is like an employee's full HR record — it contains everything: name, contact details, salary, and confidential notes. When HR publishes a public company directory, they take the full record and redact the confidential sections with a black marker before printing. The printed version is derived from the original — if the template gains a new section like "office location," the printed copy includes it automatically without anyone re-typing it. `Omit` is the black marker: you specify which fields to redact, and everything else flows through automatically.
43
Practical

How do you define and use a string enum for order statuses in an API response?

A string enum is TypeScript's way of defining a fixed, named set of string constants. Without one, order statuses in your codebase are just raw strings — and a typo like "SHIPED" is a perfectly valid string that compiles, passes linting, and breaks silently at runtime because the branch never matches. A string enum makes the complete set of valid values explicit and compiler-enforced.

The problem without an enum:

``ts
function processOrder(status: string) {
if (status === "SHIPED") { // ❌ typo — this branch never executes
sendTrackingEmail();
}
}

processOrder("SHIPPED"); // runs fine, but the typo above is invisible to TypeScript
`

TypeScript accepts any string — it can't know which strings are valid statuses.

Defining a string enum:

`ts
enum OrderStatus {
Pending = "PENDING",
Confirmed = "CONFIRMED",
Shipped = "SHIPPED",
Delivered = "DELIVERED",
Cancelled = "CANCELLED",
}
`

Each member maps a readable name (left) to the exact string that travels in JSON and logs (right). TypeScript knows the complete valid set.

Using the enum — the typo is now impossible:

`ts
function processOrder(orderId: string, status: OrderStatus): void {
switch (status) {
case OrderStatus.Shipped:
sendTrackingEmail(orderId);
break;
case OrderStatus.Cancelled:
refundCustomer(orderId);
break;
default:
updateStatusLog(orderId, status);
}
}

processOrder("ord-123", OrderStatus.Shipped); // ✅
processOrder("ord-123", "SHIPPED"); // ❌ compile error — must use the enum
processOrder("ord-123", OrderStatus.Shiped); // ❌ compile error — no such member
`

Safely parsing a raw API string into the enum:

At the API boundary the value arrives as a plain string. Validate it before trusting the enum type:

`ts
function parseOrderStatus(raw: string): OrderStatus {
if (Object.values(OrderStatus).includes(raw as OrderStatus)) {
return raw as OrderStatus;
}
throw new Error(
Unexpected order status from API: "${raw}");
}

const status = parseOrderStatus(apiResponse.status); // ✅ validated before use
processOrder(apiResponse.id, status);
`

Why string enums, not numeric?

Numeric enums (
Pending = 0, Confirmed = 1, ...) store integers. When 1 appears in a log, a database query, or a network request, it's meaningless without a lookup table. String enums store the human-readable value ("CONFIRMED") directly — logs and payloads are immediately readable by anyone, no decoder ring required.

Rule of thumb:
- Use string enums for any finite set of named states that travel in API responses or appear in logs.
- Always reference the enum member (
OrderStatus.Shipped) in code — never the raw string ("SHIPPED"`) — so you get autocomplete, type checking, and rename refactoring for free.
- Validate raw API strings at the system boundary before casting to the enum type.
💡 Plain English: Status labels on parcels at a sorting depot. The depot could track each parcel with numbers — 0 = pending, 1 = in transit, 2 = out for delivery. But the label on the physical box says "OUT FOR DELIVERY" — human-readable, instantly meaningful to anyone who picks it up, no lookup table required. String enums put that human-readable label directly in your code and in the JSON payload. A developer reading logs at 2am sees `"CANCELLED"` and immediately knows the state. The enum is the lookup table, defined once in the code, enforced by the compiler everywhere it's used.
44
Practical

How do you write a TypeScript factory function that creates test users with different roles — admin, guest, or regular — for use across test scenarios?

A test data factory is a function that builds a fully-typed test object with sensible defaults, but lets you override just the fields you care about in each test. Without one, every test file either duplicates the same object literal or passes incomplete objects that cause cryptic runtime errors.

Why this matters in test automation:
Role-based access control is one of the most common things QA engineers test. You need an admin user, a guest user, a regular user — and sometimes slight variations of each (e.g. an admin who is inactive). A factory function makes that trivial.

Step 1 — Define the User interface once:

``ts
// types/index.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'guest' | 'regular';
active: boolean;
}
`

The
role field uses a string literal union — TypeScript will reject any value that isn't one of these three exact strings.

Step 2 — Write the factory with Partial<User> overrides:

`ts
// helpers/factories.ts
import type { User } from '../types';

export function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'regular',
active: true,
...overrides, // caller's values replace the defaults above
};
}
`

Partial<User> means "an object where every field from User is optional." The spread (...overrides) replaces only the fields the caller provides — everything else keeps its default.

Step 3 — Use it in tests:

`ts
// Any single line creates a complete, typed User:
const admin = makeUser({ role: 'admin' });
const guest = makeUser({ role: 'guest', active: false });
const regular = makeUser(); // all defaults

// TypeScript enforces the union — this is a compile error:
const broken = makeUser({ role: 'superuser' }); // ❌ not a valid role
`

Step 4 — Add named shortcuts for common roles:

`ts
export const makeAdmin = (o: Partial<User> = {}) => makeUser({ role: 'admin', ...o });
export const makeGuest = (o: Partial<User> = {}) => makeUser({ role: 'guest', ...o });
export const makeRegular = (o: Partial<User> = {}) => makeUser({ role: 'regular', ...o });
`

Now a Playwright test reads cleanly:

`ts
test('admin can access settings page', async ({ page }) => {
const user = makeAdmin({ name: 'Alice' });
await loginAs(page, user);
await expect(page.locator('[data-testid="settings-nav"]')).toBeVisible();
});

test('guest cannot access settings page', async ({ page }) => {
const user = makeGuest();
await loginAs(page, user);
await expect(page.locator('[data-testid="settings-nav"]')).not.toBeVisible();
});
`

Rule of thumb:
Keep one factory per domain object in a shared
helpers/factories.ts` file. Never copy-paste object literals across tests — when the User interface grows a new required field, you fix it in one place and every test stays green.
💡 Plain English: A sandwich shop's "house special" with optional customisation. The house special already has a complete set of ingredients — bread, filling, sauce, sides. When a customer says "the usual, but swap the sauce for mustard," the kitchen doesn't rebuild the sandwich from scratch. They start with the standard recipe and swap just the one thing the customer changed. `makeUser({ role: 'admin' })` is the same: start with the full default User, swap only the `role`. You get a complete, valid object every time without listing every field.
45
Practical

How do you convert a simple JavaScript utility function to TypeScript?

Converting a JavaScript function to TypeScript is mostly about making existing assumptions explicit. The function already works — you're adding a written contract that describes what it accepts and what it returns, so TypeScript can check every caller and catch misuse at compile time instead of at runtime.

The JavaScript starting point — assumptions are invisible:

``js
function formatCurrency(amount, currency) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}

formatCurrency("twenty", "USD"); // no error — produces "$NaN"
formatCurrency(20, "BANANA"); // no error — crashes inside Intl at runtime
`

JavaScript quietly accepts wrong inputs. The bugs surface later, far from where the mistake was made.

Step 1 — Annotate parameter types and the return type:

`ts
function formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}

formatCurrency("twenty", "USD"); // ❌ compile error — amount must be number
`

Adding
number to amount immediately prevents the first class of bug. The return type : string documents the contract and TypeScript verifies it.

Step 2 — Tighten loose types with literal unions:

currency: string still accepts "BANANA". The valid currency codes are a finite known set — express that:

`ts
type SupportedCurrency = "USD" | "EUR" | "GBP" | "INR";

function formatCurrency(amount: number, currency: SupportedCurrency): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}

formatCurrency(20, "USD"); // ✅
formatCurrency(20, "BANANA"); // ❌ compile error — not a SupportedCurrency
`

This is TypeScript's biggest practical win over plain
string — the set of valid values is enforced everywhere the function is called.

Step 3 — Let TypeScript surface hidden inconsistencies:

TypeScript may flag things inside the function body that JavaScript ignored. For example, if you access a property that doesn't exist on the inferred type, or if a conditional branch produces the wrong type. Fix each complaint — they're usually revealing real assumptions the JavaScript was silently trusting.

Step 4 — Add a JSDoc comment for exported utilities:

`ts
/**
* Formats a number as a currency string.
* @param amount - The numeric amount to format.
* @param currency - ISO 4217 currency code.
*/
function formatCurrency(amount: number, currency: SupportedCurrency): string { ... }
`

Editors show this as a hover tooltip at every call site — instant inline documentation.

Rule of thumb:
- Always annotate both parameter types AND the return type, even when TypeScript can infer the return — it's documentation.
- Replace plain
string or number` with literal unions wherever you know the valid values.
- Treat TypeScript's complaints in the function body as signals, not noise — each one is revealing an assumption the JavaScript was hiding.
💡 Plain English: Adding labels to every container in a commercial kitchen. The JavaScript version had unlabelled jars — anything could be poured in and the chef had to remember what went where. TypeScript labels say "this slot takes flour (dry ingredient, measured in grams, one of: Plain/Self-Raising/Wholemeal)." A wrong ingredient is caught at the prep station — not when the baked result comes out wrong at the end. The label doesn't change what the kitchen does. It just makes every mistake visible before anything is mixed.
46
Types

What is the spread operator and how does TypeScript handle its types?

The spread operator (...) copies properties from one object (or elements from one array) into another. In JavaScript you use it constantly for immutable updates and merging. TypeScript's job is to track the resulting type — which properties exist after the spread, what their types are, and which version wins when two spreads define the same property.

Object spread — TypeScript infers the merged type:

``ts
const defaults = { color: "blue", size: 12 };
const custom = { size: 16, bold: true };

const merged = { ...defaults, ...custom };
// TypeScript infers: { color: string; size: number; bold: boolean }
`

Notice:
size appears in both objects. The later spread winsmerged.size is 16 (from custom). TypeScript knows this and types the final property based on the last source. Order matters.

Practical use — immutable updates in React and state management:

This is the most common reason you'll reach for object spread:

`ts
interface User { id: number; name: string; email: string; }

function updateUser(user: User, changes: Partial<User>): User {
return { ...user, ...changes };
// TypeScript verifies the result still satisfies the full User shape
}

const updated = updateUser(currentUser, { name: "Asha" });
// updated.id and updated.email are preserved from 'currentUser'
// updated.name is overwritten with "Asha"
`

This is the pattern behind React's
setState merging and Redux reducers.

Array spread — TypeScript infers the element type:

`ts
const a: number[] = [1, 2];
const b: number[] = [3, 4];

const combined = [...a, ...b]; // TypeScript infers: number[]
const mixed = [...a, "hello"]; // TypeScript infers: (number | string)[]
const withItem = [0, ...a, 999]; // number[]
`

The inferred element type is the union of all element types across all spreads.

Function argument spread — requires a tuple type:

You can unpack an array into a function's argument list with spread. TypeScript checks the types:

`ts
function add(x: number, y: number): number { return x + y; }

const args: [number, number] = [1, 2]; // must be a tuple, not number[]
add(...args); // ✅ TypeScript verifies [number, number] matches (x: number, y: number)

const args2 = [1, 2]; // TypeScript infers number[] (length unknown)
add(...args2); // ❌ a "number[]" could have 0 or 100 elements — use as const or a tuple
`

Annotate as
[number, number] or use as const to tell TypeScript it's exactly two numbers.

Rule of thumb:
- Object spread produces the union of all properties; later spreads override earlier ones for shared keys.
- Array spread produces an array whose element type is the union of all spreaded arrays' element types.
- For spreading into function calls, annotate the array as a tuple so TypeScript can verify arity and types.
- The pattern
{ ...existing, changedField: newValue }` is the idiomatic immutable update — TypeScript fully understands it.
💡 Plain English: Merging two printed forms into one. You lay the first form down and copy all its fields. Then you place the second form on top and copy its fields — where both forms have a field with the same name, the second form's version overwrites the first. The final merged form contains every unique field from both, with conflicts resolved in favour of the later one. TypeScript reads the merged form and knows exactly which fields exist and what type each field has — based on which form it came from last.
47
Async

What happens in TypeScript when you forget to `await` an async function in a test, and how do you spot and fix it?

Forgetting await on an async call is one of the most common bugs in TypeScript test automation. The function call runs but returns a Promise object — not the resolved value you expected. Your code then works on that Promise as if it were the real result, producing silent wrong behaviour or confusing errors that don't point to the real cause.

What "forgetting await" looks like:

``ts
// A helper that creates a user via API and returns the created User object:
async function createUser(data: Partial<User>): Promise<User> {
const response = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
return response.json();
}

// ❌ BUG — missing await:
test('new user can log in', async ({ page }) => {
const user = createUser({ name: 'Alice', role: 'regular' });
// user is Promise<User>, NOT User
await page.goto(
/login?email=${user.email}); // user.email is undefined!
// The URL becomes /login?email=undefined — test fails with a cryptic mismatch
});
`

The test still runs — no crash at the
createUser line — because you CAN put a Promise in a variable. The error only surfaces when you try to use the fields.

How TypeScript warns you — the compile error:

If you have strict mode on (you should), TypeScript flags this immediately:

`
Property 'email' does not exist on type 'Promise<User>'.
`

This tells you exactly what happened: you're accessing
.email on a Promise, not on a User. The fix is one word: add await.

`ts
// ✅ FIXED:
const user = await createUser({ name: 'Alice', role: 'regular' });
await page.goto(
/login?email=${user.email}); // user.email is now a real string
`

The same problem with Playwright's own async methods:

Playwright methods like
page.title(), page.url(), and most locator calls return Promises. Forgetting await on them is especially common with assertions:

`ts
// ❌ Wrong — page.title() returns Promise<string>, not string:
expect(page.title()).toBe('Dashboard'); // always passes or behaves unpredictably

// ✅ Correct — await resolves the Promise first:
expect(await page.title()).toBe('Dashboard');

// ✅ Better — use Playwright's built-in async assertions:
await expect(page).toHaveTitle('Dashboard');
`

How to spot missing await at a glance:
- TypeScript error: "Property 'X' does not exist on type 'Promise<Y>'"
- ESLint rule:
@typescript-eslint/no-floating-promises — warns when a Promise isn't awaited or explicitly ignored
- The test passes unexpectedly, or a navigation goes to a URL containing "undefined"

Rule of thumb:
Any function that returns
Promise<T> must be awaited before you use its value. In Playwright tests, nearly every page.* and locator.* call is async — default to await` and remove it only if you deliberately don't need the result.
💡 Plain English: Ordering a pizza and immediately asking "what toppings does my order have?" before it arrives. You placed the order (called the async function) but didn't wait for delivery (`await`). The "order" exists — it's in the system — but right now it's just a slip of paper (a Promise), not an actual pizza. When you ask about toppings, the answer is "I don't know yet." `await` is you sitting down and waiting until the pizza actually arrives before opening the box. Only then can you tell what's on it.
48
Practical

How do you type an async function that fetches and returns a list of users?

An async fetch function sits right at the TypeScript–runtime boundary: the return type tells every caller what they'll get, but the actual JSON from the server is untyped. Getting this right means being explicit about the data shape AND honest about the fact that the API might not send what you expect.

Step 1 — Define the shape with an interface:

``ts
interface User {
id: number;
name: string;
email: string;
active: boolean;
}
`

Step 2 — Annotate the function with
Promise<User[]>:

An
async function always wraps its return value in a Promise. If you want the caller to get User[], annotate the return type as Promise<User[]>:

`ts
async function getActiveUsers(): Promise<User[]> {
const res = await fetch("/api/users?active=true");

if (!res.ok) {
throw new Error(
API error: ${res.status} ${res.statusText});
// ⚠️ fetch() does NOT throw on 4xx/5xx — you must check res.ok yourself
}

const data = await res.json() as User[];
return data;
}
`

The
as User[] is a type assertion — it tells TypeScript "trust me, this is the shape." It works for internal code but provides no runtime guarantee.

What the caller gains from the typed return:

`ts
const users = await getActiveUsers();
// TypeScript knows: users is User[]

users.forEach(u => console.log(u.name, u.email)); // ✅ autocomplete works
users[0].nonExistent; // ❌ compile error — not on User
`

Every access on the result is checked. Rename a field in the interface and TypeScript finds every caller that needs updating.

Making it production-safe — validate the response with Zod:

as User[] trusts the server blindly. When the API changes shape (field renamed, field dropped), the code compiles but breaks at runtime. For production, validate the response:

`ts
import { z } from "zod";

const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
active: z.boolean(),
});

const UserListSchema = z.array(UserSchema);
type User = z.infer<typeof UserSchema>; // TypeScript type inferred from the schema

async function getActiveUsers(): Promise<User[]> {
const res = await fetch("/api/users?active=true");
if (!res.ok) throw new Error(
API error: ${res.status});
return UserListSchema.parse(await res.json());
// ↑ throws a descriptive ZodError if any item doesn't match — field-level detail
}
`

Rule of thumb:
- Always annotate async fetch functions with an explicit
Promise<T> return type.
- Always check
res.okfetch succeeds on 4xx/5xx, it just gives you an error body.
- Use
as UserType for quick internal code; use Zod or a type guard for production API calls.
- The interface and the Zod schema should match — or better, derive the interface from the schema with
z.infer<typeof Schema>` so there's only one source of truth.
💡 Plain English: A courier promising to deliver "a box of Users." The TypeScript return type is the promise label — it tells the recipient exactly what they're getting. But the label doesn't inspect the contents. `as User[]` means trusting the label completely: if the box actually contains something different, you find out when you open it later. Zod's `parse()` is the customs check at the delivery door — it opens the box, compares every item against the manifest, and refuses the delivery if anything is missing or the wrong type, with a detailed note saying which item failed and why.
49
Types

What is the difference between `type` and `interface` and when do you pick each in practice?

Both type and interface let you describe the shape of an object in TypeScript. For plain object types, they are nearly interchangeable — TypeScript treats them structurally, so a type User and an interface User with the same fields are compatible everywhere. The real differences show up at the edges: things one can do that the other simply cannot.

For plain objects — either works:

``ts
interface User { id: number; name: string; }
type User = { id: number; name: string; };
// Structurally identical. You can use either anywhere the other is accepted.
`

What
interface can do that type cannot:

1 — Declaration merging. Two
interface declarations with the same name automatically combine:

`ts
// In your code:
interface Window { analytics: Analytics; }

// TypeScript merges this with the built-in Window interface automatically.
// This is how libraries extend Express.Request, jest globals, etc.
`

Two
type aliases with the same name are a duplicate identifier error.

2 —
extends for readable inheritance:

`ts
interface Animal { name: string; }
interface Dog extends Animal { breed: string; }
// Dog has: name (inherited) + breed (own)
`

type achieves this with & (intersection), but interface extends reads more clearly for hierarchical models.

What
type can do that interface cannot:

1 — Union types — the most important difference:

`ts
type Status = "active" | "inactive" | "banned"; // ✅ only with type
type Result = { ok: true; data: User } | { ok: false; error: string };
type Nullable = User | null;
`

There is no way to express a union with
interface.

2 — Aliases for primitives, tuples, and computed types:

`ts
type UserID = string; // primitive alias
type Pair = [string, number]; // tuple
type Callback = (err: Error | null) => void; // function signature
type ReadUsers = Readonly<User[]>; // utility type alias
`

None of these are expressible with
interface.

The practical decision rule:

`
Defining an object/class shape? → interface (or type — either works)
Creating a union? → type (required)
Aliasing a primitive, tuple, or fn? → type (required)
Extending a library's type? → interface (declaration merging)
`

Rule of thumb:
- Use
interface for object shapes — especially exported API contracts and class definitions.
- Use
type for unions, primitives, tuples, and computed types.
- When either works, follow your team's convention — consistency matters more than the choice itself.
- The TypeScript team recommends preferring
interface` for objects when either works, because its error messages tend to be more readable.
💡 Plain English: Two hammers that drive the same nail for most jobs. For everyday object shapes, grab whichever one your team's toolbox already uses — the result is identical. But for certain jobs, only one fits: `type` is the only tool that can drive a union (a nail that splits into multiple possible shapes). `interface` is the only tool with a declaration-merging head, which lets you extend types you don't own. Know which jobs require which tool, and use your team's preferred hammer for everything else.
50
Practical

How does TypeScript help you when working with a Playwright or API test automation framework?

Test code has the same type-safety problems as application code — wrong argument order, renamed fields, incorrect property access — except the failures are often more confusing because they surface as mysterious test failures rather than obvious crashes. TypeScript in a test suite catches these mistakes at compile time, before a single test runs.

Benefit 1 — Typed page objects catch mistakes immediately:

Without types, passing the wrong argument type to a page object method silently produces wrong behaviour:

``ts
// Plain JavaScript — no safety:
await loginPage.login(email, password); // works
await loginPage.login(password, email); // ❌ args swapped — test passes wrong credentials silently

// TypeScript — swapped args are a compile error:
class LoginPage {
constructor(private page: Page) {}

async login(email: string, password: string): Promise<void> {
await this.page.fill("#email", email);
await this.page.fill("#password", password);
await this.page.click("button[type=submit]");
}
}
`

The method signature
(email: string, password: string) doesn't prevent swapped strings — but it does catch passing a number, an object, or undefined. Combined with named parameters in a config object, it prevents the swap too.

Benefit 2 — Typed test data factories with
Partial<T>:

A test factory with a typed interface means each test overrides only the fields it cares about, and wrong field names are caught immediately:

`ts
interface User { id: number; name: string; email: string; role: "admin" | "viewer"; }

function createTestUser(overrides: Partial<User> = {}): User {
return { id: 1, name: "Test User", email: "test@x.com", role: "viewer", ...overrides };
}

const admin = createTestUser({ role: "admin" }); // ✅
const bad = createTestUser({ rol: "admin" }); // ❌ typo — compile error
const wrong = createTestUser({ role: "superuser" }); // ❌ not a valid role — compile error
`

Benefit 3 — Typed API responses make assertions safe:

When you call an API in a test, typing the response means you can only assert on properties that actually exist:

`ts
const user = await apiClient.createUser({ name: "Asha", role: "admin" });
// TypeScript knows 'user' is User

expect(user.role).toBe("admin"); // ✅ autocomplete confirms .role exists
expect(user.permisisons).toBe("full"); // ❌ typo — compile error, not a runtime failure
`

Benefit 4 — Safe refactoring across the entire test suite:

This is the biggest long-term win. When the application changes a field name — say
user.role becomes user.accessLevel — TypeScript immediately flags every test that references the old field name. No more hunting through hundreds of test files manually; the compiler gives you the full list.

`ts
// Rename 'role' to 'accessLevel' in the User interface:
// TypeScript instantly highlights every test that used user.role — compiler-guided refactor.
`

Rule of thumb:
- Type every page object method's parameters and return type — it's the minimum viable safety net.
- Use
Partial<T> in test data factories so tests only specify what they care about.
- Type API response objects even in tests — assertions on non-existent fields should be compile errors, not silent
undefined` comparisons.
- The real payoff is during refactors — TypeScript turns "find all usages manually" into "fix the compile errors."
💡 Plain English: A type-checked assembly line. In a plain JavaScript test suite, if a part changes shape — a field is renamed, a parameter order swaps — the assembly line keeps running silently and the faulty product reaches the end. You find out through a failed test run, or worse, through a false-passing test that asserts on `undefined`. With TypeScript, every station on the line knows exactly what shape the part should be. The moment the part changes, every station that touches it immediately flags the mismatch — before anything moves down the line.

Mid-Level (2–5 years)

1
Generics

What are generics, and why use them?

A generic is a way to write a function, class, or interface that works with *any* type — but still gives you full type safety. Instead of hardcoding a specific type, you use a type parameter (usually written <T>) as a placeholder, and TypeScript fills it in from context when you call the code.

Why they exist — the problem without generics:

Without generics, you face a choice:

``ts
// Option A: write a version per type (tedious, not scalable)
function firstNumber(arr: number[]): number { return arr[0]; }
function firstString(arr: string[]): string { return arr[0]; }

// Option B: use any (no type safety — defeats the purpose of TypeScript)
function first(arr: any[]): any { return arr[0]; }

const val = first([1, 2, 3]);
val.toUpperCase(); // ❌ TypeScript allows this — no error — but it crashes at runtime
`

With a generic:

`ts
function first<T>(arr: T[]): T {
return arr[0];
}

const n = first([1, 2, 3]); // TypeScript infers T = number
n.toFixed(2); // ✅ number method — works
n.toUpperCase(); // ❌ TypeScript catches this at compile time

const s = first(["a", "b"]); // TypeScript infers T = string
s.toUpperCase(); // ✅ string method — works
`

TypeScript infers
T automatically from your input — you rarely need to write first<number>([...]) explicitly.

Real-world use case — a typed API fetch helper:

`ts
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json() as T;
}

// Usage — TypeScript knows exactly what comes back:
const user = await fetchJson<{ id: number; name: string }>("/api/user/1");
console.log(user.name); // ✅ autocomplete works; typos caught at compile time
`

Rule of thumb:
- Use a generic when the function's logic doesn't care about the specific type, but the *caller* does.
- If you're tempted to write
any`, ask yourself: "could this be a generic instead?"
- Generics preserve the type relationship between inputs and outputs — that's their superpower.
💡 Plain English: Think of a generic like a resealable, labelled container at a meal-prep service. The container design (function) is always the same — it holds one portion, seals airtight, and stacks neatly. But what goes *inside* — chicken, tofu, or pasta — is decided by the customer (the caller). The label (`<T>`) is filled in at packing time. You get the same container behaviour regardless of the contents, and nobody accidentally serves you pasta from a container labelled "chicken" — TypeScript checks the label.
2
Generics

What is a generic constraint?

A generic constraint limits which types can be passed as a type parameter. You write it as <T extends SomeType>. Without it, TypeScript treats T as completely unknown — you can't access any properties on it, because they might not exist.

The problem without a constraint:

``ts
function getLength<T>(x: T): number {
return x.length; // ❌ Error: Property 'length' does not exist on type 'T'
}
`

TypeScript refuses because
T could be number, boolean, or anything — and those don't have .length.

With a constraint:

`ts
function getLength<T extends { length: number }>(x: T): number {
return x.length; // ✅ safe — T is guaranteed to have a length property
}

getLength("hello"); // ✅ string — has .length
getLength([1, 2, 3]); // ✅ array — has .length
getLength(42); // ❌ compile-time error — number has no .length
`

The constraint says: "I'll accept any type, *as long as it has at least these members*."

A more practical example — constraining to a known interface:

`ts
interface HasId { id: number }

function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}

// Works with any object that has an id field:
findById([{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], 1);
findById([{ id: 10, price: 99 }], 10);
`

Using
keyof as a constraint — a very common pattern:

`ts
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user = { id: 1, name: "Alice" };
getProperty(user, "name"); // ✅ returns string
getProperty(user, "age"); // ❌ "age" is not a key of user — caught at compile time
``

Rule of thumb:
- Add a constraint when your generic function needs to *use* something from the type — a property, method, or index.
- The constraint is the minimum contract: "give me anything that has at least this shape."
💡 Plain English: Imagine a bouncer at a club with a dress code: "Any guest is welcome — *as long as they're wearing shoes and a collared shirt.*" The bouncer (constraint) doesn't care about hair colour, height, or anything else. He only checks the minimum requirement. If you have shoes and a collar, you're in. If you're barefoot, you get turned away — even if you're otherwise fine. The `extends` keyword is that dress code.
3
Utility Types

What are utility types? Name the common ones.

Utility types are built-in TypeScript generics that *transform* an existing type into a new one. Instead of rewriting an interface from scratch, you derive a variant from it — keeping the two in sync automatically.

Why they exist:

Imagine you have a User type. You need:
- A version for PATCH requests where all fields are optional.
- A public version without the password hash.
- A version for forms that only has name and email.

Without utility types, you'd write three separate interfaces. If User changes, all three need manual updates — a maintenance headache. Utility types solve this by deriving variants automatically.

The most common utility types:

``ts
interface User {
id: number;
name: string;
email: string;
passwordHash: string;
}

// Partial — all fields become optional (great for PATCH/update payloads)
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string; passwordHash?: string }

// Required — all fields become required (opposite of Partial)
type StrictUser = Required<User>;

// Readonly — all fields become read-only (great for immutable config objects)
type FrozenUser = Readonly<User>;
const u: FrozenUser = { id: 1, name: "Alice", email: "a@b.com", passwordHash: "x" };
u.name = "Bob"; // ❌ Error: cannot assign to 'name' — it is read-only

// Pick — keep only the listed fields
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }

// Omit — remove the listed fields
type SafeUser = Omit<User, "passwordHash">;
// { id: number; name: string; email: string }
`

Real-world use case — REST API types:

`ts
interface Product { id: number; name: string; price: number; stock: number; }

// POST /products — caller provides everything except id (server generates it)
type CreateProductDto = Omit<Product, "id">;

// PATCH /products/:id — caller provides only what changed
type UpdateProductDto = Partial<Omit<Product, "id">>;

// GET /products — public list view hides stock levels
type ProductSummary = Pick<Product, "id" | "name" | "price">;
`

One source type, three derived variants — all stay in sync if
Product changes.

Rule of thumb:
-
Partial — update payloads, form state, optional config.
-
Omit — strip server-managed or sensitive fields.
-
Pick — narrow a type to a subset for display or passing around.
-
Readonly — config objects, props that must not be mutated.
-
Required` — enforce that all optional fields were filled in before saving.
💡 Plain English: Think of utility types as cookie cutters for types. You have one big batch of dough (the base `User` type). Instead of making each cookie shape from scratch, you press different cutters into the same dough: `Partial` cuts a "soft edges" version, `Omit` cuts out a hole where the secret field was, `Pick` cuts out just the shape you need. Every cutter works on the same dough — if the dough recipe changes, all your cookies update automatically.
4
Utility Types

What is the Record<K, V> type?

Record<K, V> is a utility type that constructs an object type where all keys are of type K and all values are of type V. It's the right tool whenever you're building a dictionary, lookup map, or mapping between a fixed set of keys and some value type.

Why it exists — the alternative is worse:

``ts
// Without Record — you lose key exhaustiveness checking:
const statusLabels: { [key: string]: string } = {
pending: "Waiting",
active: "Running",
// Forgot "failed"? TypeScript doesn't care — no error
};

// With Record over a union — TypeScript enforces every key is present:
type Status = "pending" | "active" | "failed";
const statusLabels: Record<Status, string> = {
pending: "Waiting",
active: "Running",
// ❌ Error: Property 'failed' is missing
// ↑ TypeScript catches the omission immediately
};
`

Three common patterns:

`ts
// 1. Simple string-key dictionary (dynamic keys, no exhaustiveness guarantee)
const userAges: Record<string, number> = { alice: 30, bob: 25 };

// 2. Union-key map (exhaustiveness guaranteed — all keys required)
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
const methodColors: Record<HttpMethod, string> = {
GET: "green",
POST: "blue",
PUT: "orange",
DELETE: "red",
};

// 3. Lookup table (e.g. caching API results by id)
const userCache: Record<number, User> = {};
userCache[1] = { id: 1, name: "Alice" };
`

Real-world use case — role-to-permissions map:

`ts
type Role = "admin" | "editor" | "viewer";
type Permission = "read" | "write" | "delete";

const rolePermissions: Record<Role, Permission[]> = {
admin: ["read", "write", "delete"],
editor: ["read", "write"],
viewer: ["read"],
};

function canDo(role: Role, action: Permission): boolean {
return rolePermissions[role].includes(action);
}
`

If a new role is added to the
Role union, TypeScript immediately flags that rolePermissions is incomplete — the map can never fall out of sync.

Rule of thumb:
- Use
Record<Union, V> when you want to be forced to handle every key in a union.
- Use
Record<string, V> or Record<number, V> for dynamic dictionaries where the key set isn't known at compile time.
- Prefer
Record<K, V> over { [key: string]: V }` whenever K is a union — you get exhaustiveness checking for free.
💡 Plain English: Imagine a wall of labelled pigeonholes at a hotel reception — one slot per room number. `Record<RoomNumber, GuestInfo>` is the TypeScript version of that wall. If you're building the board and you know the hotel has rooms 101, 102, and 103 (a union type), TypeScript checks that you've drilled a slot for every room before you open for business. Forget room 103? Compile error. Every key accounted for, every slot ready.
5
Narrowing

What is type narrowing?

Type narrowing is TypeScript's ability to refine a broad type to a more specific one inside a conditional branch. When you check something about a value — "is this a string?", "does this have a .name property?", "is this null?" — TypeScript tracks that check and adjusts what it believes the type is *inside* that branch.

Why it matters — union types are everywhere:

``ts
function format(x: string | number): string {
return x.toFixed(2); // ❌ Error: 'toFixed' does not exist on type 'string'
}
`

TypeScript sees
x as string | number — it can't let you call .toFixed() because strings don't have that method. You must narrow first.

The four main ways to narrow:

`ts
// 1. typeof — works for primitives: string, number, boolean, bigint, symbol, undefined, function
function format(x: string | number): string {
if (typeof x === "string") {
return x.toUpperCase(); // TS: x is string here
}
return x.toFixed(2); // TS: x is number here (the only remaining option)
}

// 2. instanceof — works for class instances
function printDate(d: Date | string) {
if (d instanceof Date) {
console.log(d.toISOString()); // TS: d is Date
} else {
console.log(d.toUpperCase()); // TS: d is string
}
}

// 3. in — checks whether a property exists on an object
type Cat = { meow: () => void };
type Dog = { bark: () => void };

function makeSound(pet: Cat | Dog) {
if ("meow" in pet) {
pet.meow(); // TS: pet is Cat
} else {
pet.bark(); // TS: pet is Dog
}
}

// 4. Equality / truthiness — narrows away null and undefined
function greet(name: string | null) {
if (name !== null) {
console.log("Hello, " + name.toUpperCase()); // TS: name is string (not null)
}
}
`

Real-world use case — handling an API response that can succeed or fail:

`ts
type ApiResult =
| { status: "ok"; data: User }
| { status: "error"; message: string };

function handleResult(result: ApiResult) {
if (result.status === "ok") {
console.log(result.data.name); // TS: result is the success variant
} else {
console.error(result.message); // TS: result is the error variant
}
}
`

This pattern — narrowing by a shared discriminant field — is called a discriminated union and is one of TypeScript's most powerful features.

Rule of thumb:
-
typeof for primitives, instanceof for classes, in for object shapes, equality checks for literals and null.
- TypeScript narrows automatically — you don't call any API; you just write a normal
if` check.
- After the last branch, TypeScript uses the process of elimination — if something passes all negative checks, it knows the remaining type.
💡 Plain English: Think of type narrowing like a security checkpoint with multiple lanes. The guard (TypeScript) sees a crowd of people (`string | number | null`) arriving. At the first checkpoint: "Step left if you're carrying a string instrument." Now the left lane is all strings — they can go to the string-instrument stage. The rest continue and the guard narrows further. No one is asked to prove they're a guitarist until they're already in the guitarist lane. TypeScript does the same — it tracks which "lanes" your value has been sorted into and gives it the exact right type for that branch.
6
Narrowing

What is a user-defined type guard?

A user-defined type guard is a function that tells TypeScript "if this returns true, treat this value as a specific type." You signal it by writing value is SomeType as the return type instead of boolean.

Why it exists:
TypeScript automatically narrows simple checks like typeof x === 'string', but it cannot figure out what shape a custom object is from a generic helper like hasOwnProperty(obj, 'data'). A type guard bridges that gap — you write the runtime check once, and TypeScript trusts the result everywhere the function is called.

Walked-through example — typing API responses in tests:

``ts
interface SuccessResponse {
status: 'success';
data: { userId: number; email: string };
}

interface ErrorResponse {
status: 'error';
message: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

// The
response is SuccessResponse return type is the type guard
function isSuccessResponse(response: ApiResponse): response is SuccessResponse {
return response.status === 'success';
}

// In a Playwright API test:
const raw = await request.get('/api/user/1');
const body: ApiResponse = await raw.json();

if (isSuccessResponse(body)) {
// TypeScript knows body is SuccessResponse inside this block
expect(body.data.email).toContain('@'); // no type error
} else {
// TypeScript knows body is ErrorResponse here
throw new Error(
API failed: ${body.message});
}
`

Real-world QA use case:
Any time your API can return success or error shapes, a type guard makes your assertion code safe — you access
.data only where it exists, and .message only where that exists. No casting with as`, no runtime surprises.

Rule of thumb: Write a type guard whenever TypeScript can't narrow a union automatically from your runtime check — write the logic once, get compile-time narrowing everywhere it's called.
💡 Plain English: A venue bouncer who checks your wristband and waves you through the VIP door. Once he signals "this person is VIP," every staff member inside treats you as VIP automatically — nobody re-checks at the bar or the backstage entrance.
7
Types

What is a discriminated (tagged) union?

A discriminated union is a set of object types that all share one common field with a literal value — that field is the "discriminant." TypeScript reads it and automatically narrows to the right shape without any casting.

Why it exists:
When a value can be one of several different shapes (API responses, test results, page states), you need a reliable way to tell them apart at runtime while keeping TypeScript informed. The discriminant field is that reliable signal — TypeScript uses it to guarantee exhaustive handling and catch missed cases at compile time.

Walked-through example — test result states:

``ts
type TestResult =
| { status: 'passed'; durationMs: number }
| { status: 'failed'; durationMs: number; errorMessage: string }
| { status: 'skipped'; reason: string };

function summarize(result: TestResult): string {
switch (result.status) {
case 'passed':
// TypeScript knows only durationMs is available here
return
Passed in ${result.durationMs}ms;
case 'failed':
// TypeScript knows errorMessage exists in this branch
return
FAILED: ${result.errorMessage} (${result.durationMs}ms);
case 'skipped':
// TypeScript knows only reason is available here
return
Skipped — ${result.reason};
}
}
`

If you add a fourth status (e.g.
'timedout') to the union without handling it in the switch, TypeScript will warn you — nothing slips through.

Real-world QA use case:
Model API responses as a discriminated union (
{ status: 'ok'; data: T } | { status: 'error'; code: number; message: string }). Your assertion helpers can safely branch on status and access fields that only exist on that branch — no as casts, no optional chaining on fields that should always be there.

Rule of thumb: Any time you have a union of object types, add a literal
status or kind` field — it makes narrowing automatic and your switch/if branches exhaustive.
💡 Plain English: Traffic lights — they share one "state" field (red/amber/green) and each state implies completely different behavior. You don't inspect every wire inside the light; the color alone tells you everything you need to do next.
8
Types

What does `keyof` do?

keyof T produces a union of all the property names of type T. It lets you write functions that only accept valid keys of an object — TypeScript catches typos and nonexistent properties at compile time.

Why it exists:
Without keyof, you'd use string for property-name parameters and lose all safety — TypeScript can't tell whether "baseURL" is a valid key or a typo for "baseUrl". keyof narrows the type down to only the keys that actually exist on the object.

Walked-through example — type-safe config lookup in tests:

``ts
interface TestConfig {
baseUrl: string;
timeout: number;
retries: number;
}

// K must be a real key of TestConfig — no arbitrary strings allowed
function getConfigValue<K extends keyof TestConfig>(
config: TestConfig,
key: K
): TestConfig[K] { // return type matches the key (string or number)
return config[key];
}

const config: TestConfig = {
baseUrl: 'https://staging.example.com',
timeout: 30000,
retries: 2,
};

const url = getConfigValue(config, 'baseUrl'); // type: string ✓
const timeout = getConfigValue(config, 'timeout'); // type: number ✓
getConfigValue(config, 'baseURL'); // TS error: 'baseURL' not in TestConfig ✓
`

TestConfig[K] is an *indexed access type* — it looks up the value type for key K, so the return type is always correct without any casting.

Real-world QA use case:
Test helpers that read values from environment config, page object properties, or API response fields often take a field name as a parameter. Using
keyof means a test author can't pass a typo'd field name — the error shows at development time, not at 3 AM in CI.

Rule of thumb: Any time a function takes a property name as an argument, replace
string with keyof TheType` — you get compile-time key validation for free.
💡 Plain English: A hotel keycard system that only accepts room numbers that actually exist in the building. You can't check in to room 9999 — the system knows the exact list of valid rooms and rejects anything not on it before you even reach the lift.
9
Types

What is the `typeof` type query (the type-level one)?

In a type position, typeof someVariable gives you the TypeScript type of that variable — without you having to write an interface manually. The value becomes the single source of truth; the type stays in sync automatically.

Why it exists:
In test automation, you often create objects first (config, fixtures, response shapes) and then want to type-check functions against them. Writing a separate interface that mirrors the object is duplication — if the object changes, you have to update the interface too. typeof eliminates that duplication.

Walked-through example — deriving a type from a test fixture:

``ts
// Define the fixture value first — no interface needed alongside it
const defaultUser = {
id: 1,
email: 'tester@example.com',
role: 'admin' as const, // 'as const' keeps the literal type 'admin'
};

// Derive the type from the value — stays in sync if the object changes
type User = typeof defaultUser;
// Equivalent to: { id: number; email: string; role: 'admin' }

// Factory function typed against the derived type
function createUser(overrides: Partial<typeof defaultUser>): typeof defaultUser {
return { ...defaultUser, ...overrides };
}

const guest = createUser({ email: 'guest@example.com' }); // type-checked ✓
createUser({ role: 'superadmin' }); // TS error: not assignable to 'admin' ✓
`

Note: this is different from the *runtime*
typeof operator that returns a string like "number" or "object". The type-level typeof only exists at compile time — it disappears in the compiled JS.

Real-world QA use case:
Playwright fixtures, test data factories, and environment config objects are all good candidates. Define the object once, use
typeof to derive the type for factory functions and helper parameters — no interface duplication.

Rule of thumb: Reach for
typeof` when you already have a value and need a type to match it — let the value be the blueprint instead of maintaining both in parallel.
💡 Plain English: Taking a photo of a finished prototype and using that photo as the official spec. You don't re-draw the blueprint from scratch — the photo is the record of what was built, and everyone references it directly.
10
Types

What is an index signature?

An index signature describes an object where you don't know the exact key names ahead of time, but you know every key will be a string (or number) and every value will be a specific type. It types the pattern — "any key you look up returns X" — rather than listing every individual key.

Why it exists:
HTTP headers, query parameters, environment variables, and test data lookup tables are all dictionaries — you know the value type but you can't enumerate every key name at design time. An index signature types this pattern without forcing you to list every possibility.

Walked-through example — typing HTTP headers in a Playwright helper:

``ts
// The [headerName: string]: string part is the index signature
interface HttpHeaders {
[headerName: string]: string;
}

// A reusable POST helper that accepts extra headers
async function postJson(
request: APIRequestContext,
url: string,
body: unknown,
extraHeaders: HttpHeaders = {}
): Promise<APIResponse> {
return request.post(url, {
data: body,
headers: {
'Content-Type': 'application/json',
...extraHeaders, // any string→string additions accepted
},
});
}

// Test usage — any header key works, value must be a string
await postJson(request, '/api/orders', payload, {
'X-Test-Run-Id': 'run-42',
'Authorization':
Bearer ${token},
});

// TypeScript catches wrong value types:
await postJson(request, '/api/orders', payload, {
'X-Retry-Count': 3, // Error: number is not assignable to string ✓
});
`

Index signatures and specific named properties can coexist, but every named property's type must be assignable to the index signature's value type.

Real-world QA use case:
Use index signatures for header maps, environment variable objects (
Record<string, string> is shorthand), query-param builders, or any test-data lookup table where keys are dynamic.

Rule of thumb: Use an index signature (or
Record<string, ValueType>`) when you know the shape of values but not the names of keys — it types the dictionary pattern without enumerating every entry.
💡 Plain English: A physical dictionary — it doesn't list every word in the introduction. It just promises: "whatever word you look up, you'll get a definition." The structure is the contract, not the individual entries.
11
Practical

How do you use TypeScript's `Record<K, V>` type to map test roles to their expected permissions in a data-driven test?

Record<K, V> creates an object type where every key is of type K and every value is of type V. In test automation it is perfect for building a lookup table that drives many test scenarios from a single source — especially for role-based access control tests.

Why it matters:
Without Record, you write separate test cases for each role, repeating the same logic. Record lets you define all roles and expected outcomes in one place, then loop over them. TypeScript enforces that every role has an entry — if you add a new role and forget to add it to the table, the code won't compile.

Step 1 — Define the types:

``ts
type Role = 'admin' | 'regular' | 'guest';

interface Permissions {
canAccessDashboard: boolean;
canEditUsers: boolean;
canDeleteData: boolean;
}
`

Step 2 — Build the lookup table:

`ts
const rolePermissions: Record<Role, Permissions> = {
admin: { canAccessDashboard: true, canEditUsers: true, canDeleteData: true },
regular: { canAccessDashboard: true, canEditUsers: false, canDeleteData: false },
guest: { canAccessDashboard: false, canEditUsers: false, canDeleteData: false },
};
`

TypeScript flags a compile error if you: miss a role key, misspell a role, or put the wrong value shape in any entry.

Step 3 — Drive a Playwright test from the table:

`ts
const roles = Object.keys(rolePermissions) as Role[];

for (const role of roles) {
test(
${role} sees correct nav options, async ({ page }) => {
const user = makeUser({ role });
await loginAs(page, user);
const expected = rolePermissions[role];

if (expected.canAccessDashboard) {
await expect(page.locator('[data-testid="dashboard-link"]')).toBeVisible();
} else {
await expect(page.locator('[data-testid="dashboard-link"]')).not.toBeVisible();
}
});
}
`

One loop generates three fully-typed tests. Add a new value to
Role and TypeScript forces you to add it to rolePermissions before the code compiles.

Rule of thumb:
Use
Record<Role, X>` any time you have a fixed set of keys (roles, environments, browsers, statuses) and a data object per key. It is the cleanest way to write data-driven tests with compile-time completeness checking.
💡 Plain English: A seating chart at a restaurant — every table (role) has a labelled reservation card (permissions). If you add a new table and forget to put a card on it, the host won't let service start. `Record` is that seating chart: TypeScript is the host who refuses to open until every key has its value.
12
Async

How do you type async functions and Promises?

An async function always returns a Promise. You type the value that the promise will eventually resolve to — not the Promise wrapper itself. await unwraps the Promise<T> back to T so the rest of your code sees the resolved value directly.

Why it matters:
Every Playwright API call (page.goto(), request.get(), locator.textContent()) returns a Promise. Typing the resolved value correctly means TypeScript knows exactly what you're working with after await — and catches mistakes like accessing .data on a response that only has .body.

Walked-through example — typed API call in a Playwright test:

``ts
interface UserResponse {
id: number;
email: string;
role: 'admin' | 'user';
}

// Return type is Promise<UserResponse> — type the resolved value, not the wrapper
async function fetchUser(
request: APIRequestContext,
userId: number
): Promise<UserResponse> {
const response = await request.get(
/api/users/${userId});
// response.json() returns Promise<unknown> — cast to your known shape
return response.json() as UserResponse;
}

// Usage in a test — await unwraps Promise<UserResponse> → UserResponse
const user = await fetchUser(request, 42);
expect(user.role).toBe('admin'); // TypeScript knows .role exists ✓
expect(user.score).toBe(100); // TS error: 'score' doesn't exist ✓
`

Awaited<T> is a utility type that extracts the resolved type from a Promise<T> — useful when you want to derive a type from an async function's return type without calling it.

Real-world QA use case:
Type every helper that wraps an HTTP call with
Promise<YourResponseShape>. This means autocomplete works on response fields in tests, and TypeScript catches the moment an API response shape changes and your test assertions no longer match.

Rule of thumb: In the return type annotation, always write the resolved value type (
Promise<UserResponse>), never Promise<any>` — that's where the safety lives.
💡 Plain English: A Promise is a ticket at a deli counter. `Promise<UserResponse>` says "a UserResponse sandwich will be ready." `await` is waiting at the counter and taking delivery — after that you have the actual sandwich, not a ticket.
13
Async

How do you handle and type errors in async TypeScript?

You wrap await calls in try/catch. The critical detail: TypeScript types the caught value as unknown — not Error — because JavaScript lets you throw anything (a string, a number, a plain object). You must narrow before accessing any properties on it.

Why it matters:
If you write catch (e) { console.log(e.message) } without narrowing, TypeScript gives you an error — because e might not be an Error object and .message might not exist. This forces you to handle unexpected error shapes rather than silently crashing.

Walked-through example — API call in a Playwright test:

``ts
async function deleteUser(request: APIRequestContext, userId: number): Promise<void> {
try {
const response = await request.delete(
/api/users/${userId});

if (!response.ok()) {
// Throw a proper Error so callers can catch and inspect
throw new Error(
Delete failed: ${response.status()} ${response.statusText()});
}
} catch (e) {
// e is typed unknown — narrow before accessing anything
if (e instanceof Error) {
console.error(
deleteUser error: ${e.message});
throw e; // re-throw so the test fails with context
}
// Non-Error throw (rare but possible from third-party code)
throw new Error(
deleteUser: unexpected error — ${String(e)});
}
}
`

Common pitfall:
Promise.reject() and Playwright's network errors both reach your catch block — but a Playwright timeout throws a TimeoutError (subclass of Error), while a DNS failure throws a plain Error. Always narrow with instanceof Error before reading .message.

Real-world QA use case:
Wrap API calls in helpers that catch, log, and re-throw with context. Tests should fail with a clear message like
"POST /api/login failed: 401 Unauthorized" — not "Cannot read properties of undefined."

Rule of thumb: Always narrow
catch (e) with instanceof Error before using .message` — TypeScript forces this, and it protects you from unexpected thrown values in production code paths.
💡 Plain English: An unaddressed package returned to sender — you don't know if it contains a broken item, a wrong item, or a handwritten note of complaint. You have to open it and inspect before you know how to respond.
14
Modules

How do import/export work — named vs default?

TypeScript has two export styles. Named exports let a file export many things by name — callers import using those exact names. Default exports let a file export one main thing — callers can import it under any name they choose.

Why it matters in test projects:
Test utilities, page objects, and fixture files often export multiple helpers. Named exports make it immediately clear what's available and let editors auto-import the right thing. Default exports are fine for single-class files but get confusing when everyone renames the import differently.

Walked-through example — a Playwright helper module:

``ts
// helpers/api.ts — named exports (preferred for multi-export files)
export async function getAuthToken(request: APIRequestContext): Promise<string> {
const res = await request.post('/api/login', { data: { user: 'test', pass: 'test' } });
const body = await res.json();
return body.token;
}

export async function deleteUser(request: APIRequestContext, id: number): Promise<void> {
await request.delete(
/api/users/${id});
}

// In a test file — import by exact name, IDE autocomplete fills these in
import { getAuthToken, deleteUser } from '../helpers/api';
`

`ts
// pages/LoginPage.ts — default export (reasonable for a single-class file)
export default class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string) { /* ... */ }
}

// In a test — caller chooses the local name
import LoginPage from '../pages/LoginPage';
`

The rename problem with defaults: nothing stops one test file from writing
import LP from '../pages/LoginPage' and another writing import Login from '../pages/LoginPage'`. Both work, but the codebase becomes inconsistent. Named exports prevent this.

Real-world QA use case:
Playwright test projects typically use named exports for shared fixtures, helper functions, and constants. Default exports are common for Page Object classes where each file holds exactly one class.

Rule of thumb: Use named exports for utility files and anything with more than one export. Default exports are fine for single-class Page Object files — but pick one convention and stick to it across the project.
💡 Plain English: Named exports are items on a menu with fixed names — you order "the Caesar salad" and everyone knows exactly what you mean. A default export is "the chef's special" — every table calls it something different, which works fine until you need to talk about it with a colleague.
15
Types

What does `as const` do?

as const tells TypeScript to treat a value as the most specific, read-only type possible — instead of widening it to a general type. An array of strings stays as a tuple of exact string literals; an object's values stay as literal types instead of broad primitives.

Why it matters:
TypeScript's default behavior is to widen: ["smoke", "regression"] becomes string[], not readonly ["smoke", "regression"]. That widening throws away the specific values. as const preserves them so you can derive precise union types and prevent accidental mutation.

Walked-through example — typed test tags:

``ts
// Without as const — TypeScript widens to string[]
const tags = ['smoke', 'regression', 'critical'];
type Tag = typeof tags[number]; // string (useless — any string allowed)

// With as const — TypeScript keeps the literals
const TEST_TAGS = ['smoke', 'regression', 'critical'] as const;
type Tag = typeof TEST_TAGS[number]; // 'smoke' | 'regression' | 'critical'

// Now this function only accepts valid tag values
function tagTest(name: string, tag: Tag): void {
console.log(
[${tag}] ${name});
}

tagTest('Login flow', 'smoke'); // ✓
tagTest('Login flow', 'slow'); // TS error: not assignable to Tag ✓
`

`ts
// as const on an object locks every value to its literal type
const ENV_URLS = {
staging: 'https://staging.example.com',
prod: 'https://app.example.com',
} as const;

type Env = keyof typeof ENV_URLS; // 'staging' | 'prod'
`

Real-world QA use case:
Use
as const for test tag enumerations, environment name maps, HTTP method constants, and any config object where the exact values matter. It's lighter than a full TypeScript enum and works better with typeof.

Rule of thumb: Reach for
as const` when you want TypeScript to remember the exact values in an array or object, not just the general type — it turns raw data into a typed constant you can derive unions from.
💡 Plain English: Laminating a printed schedule — once laminated, nobody can write new sessions on it, and you can read the exact times rather than just "some times." The original values are locked in and visible forever.
16
Types

What is the non-null assertion operator (`!`)?

The non-null assertion operator (!) is a postfix operator that tells TypeScript "I guarantee this value is not null or undefined right here." TypeScript removes those possibilities from the type and stops warning about them — but it does nothing at runtime. If you're wrong, you get a runtime crash.

Why it exists:
Sometimes TypeScript infers that a value might be null — because a DOM query, an optional field, or an environment variable might not exist — but you have context the compiler doesn't. The ! operator lets you say "trust me on this one" without adding a runtime check.

Walked-through example — Playwright environment config:

``ts
// process.env values are string | undefined — TypeScript insists you handle undefined
const baseUrl: string | undefined = process.env.BASE_URL;

// Option 1: proper null check (preferred — fails fast with a clear message)
if (!baseUrl) {
throw new Error('BASE_URL environment variable is required');
}
// baseUrl is now string here — TypeScript narrowed it

// Option 2: non-null assertion (use only when you're certain it's set)
const url: string = process.env.BASE_URL!; // asserts it's not undefined
`

`ts
// Common misuse to avoid — map() returns T | undefined when find() is used
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const found = users.find(u => u.id === 1)!; // ! here is reasonable if you know it exists
found.name; // safe if the assertion was correct
`

When
! is appropriate vs when to use a real check:
- Use a real check (
if (!x) throw new Error(...)) for environment variables, external config, and anything that might genuinely be absent — fail with a meaningful message instead of a cryptic Cannot read properties of undefined.
- Use
! for values you can prove exist from surrounding context — e.g., after a find() on a list you just created, or in a test fixture setup block where the value is always assigned before use.

Real-world QA use case:
Playwright's
page.locator() returns a Locator, not Locator | null — so ! isn't needed there. But optional fields in API response types (user.address?.city) or test fixture optional properties are common places where teams reach for ! when they should use a proper guard.

Rule of thumb: Treat
! like a loan — use it only when you're certain you can repay it. Every !` is a pinky promise to the compiler; add a comment explaining why the value is guaranteed if it's not obvious.
💡 Plain English: Signing a legal waiver before an activity — you're personally guaranteeing there's no risk. The venue (TypeScript) stops warning you, but if you were wrong, the consequences fall entirely on you at runtime.
17
Practical

A shared Playwright helper should accept either a CSS selector string or a `Locator` object as its target. How do you type this correctly?

This is one of the most common typing challenges when building a shared Playwright helper layer. Some callers have a CSS selector string ready; others already hold a Locator from a previous call. The solution is a union parameter type combined with a type guard that resolves both to a Locator at the top of the function.

Why this situation comes up:
In a Page Object you get a Locator from a method. In a quick one-off helper you might just pass a selector string. You want one function that handles both without forcing callers to convert.

The typed helper — union + typeof check:

``ts
import { Page, Locator } from '@playwright/test';

async function safeClick(page: Page, target: string | Locator): Promise<void> {
const locator = typeof target === 'string'
? page.locator(target) // resolve string to Locator once
: target; // already a Locator — use as-is

await locator.click();
}
`

The
typeof check is a type guard — inside the ternary's true branch TypeScript knows target is string; in the false branch it knows it is Locator. Resolving to a single Locator at the top means the rest of the function only deals with one type.

Calling the helper — both forms work:

`ts
// CSS selector string:
await safeClick(page, '#submit-button');
await safeClick(page, '[data-testid="login-btn"]');

// Playwright Locator:
const btn = page.getByRole('button', { name: 'Submit' });
await safeClick(page, btn);
`

Extending to a helper base class:

`ts
class ActionHelper {
constructor(private page: Page) {}

private resolve(target: string | Locator): Locator {
return typeof target === 'string' ? this.page.locator(target) : target;
}

async fill(target: string | Locator, value: string): Promise<void> {
await this.resolve(target).fill(value);
}

async getText(target: string | Locator): Promise<string> {
return this.resolve(target).innerText();
}
}
`

A private
resolve() method handles the conversion once — all other methods just call it.

Rule of thumb:
Use
string | Locator for any helper that wraps Playwright interactions. Resolve to Locator immediately via a ternary — don't repeat the typeof` check deeper in the function body.
💡 Plain English: A GPS that accepts either a typed address or a pin dropped on the map. It doesn't care which form you give it — it converts both to coordinates internally, then gives you the same directions. The `string | Locator` union is "address or pin"; resolving to a `Locator` is converting to coordinates before driving.
18
Types

Enums or a union of string literals — which should you use?

Both model a fixed set of allowed values, but they behave differently. A union of string literals is a pure compile-time type — zero runtime footprint, easy to narrow, and works naturally with as const. An enum generates actual runtime JavaScript — an object you can iterate over and look up by value — which adds weight but occasionally that's what you need.

Why the choice matters:
In test automation, you're defining things like test levels, environments, browser names, and API status categories. These are usually just labels — you compare strings, you don't iterate. A literal union is the lighter, simpler fit. Enums become worth the tradeoff when you genuinely need to iterate over all values at runtime (e.g., run tests for every environment in a loop from the enum keys).

Walked-through example — test environment in a Playwright config:

``ts
// Option A: literal union (preferred for most QA use cases)
type Environment = 'staging' | 'uat' | 'production';

function getBaseUrl(env: Environment): string {
const urls: Record<Environment, string> = {
staging: 'https://staging.example.com',
uat: 'https://uat.example.com',
production: 'https://app.example.com',
};
return urls[env];
}

getBaseUrl('staging'); // ✓
getBaseUrl('dev'); // TS error: not assignable to Environment ✓
`

`ts
// Option B: enum (useful when you need to iterate over all values)
enum Browser { Chromium = 'chromium', Firefox = 'firefox', WebKit = 'webkit' }

// You can iterate enum values at runtime — hard with a literal union
Object.values(Browser).forEach(b => console.log(
Running tests in: ${b}));
`

The key practical differences:
- Literal unions: no runtime cost, tree-shaken away, plays well with
typeof and as const
- Enums: generate runtime code, allow iteration and reverse lookups, slightly more verbose to define

Real-world QA use case:
Use literal unions for test tags, status codes, environment names, and HTTP methods. Reach for enums when you need to loop over all values at test-runner setup time (e.g., running a smoke suite across all browsers in a
for...of` over enum values).

Rule of thumb: Default to a literal union. Switch to an enum only when you need to iterate over every value at runtime — that's the one thing unions can't do cleanly.
💡 Plain English: A literal union is a sticky label on a box — it tells you what's allowed, weighs nothing, and disappears once the label's job is done. An enum is a physical key ring of tokens — heavier, but you can actually count the tokens, hand them out, and loop through them at any time.
19
Functions

How do you type the callback you pass to `page.on("request", handler)` in Playwright — for example, to log or assert on outgoing API calls during a test?

Playwright fires network events during test execution and lets you hook into them with page.on(). The callback receives a strongly-typed Request object — importing and using this type gives you full autocomplete on URL, method, headers, and post body, and prevents accessing properties that don't exist.

Why the types matter here:
Without the Request type annotation, TypeScript infers the parameter as any and you lose all safety. Import Request from '@playwright/test' (not from Node or the browser — Playwright has its own type).

Logging outgoing requests during a test:

``ts
import { Page, Request } from '@playwright/test';

// Typed handler — full autocomplete on request.url(), .method(), .headers(), etc.
const logRequest = (request: Request): void => {
console.log(
[${request.method()}] ${request.url()});
};

page.on('request', logRequest);
// Remember to clean up after the test:
page.off('request', logRequest);
`

Asserting a specific endpoint was called:

`ts
const calledUrls: string[] = [];
page.on('request', (req: Request) => {
calledUrls.push(req.url());
});

await page.click('[data-testid="save-button"]');

expect(calledUrls.some(url => url.includes('/api/save'))).toBe(true);
`

Intercepting and mocking with
page.route() — uses Route and Request:

`ts
import { Route, Request } from '@playwright/test';

await page.route('**/api/users', async (route: Route, request: Request) => {
if (request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }]),
});
} else {
await route.continue(); // pass through POST, PUT, etc.
}
});
`

Playwright's key network types:
-
Request — outgoing request (url, method, headers, postData)
-
Response — incoming response (status, body, headers)
-
Route — intercept handle (fulfill, continue, abort)

Rule of thumb:
Always import the specific Playwright type (
Request, Response, Route`) rather than leaving the callback untyped. The extra import costs one line; the autocomplete and error-catching it buys are worth it every time.
💡 Plain English: A typed phone operator's script. The operator (your callback) receives calls (requests) and the script tells them exactly what information they'll get from each call — caller ID (`url()`), call type (`method()`), message content (`postData()`). Without the typed script, the operator is just told "someone will call" — no idea what to expect or how to respond.
20
Tooling

How do you use types for a JavaScript library that doesn't ship its own?

When a JavaScript library doesn't include TypeScript types, you have three options in order of preference: check if the library already bundles types, install community types from DefinitelyTyped, or write a minimal declaration yourself.

Why this comes up in test projects:
Many test utilities — older assertion libraries, custom reporters, data generators, logging tools — are plain JavaScript. Without types, every import gives you any and you lose autocomplete and error-catching. Adding types, even minimal ones, restores that safety.

Walked-through example — the three options:

``ts
// Option 1: library already bundles types (most modern packages)
// e.g., Playwright, Zod, Axios all ship their own — nothing to do
import { chromium } from 'playwright'; // types included ✓

// Option 2: install community types from DefinitelyTyped
// Check at https://www.npmjs.com/~types or search @types/<name>
// npm install -D @types/lodash
import _ from 'lodash';
_.chunk([1, 2, 3, 4], 2); // now fully typed ✓

// Option 3: write a minimal declaration file when @types doesn't exist
// Create: types/some-legacy-lib.d.ts
`

`ts
// types/some-legacy-lib.d.ts
declare module 'some-legacy-reporter' {
export function report(results: { passed: number; failed: number }): void;
export function init(outputDir: string): void;
}
`

`ts
// tsconfig.json — tell TypeScript where to find your declarations
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
}
}
`

You only need to type the parts you actually use — not the entire library API.

Real-world QA use case:
You're integrating a legacy test data generator or a custom CI reporting tool that has no types. Write a
.d.ts with just the function signatures you call — it takes 10 minutes and saves the entire team from any-typed calls for the life of the project.

Rule of thumb: Check for bundled types first, then
@types/, then write a minimal .d.ts` for only the surface area you actually use.
💡 Plain English: A foreign appliance with no English manual. First check if it came with one (bundled types). If not, look for a community translation on the internet (`@types`). If that doesn't exist, write a one-page cheat sheet for the buttons you actually press (a minimal `.d.ts`).
21
Types

What does the `satisfies` operator do?

satisfies validates that a value matches a type at compile time, but without widening the value's inferred type. You get the safety of a type annotation and the specificity of direct inference — the best of both.

Why it exists — the problem it solves:
A regular type annotation (const x: SomeType = ...) widens the inferred type to SomeType, so TypeScript forgets the exact literal values. as is the opposite problem — it bypasses checking entirely. satisfies validates against the type without losing the specific inferred type.

Walked-through example — test environment config:

``ts
type EnvConfig = Record<string, { baseUrl: string; timeout: number }>;

// With a type annotation — TypeScript widens to EnvConfig
// You lose autocomplete on specific keys like cfg.staging
const cfg1: EnvConfig = {
staging: { baseUrl: 'https://staging.example.com', timeout: 30000 },
prod: { baseUrl: 'https://app.example.com', timeout: 10000 },
};
cfg1.staging; // type: { baseUrl: string; timeout: number } ✓
cfg1.nope; // no error — EnvConfig allows any string key ✗

// With satisfies — validates shape, keeps exact keys
const cfg2 = {
staging: { baseUrl: 'https://staging.example.com', timeout: 30000 },
prod: { baseUrl: 'https://app.example.com', timeout: 10000 },
} satisfies EnvConfig;

cfg2.staging; // type: { baseUrl: string; timeout: number } ✓
cfg2.nope; // TS error: 'nope' doesn't exist on this object ✓
cfg2.staging.baseUrl; // string ✓ — full autocomplete preserved
`

The validation catches mistakes (wrong shape, typo'd field names) while the inferred type retains the exact keys — so accessing a key that doesn't exist is an error.

Real-world QA use case:
Use
satisfies for test configuration objects, role-permission maps, and browser launch options. You validate the shape is correct AND keep specific key access with full type safety — no as casts needed downstream.

Rule of thumb: Reach for
satisfies` instead of a type annotation when you want validation without losing the specific inferred type. Think of it as "check this matches the contract, but remember what it actually is."
💡 Plain English: A building inspection — the inspector checks the building meets the safety code (validation) without replacing your architectural plans with a generic "building" label. Your detailed plans survive the inspection intact; you just know they meet the standard.
22
Narrowing

How do you safely use a value typed as `unknown`?

unknown is TypeScript's safe version of any. You cannot do anything with an unknown value — no property access, no method calls, no arithmetic — until you've proven what type it is through a narrowing check. TypeScript enforces this at compile time.

Why unknown exists and why it matters:
any turns off type checking entirely — it lets you access any property without proof. unknown says "this could be anything, but you must prove what it is before using it." API responses, JSON.parse() results, and catch block errors are all unknown (or should be) — they cross a trust boundary where you genuinely don't know the shape.

Walked-through example — API response body:

``ts
async function fetchUserData(request: APIRequestContext, id: number): Promise<string> {
const response = await request.get(
/api/users/${id});
const body: unknown = await response.json(); // explicitly unknown — could be anything

// Can't do this — TypeScript blocks it:
// return body.email; ❌ Error: body is unknown

// Option 1: typeof check (for primitives)
if (typeof body === 'string') return body;

// Option 2: property check (for objects)
if (
typeof body === 'object' &&
body !== null &&
'email' in body &&
typeof (body as { email: unknown }).email === 'string'
) {
return (body as { email: string }).email;
}

throw new Error('Unexpected response shape from /api/users');
}
`

`ts
// Option 3: use Zod for cleaner runtime validation (recommended for complex shapes)
import { z } from 'zod';

const UserSchema = z.object({ id: z.number(), email: z.string() });

const body: unknown = await response.json();
const user = UserSchema.parse(body); // throws if shape is wrong
return user.email; // fully typed ✓
`

Real-world QA use case:
In API tests, never cast response bodies directly with
as YourType — that silently accepts wrong shapes. Keep the body as unknown and validate its shape. If validation fails, the test fails with a clear "unexpected response shape" message rather than a cryptic undefined property access error three lines later.

Rule of thumb:
unknown is the correct type at trust boundaries (API responses, catch blocks, JSON.parse). Narrow it immediately — use typeof/instanceof` for simple cases, Zod for complex object shapes.
💡 Plain English: A sealed evidence bag — you cannot handle the contents until you've formally identified what's inside. `unknown` enforces that protocol; `any` is like reaching into the bag with no gloves and no documentation, hoping for the best.
23
Practical

How do you type the JSON response from an API?

You define an interface for the expected shape and then either cast the parsed result to that interface (quick but no runtime guarantee) or validate it with a schema library like Zod (slower to set up, but catches real shape mismatches at test time).

Why this matters in API testing:
response.json() in Playwright returns Promise<any> — TypeScript has no idea what shape it is. Without typing it, you lose all autocomplete and the compiler can't catch when you access a field that doesn't exist or changed name. With a runtime validator like Zod, your test actually fails when the API breaks its contract — not silently passes with undefined values.

Walked-through example — two approaches:

``ts
// Approach 1: type assertion (fast, no runtime check)
interface OrderResponse {
orderId: string;
status: 'pending' | 'shipped' | 'delivered';
total: number;
}

const response = await request.post('/api/orders', { data: orderPayload });
const body = await response.json() as OrderResponse;

// TypeScript trusts your assertion — but if the API sends { order_id: ... }
// instead of { orderId: ... }, this silently gives you undefined
expect(body.orderId).toBeDefined();
`

`ts
// Approach 2: Zod validation (runtime check — recommended for contract testing)
import { z } from 'zod';

const OrderSchema = z.object({
orderId: z.string(),
status: z.enum(['pending', 'shipped', 'delivered']),
total: z.number(),
});

const response = await request.post('/api/orders', { data: orderPayload });
const raw: unknown = await response.json();

// parse() throws a ZodError if the shape doesn't match — test fails immediately
const body = OrderSchema.parse(raw);

expect(body.orderId).toBeDefined(); // TypeScript knows the type ✓
expect(body.status).toBe('pending'); // TypeScript knows status is the enum ✓
`

When to use each:
- Type assertion — quick scripting, trusted internal APIs, throwaway tests
- Zod validation — contract testing, external APIs, anywhere catching real regressions matters

Real-world QA use case:
In an API regression suite, run Zod validation on every response. When the backend team renames a field or changes a type, the test fails with a precise
ZodError pinpointing the mismatch — not a confusing undefined further down the test.

Rule of thumb: Use
as YourType` for speed; use Zod when you want the test to actually catch API contract breaks rather than silently pass with wrong data.
💡 Plain English: A delivery slip versus a physical inspection. The slip (type assertion) describes what should be in the box — it's useful and quick, but it can be wrong. The inspection (Zod validation) actually opens the box and checks every item — slower, but you know for certain what you received.
24
Types

Why does assigning an object literal with an extra field sometimes error?

When you assign an object literal directly to a typed variable or parameter, TypeScript runs an extra check called *excess property checking* — it flags any property that doesn't exist in the target type. This is different from TypeScript's normal structural typing, where extra properties are fine. The check only fires on fresh object literals, not on values passed through a variable.

Why it exists:
Extra properties on a fresh object literal are almost always a typo or a misunderstanding. TypeScript catches them at the point of assignment — where the mistake is clearest — rather than letting them silently vanish. The check is intentionally strict at assignment but relaxed for variable assignment, because a variable might legitimately carry extra properties that downstream code needs.

Walked-through example — test data factory:

``ts
interface LoginPayload {
email: string;
password: string;
}

async function login(request: APIRequestContext, payload: LoginPayload) {
return request.post('/api/login', { data: payload });
}

// ❌ Excess property check fires — 'rememberMe' isn't in LoginPayload
await login(request, {
email: 'test@example.com',
password: 'secret',
rememberMe: true, // Error: Object literal may only specify known properties
});

// ✅ Assigning to a variable first bypasses the check
const creds = { email: 'test@example.com', password: 'secret', rememberMe: true };
await login(request, creds); // structurally compatible — no error
``

The practical takeaway for test code:
If you hit this error passing a fixture object to a helper function, it usually means one of two things:
1. You have a typo in a field name — fix it (this is the check doing its job)
2. You genuinely need the extra field elsewhere — assign to a variable first, or widen the type

Real-world QA use case:
Test data factories often build objects with more fields than a specific endpoint needs. Assign the full factory result to a variable, then pass it — the excess property check fires at the assignment, not when passing the variable around, keeping things practical.

Rule of thumb: Excess property checking fires on fresh object literals assigned directly to a typed target. It's TypeScript catching typos early. If it fires and it's not a typo, store in a variable first.
💡 Plain English: A customs declaration form — if you hand in a fresh form and write something outside the declared fields, the officer flags it immediately. But if you hand over a pre-packed bag that was already checked elsewhere, they scan it structurally and wave it through.
25
Generics

How do you write a generic TypeScript helper for Playwright API testing that returns the response body typed as a specific interface?

When you write Playwright API tests, every request.get() or request.post() call returns a APIResponse object — and .json() returns any. Without a typed helper, every test has to cast the response manually. A generic helper wraps the fetch-and-parse in one place, and the caller specifies the expected response type.

Why generics here:
Without generics you'd write getUser(), getOrder(), getProduct() — one function per endpoint, all doing the same thing. With <T>, one function works for every endpoint. The return type is determined by what the caller passes as T.

The generic helper:

``ts
import { APIRequestContext } from '@playwright/test';

async function apiGet<T>(request: APIRequestContext, url: string): Promise<T> {
const response = await request.get(url);
expect(response.ok()).toBeTruthy(); // assert 2xx before parsing
return response.json() as T;
}
`

T is the type the caller says the response will be. TypeScript uses it to type the returned value.

Using it in tests:

`ts
interface User { id: number; name: string; email: string; role: string; }
interface Order { id: number; userId: number; status: string; total: number; }

test('GET /users/1 returns a valid user', async ({ request }) => {
const user = await apiGet<User>(request, '/api/users/1');
// user is typed as User — autocomplete and type checking on every field:
expect(user.name).toBeTruthy();
expect(user.role).toBe('admin');
});

test('GET /orders/42 returns a valid order', async ({ request }) => {
const order = await apiGet<Order>(request, '/api/orders/42');
expect(order.status).toBe('shipped');
expect(order.total).toBeGreaterThan(0);
});
`

Adding a typed POST helper:

`ts
async function apiPost<TBody, TResponse>(
request: APIRequestContext,
url: string,
body: TBody
): Promise<TResponse> {
const response = await request.post(url, { data: body });
expect(response.ok()).toBeTruthy();
return response.json() as TResponse;
}

const created = await apiPost<Partial<User>, User>(
request, '/api/users', { name: 'Alice', role: 'regular' }
);
// created is typed as User
`

Rule of thumb:
Write one
apiGet<T> and apiPost<TBody, TResponse>` helper per project. All API tests import and use these — they centralise the status assertion and cast, and every caller gets full type safety for free.
💡 Plain English: A typed post office counter. You hand in a labelled package (`T`) and the clerk says "I'll fetch whatever this label says — and when it comes back, it'll be exactly what the label promises." The same counter handles all parcel types; the label (generic parameter) tells it what to expect in return.
26
Types

What is the difference between an optional property, a default value, and `| undefined`?

These three patterns look similar but mean different things to TypeScript — specifically around whether a field can be omitted entirely versus explicitly set to undefined, and whether a fallback value is guaranteed inside the function.

The three patterns side by side:

``ts
// 1. Optional property (?) — the caller may omit the field entirely
interface Options1 { timeout?: number }
// timeout has type: number | undefined
// Caller can write: {} or { timeout: 30000 } or { timeout: undefined }

// 2. Explicit union with undefined — the field must be present, but undefined is allowed
interface Options2 { timeout: number | undefined }
// Caller must write: { timeout: 30000 } or { timeout: undefined }
// Cannot write: {} — missing the field is an error

// 3. Default parameter value — omitting gives a guaranteed fallback inside the function
function runTest(timeout = 30000) {
// timeout is number here — never undefined, even if caller passed nothing
}
`

Why the difference matters in test helpers:

`ts
// Optional property — useful for override objects in factories
interface UserOverrides {
email?: string;
role?: 'admin' | 'user';
}

function createUser(overrides: UserOverrides = {}): User {
return {
id: Math.random(),
email: overrides.email ?? 'default@example.com', // must handle undefined
role: overrides.role ?? 'user',
};
}

// Default parameter — useful when the value has a sensible fallback
async function waitForElement(
page: Page,
selector: string,
timeout = 5000 // 5s default; caller can override; body always gets a number
): Promise<Locator> {
return page.waitForSelector(selector, { timeout });
}
`

Strict mode note: with
exactOptionalPropertyTypes: true in tsconfig, an optional field timeout?: number no longer accepts { timeout: undefined } — it can only be omitted. This tightens the distinction further.

Real-world QA use case:
Test data factory functions use optional properties (
Partial<User>) so callers only specify what a test actually needs. Helper functions use default parameters so they always have a usable value inside without forcing callers to pass one.

Rule of thumb: Use
? for factory/override objects where fields are genuinely skippable. Use defaults for function parameters where you want a guaranteed value inside the function. Reserve | undefined` in required fields for when you explicitly want to force callers to acknowledge a field even when they have nothing to put in it.
💡 Plain English: Optional (`?`) is a hotel breakfast form — you can leave the egg section blank. Explicit `| undefined` is the same form but with a required tick box that says "no eggs." A default parameter is a buffet line — if you skip a station, the chef hands you the standard portion anyway.
27
Practical

Explain how you have used TypeScript in a test automation framework — what specific benefits did it give you?

TypeScript gives a test automation framework four concrete benefits: type-safe Page Object Models, typed test data factories, refactoring safety at scale, and self-documenting code that onboards engineers faster.

Why this matters beyond "type safety":
"Type safety" is the abstract answer. What it means in practice is: fewer 3 AM CI failures caused by a renamed API field, faster test authoring because your editor autocompletes selectors and method signatures, and the ability to rename things confidently without manually searching every test file.

Benefit 1 — Page Object with typed methods:

``ts
interface CreditCard {
number: string;
expiry: string; // MM/YY
cvv: string;
}

interface OrderConfirmation {
orderId: string;
total: number;
}

class CheckoutPage {
constructor(private page: Page) {}

async placeOrder(card: CreditCard): Promise<OrderConfirmation> {
await this.page.fill('[data-testid="card-number"]', card.number);
await this.page.fill('[data-testid="card-expiry"]', card.expiry);
await this.page.fill('[data-testid="card-cvv"]', card.cvv);
await this.page.click('[data-testid="submit-order"]');
return this.parseConfirmation();
}

private async parseConfirmation(): Promise<OrderConfirmation> {
const text = await this.page.locator('[data-testid="order-id"]').textContent() ?? '';
return { orderId: text, total: 0 };
}
}
`

TypeScript ensures
placeOrder always receives a valid CreditCard — if a test passes { cardNumber: '...' } (typo'd field), it fails at compile time, not at 2 AM.

Benefit 2 — Typed test data factory:

`ts
function makeUser(overrides: Partial<User> = {}): User {
return { id: 1, name: 'Test User', email: 'test@example.com', role: 'viewer', ...overrides };
}

// Each test only specifies what it cares about
const admin = makeUser({ role: 'admin' });
const guest = makeUser({ email: 'guest@example.com', role: 'guest' });
makeUser({ rol: 'admin' }); // TS error: 'rol' isn't a property of User ✓
`

Benefit 3 — Refactoring safety:
Rename
role to userRole in the User interface. TypeScript immediately flags every test, page object, and helper that needs updating — across hundreds of files, before running a single test.

Benefit 4 — Self-documenting code:
New engineers see that
placeOrder requires a CreditCard and returns an OrderConfirmation` from the signature alone — no test execution or reading implementation required.

Rule of thumb: The ROI of TypeScript in test frameworks compounds with project size. At 50 tests, it's mildly useful. At 500 tests across a team of 5, the refactoring safety and onboarding speed become irreplaceable.
💡 Plain English: TypeScript in a test framework is a GPS for new drivers — it shows the legal routes, warns you when you're about to turn the wrong way, and confirms you've arrived at a valid destination. Without it, you're navigating by memory and hoping nothing moved since you last drove.
28
Generics

How do you use generics to build a type-safe API client?

A type-safe API client uses generics so that each endpoint call returns the correct type automatically — the caller passes a type parameter once, and everything downstream is typed without extra casting. The most powerful pattern is a typed endpoint map where the return type is driven by the endpoint path itself, making wrong combinations a compile error.

Why this matters beyond a simple generic helper:
A basic apiGet<T>(url) still lets you call apiGet<User>('/api/orders') — wrong type, but TypeScript doesn't know. A typed endpoint map closes that gap: the return type is derived from the URL you pass, so mismatches are caught at compile time.

Step 1 — Basic generic helper (simpler, good starting point):

``ts
import { APIRequestContext } from '@playwright/test';

class ApiClient {
constructor(private request: APIRequestContext, private baseUrl: string) {}

async get<T>(path: string): Promise<T> {
const response = await this.request.get(
${this.baseUrl}${path});
if (!response.ok()) throw new Error(
GET ${path} failed: ${response.status()});
return response.json() as T;
}

async post<TBody, TResponse>(path: string, body: TBody): Promise<TResponse> {
const response = await this.request.post(
${this.baseUrl}${path}, { data: body });
if (!response.ok()) throw new Error(
POST ${path} failed: ${response.status()});
return response.json() as TResponse;
}
}
`

`ts
// Usage in tests
const client = new ApiClient(request, 'https://staging.example.com');
const user = await client.get<User>('/api/users/1'); // type: User
const order = await client.get<Order>('/api/orders/42'); // type: Order
`

Step 2 — Typed endpoint map (advanced — return type driven by the URL):

`ts
interface EndpointMap {
'/api/users': User[];
'/api/orders': Order[];
'/api/products': Product[];
}

async function typedGet<K extends keyof EndpointMap>(
request: APIRequestContext,
path: K
): Promise<EndpointMap[K]> {
const response = await request.get(path);
return response.json();
}

// Return type is inferred from the path — no type parameter needed
const users = await typedGet(request, '/api/users'); // type: User[]
const orders = await typedGet(request, '/api/orders'); // type: Order[]
await typedGet(request, '/api/invoices'); // TS error: not in EndpointMap ✓
`

The endpoint map makes calling a nonexistent endpoint a compile error, and the return type is always correct without the caller specifying
<T>.

Real-world QA use case:
Build the typed endpoint map from your OpenAPI spec (e.g., using
openapi-typescript`). Your entire API test suite gets compile-time endpoint validation — if the backend removes or renames an endpoint, every affected test fails to compile before you even run CI.

Rule of thumb: Start with the simple generic class for most projects. Graduate to a typed endpoint map when your API surface is large and you want to catch URL typos and mismatched response types at compile time.
💡 Plain English: A type-aware hotel concierge — you hand them a room number and they automatically retrieve the right type of service associated with that room. The typed endpoint map is the concierge's master registry: ask for a room that doesn't exist and they tell you immediately, rather than sending someone up to find nothing.
29
Types

How do you use a TypeScript discriminated union to represent test result states — pass, fail, and skip — so a custom reporter handles all three correctly?

A discriminated union is a TypeScript pattern where each member of the union has a literal "tag" field (like status) that distinguishes it. TypeScript uses the tag to narrow the type inside each branch — so you can only access fields that actually exist for that state.

Why this matters in test automation:
A test result has three very different shapes. A pass needs duration. A failure needs duration, error message, and maybe a screenshot. A skip needs a reason but no duration. If you model this with optional fields on a single interface, nothing stops you creating impossible combinations — like { passed: true, failed: true }. A discriminated union makes illegal states unrepresentable.

The naive approach — allows impossible combinations:

``ts
// ❌ Allows: { passed: true, failed: true, error: "something" }
interface TestResult {
passed?: boolean;
failed?: boolean;
skipped?: boolean;
error?: string;
durationMs?: number;
reason?: string;
}
`

The discriminated union — each state is precise:

`ts
type TestResult =
| { status: 'pass'; durationMs: number }
| { status: 'fail'; durationMs: number; error: string; screenshot?: string }
| { status: 'skip'; reason: string };
`

Now each member only has the fields that make sense for that state.
screenshot only exists on fail — you can't accidentally access it on a pass result.

Using it in a reporter:

`ts
function formatResult(result: TestResult): string {
switch (result.status) {
case 'pass':
return
✅ Passed in ${result.durationMs}ms;
case 'fail':
// TypeScript knows result.error exists here — it's only on 'fail'
return
❌ Failed after ${result.durationMs}ms: ${result.error};
case 'skip':
// TypeScript knows result.reason exists here — it's only on 'skip'
return
⏭️ Skipped: ${result.reason};
}
}
`

Exhaustiveness check — protect against future states:

`ts
default:
const _exhaustive: never = result;
throw new Error(
Unhandled result status: ${JSON.stringify(result)});
`

Add a new status (e.g.
'timeout') to the union and the compile error forces you to handle it in every switch before the code will build.

Rule of thumb:
Any time you have an object that can be in multiple distinct states — each with different fields — model it as a discriminated union, not a flat interface with optional fields. The
status (or type, or kind`) literal tag is what TypeScript uses to narrow.
💡 Plain English: A hospital triage system with separate forms per patient type. A discharged patient's form has a discharge time. A transferred patient's form has a destination hospital. An admitted patient's form has a ward number. You never see a "discharge time" field on a transfer form — it's not applicable. A discriminated union is those separate forms: each state has exactly the fields that belong to it.
30
Utility Types

What is ReturnType<T> and when do you use it?

ReturnType<T> is a built-in TypeScript utility that extracts whatever type a function returns. You pass the function type in (usually via typeof myFunction), and you get the return type back — so you never have to write the same type twice.

Why it exists:
Complex return types, especially from factory functions or setup helpers, are tedious to maintain in two places. If you define the type separately as an interface AND in the function, they can drift. ReturnType makes the function the single source of truth — the type derives from the implementation automatically.

Walked-through example — test data factory:

``ts
// The factory function — return type is inferred, not written explicitly
function createTestUser(overrides: Partial<{ name: string; role: string; email: string }> = {}) {
return {
id: Math.floor(Math.random() * 10000),
name: overrides.name ?? 'Test User',
role: overrides.role ?? 'viewer',
email: overrides.email ?? 'test@example.com',
createdAt: new Date().toISOString(),
};
}

// Derive the type from the function — stays in sync automatically
type TestUser = ReturnType<typeof createTestUser>;
// { id: number; name: string; role: string; email: string; createdAt: string }

// Now type other helpers against it — no separate interface needed
function assertUserDisplayed(page: Page, user: TestUser): Promise<void> {
return expect(page.locator('[data-testid="user-name"]')).toHaveText(user.name);
}
`

Practical use with Playwright fixture setup:

`ts
// Playwright fixtures often return complex objects
async function setupAuthenticatedContext(browser: Browser) {
const context = await browser.newContext();
const page = await context.newPage();
const token = await getAuthToken(page);
return { context, page, token };
}

// Derive the type from the setup function — no need to maintain a separate interface
type AuthFixture = ReturnType<typeof setupAuthenticatedContext>;
// Actually: Awaited<ReturnType<typeof setupAuthenticatedContext>> for async functions
type AuthFixtureResolved = Awaited<ReturnType<typeof setupAuthenticatedContext>>;
// { context: BrowserContext; page: Page; token: string }
`

For async functions, pair
ReturnType with Awaited to unwrap the Promise.

Real-world QA use case:
Use
ReturnType for test data factories, custom fixture setup functions, and builder patterns — anywhere you want other helpers typed against the output of a function without duplicating the shape.

Rule of thumb: Use
ReturnType<typeof fn> (and Awaited<ReturnType<typeof fn>>` for async) when the function's output is the source of truth — let the type follow the implementation, not the other way around.
💡 Plain English: Instead of writing a product spec card separately from the blueprint, you stamp the card directly from the blueprint at the end. If the blueprint changes, the stamp changes too — one source of truth, zero drift.
31
Practical

How do you use TypeScript with Zod for runtime validation — walk me through a practical example.

TypeScript types exist only at compile time — they disappear completely in the compiled JavaScript. So a response body typed as User might actually be { user_id: ... } at runtime and TypeScript will never know. Zod solves this by validating the shape at runtime AND automatically inferring the TypeScript type from the same schema — one definition, two guarantees.

Why this matters in API testing:
Without runtime validation, your tests can silently pass with wrong data — the API returns a renamed field, you cast it as the old type, TypeScript is happy, but your expect(user.email) is checking undefined all along. Zod makes that failure loud and immediate.

Walked-through example — API response validation in a Playwright test:

``ts
import { z } from 'zod';
import { APIRequestContext } from '@playwright/test';

// Step 1 — define the schema (the single source of truth)
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
});

// Step 2 — derive the TypeScript type from the schema — no separate interface needed
type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string; role: 'admin' | 'user' | 'viewer' }

// Step 3 — use it in a test helper
async function fetchUser(request: APIRequestContext, id: number): Promise<User> {
const response = await request.get(
/api/users/${id});
const raw: unknown = await response.json();

// parse() throws ZodError with a precise field-level message if shape is wrong
return UserSchema.parse(raw);
}
`

`ts
// In a test:
const user = await fetchUser(request, 1);
// user is fully typed as User — autocomplete, null safety, everything
expect(user.role).toBe('admin');
`

What a ZodError looks like when the API breaks:
`
ZodError: [
{ "path": ["email"], "message": "Invalid email" },
{ "path": ["role"], "message": "Invalid enum value. Expected 'admin' | 'user' | 'viewer', received 'superadmin'" }
]
`

Precise, actionable, immediately points to the contract break.

parse vs safeParse:
-
parse(raw) — throws on failure (good for tests; failure = hard stop)
-
safeParse(raw) — returns { success, data, error } (good when you want to handle errors gracefully)

Real-world QA use case:
Put Zod validation at the boundary of every external API call in your test helpers. When a backend deploy breaks the API contract, the first failing test gives you the exact field and the exact mismatch — no debugging required.

Rule of thumb: Define the schema once with Zod, derive the TypeScript type with
z.infer, validate with parse()` at the boundary. Zero duplication, runtime and compile-time safety together.
💡 Plain English: TypeScript is the label on a shipping box — it says what should be inside. Zod is the customs X-ray scanner that checks the contents actually match the label before you clear the package. The label can be forged or outdated; the scanner cannot lie.
32
Practical

How do you manage turning on strict mode in a large existing TypeScript codebase?

You can't flip "strict": true overnight on a large codebase — it typically produces hundreds or thousands of errors. The safe approach is to enable strict flags one at a time, fix the errors each one surfaces, and ensure new code is always written to the stricter standard.

Why this matters in test frameworks:
A test codebase with strict: false has hidden bugs — implicit any parameters that accept wrong types silently, null-unsafe access that crashes at runtime rather than failing at compile time. Enabling strict mode gradually surfaces these real issues without drowning the team.

Step 1 — Enable flags one at a time (recommended order):

``json
// tsconfig.json — add one flag per sprint, fix errors before adding the next
{
"compilerOptions": {
"noImplicitAny": true, // catches un-typed parameters and variables
"strictNullChecks": true, // catches potential null/undefined access
"strictFunctionTypes": true, // tightens callback parameter variance
"noImplicitReturns": true, // ensures all code paths return a value
"strict": true // eventually: enables all of the above + more
}
}
`

Step 2 — Handle errors you can't fix immediately:

`ts
// Use @ts-expect-error (NOT @ts-ignore) for known, temporary suppressions
// @ts-expect-error — TODO: fix after UserService is refactored (ticket #234)
const user = legacyGetUser(id);

// Why @ts-expect-error over @ts-ignore:
// If the underlying issue is fixed, @ts-expect-error itself becomes an error
// (because there's nothing to suppress) — it self-destructs when no longer needed.
// @ts-ignore silently hides issues forever.
`

Step 3 — Track and enforce progress:

`bash
# Count remaining errors — should only go down, week over week
npx tsc --noEmit 2>&1 | wc -l

# In CI: fail the build if new errors are introduced above a threshold
# (some teams use typescript-strict-plugin to enforce per-file)
`

The one non-negotiable rule:
New code must always be written to the stricter standard from day one — even while legacy code is being fixed. Agree with the team that the error count can only decrease, never increase.

Real-world QA use case:
When migrating a Playwright test suite from JavaScript to TypeScript, start with
noImplicitAny. Every un-typed request, page, or response parameter shows up — fix them and the whole suite gets autocomplete and safety for those types. Then enable strictNullChecks and find every place a locator's .textContent() (which returns string | null) is used as if it's always a string.

Rule of thumb: Enable one strict flag per sprint, fix the errors it surfaces, commit. Never skip errors by downgrading flags — use
@ts-expect-error` with a ticket number for anything genuinely deferred.
💡 Plain English: Bringing an old building up to modern fire code floor by floor. You don't tear it down and rebuild — you fix the first floor's wiring, then the second floor's exits, document what still needs work with official exemptions, and ensure every new extension is built to the new code from day one.
33
Types

What OOP concepts does TypeScript support and how have you used them in a test automation framework?

TypeScript supports the four core OOP concepts — encapsulation, inheritance, abstraction, and polymorphism — and all four have direct practical applications in a Playwright Page Object Model framework.

Why OOP in test automation specifically:
Without OOP, page logic leaks into test files, selectors get duplicated, and shared setup gets copy-pasted. OOP gives each page its own encapsulated boundary, lets shared behaviour live in one base class, and lets the test runner treat any page object polymorphically through a shared interface.

All four concepts in a Playwright POM:

``ts
import { Page } from '@playwright/test';

// ABSTRACTION — abstract base defines the contract; subclasses fill in the detail
abstract class BasePage {
constructor(protected page: Page) {}

// Shared behaviour — every page can use this
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}

// Contract — every subclass must implement this
abstract getPageTitle(): Promise<string>;
}
`

`ts
// INHERITANCE — LoginPage reuses waitForLoad, implements getPageTitle
class LoginPage extends BasePage {
async getPageTitle(): Promise<string> {
return this.page.title();
}

async login(email: string, password: string): Promise<void> {
await this.page.fill('[data-testid="email"]', email);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[type="submit"]');
await this.page.waitForURL('/dashboard');
}
}
`

`ts
// ENCAPSULATION — private selector strings; tests can't bypass the page object API
class CartPage extends BasePage {
private readonly addToCartSelector = '[data-testid="add-to-cart"]';
private readonly checkoutSelector = '[data-testid="proceed-checkout"]';

async getPageTitle(): Promise<string> {
return this.page.title();
}

async addItem(): Promise<void> {
await this.page.click(this.addToCartSelector); // selector is an impl detail
}

async proceedToCheckout(): Promise<void> {
await this.page.click(this.checkoutSelector);
}
}
`

`ts
// POLYMORPHISM — a helper that works with any BasePage subclass
async function assertPageLoaded(pageObj: BasePage): Promise<void> {
await pageObj.waitForLoad();
const title = await pageObj.getPageTitle();
if (!title) throw new Error('Page has no title after load');
}

// Works for any BasePage subclass — LoginPage, CartPage, CheckoutPage, etc.
await assertPageLoaded(new LoginPage(page));
await assertPageLoaded(new CartPage(page));
`

The practical payoff:
- Private selectors mean a test can never accidentally reference a raw CSS string that moved
- Abstract
getPageTitle means forgetting to implement it is a compile error, not a test failure
- The
assertPageLoaded helper works for all current and future page objects — no changes needed

Rule of thumb: Use
abstract for the contract every page must fulfil. Use private/protected for selectors and internal helpers. Use extends` for shared navigation and wait helpers. Polymorphism falls out naturally once you do the first three correctly.
💡 Plain English: A chain of hotel properties. The head office (BasePage) sets the standard welcome procedure and requires each property to have a name (abstract method). Each hotel (page class) implements its own name and adds its own amenities (encapsulation). A mystery shopper (polymorphic helper) can assess any property using the standard checklist — they don't need to know which specific hotel it is.
34
Practical

Your test suite has `TestStatus = "pass" | "fail" | "skip" | "blocked"`. How do you derive sub-types for only the terminal states and only the non-terminal states without repeating the values?

Exclude<T, U> and Extract<T, U> are TypeScript utility types that filter a union by removing or keeping members that match a condition. They let you derive precise sub-types from an existing union — no copy-pasting, no risk of the sub-type drifting out of sync.

The problem without them:

``ts
type TestStatus = 'pass' | 'fail' | 'skip' | 'blocked';

// ❌ Manual sub-types — duplicated, drift-prone:
type TerminalStatus = 'pass' | 'fail'; // repeated from above
type NonTerminalStatus = 'skip' | 'blocked'; // repeated from above
`

If someone adds
'timeout' to TestStatus, the manual sub-types silently become wrong.

With Exclude and Extract:

`ts
// Extract — keep only members that match:
type TerminalStatus = Extract<TestStatus, 'pass' | 'fail'>;
// 'pass' | 'fail'

// Exclude — remove members that match:
type NonTerminalStatus = Exclude<TestStatus, 'pass' | 'fail'>;
// 'skip' | 'blocked'
`

Both are derived from
TestStatus — add 'timeout' to the base type and re-run tsc. TypeScript tells you immediately which functions need updating.

Using the sub-types in practice:

`ts
// Slack notifications only fire for tests that actually ran and produced a result:
function sendSlackAlert(result: { status: TerminalStatus; name: string; durationMs: number }) {
// Only accepts 'pass' or 'fail' — skipped/blocked tests don't notify
}

// Retry scheduler only accepts tests that didn't fully execute:
function scheduleRetry(status: NonTerminalStatus, testId: string) {
// Only accepts 'skip' or 'blocked' — you wouldn't retry a pass or fail
}

sendSlackAlert({ status: 'pass', name: 'login test', durationMs: 1200 }); // ✅
sendSlackAlert({ status: 'skip', name: 'login test', durationMs: 0 }); // ❌ compile error
scheduleRetry('blocked', 'test-42'); // ✅
scheduleRetry('fail', 'test-42'); // ❌ compile error
`

Rule of thumb:
Derive sub-types from a base union using
Extract or Exclude` — never copy-paste values. This keeps the codebase consistent: one change to the base type propagates to every derived type automatically.
💡 Plain English: A hotel key card system. The master list of room types is `TestStatus`. `Extract` is the access list for executive-floor rooms — only certain types can enter. `Exclude` is the "no access" list — certain types are blocked. Both lists are generated from the master list, so when a new room type is added, the access system updates automatically rather than someone having to manually patch two separate lists.
35
Utility Types

What is NonNullable<T> and when is it useful?

NonNullable<T> is a built-in TypeScript utility that removes null and undefined from a type, leaving only the non-nullable version. It's useful in generic helpers that assert a value exists, and when you want to strip nullability from a type you didn't define yourself.

Why it matters in test code:
Many values in test automation are nullable by their type — locator.textContent() returns string | null, environment variables are string | undefined, and API optional fields can be null. When you write a helper that guarantees a value is present after a check, NonNullable<T> expresses that guarantee in the return type.

Walked-through example — safe text extraction helper:

``ts
import { Locator } from '@playwright/test';

// locator.textContent() returns string | null
// This helper asserts it's non-null and returns string (not string | null)
async function requireText(locator: Locator, description: string): Promise<string> {
const text = await locator.textContent();

if (text === null) {
throw new Error(
Expected ${description} to have text content, but got null);
}

return text; // TypeScript narrows to string here — NonNullable<string | null>
}

// Usage in tests — clean, no null check needed at the call site
const heading = await requireText(page.locator('h1'), 'page heading');
expect(heading).toBe('Dashboard');
`

Using it explicitly in generic utilities:

`ts
// A generic "require" helper — asserts a value is defined, returns the non-null type
function require<T>(value: T, label: string): NonNullable<T> {
if (value == null) throw new Error(
Required value missing: ${label});
return value as NonNullable<T>;
}

// Practical: safe environment variable access
const baseUrl = require(process.env.BASE_URL, 'BASE_URL');
// baseUrl is string, not string | undefined
`

In mapped types — stripping nullability from an entire interface:

`ts
// An API response has optional fields; after validation they're all guaranteed
type ApiUser = { id: number; email: string | null; role: string | undefined };

type ValidatedUser = {
[K in keyof ApiUser]: NonNullable<ApiUser[K]>;
};
// { id: number; email: string; role: string }
`

Real-world QA use case:
After a Zod
parse() or a successful null check, downstream helpers shouldn't need to re-check nullability. Use NonNullable in the return type to express "this function guarantees the value is present" — the type reflects the runtime guarantee.

Rule of thumb: Use
NonNullable<T>` when you've already checked a value is non-null and want the return type to reflect that guarantee — so callers don't have to check again.
💡 Plain English: A ticket redemption window — you hand in a "maybe valid / maybe void" ticket and the clerk checks it. If it's valid, they stamp it and hand it back as a guaranteed-valid ticket. `NonNullable` is the type of the stamped ticket: the nullability has been checked and removed.
36
Practical

How do you write a typed custom Playwright fixture that gives every test in a suite a pre-authenticated Page — without repeating the login steps in each test?

Playwright's fixture system lets you extend the built-in test object with your own setup/teardown logic. TypeScript types the custom fixture so every test that uses it gets full autocomplete and type safety on the object the fixture provides.

Why this matters:
Without a fixture, every test that needs an authenticated page copies the login steps. When the login flow changes, you update it in dozens of places. A typed fixture centralises it in one place.

Step 1 — Define the fixture type:

``ts
import { test as base, Page, expect } from '@playwright/test';

type AuthFixtures = {
authenticatedPage: Page;
};
`

Step 2 — Extend the base test with your fixture:

`ts
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// ── Setup ───────────────────────────────────────────
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@example.com');
await page.fill('[data-testid="password"]', 'TestPass123!');
await page.click('[type="submit"]');
await page.waitForURL('/dashboard');

// ── Hand the page to the test ────────────────────────
await use(page);

// ── Teardown (runs after the test, even if it fails) ──
await page.goto('/logout');
},
});

export { expect };
`

Step 3 — Import and use the extended test in your spec files:

`ts
// admin.spec.ts
import { test, expect } from '../fixtures/auth';

test('admin can access user management', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/admin/users');
await expect(authenticatedPage.locator('h1')).toHaveText('User Management');
});

test('admin can delete a user', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/admin/users/42');
await authenticatedPage.click('[data-testid="delete-btn"]');
await expect(authenticatedPage.locator('.success-toast')).toBeVisible();
});
`

Both tests get a fully authenticated page — login happens once per test, teardown logs out after. No duplication. TypeScript types
authenticatedPage as Page — full autocomplete on every method.

Adding multiple fixtures:

`ts
type Fixtures = {
authenticatedPage: Page;
adminPage: Page;
guestPage: Page;
};
``

Each fixture can have its own setup, so you can have three differently-authenticated pages in one file.

Rule of thumb:
Any setup that more than one test needs — authentication, seeded data, a specific URL — belongs in a fixture. The TypeScript type for the fixture is the contract that guarantees every consumer gets exactly what it expects.
💡 Plain English: A hotel room that the concierge prepares before you arrive — minibar stocked, TV tuned, AC set. You (the test) just walk in and get to work. When you leave, housekeeping resets it. You never have to stock the minibar yourself, and if the minibar list changes, only the concierge's instructions update — every guest's experience stays the same.
37
Async

What is Awaited<T> and when do you actually need it?

Awaited<T> is a built-in TypeScript utility that unwraps the resolved type from a Promise<T> — recursively, through as many layers of nesting as needed. Its most common use in test automation is pairing it with ReturnType to extract the resolved output of an async setup function without writing the type manually.

Why it exists:
ReturnType<typeof asyncFn> gives you Promise<User>, not User. That's often not what you want when you're trying to type a variable that will hold the resolved value. Awaited unwraps the Promise layer so you get User directly.

Walked-through example — typing a Playwright fixture's resolved value:

``ts
// An async setup function that returns multiple things
async function setupTestContext(browser: Browser) {
const context = await browser.newContext();
const page = await context.newPage();
const token = await getAuthToken(page);
return { context, page, token };
}

// ReturnType gives you Promise<{context, page, token}> — still wrapped
type Wrapped = ReturnType<typeof setupTestContext>;
// Promise<{ context: BrowserContext; page: Page; token: string }>

// Awaited unwraps the Promise — you get the resolved shape
type TestContext = Awaited<ReturnType<typeof setupTestContext>>;
// { context: BrowserContext; page: Page; token: string }

// Now you can type helpers that receive the resolved fixture object
async function assertLoggedIn(ctx: TestContext): Promise<void> {
await expect(ctx.page.locator('[data-testid="user-menu"]')).toBeVisible();
}
`

Multi-layer unwrapping (rare but handled):

`ts
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number (unwraps both layers)
type C = Awaited<string>; // string (non-Promise stays as-is)
`

In generic retry helpers:

`ts
// T might be a Promise — Awaited<T> normalises it to the resolved type
async function retry<T>(
fn: () => Promise<T>,
maxAttempts = 3
): Promise<T> {
for (let i = 0; i < maxAttempts; i++) {
try { return await fn(); } catch (e) {
if (i === maxAttempts - 1) throw e;
}
}
throw new Error('unreachable');
}
`

Real-world QA use case:
The most common need is
Awaited<ReturnType<typeof someSetupFn>> — you have an async helper that sets up a test context and you want to type the object it resolves to, so other helpers can accept that type without repeating the shape.

Rule of thumb: Reach for
Awaited<ReturnType<typeof fn>> when you need the resolved output type of an async function as a standalone type. It's the complement to ReturnType` for the async world.
💡 Plain English: Unpacking a set of Russian nesting dolls — `Awaited` keeps opening outer dolls (Promise wrappers) until it reaches the innermost solid figure (the resolved value), however many layers deep it was wrapped.
38
Practical

How do you use TypeScript's strict null checks to avoid "cannot read property of undefined" crashes when working with optional API response fields in tests?

Strict null checks ("strictNullChecks": true in tsconfig) make TypeScript treat null and undefined as distinct types — not secretly present in every type. This means the compiler tells you BEFORE runtime when you're about to access a field that might not exist.

The problem without strict null checks:

``ts
// TypeScript with strictNullChecks off — silently allows:
async function getUser(id: number): Promise<User> {
const res = await request.get(
/api/users/${id});
return res.json(); // might return null or {} — TypeScript doesn't care
}

const user = await getUser(999);
console.log(user.profile.avatar); // 💥 "Cannot read property 'avatar' of undefined" at runtime
`

With strict null checks — the compiler catches it:

`ts
interface UserProfile { avatar: string; bio: string; }
interface User {
id: number;
name: string;
profile: UserProfile | null; // explicitly optional
}

async function getUser(id: number): Promise<User | null> { ... }

const user = await getUser(999);
console.log(user.profile.avatar); // ❌ compile error: 'user' is possibly null
console.log(user?.profile.avatar); // ❌ compile error: 'profile' is possibly null
console.log(user?.profile?.avatar); // ✅ undefined if any step is null — no crash
`

The three safe patterns — use the right tool for each situation:

`ts
// 1. Guard check — when you need to assert it's not null and stop if it is:
if (user === null) throw new Error(
User ${id} not found);
console.log(user.profile?.avatar); // TypeScript now knows user is not null

// 2. Optional chaining (?.) — when null/undefined is acceptable:
const avatar = user?.profile?.avatar; // string | undefined — safe, no crash

// 3. Nullish coalescing (??) — when you want a default:
const displayName = user?.name ?? 'Anonymous'; // fallback to 'Anonymous' if null
const avatar = user?.profile?.avatar ?? '/default-avatar.png';
`

In a Playwright API test — checking a response that might miss a field:

`ts
test('GET /users/:id returns profile when present', async ({ request }) => {
const response = await request.get('/api/users/1');
const user: User | null = await response.json();

// TypeScript forces you to handle the null case before accessing fields:
expect(user).not.toBeNull();
if (user === null) return;

// Now TypeScript is confident user is User (not null):
expect(user.name).toBeTruthy();
expect(user.profile?.avatar).toBeDefined();
});
`

Rule of thumb:
Enable
strictNullChecks (it's included in "strict": true`) in every TypeScript test project. The slight friction of handling nulls explicitly is worth it — every crash of the form "cannot read property of undefined" in production was a compile error waiting to be caught.
💡 Plain English: A luggage scanner that flags bags labelled "might be empty" before they go on the conveyor belt. Without strict null checks, every bag goes through even if it might be empty — and you only find out when you open it at the destination. With strict null checks, the scanner makes you check and declare what to do with potentially empty bags before they get on the belt.
39
Practical

How do you type `page.evaluate()` in Playwright to get a typed return value instead of `any`?

page.evaluate() runs a function inside the browser context and returns the result. Without a type parameter, TypeScript infers the return as unknown — you lose all safety on what comes back. Passing a generic type parameter tells TypeScript exactly what the function will return, giving you full autocomplete and type checking on the result.

Why this matters:
page.evaluate() is one of the most powerful Playwright tools — you can extract DOM data, computed styles, or application state. Without types, every access on the result is a guess.

Basic usage — type the return value:

``ts
// Without type — result is unknown:
const title = await page.evaluate(() => document.title);
// title: unknown — TypeScript won't let you use it as a string

// With type parameter — result is string:
const title = await page.evaluate<string>(() => document.title);
console.log(title.toUpperCase()); // ✅ TypeScript knows it's a string
`

Extracting structured data from the page:

`ts
interface ProductDetails {
name: string;
price: number;
inStock: boolean;
reviewCount: number;
}

const product = await page.evaluate<ProductDetails>(() => ({
name: document.querySelector('.product-name')?.textContent ?? '',
price: parseFloat(document.querySelector('.price')?.textContent ?? '0'),
inStock: document.querySelector('.in-stock') !== null,
reviewCount: parseInt(document.querySelector('.review-count')?.textContent ?? '0', 10),
}));

// product is typed as ProductDetails:
expect(product.price).toBeGreaterThan(0); // TypeScript knows price is number
expect(product.inStock).toBe(true); // TypeScript knows inStock is boolean
`

Passing arguments into the browser function:

`ts
// page.evaluate<ReturnType, ArgType>(fn, arg)
const itemText = await page.evaluate<string, string>(
(selector) => document.querySelector(selector)?.textContent ?? '',
'[data-testid="cart-total"]'
);
`

The second type parameter is the argument type. TypeScript checks that the arg you pass matches.

Extracting a list of items:

`ts
interface CartItem { name: string; qty: number; price: number; }

const items = await page.evaluate<CartItem[]>(() =>
Array.from(document.querySelectorAll('.cart-item')).map(el => ({
name: el.querySelector('.item-name')?.textContent ?? '',
qty: parseInt(el.querySelector('.qty')?.textContent ?? '1', 10),
price: parseFloat(el.querySelector('.price')?.textContent ?? '0'),
}))
);

expect(items.length).toBeGreaterThan(0);
expect(items[0].price).toBeGreaterThan(0);
`

Rule of thumb:
Always pass a type parameter to
page.evaluate<T>()` when you use the return value. If the page function returns a complex object, define an interface for it first — it documents what you expect and catches mismatches.
💡 Plain English: Sending a field agent into a building (the browser) and giving them a typed briefing form to fill in. Without the form (`unknown` return), they come back with a verbal report — you have to guess what they found. With the typed form (`<ProductDetails>`), they come back with a completed form where every field is labelled and in the right format. You know exactly what information you'll get and can act on it immediately.
40
Practical

How do you use `as const` to define test tags — like "smoke", "regression", "critical" — as a typed union so that incorrectly tagged tests are a compile error?

In test automation, you often tag tests with categories for CI filtering (run only smoke tests on every commit, full regression on nightly). Without types, tags are plain strings — a typo like 'smooke' slips through and those tests never run in the right pipeline. as const lets you define the valid tags once and derive a union type from them that TypeScript enforces everywhere.

The problem with plain strings:

``ts
// No enforcement — any string is accepted:
function tagTest(name: string, tags: string[]) { ... }

tagTest('login flow', ['smooke', 'critcal']); // ✅ compiles — typos silently fail
`

Step 1 — Define the tags array with
as const:

`ts
// Without as const — TypeScript infers string[], not useful:
const TEST_TAGS = ['smoke', 'regression', 'critical', 'slow'];
type TestTag = typeof TEST_TAGS[number]; // string

// With as const — TypeScript locks in the exact literal values:
const TEST_TAGS = ['smoke', 'regression', 'critical', 'slow'] as const;
type TestTag = typeof TEST_TAGS[number];
// 'smoke' | 'regression' | 'critical' | 'slow'
`

as const freezes the array — TypeScript remembers 'smoke', 'regression', etc., not just string.

Step 2 — Use the type in test metadata:

`ts
interface TestMeta {
name: string;
tags: TestTag[];
}

function defineTest(meta: TestMeta, fn: () => Promise<void>) { /* ... */ }

// ✅ Valid:
defineTest({ name: 'login flow', tags: ['smoke', 'critical'] }, async () => { ... });
defineTest({ name: 'report export', tags: ['slow', 'regression'] }, async () => { ... });

// ❌ Compile error — 'smooke' is not a TestTag:
defineTest({ name: 'cart checkout', tags: ['smooke', 'critcal'] }, async () => { ... });
`

Step 3 — Use the array for validation and iteration:

`ts
// Validate a tag from an environment variable:
const ciTag = process.env.TEST_TAG;
if (TEST_TAGS.includes(ciTag as TestTag)) {
// ciTag is a valid TestTag — safe to use
}

// List all valid tags (e.g. in a help message):
console.log('Valid tags:', TEST_TAGS.join(', '));
// Logs: "Valid tags: smoke, regression, critical, slow"
`

The same
TEST_TAGS constant works as both a runtime array (for iteration and validation) AND the source of the compile-time type (for enforcement). One source of truth.

Rule of thumb:
Any fixed list of string values — tags, environments, browser names, test levels — that appears in more than one place should be defined with
as const` and have a derived union type. Typos become compile errors, and adding a new valid value requires updating exactly one place.
💡 Plain English: A bouncer with a typed guest list. Without `as const`, the list is a scrappy handwritten note — anything loosely matching gets in. With `as const`, the list is laminated and official. The bouncer (TypeScript) checks every name against the exact list and turns away anyone who doesn't match — even if they're only one letter off.
41
Practical

How do you write a typed Playwright API assertion helper that validates both the HTTP status code and the response body shape in one call?

When you write Playwright API tests, every test needs two assertions: check the status code AND check the body shape. Without a helper, this logic is duplicated across tests. A generic typed helper centralises both checks and returns the body as the correct type so you can immediately assert on its fields.

The helper — generic over the response body type:

``ts
import { APIResponse, expect } from '@playwright/test';

async function assertResponse<T>(
response: APIResponse,
expectedStatus: number
): Promise<T> {
expect(
response.status(),
Expected HTTP ${expectedStatus}, got ${response.status()}
).toBe(expectedStatus);

const body = (await response.json()) as T;
return body;
}
`

T is the type the caller says the body will be. TypeScript uses it to type the return value.

Using it in tests:

`ts
interface User { id: number; name: string; email: string; role: string; }
interface Order { id: number; userId: number; status: string; total: number; }

test('POST /users creates a new user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Alice', email: 'alice@example.com', role: 'regular' },
});

const user = await assertResponse<User>(response, 201);
// user is typed as User — full type safety:
expect(user.id).toBeGreaterThan(0);
expect(user.name).toBe('Alice');
expect(user.role).toBe('regular');
});

test('GET /orders/42 returns the correct order', async ({ request }) => {
const response = await request.get('/api/orders/42');

const order = await assertResponse<Order>(response, 200);
expect(order.status).toBe('shipped');
expect(order.total).toBeGreaterThan(0);
});

test('GET /users/999 returns 404', async ({ request }) => {
const response = await request.get('/api/users/999');

// Asserting a 404 — no body typing needed, so use unknown:
await assertResponse<unknown>(response, 404);
});
`

Adding response body validation with Zod (optional but recommended):

`ts
import { z } from 'zod';

const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'regular', 'guest']),
});

async function assertResponseValidated<T>(
response: APIResponse,
expectedStatus: number,
schema: z.ZodType<T>
): Promise<T> {
expect(response.status()).toBe(expectedStatus);
return schema.parse(await response.json()); // throws a ZodError if shape is wrong
}

const user = await assertResponseValidated(response, 201, UserSchema);
// user is typed AND validated at runtime
`

Rule of thumb:
Write one
assertResponse<T>` helper per project. Use the basic version for most tests (fast, no dependency). Add Zod validation for contract tests where you need to confirm the actual shape, not just assert on specific fields.
💡 Plain English: A quality inspector on a production line with two checks: first, check the box is the right size (status code); second, open it and verify the contents match the order form (body shape). Without a helper, every worker does both checks from memory. The helper is the standardised inspection form — same checks, same order, same way, every time.
42
Testing

How do you test TypeScript code — what tools and patterns do you use?

TypeScript test code uses the same tools as JavaScript tests — Playwright for E2E, Vitest or Jest for unit/integration — but with types giving you two extra guarantees: mock data that matches the real shape, and helper signatures that break loudly when the codebase changes.

Why the tooling choice matters:
For a pure TypeScript project, Vitest is faster and native-ESM-friendly with no config needed. For a Playwright-heavy project, Playwright's own test runner (@playwright/test) is the natural choice — it already handles TypeScript via its built-in ts-node.

Walked-through example — the patterns that matter in a QA context:

``ts
// Pattern 1 — Type-safe test data factory
// Partial<User> means the caller only specifies what the test actually cares about
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
}

function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides,
};
}

// Each test only specifies what it needs — the rest gets sensible defaults
const admin = makeUser({ role: 'admin' });
makeUser({ rol: 'admin' }); // TS error: 'rol' is not a key of User ✓
`

`ts
// Pattern 2 — Type-safe Jest mock
// The generic parameters type the return value and the arguments
const mockGetUser = jest.fn<Promise<User | null>, [number]>();
mockGetUser.mockResolvedValue(makeUser({ role: 'admin' }));

// If User gains a new required field, makeUser fails to compile until you add a default
`

`ts
// Pattern 3 — Testing the TypeScript types themselves (Vitest)
import { expectTypeOf } from 'vitest';

// Useful for utility type helpers and generic function return types
expectTypeOf(makeUser()).toEqualTypeOf<User>();
expectTypeOf(makeUser({ role: 'admin' }).role).toEqualTypeOf<'admin' | 'user' | 'viewer'>();
`

The compounding benefit:
Add a required
department field to User. The factory fails to compile immediately — TypeScript tells you to add a default. Every test using the factory still works once you do. Without TypeScript, you'd get silent undefined in tests that didn't set the field.

Real-world QA use case:
In a Playwright project, write one
makeUser, makeOrder, makeProduct factory per entity. Every test uses them with typed overrides. When the backend contract changes, the factories surface every affected test at compile time — before you run a single test.

Rule of thumb: Invest in typed factories first — they give the most compile-time coverage for the least code. Add typed mocks for unit-testing helpers. Use
expectTypeOf` for utility types only when the type itself is the thing being tested.
💡 Plain English: A typed dress rehearsal — every actor (test) must follow the exact script (type contract), and the director (TypeScript compiler) stops the rehearsal the moment someone improvises a line that doesn't match. Opening night (CI) runs clean because every deviation was caught at rehearsal.
43
Practical

How do you handle a third-party library with wrong or missing TypeScript type definitions?

When a library's types are missing or incorrect, you have four options in order of preference: install community types, patch with module augmentation, write a full declaration file, or (last resort) silence the module entirely. The right choice depends on how much of the library you use and how wrong the types are.

Why this matters in test projects:
Custom reporters, legacy test data generators, old assertion plugins, and internal tooling often ship without types — or with types that don't match the actual runtime API. Without fixing this, every call gives you any, and you lose the safety TypeScript promises.

Option 1 — Install community types (DefinitelyTyped):

``bash
# Check https://www.npmjs.com/~types or search @types/<name>
npm install -D @types/lodash # fully-typed lodash
npm install -D @types/uuid # type-safe uuid generation
`

Option 2 — Patch wrong types with module augmentation:

`ts
// types/some-reporter.d.ts — adds a missing property to an existing type
declare module 'some-reporter' {
interface ReporterOptions {
outputDir: string; // this exists at runtime but is missing from the published types
verbose?: boolean;
}
}
`

Module augmentation merges with existing types rather than replacing them — safe for small patches.

Option 3 — Write a full declaration file for an untyped library:

`ts
// types/legacy-data-generator.d.ts — describes only what your tests actually use
declare module 'legacy-data-generator' {
export function generateUser(role: 'admin' | 'user'): {
id: number;
email: string;
role: string;
};
export function generateOrder(userId: number): {
orderId: string;
total: number;
};
}
`

`json
// tsconfig.json — tell TypeScript to find your custom declarations
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
}
}
`

Only type the surface area you actually use — not the whole library.

Option 4 — Silence the module (last resort, escape hatch only):

`ts
// types/badly-typed-lib.d.ts
declare module 'badly-typed-lib'; // every import is any — no checking at all
`

Use this only for transitive dependencies you don't call directly.

Practical workflow: log the runtime output to see the actual shape, then write the declaration to match that — don't guess from the README. If the issue is in a widely-used library, consider contributing the fix to DefinitelyTyped.

Rule of thumb: Try
@types/ first (30 seconds). If none exists, write a minimal .d.ts` for only what you call (30 minutes). Never use the silence-the-module escape hatch on code you directly call.
💡 Plain English: A foreign appliance with a missing or wrong manual. First check if someone already translated it (`@types/`). If pages are missing, add them yourself (augmentation). If the whole manual describes the wrong appliance, write your own accurate one for the buttons you actually press (full declaration). Only as a last resort, tape over the warning labels and hope for the best (silence).
44
Types

What are the benefits of combining `readonly` and `as const` for configuration objects?

Using as const on a configuration object freezes every value to its most specific literal type and makes all properties deeply readonly. The combination means you can derive precise union types from the config, functions reject loose string arguments in favour of exact known values, and TypeScript catches accidental mutation at compile time.

Why this matters for test configuration:
Test configs — environment URLs, browser names, test tags, timeout values — are constants. Without as const, TypeScript widens them to broad primitives (string, number) and you lose the ability to use config values as type constraints. With as const, those exact values become types you can enforce across the whole test suite.

Walked-through example — Playwright test config:

``ts
// Without as const — TypeScript widens everything
const config = {
environment: 'staging', // type: string (too broad)
browsers: ['chromium', 'firefox'], // type: string[] (too broad)
timeout: 30000, // type: number (too broad)
};

// A function that only accepts known environments rejects this:
function getBaseUrl(env: 'staging' | 'prod'): string { return '...'; }
getBaseUrl(config.environment); // ❌ TS error: string not assignable to 'staging' | 'prod'
`

`ts
// With as const — literals preserved, all properties readonly
const CONFIG = {
environment: 'staging',
browsers: ['chromium', 'firefox'],
defaultTimeout: 30000,
tags: ['smoke', 'regression', 'critical'],
} as const;
// CONFIG.environment: 'staging' (not string)
// CONFIG.browsers: readonly ['chromium', 'firefox']
// CONFIG.defaultTimeout: 30000 (not number)

// Derive types from the config — single source of truth
type Environment = typeof CONFIG['environment']; // 'staging'
type Browser = typeof CONFIG['browsers'][number]; // 'chromium' | 'firefox'
type TestTag = typeof CONFIG['tags'][number]; // 'smoke' | 'regression' | 'critical'

// These functions now only accept values that exist in the config
function getBaseUrl(env: Environment): string {
return env === 'staging' ? 'https://staging.example.com' : '...';
}

getBaseUrl(CONFIG.environment); // ✓ — literal type matches
getBaseUrl('prod'); // ❌ TS error: 'prod' is not assignable to 'staging' ✓
`

`ts
// Mutation prevention — readonly catches accidental reassignment
CONFIG.environment = 'prod'; // ❌ TS error: Cannot assign to 'environment' — it is read-only ✓
`

Real-world QA use case:
Store all test constants (environments, browsers, tags, API versions) in a single
as const object. Import it everywhere — your functions accept only valid values from that object, and any out-of-range value is a compile error, not a silent runtime misconfiguration.

Rule of thumb: Any config object that should never change at runtime belongs behind
as const`. The literal types it preserves become constraints you can enforce across the codebase for free.
💡 Plain English: Engraving settings into a metal plate rather than writing them on a whiteboard. The engraved version is read-only, preserves the exact text, and can be copied precisely — any attempt to alter it shows up immediately, and every copy carries the exact original values.
45
Practical

Walk me through how you would approach migrating a JavaScript test framework to TypeScript.

Migrate incrementally — never as a big-bang rewrite. The strategy is to set up TypeScript alongside JavaScript so both can run simultaneously, then migrate files in order of impact: shared utilities first (they flow types upstream), page objects next (they benefit most from types), then individual test files last.

Why incremental matters:
A big-bang migration blocks the team for weeks and produces a huge PR that nobody can review properly. Incremental migration keeps CI green throughout, lets engineers learn the patterns gradually, and delivers value at each step.

Step 1 — Set up TypeScript without breaking existing tests:

``bash
npm install -D typescript @playwright/test
# If using Jest: npm install -D typescript ts-jest @types/jest
`

`json
// tsconfig.json — allowJs lets .ts and .js files coexist
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"allowJs": true, // existing .js files still compile
"checkJs": false, // don't type-check .js files yet
"strict": false, // start lenient; tighten later
"outDir": "./dist",
"esModuleInterop": true
},
"include": ["src//*", "tests//*"]
}
`

At this point, all existing
.js tests still run. Nothing breaks.

Step 2 — Migrate shared utilities first (highest impact):

`
helpers.js → helpers.ts (adds types to functions used by all tests)
api-client.js → api-client.ts (typed request/response shapes flow everywhere)
test-data.js → test-data.ts (typed factories catch bad overrides in every test)
`

These files are imported by many tests — migrating them first spreads type coverage automatically.

Step 3 — Migrate Page Objects (biggest quality win):

`ts
// Before (JS): LoginPage.js
class LoginPage {
async login(email, password) { ... } // no types — wrong args accepted silently
}

// After (TS): LoginPage.ts
class LoginPage {
constructor(private page: Page) {}
async login(email: string, password: string): Promise<void> { ... }
}
`

Private selectors, typed method signatures, typed return values — everything the POM pattern promises becomes enforced.

Step 4 — Enable strict flags one at a time:

`bash
# After each flag, fix errors before adding the next
noImplicitAny → week 1 (forces explicit types — biggest single improvement)
strictNullChecks → week 2 (surfaces null-related bugs)
strict: true → eventually
`

Step 5 — Track and enforce progress:

`bash
npx tsc --noEmit 2>&1 | wc -l # count should only decrease
`

The one non-negotiable rule: all new test files must be
.ts from day one of the migration. Never allow new .js files to be added — the migration should only move forward.

Real-world QA use case:
A 300-test Playwright suite in JavaScript. Start by adding TypeScript for the 5 shared helper files. Every test immediately gets type-checked calls to those helpers. Then migrate the 15 page objects. By the time you touch individual test files, 80% of the type coverage is already in place.

Rule of thumb: Migrate bottom-up (utilities → page objects → tests). Keep CI green throughout. Never allow new
.js additions. The migration is complete when strict: true` compiles with zero errors.
💡 Plain English: Converting a paper-based filing system to digital. You don't scan every document on day one — you start with the most-referenced reference guides (utilities), then the frequently-accessed folders (page objects), then individual case files (test specs). The office keeps running throughout, and every migrated section immediately improves the whole system's reliability.
46
Types

What is the difference between `any` and `unknown` in TypeScript, and which should you use when handling data from an API response in tests?

Both any and unknown represent "a value whose type we don't know yet," but they behave very differently. any turns off type checking entirely — you can access any property, call anything, and TypeScript stays silent. unknown forces you to check the type before you can use the value — it keeps type safety on.

The difference in practice:

``ts
// any — TypeScript trusts you completely, even when you're wrong:
const data: any = await response.json();
console.log(data.user.profile.avatar); // ✅ TypeScript: "sure, no problem"
// 💥 Runtime: "Cannot read property 'profile' of undefined"

// unknown — TypeScript makes you prove the type before accessing it:
const data: unknown = await response.json();
console.log(data.user.profile.avatar); // ❌ Compile error: 'data' is of type 'unknown'
`

unknown turns a potential runtime crash into a compile-time error. You have to handle the uncertainty explicitly.

Three ways to safely use an
unknown response:

Option 1 — Type assertion (quick, trust-based):
`ts
const data: unknown = await response.json();
const user = data as User; // tell TypeScript "I know what this is"
expect(user.name).toBe('Alice');
`
Simple, but risky — if the API sends the wrong shape, you get a runtime error not a compile error.

Option 2 — Type guard (safe, manual):
`ts
function isUser(x: unknown): x is User {
return (
typeof x === 'object' && x !== null &&
typeof (x as any).id === 'number' &&
typeof (x as any).name === 'string'
);
}

const data: unknown = await response.json();
if (!isUser(data)) throw new Error('Response shape does not match User');
expect(data.name).toBe('Alice'); // TypeScript narrows to User here
`

Option 3 — Zod schema (safe, automatic — best for API contract tests):
`ts
import { z } from 'zod';

const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string() });

const data: unknown = await response.json();
const user = UserSchema.parse(data); // throws ZodError with details if shape is wrong
expect(user.name).toBe('Alice'); // user is typed as { id: number; name: string; email: string }
`

When to use which:
-
unknown over any — always, at system boundaries (API responses, JSON.parse, external data).
- Type assertion (
as) — only when you control and trust the source.
- Type guard — when you want a custom error message or need to check shape manually.
- Zod — when you want the type AND runtime validation from a single schema definition.

Rule of thumb:
Never use
any for API response data in tests. Use unknown` as the starting point, then narrow it with one of the three options. A test that masks a wrong response shape is worse than no test at all.
💡 Plain English: `any` is a master key that opens every door without question. `unknown` is a locked door — you have to show the right key (type check or assertion) before it lets you through. In a test suite, you WANT locked doors: an API response that doesn't match your expected shape should be caught and flagged, not silently accepted.
47
Narrowing

How do you build a robust type guard for an unknown API response object?

A robust type guard checks not just that properties exist, but that each property has the right type — including enum membership for literal unions. The return type x is User tells TypeScript to narrow the value to User in any code path where the guard returns true.

Why "robust" matters:
A shallow guard that only checks typeof x === 'object' will accept any object — it won't catch a missing field or a wrong value type. A thorough guard validates every field the interface declares, so it accurately reflects the runtime shape.

Walked-through example — guarding a User response:

``ts
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
}

function isUser(x: unknown): x is User {
// Step 1: must be a non-null object (primitive check)
if (typeof x !== 'object' || x === null) return false;

// Step 2: cast to a record for property checks without silencing TS entirely
const obj = x as Record<string, unknown>;

// Step 3: check each required field's type
if (typeof obj['id'] !== 'number') return false;
if (typeof obj['name'] !== 'string') return false;
if (typeof obj['email'] !== 'string') return false;

// Step 4: check enum membership for literal union fields
if (!['admin', 'user', 'viewer'].includes(obj['role'] as string)) return false;

return true;
}

// Usage in an API test:
const data: unknown = await response.json();

if (!isUser(data)) {
throw new Error(
Unexpected response shape: ${JSON.stringify(data)});
}

// TypeScript knows data is User from here — full autocomplete
expect(data.email).toContain('@');
expect(data.role).toBe('admin');
`

The practical trade-off — hand-written guard vs Zod:

`ts
// Hand-written guard: ~15 lines, manual, can drift from the interface
function isUser(x: unknown): x is User { /* ... */ }

// Zod: 6 lines, single source of truth for type AND validation
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
});
type User = z.infer<typeof UserSchema>; // type derived from schema — no drift possible
const user = UserSchema.parse(data); // throws with field-level detail on mismatch
``

Use a hand-written guard when you have no schema library available or for very simple shapes. Use Zod for anything with more than 3-4 fields or nested objects — the guard can't drift from the type because they're the same thing.

Rule of thumb: Write a thorough guard (check every field's type, not just existence). For anything complex, prefer Zod — it generates the TypeScript type and the runtime validator from a single definition, so they can never disagree.
💡 Plain English: A thorough customs inspection — not just "is there a passport?" but "is the name field a string? is the ID number actually a number? is the nationality one of the valid country codes?" Each check rules out a category of invalid responses. Zod is a trained inspector who does all those checks automatically from a pre-loaded checklist — faster and more reliable than a manual inspection of the same checklist.
48
Practical

How do you type environment variables in a TypeScript Node.js project?

Every process.env value is typed as string | undefined by default — TypeScript has no idea which variables are required, which are optional, or what their values mean. The fix is a validated config module that reads from process.env once at startup, validates every required variable, and exports a fully typed object that the rest of the app (and test suite) imports.

Why this matters in test automation:
Test suites read environment variables constantly — BASE_URL, API_KEY, TEST_USER_EMAIL, environment names, timeout overrides. Without typed config, every file that accesses process.env.BASE_URL gets string | undefined and has to defensively check for undefined. With a validated config module, you fail fast with a clear error at startup and get clean string types everywhere else.

Walked-through example — Playwright test config module:

``ts
// config/env.ts — the single place where process.env is read and validated
import { z } from 'zod';

const EnvSchema = z.object({
BASE_URL: z.string().url(), // must be a valid URL
TEST_USER_EMAIL: z.string().email(),
TEST_USER_PASS: z.string().min(8),
ENVIRONMENT: z.enum(['staging', 'uat', 'production']),
API_TIMEOUT_MS: z.string().transform(Number).default('30000'), // string → number
});

// parse() throws at startup with a clear message if any variable is wrong or missing:
// ZodError: BASE_URL: Invalid url, ENVIRONMENT: Invalid enum value
export const env = EnvSchema.parse(process.env);

// Types after parsing:
// env.BASE_URL: string (not string | undefined)
// env.ENVIRONMENT: 'staging' | 'uat' | 'production'
// env.API_TIMEOUT_MS: number (transformed from string)
`

`ts
// playwright.config.ts — clean, no optional chaining or null checks
import { env } from './config/env';

export default defineConfig({
use: {
baseURL: env.BASE_URL,
actionTimeout: env.API_TIMEOUT_MS,
extraHTTPHeaders: {
'X-Test-Environment': env.ENVIRONMENT,
},
},
});
`

`ts
// A test helper — no process.env anywhere, just the typed config
import { env } from '../config/env';

async function loginAsTestUser(page: Page): Promise<void> {
await page.fill('[data-testid="email"]', env.TEST_USER_EMAIL);
await page.fill('[data-testid="password"]', env.TEST_USER_PASS);
await page.click('[type="submit"]');
}
`

Without Zod — a lighter alternative (no runtime validation, but typed):

`ts
// Simpler, no validation — still better than raw process.env everywhere
export const env = {
baseUrl: process.env.BASE_URL!, // ! asserts non-null — runtime risk
timeout: Number(process.env.TIMEOUT ?? '30000'),
environment: (process.env.ENVIRONMENT ?? 'staging') as 'staging' | 'uat' | 'production',
} as const;
`

Use Zod when you want to catch misconfiguration at startup with a clear error message. Use the lighter approach for small projects where the variables are always set by CI.

Rule of thumb: Never scatter
process.env.SOMETHING` calls throughout your test code. Centralise all environment variable access in one validated config module — fail fast at startup, use clean types everywhere else.
💡 Plain English: A pre-flight checklist that verifies every required instrument is present, calibrated, and showing a valid reading before takeoff. You don't discover mid-flight that the altimeter is missing — you catch it on the ground, before any passenger boards.
49
Generics

How do you write a typed `retry` helper in TypeScript that wraps any async test action and automatically returns the correct type?

A retry helper re-runs an async function on failure, up to a maximum number of attempts. Without generics, the return type would be any or unknown — the caller would lose type information about what the action returns. With a generic <T>, the helper infers T from the function you pass in and returns the exact same type.

Why generics solve this:
You want ONE retry helper that works for every action — apiGet<User>, apiGet<Order>, page.evaluate<string>, anything. The return type should match whatever the wrapped function returns, automatically.

The typed retry helper:

``ts
async function retry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; delayMs?: number } = {}
): Promise<T> {
const { maxAttempts = 3, delayMs = 500 } = options;
let lastError: unknown;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(); // return immediately on success
} catch (err) {
lastError = err;
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, delayMs));
}
}
}

throw lastError; // re-throw after all attempts exhausted
}
`

T is inferred from fn's return type. TypeScript figures it out automatically — you don't pass T explicitly.

Using it in tests — TypeScript infers the return type from the wrapped function:

`ts
interface User { id: number; name: string; email: string; }
interface Order { id: number; status: string; total: number; }

// user is typed as User — not any:
const user = await retry(() => apiGet<User>(request, '/api/users/1'));
expect(user.name).toBeTruthy();

// order is typed as Order — not any:
const order = await retry(
() => apiGet<Order>(request, '/api/orders/42'),
{ maxAttempts: 5, delayMs: 1000 }
);
expect(order.status).toBe('shipped');

// Works with Playwright actions too:
const title = await retry(() => page.evaluate<string>(() => document.title));
// title is string — not any
`

Practical use case — retrying a flaky API endpoint in CI:

`ts
test('order summary loads after payment processing', async ({ request }) => {
// Payment processing can take a few seconds — poll until it's ready:
const order = await retry(
async () => {
const resp = await request.get('/api/orders/42');
if (resp.status() === 202) throw new Error('Still processing'); // trigger retry
return resp.json() as Order;
},
{ maxAttempts: 10, delayMs: 800 }
);

expect(order.status).toBe('paid');
expect(order.total).toBeGreaterThan(0);
});
`

Rule of thumb:
The
fn: () => Promise<T> signature is the key pattern. Whenever you write a helper that accepts and invokes an async callback, make the return type generic over that callback's return type — TypeScript infers T` automatically and the helper stays type-safe for every use.
💡 Plain English: A courier service that guarantees delivery of any parcel. You hand them the parcel (the async function) and they try up to three times to deliver it. The guarantee is the same regardless of what's in the parcel — a letter (User), a box (Order), or a package (string). The courier doesn't care what's inside; the recipient (your test) gets exactly what was sent.
50
Practical

How do you use TypeScript to keep test helper functions and fixtures type-safe across a large test suite?

The foundation is a set of typed factory functions — one per entity — that the whole suite imports. Each factory accepts a Partial<T> override so tests only specify what they care about, while TypeScript ensures every override matches the real interface. When the interface changes, every factory and every test that uses it fails to compile — before you run a single test.

Why this matters at scale:
In a 200+ test suite, test data factories are called hundreds of times. Without types, a renamed field (roleuserRole) silently breaks dozens of tests — they pass role into a factory that now ignores it, tests run with the wrong user state, and failures are mysterious. With typed factories, every broken call is a compile error the moment you rename the field.

Walked-through example — typed factory layer:

``ts
// types/entities.ts — shared interfaces (single source of truth)
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}

export interface Order {
id: number;
userId: number;
status: 'pending' | 'paid' | 'shipped' | 'cancelled';
items: { productId: number; qty: number }[];
total: number;
}
`

`ts
// factories/user.factory.ts
import { User } from '../types/entities';

export function makeUser(overrides: Partial<User> = {}): User {
return {
id: Math.floor(Math.random() * 10000),
name: 'Test User',
email: 'test@example.com',
role: 'viewer',
createdAt: new Date().toISOString(),
...overrides,
};
}

// tests/admin.spec.ts
const admin = makeUser({ role: 'admin' });
const editor = makeUser({ email: 'editor@example.com', role: 'editor' });
makeUser({ rol: 'admin' }); // TS error: 'rol' is not a key of Partial<User> ✓
`

`ts
// factories/order.factory.ts
import { Order } from '../types/entities';

export function makeOrder(userId: number, overrides: Partial<Order> = {}): Order {
return {
id: Math.floor(Math.random() * 10000),
userId,
status: 'pending',
items: [],
total: 0,
...overrides,
};
}
`

Typed static fixtures — for stable, reusable test data:

`ts
// fixtures/seed-data.ts
import { makeUser, makeOrder } from '../factories';

export const adminUser = makeUser({ role: 'admin', id: 1 });
export const regularUser = makeUser({ role: 'viewer', id: 2 });
export const paidOrder = makeOrder(adminUser.id, { status: 'paid', total: 299.99 });
// All validated at compile time — stale shapes fail here, not in a test at 2 AM
`

Playwright fixture integration — typed setup that every test can use:

`ts
// fixtures/index.ts
import { test as base } from '@playwright/test';
import { makeUser } from '../factories/user.factory';
import { User } from '../types/entities';

type TestFixtures = { testUser: User; adminUser: User };

export const test = base.extend<TestFixtures>({
testUser: async ({}, use) => use(makeUser()),
adminUser: async ({}, use) => use(makeUser({ role: 'admin' })),
});
`

The compounding benefit across a large suite:
When
User adds a required department field, the factories fail to compile until you add a default. One fix in one factory file — every test that uses it is automatically updated.

Rule of thumb: Put shared interfaces in
types/, factories in factories/, and static fixtures in fixtures/`. Every test imports from these — no inline object literals with raw field names. The entire test suite's data correctness is enforced at compile time.
💡 Plain English: A typed props department for a film production. Every prop is catalogued by type and labelled for which scene it belongs to. New crew members grab exactly the right prop without guessing, and if the script rewrites a character's role, the props master flags every scene that needs updating before a single frame is shot.

Senior (5+ years)

1
Playwright

How do you architect Playwright test sharding across CI workers for a large test suite?

Sharding splits your test suite into slices that run in parallel across multiple CI machines — if you have 500 tests and 5 workers, each worker runs ~100 tests independently, cutting total wall-clock time by ~5×.

How Playwright sharding works:
``ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true, // also run tests within each shard in parallel
});
`

`bash
# CI job 1 of 5 — pass --shard flag; Playwright divides automatically
npx playwright test --shard=1/5

# CI job 2 of 5
npx playwright test --shard=2/5
`

Merging shard reports into one:
`ts
// Each shard writes a partial blob report
export default defineConfig({
reporter: process.env.CI ? 'blob' : 'html',
});
`
`bash
# Final CI job collects all blob artifacts, then merges
npx playwright merge-reports --reporter html ./all-blob-reports
`

Key design decisions at senior level:
- Tag slow tests (
@slow) and give them dedicated workers to prevent stragglers from blocking all shards
- Use the same
--shard` total count across every run — shard 1/5 always gets the same file bucket
- Run global auth setup once per CI pipeline, not once per shard — store auth state to a file and share it
- Never let multiple shards write to the same test database record; isolate per-shard data with unique IDs or separate schemas

Rule of thumb: sharding reduces time linearly only when shards have no shared mutable state — the architectural challenge is isolation, not just splitting files.
💡 Plain English: Dividing a long exam paper among 5 graders — each grader marks their own section independently and quickly. The work scales linearly, but only if each section is self-contained and graders don't argue over the same answer sheet.
2
TypeScript

How do you design a type-safe Page Object Model hierarchy using TypeScript generics and abstract classes?

A Page Object Model (POM) wraps page interactions into classes so tests read as business logic, not raw Playwright selectors. TypeScript abstract classes let you put shared infrastructure (navigation, waiting, error handling) in one place; generics keep each page's API strongly typed without duplication.

Abstract base class — shared behaviour once:
``ts
import { Page } from '@playwright/test';

abstract class BasePage {
constructor(protected readonly page: Page) {}

async goto(path: string): Promise<void> {
await this.page.goto(path);
await this.waitForReady(); // delegates to subclass
}

// Each subclass defines what "ready" means for that page
protected abstract waitForReady(): Promise<void>;
}
`

Generic form helper — reusable across any form page:
`ts
import { Locator } from '@playwright/test';

// T is a map of field-name → Locator; different pages pass different maps
class FormPage<T extends Record<string, Locator>> extends BasePage {
constructor(page: Page, protected fields: T) {
super(page);
}

async fill(data: Partial<{ [K in keyof T]: string }>): Promise<void> {
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) await (this.fields[key] as Locator).fill(value);
}
}

protected async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
}
`

Concrete page using the generic base:
`ts
class LoginPage extends FormPage<{ email: Locator; password: Locator }> {
constructor(page: Page) {
super(page, {
email: page.locator('[data-testid="email"]'),
password: page.locator('[data-testid="password"]'),
});
}

async login(email: string, password: string): Promise<void> {
await this.goto('/login');
await this.fill({ email, password }); // TypeScript: only email/password are valid keys
await this.page.locator('[type="submit"]').click();
}
}
`

What TypeScript buys you:
-
fill({ username: 'x' }) is a compile-time error — LoginPage has no username field
- Forgetting
waitForReady in a subclass is a compile-time error (abstract enforcement)
- Refactoring
goto()` in the base class propagates to every page automatically

Rule of thumb: abstract classes own the "how" (navigation, waiting); concrete page classes own the "what" (selectors and actions for that specific page).
💡 Plain English: A restaurant chain — head office (abstract base) defines the standard kitchen layout and safety rules every branch must follow. Each branch (concrete page) adds its own menu but can't skip the health inspection (abstract methods enforced at compile time).
3
Test Data

How do you manage test data at scale — typed factories, seed strategies, and teardown?

Hard-coded test data breaks the moment another parallel test edits the same record. The solution: each test creates exactly the data it needs, owns it for its lifetime, then cleans it up — regardless of whether the test passes or fails.

Typed factory function:
``ts
interface User {
id: string;
email: string;
role: 'admin' | 'viewer';
plan: 'free' | 'pro';
}

// Partial<User> so callers override only what matters for each test
function buildUser(overrides: Partial<User> = {}): User {
return {
id: crypto.randomUUID(), // unique per call — no collisions
email:
test-${Date.now()}@example.com,
role: 'viewer',
plan: 'free',
...overrides,
};
}

const adminUser = buildUser({ role: 'admin', plan: 'pro' });
const freeUser = buildUser(); // all defaults
`

Playwright fixture for automatic seeding and teardown:
`ts
import { test as base } from '@playwright/test';

const test = base.extend<{ seededUser: User }>({
seededUser: async ({ request }, use) => {
// SETUP — create via API before the test runs
const user = buildUser({ role: 'admin' });
await request.post('/api/users', { data: user });

await use(user); // hand the live user object to the test

// TEARDOWN — runs after the test, whether it passed or failed
await request.delete(
/api/users/${user.id});
},
});

test('admin can view billing', async ({ page, seededUser }) => {
await page.goto(
/users/${seededUser.id}/billing);
await page.waitForSelector('h1:text("Billing")');
});
``

Seed strategy tiers:
| Scope | When to use | Example |
|---|---|---|
| Test-level fixture | Mutable data — each test needs its own copy | User, order, session |
| Worker-level fixture | Read-only reference data — safe to share within one worker | Country list, plan config |
| Global setup | One-time immutable seed — shared across all workers | Static product catalog |

Rule of thumb: mutable data = test-scoped; read-only reference data = worker or global scope. Never share mutable test data across parallel tests.
💡 Plain English: A pop-up market stall at a festival — each vendor (test) assembles their own stall, serves customers, then fully dismantles it. Nobody argues over the same table and the field is clean for the next event.
4
Playwright

How do you design a type-safe custom Playwright reporter in TypeScript?

Playwright reporters receive lifecycle events as tests run and let you do anything with that data — post to Slack, write to a database, generate custom dashboards. You implement the Reporter interface and TypeScript enforces every method signature at compile time.

Custom Slack alert reporter:
``ts
import type {
Reporter, TestCase, TestResult, FullResult
} from '@playwright/test/reporter';

class SlackReporter implements Reporter {
private failures: string[] = [];

// Called after each test — TestResult.status is a typed union
onTestEnd(test: TestCase, result: TestResult): void {
if (result.status !== 'passed') {
// titlePath() returns ['Suite name', 'test name'] — join for readability
this.failures.push(test.titlePath().join(' › '));
}
}

// Called once when the entire run finishes
async onEnd(result: FullResult): Promise<void> {
if (this.failures.length === 0) return; // nothing to report

const body = {
text:
❌ ${this.failures.length} test(s) failed in CI,
blocks: this.failures.map(name => ({
type: 'section',
text: { type: 'mrkdwn', text:
• ${name} },
})),
};

await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
}

export default SlackReporter;
`

Register in playwright.config.ts:
`ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
reporter: [
['./reporters/slack-reporter.ts'], // custom reporter
['html'], // keep default HTML report alongside
],
});
`

What TypeScript enforces here:
- Wrong method signatures (
onTestEnd(test: string)) error at compile time — the interface is the contract
-
TestResult.status is 'passed' | 'failed' | 'skipped' | 'timedout' | 'interrupted' — exhaustiveness checking prevents silent omissions
-
async onEnd is fully supported — Playwright awaits it before the process exits

Rule of thumb: only implement the lifecycle hooks you need — unused
Reporter methods are optional. Start with onTestEnd + onEnd, add onStepEnd and onError` only if your use case needs them.
💡 Plain English: A sports commentator who only speaks when something significant happens — the broadcast network (Playwright) calls you for each event, and you decide what to air. You don't run the game; you observe and report it.
5
Declaration Files

How do you write a declaration file (.d.ts) for untyped JavaScript?

A .d.ts file is a pure type description — it contains no executable code, only the shapes of functions, classes, and values that a JavaScript module exports. TypeScript reads it to understand the module without re-implementing it.

Why this matters at senior QA level:
Internal test tooling, legacy reporters, and custom CI utilities are often JavaScript with no types. Without a declaration file, every import from those modules is typed as any, losing all safety. Writing even a minimal .d.ts for the surface area you actually call restores type checking instantly.

Walked-through example — typing a legacy test reporter:

``ts
// types/legacy-reporter.d.ts

declare module 'legacy-test-reporter' {
// Describe the shapes you actually use — not every function in the library

export interface TestSuiteResult {
suiteName: string;
passed: number;
failed: number;
skipped: number;
durationMs: number;
}

// Function overload: two call signatures, one implementation
export function report(result: TestSuiteResult): void;
export function report(result: TestSuiteResult, outputPath: string): void;

export function init(config: { outputDir: string; verbose?: boolean }): void;

// A class in the module
export class ReportFormatter {
constructor(template: string);
format(result: TestSuiteResult): string;
}
}
`

`ts
// types/global.d.ts — for values injected into the global scope (e.g. by a test runner)
declare const __TEST_RUN_ID__: string;
declare const __BUILD_NUMBER__: number;
`

`json
// tsconfig.json — tell TypeScript where your declarations live
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
}
}
`

Three patterns in practice:

`ts
// Pattern 1 — declare module for an npm package without types
declare module 'untyped-npm-package' {
export function process(input: string): string;
}

// Pattern 2 — augment an existing module (add a missing method)
declare module 'some-library' {
interface SomeLibraryOptions {
missingProp: boolean; // exists at runtime but not in the published types
}
}

// Pattern 3 — declare global variable injected by a build tool or test runner
declare const __ENVIRONMENT__: 'staging' | 'production';
`

What to type and what to skip:
Only type the functions and properties you actually call. A 5-function declaration file for a 200-function library is completely valid — TypeScript only checks what you typed, and silently allows access to anything else via structural compatibility. Grow the declaration as your usage grows.

Real-world QA use case:
Your team uses an internal Node.js script for seeding test data. It has no types. Write a
.d.ts that describes the three functions you call (seedUser, seedOrder, clearDatabase). All callers in the test suite immediately get autocomplete and type checking — one 15-line file protects the entire seed layer.

Rule of thumb: Only type the surface area you use. A minimal
.d.ts` that's accurate is infinitely better than a comprehensive one that guesses wrong and silently misleads.
💡 Plain English: Writing the instruction manual for a machine that shipped without one — you describe the buttons you actually press and what they do. You don't need to reverse-engineer the entire internal circuit; just document the interface that operators use.
6
Playwright

How do you implement Playwright fixtures for per-test authentication state isolation?

Re-authenticating before every test is slow and fragile. Playwright fixtures let you authenticate once per role, save that state to a file, then hand each test a fresh isolated browser context pre-loaded with the right credentials — no network round-trip per test.

Step 1 — Global setup: authenticate once and save state:
``ts
// global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto('/login');
await page.fill('[name="email"]', process.env.TEST_ADMIN_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_ADMIN_PASSWORD!);
await page.click('[type="submit"]');
await page.waitForURL('/dashboard');

// Persist cookies + localStorage to a file
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
await browser.close();
}
`

Step 2 — Config: apply saved state to every test by default:
`ts
// playwright.config.ts
export default defineConfig({
globalSetup: './global-setup.ts',
use: {
storageState: 'playwright/.auth/admin.json', // every test starts already logged in
},
});
`

Step 3 — Typed fixture for multiple roles:
`ts
// fixtures.ts
import { test as base, Browser, Page } from '@playwright/test';

type RoleFixtures = { adminPage: Page; viewerPage: Page };

const test = base.extend<RoleFixtures>({
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const page = await ctx.newPage();
await use(page);
await ctx.close(); // isolated context — cookie changes don't leak to other tests
},

viewerPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/viewer.json' });
const page = await ctx.newPage();
await use(page);
await ctx.close();
},
});

// Test using two roles at once — each has a fully isolated context
test('viewer cannot access admin panel', async ({ adminPage, viewerPage }) => {
await adminPage.goto('/admin/settings');
await adminPage.waitForSelector('h1:text("Settings")'); // admin: OK

await viewerPage.goto('/admin/settings');
await viewerPage.waitForURL('/403'); // viewer: blocked
});

export { test };
`

Why each
newContext() is the isolation key:
-
storageState` loads cookies + localStorage into a fresh context — no actual login network call
- Each context has its own isolated cookie jar — mutations in one test cannot affect another
- Parallel workers each get independent contexts — no race conditions on auth state

Rule of thumb: global setup = authenticate each role once; fixtures = hand each test a clean context pre-loaded with the right role's state, then close it on teardown.
💡 Plain English: Hotel keycards — the front desk (global setup) programs all keycards once at check-in. Each guest's card (fixture) opens only their room and is deactivated at checkout. Changing your room settings doesn't affect anyone else's keycard.
7
Advanced Types

How do you simulate nominal typing (branded types)?

TypeScript uses structural typing — two types with the same shape are interchangeable, even if they represent semantically different things. A branded type adds a phantom property to a primitive (like number or string) that makes TypeScript treat it as a distinct type, even though the phantom property has no runtime value.

Why structural typing creates real bugs in test automation:
A UserId and a TestRunId might both be number. Without branding, TypeScript lets you pass one where the other is expected — silently. In an API test, passing the wrong ID to the wrong endpoint is a bug that compiles cleanly and produces a confusing 404 at runtime.

Walked-through example — typed IDs in an API test suite:

``ts
// The phantom brand property exists only at the type level — zero runtime cost
type UserId = number & { readonly __brand: 'UserId' };
type OrderId = number & { readonly __brand: 'OrderId' };
type TestRunId = string & { readonly __brand: 'TestRunId' };

// Brand factory functions — the only place you cast
function asUserId(id: number): UserId { return id as UserId; }
function asOrderId(id: number): OrderId { return id as OrderId; }
function asTestRunId(id: string): TestRunId { return id as TestRunId; }

// API helpers that accept only the correct ID type
async function getUser(request: APIRequestContext, id: UserId): Promise<User> {
return request.get(
/api/users/${id}).then(r => r.json());
}

async function getOrder(request: APIRequestContext, id: OrderId): Promise<Order> {
return request.get(
/api/orders/${id}).then(r => r.json());
}
`

`ts
// Usage in tests — branding must be intentional
const userId = asUserId(42);
const orderId = asOrderId(99);

await getUser(request, userId); // ✓
await getOrder(request, orderId); // ✓

await getUser(request, orderId); // ❌ TS error: OrderId not assignable to UserId ✓
await getUser(request, 42); // ❌ TS error: number not assignable to UserId ✓
`

A lighter pattern — opaque type alias via intersection:

`ts
// Useful for string IDs that should not be confused
type StagingUrl = string & { readonly _tag: 'StagingUrl' };
type ProductionUrl = string & { readonly _tag: 'ProductionUrl' };

function runSmokeTest(url: StagingUrl): Promise<void> { /* ... */ }

const staging = 'https://staging.example.com' as StagingUrl;
runSmokeTest(staging); // ✓
runSmokeTest('https://app.example.com' as ProductionUrl); // ❌ TS error ✓
``

Real-world QA use case:
In a large API test suite with many entities (users, orders, products, sessions), branded IDs prevent the "wrong ID, right type" bug class entirely. The brand factory functions become the single chokepoint where raw database IDs get typed — everywhere else is safe.

Rule of thumb: Brand any ID or token type where structural equivalence would allow silent mismatches. The runtime cost is zero; the bug-prevention value is high in suites that manipulate multiple entity types.
💡 Plain English: Two physically identical keys that open different doors — branding stamps each one with its purpose. Without the stamp, the locksmith (TypeScript) can't tell them apart. With it, using the house key on the car is immediately detected before you even try the lock.
8
Advanced Types

How do you guarantee you handled every case of a union (exhaustiveness checking)?

Exhaustiveness checking is a TypeScript pattern that causes a compile error the moment you add a new variant to a discriminated union but forget to handle it somewhere. The mechanism: assign the unhandled value to a variable typed as never in the default branch — TypeScript errors because nothing is assignable to never except an already-narrowed-away value.

Why this matters in test tooling:
Custom reporters, result formatters, and retry schedulers all switch on status unions like 'passed' | 'failed' | 'skipped' | 'timedout'. When a new status is added, forgetting to update one of these functions creates a silent gap — the case falls through to the default, wrong behavior runs, and no alarm fires. Exhaustiveness checking turns that silent gap into a compile error.

Walked-through example — test result reporter:

``ts
type TestStatus = 'passed' | 'failed' | 'skipped' | 'timedout';

// The never-assignment pattern — exhaustiveness guard
function assertNever(x: never, message: string): never {
throw new Error(
${message}: ${JSON.stringify(x)});
}

function formatResult(status: TestStatus): string {
switch (status) {
case 'passed': return '✅ Passed';
case 'failed': return '❌ Failed';
case 'skipped': return '⏭️ Skipped';
case 'timedout': return '⏱️ Timed out';
default:
// If all cases above are handled, TypeScript narrows status to never here
// If a new status is added but not handled, this line becomes a compile error
return assertNever(status, 'Unhandled TestStatus');
}
}
`

`ts
// Now add a new status to the union:
type TestStatus = 'passed' | 'failed' | 'skipped' | 'timedout' | 'interrupted';

// TypeScript immediately errors on the assertNever line in formatResult:
// Argument of type 'string' is not assignable to parameter of type 'never'
// → Forces you to add the 'interrupted' case before the code compiles ✓
`

The two common forms:

`ts
// Form 1 — throw at runtime (good for reporters and utilities)
default:
return assertNever(status, 'Unhandled TestStatus in formatResult');

// Form 2 — compile-time only, no runtime throw (good when default is unreachable)
default:
const _check: never = status;
return _check; // TypeScript errors here if status isn't fully narrowed
`

Real-world QA use case:
A Playwright custom reporter handles
onTestEnd and switches on result.status. Add 'interrupted' to the status union when Playwright gains that status in a future version — the compile error in the reporter catches the gap before it ships to CI and silently swallows interrupted test results.

Rule of thumb: Add a
never` guard to every switch or if-chain that dispatches on a discriminated union. The one-time cost is two lines; the protection lasts for the life of the codebase.
💡 Plain English: A checklist with a final "sign-off" box that only unlocks when every previous box is ticked. Add a new requirement to the list and the sign-off box becomes unavailable again — it refuses to be completed until the new item is also checked.
9
Narrowing

What are assertion functions (`asserts x is T`)?

An assertion function is a function whose return type is declared as asserts x is T or asserts condition. When it returns normally (without throwing), TypeScript narrows the type for the rest of the calling scope. When it throws, execution stops and the narrowing never applies. The key difference from a type guard: assertion functions are statements, not conditions — you call them and move on, rather than using them in an if.

Why this pattern exists:
Type guards require an if statement around every use. Assertion functions let you write a single asserting call at the top of a block and have TypeScript trust the narrowed type for everything that follows — cleaner for setup checks and precondition validation.

Walked-through example — test precondition assertions:

``ts
// Assertion function — throws if condition is false, narrows the type if it passes
function assertDefined<T>(
value: T | null | undefined,
label: string
): asserts value is T {
if (value == null) {
throw new Error(
Assertion failed: ${label} must not be null or undefined);
}
}

// Usage in a Playwright test setup:
async function setupTest(page: Page) {
const token = await getAuthToken(page); // returns string | null
assertDefined(token, 'auth token');

// From here, TypeScript knows token is string — no optional chaining needed
return { token, headers: { Authorization:
Bearer ${token} } };
// ^^^^ no TS error ✓
}
`

`ts
// A boolean assertion variant — narrows based on an arbitrary condition
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(
Assertion failed: ${message});
}

// Usage — TypeScript trusts the narrowing after the assertion
const response = await request.get('/api/users/1');
assert(response.ok(),
Expected 200, got ${response.status()});
// TypeScript now treats the execution path past this line as "response was ok"

const body = await response.json() as User;
// Confident: the response was 2xx before we parsed the body
`

Assertion function vs type guard — when to use each:

`ts
// Type guard — used as a condition (good for branching logic)
if (isUser(data)) {
console.log(data.email); // TypeScript narrows inside the if
}

// Assertion function — used as a statement (good for preconditions and setup)
assertIsUser(data);
console.log(data.email); // TypeScript narrows from this point forward
`

Real-world QA use case:
Use assertion functions in Playwright fixture setup to validate that environment variables, authentication tokens, and API responses meet preconditions before tests run. A clear thrown error from
assertDefined(process.env.BASE_URL, 'BASE_URL') beats a cryptic TypeError: Cannot read properties of undefined` three levels deep in a test.

Rule of thumb: Use assertion functions for "this must be true before we continue" checks. Use type guards for "branch on whether this is true" checks. Both narrow types — the difference is whether you want branching or a hard stop.
💡 Plain English: A bouncer with a stamp versus a guard at a fork in the road. The bouncer (assertion function) either throws you out or stamps your hand — once stamped, every staff member downstream treats you as verified without rechecking. The guard (type guard) directs you left or right based on what you carry — useful when you need to handle both paths.
10
TypeScript

How do you migrate a large JavaScript Playwright test suite to TypeScript incrementally?

Renaming every .js file to .ts and fixing all errors at once is impractical on a large suite. The right approach lets JS and TS coexist, tightens strictness file by file, and keeps the CI pipeline green throughout.

Step 1 — Allow JS and TS to coexist in tsconfig:
``json
{
"compilerOptions": {
"allowJs": true, // existing .js files still compile
"checkJs": false, // don't type-check .js files yet — adds them to build without blocking
"strict": false, // start lenient; enable per-file as you migrate
"target": "ES2022",
"module": "CommonJS"
},
"include": ["tests//*", "pages//*", "fixtures/**/*"]
}
`

Step 2 — Start with shared utilities, not test files:
Shared helpers are used everywhere — typing them first gives the widest safety coverage for the least effort.

`ts
// Before: helpers/api.js
export function buildRequest(endpoint, payload) {
return { url:
/api/${endpoint}, body: payload };
}

// After: helpers/api.ts — rename file, add types
interface RequestConfig { url: string; body: unknown }

export function buildRequest(endpoint: string, payload: unknown): RequestConfig {
return { url:
/api/${endpoint}, body: payload };
}
`

Step 3 — Migration priority order:
1. Shared fixtures and utilities (highest leverage — type once, protect every test)
2. Page Object Models (typed selectors catch the most real selector-drift bugs)
3. Test data factories and helpers
4. Individual spec files (do these last — lowest leverage per file)

Step 4 — Tighten the CI gate as coverage grows:
`bash
# tsconfig.strict.json — only covers already-migrated folders
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["pages//*", "fixtures//*", "helpers/**/*"]
}
`
`bash
# CI step — fails if migrated files regress
npx tsc --noEmit --project tsconfig.strict.json
``

Rule of thumb: migrate infrastructure first, test files last. Every utility you type correctly ripples up and protects every test that uses it — and the CI gate grows as you go.
💡 Plain English: Renovating a building floor by floor without closing it — harden the foundation and shared corridors first (highest impact), then renovate each room as the team cycles through. Tenants keep working throughout.
11
TypeScript

How do you configure TypeScript (tsconfig) correctly for a Playwright test project?

Playwright runs in Node.js and has its own type definitions. Getting tsconfig wrong produces "type not found" errors, broken path aliases, or slow CI builds. A few targeted settings give you maximum safety with minimum friction.

Recommended tsconfig.json for a Playwright project:
``json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS", // Playwright runs in Node.js — not ESM
"lib": ["ES2022"],
"strict": true, // catch null errors and implicit any
"noUncheckedIndexedAccess": true, // arr[0] is T | undefined — forces null guards
"esModuleInterop": true,
"skipLibCheck": true, // skip type-checking node_modules — much faster
"paths": {
"@fixtures": ["./fixtures/index.ts"],
"@pages/*": ["./pages/*"],
"@helpers/*":["./helpers/*"]
},
"baseUrl": "."
},
"include": [
"tests/**/*.ts",
"fixtures/**/*.ts",
"pages/**/*.ts",
"helpers/**/*.ts",
"playwright.config.ts",
"global-setup.ts"
],
"exclude": ["node_modules", "playwright-report", "test-results"]
}
`

Key flags explained:
| Flag | Why |
|---|---|
|
strict: true | Catches null errors and implicit any — the two biggest sources of test flakiness |
|
noUncheckedIndexedAccess | locators[0] is Locator | undefined — forces you to guard before using it |
|
skipLibCheck | Saves 10–30 s on large projects — you trust library authors' types |
|
paths | Avoids ../../../fixtures chains — one folder rename doesn't break 50 imports |

Integrate into CI — fail before tests run:
`bash
# package.json
"scripts": {
"typecheck": "tsc --noEmit",
"pretest": "npm run typecheck"
}
`

Common mistake: using
"module": "ESNext" in a Playwright project. Playwright's test runner uses Node.js CommonJS resolution — ESNext modules break require() in global setup files.

Rule of thumb:
strict: true + skipLibCheck: true + paths` for aliases — that combination gives maximum safety at minimum build-time cost.
💡 Plain English: Configuring a professional camera before a shoot — ISO, aperture, white balance are set once for the lighting conditions, not tweaked shot by shot. Wrong settings silently degrade every photo; the right settings let you focus on composition.
12
API Testing

How do you generate TypeScript types from an OpenAPI spec and use them for typed API contract testing?

When a backend publishes an OpenAPI spec, you can auto-generate TypeScript types from it — so your API tests always test against the actual contract. If the backend renames a field or changes a response shape, your test code gets a compile error before anything runs.

Step 1 — Generate types from the spec:
``bash
# openapi-typescript converts an OpenAPI YAML/JSON to a .d.ts file
npx openapi-typescript ./api/openapi.yaml -o ./types/api.d.ts
`

The generated file looks like:
`ts
// types/api.d.ts — auto-generated, never edit by hand
export interface components {
schemas: {
User: { id: number; email: string; role: 'admin' | 'viewer' };
ErrorBody: { code: string; message: string };
};
}
`

Step 2 — Use generated types in Playwright API tests:
`ts
import { test, expect } from '@playwright/test';
import type { components } from '../types/api';

type User = components['schemas']['User'];
type ErrorBody = components['schemas']['ErrorBody'];

test('GET /users/:id returns a valid user shape', async ({ request }) => {
const res = await request.get('/users/42');
const body = await res.json() as User;

expect(res.status()).toBe(200);
// TypeScript knows role is 'admin' | 'viewer' — exact values from the spec
expect(['admin', 'viewer']).toContain(body.role);
});

test('GET /users/:id returns 404 shape for missing user', async ({ request }) => {
const res = await request.get('/users/99999');
const body = await res.json() as ErrorBody;

expect(res.status()).toBe(404);
expect(typeof body.code).toBe('string');
expect(typeof body.message).toBe('string');
});
`

Step 3 — Regenerate automatically in CI so types never go stale:
`bash
# package.json
"scripts": {
"generate:types": "openapi-typescript ./api/openapi.yaml -o ./types/api.d.ts",
"pretest": "npm run generate:types && tsc --noEmit"
}
`

What this catches automatically:
- Backend renames
roleuserRole → generated type changes → body.role is a compile error
- Backend adds a required field → missing assertion becomes visible at the type level
- Backend removes an endpoint → import of that path type errors immediately

Rule of thumb: generate types in CI as a
pretest` step — a stale generated type file gives false confidence and is worse than no types at all.
💡 Plain English: An architect working from the live blueprint stored at city hall, not a photocopy from last year. Every time the official plans update, your drawings automatically reflect the change — and any work that conflicts with the new plans gets flagged before construction starts.
13
Safety

TypeScript types are erased at runtime — how do you keep external data safe?

TypeScript types exist only at compile time — every interface, union, and type alias disappears completely in the compiled JavaScript. A value typed as User at compile time is just a plain JavaScript object at runtime, and nothing stops the actual data from having a completely different shape. External data (API responses, JSON.parse, environment variables, database results) is the boundary where TypeScript can't help you — you need runtime validation there.

Why this matters at senior level:
The dangerous anti-pattern is using as on API responses: const user = await res.json() as User. This compiles cleanly, gives you full autocomplete, and lies. If the API sends { user_id: 1 } instead of { id: 1 }, TypeScript will never know — your test assertions run against undefined and either pass wrongly or fail mysteriously.

The safe pattern — validate at the boundary with Zod:

``ts
import { z } from 'zod';

// Schema IS the single source of truth for both type and validation
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
// Optional field — must handle undefined downstream
department: z.string().optional(),
});

// TypeScript type derived from the schema — no separate interface needed
type User = z.infer<typeof UserSchema>;

// Validation happens at the boundary — before the data enters your typed code
async function fetchUser(request: APIRequestContext, id: number): Promise<User> {
const response = await request.get(
/api/users/${id});
const raw: unknown = await response.json(); // unknown forces you to validate

// parse() throws a ZodError with field-level detail if the shape is wrong
return UserSchema.parse(raw);
// After this line, user is genuinely a User — not just typed as one
}
`

What a ZodError tells you when the API breaks its contract:
`
ZodError: [
{ "path": ["role"], "message": "Invalid enum value. Expected 'admin'|'user'|'viewer', received 'superadmin'" }
]
`

This surfaces in CI the moment the backend changes the role format — not as a subtle assertion failure three tests later.

The spectrum of options and their trade-offs:

`ts
// Option 1 — type assertion (fast, unsafe, appropriate only for trusted internal data)
const user = (await res.json()) as User;
// TypeScript trusts you. Runtime trusts nothing.

// Option 2 — hand-written type guard (safe, verbose, maintenance burden)
if (!isUser(raw)) throw new Error('Wrong shape');

// Option 3 — Zod (safe, minimal, generates the type too — best for external data)
const user = UserSchema.parse(raw);

// Option 4 — safeParse (safe, non-throwing — good when bad shape is a recoverable condition)
const result = UserSchema.safeParse(raw);
if (!result.success) {
console.error('API contract break:', result.error.format());
return null;
}
const user = result.data; // type: User ✓
`

Where to apply validation — the boundary rule:
Validate at every point where data crosses from the outside world into your typed code: API responses,
JSON.parse, environment variables, database query results, webhook payloads. Inside your own code — passing a User you already validated to another function — trust the TypeScript types fully.

Rule of thumb:
as SomeType is a lie you tell TypeScript. At trust boundaries, replace every as` with a schema validation. Inside your own codebase, trust the types.
💡 Plain English: Passport control at an international border — inside the country (your typed code), you trust everyone's ID without rechecking it. But at the border crossing (API boundary), you physically inspect every passport with an X-ray scanner (Zod), because the label on a package can be anything.
14
TypeScript

Walk me through how you debug a confusing TypeScript type error in a Playwright test file.

TypeScript error messages can be long and intimidating — pointing at the wrong line and burying the real cause. A systematic approach gets you to the root faster than re-reading the error.

Step 1 — Read the LAST line of the error, not the first:
TypeScript errors unwind from outer to inner. The top of the message is the consequence; the bottom is the root cause.
``
Argument of type 'string | null' is not assignable to parameter of type 'string'.
Type 'null' is not assignable to type 'string'. // ← this is the actual problem
`

Step 2 — Hover over the variable to see what TypeScript inferred:
Before changing anything, hover in your IDE to see the actual inferred type vs. what you expected.
`ts
const locators = await page.locator('li').all(); // hover: Locator[]
const first = locators[0]; // hover: Locator | undefined (noUncheckedIndexedAccess)
await first.click(); // ❌ 'first' is possibly undefined
`

Step 3 — Narrow the type to fix it:
`ts
// Option A: guard explicitly — correct for production tests
if (!first) throw new Error('No list items found');
await first.click(); // ✅ TypeScript knows first is Locator here

// Option B: use .nth() — Playwright never returns undefined from .nth()
await page.locator('li').nth(0).click(); // ✅ always a Locator
`

Step 4 — Extract to a temp variable to isolate where the type breaks:
`ts
// If a chain of calls produces the error, split it into steps and hover each one
const response = await request.get('/api/users');
const body = await response.json(); // type: any
const users = (body as { users: User[] }).users; // hover: User[]
const first = users[0]; // hover: User | undefined — guard here
`

Common Playwright-specific type errors and fixes:
| Error | Cause | Fix |
|---|---|---|
|
Locator | undefined | Array index access (locators[0]) | Use .nth(0) or add a null guard |
|
string | null not assignable | getAttribute() returns null when absent | Add ?? '' or check before use |
|
Promise<void> not assignable | Missing await on an async Playwright call | Add await |
|
unknown not assignable to User | res.json() returns unknown | Cast with as User after validating, or use Zod |

Rule of thumb: 90% of TypeScript errors in Playwright tests are one of four things — null/undefined not handled, a missing
await, an unvalidated API response, or a stale type after a refactor. Check those four first before reaching for as any`.
💡 Plain English: A plumber diagnosing a leak — you don't start by replacing all the pipes. You follow the water to the lowest point (last line of the error), then trace back upstream to the source where the type diverged from what you expected.
15
Tooling

How do you configure tsconfig for a large project?

A large TypeScript project — whether a monorepo or a single large test suite — requires tsconfig to balance three competing concerns: maximum type safety, fast incremental build times, and maintainable import paths. The key mechanisms are strict flags, project references for sub-project isolation, path aliases for stable imports, and a shared base config that all packages extend.

Why this is a senior-level concern:
In a small project, one tsconfig is fine. At scale, a single tsconfig re-checks everything on every build. Without project references, a change to a shared utility rebuilds the entire codebase. Without path aliases, a utility rename breaks dozens of import paths. These aren't hypothetical — they're the concrete pain points that show up around the 50-file mark.

Walked-through example — large Playwright monorepo structure:

``
project-root/
├── tsconfig.base.json ← shared settings all packages extend
├── packages/
│ ├── shared-utils/
│ │ └── tsconfig.json ← extends base, "composite": true
│ ├── api-tests/
│ │ └── tsconfig.json ← extends base, references shared-utils
│ └── e2e-tests/
│ └── tsconfig.json ← extends base, references shared-utils
`

`json
// tsconfig.base.json — shared safety and quality settings
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@shared/*": ["../shared-utils/src/*"],
"@fixtures": ["../e2e-tests/fixtures/index.ts"],
"@pages/*": ["../e2e-tests/pages/*"]
},
"baseUrl": "."
}
}
`

`json
// packages/shared-utils/tsconfig.json — project reference producer
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true, // required to be referenced by other projects
"outDir": "./dist",
"declarationDir": "./dist"
},
"include": ["src/**/*.ts"]
}
`

`json
// packages/api-tests/tsconfig.json — project reference consumer
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "../shared-utils" } // only rebuilds shared-utils when IT changes
],
"include": ["src//*.ts", "tests//*.ts"]
}
`

Key flag rationale at scale:

| Flag | Why it matters at scale |
|---|---|
|
strict: true | Catches null/implicit-any bugs — the two biggest real-world error classes |
|
noUncheckedIndexedAccess | arr[0] is T | undefined — forces null guards on indexed access |
|
skipLibCheck: true | Saves 15–45 s per build on large suites — you trust published .d.ts files |
|
composite: true | Required for project references — enables incremental per-package builds |
|
paths | Stable imports — rename a folder without touching 80 import statements |

CI integration — type-check as a gate before tests run:

`bash
# Build all packages in dependency order — fast because project references only
# rebuild what changed
npx tsc --build --verbose

# Or in package.json for each package:
"pretest": "tsc --noEmit"
`

Rule of thumb: one base config, one config per sub-project that extends it. Enable
composite and references` once any package imports from another — the build time saving compounds quickly.
💡 Plain English: Zoning a large city — uniform building codes apply everywhere (base config), districts are independently planned (project references), streets have stable names (path aliases), and the code inspector skips checking houses that were built last year and haven't changed (incremental builds).
16
Playwright

How do you structure a large Playwright test project for a team of multiple QA engineers?

As the suite grows and more people contribute, an unstructured folder of spec files becomes a maintenance problem — duplicated selectors, clashing utilities, and no clear ownership. A deliberate structure makes tests easy to find, share, and extend without engineers stepping on each other.

Recommended folder layout:
``
tests/
e2e/ # feature-grouped spec files
auth/
login.spec.ts
logout.spec.ts
billing/
checkout.spec.ts
api/ # API-level tests (Playwright APIRequestContext)
users.spec.ts

pages/ # Page Object Models — one file per page or feature area
LoginPage.ts
DashboardPage.ts
CheckoutPage.ts

fixtures/ # Playwright fixture definitions — auth, seeded data, roles
auth.fixture.ts
user.fixture.ts
index.ts # re-exports all fixtures as one combined 'test' object

helpers/ # Typed utilities — factories, assertions, API wrappers
factories.ts
assertions.ts

types/ # Shared interfaces — auto-generated + hand-written domain models
api.d.ts # generated from OpenAPI spec
models.ts

playwright.config.ts
global-setup.ts
tsconfig.json
`

Single
test import via fixture composition — one line per spec file:
`ts
// fixtures/index.ts — everyone imports from here, not @playwright/test directly
import { test as base } from '@playwright/test';
import { authFixtures } from './auth.fixture';
import { userFixtures } from './user.fixture';

export const test = base.extend(authFixtures).extend(userFixtures);
export { expect } from '@playwright/test';
`
`ts
// any spec file
import { test, expect } from '../fixtures'; // one import — all fixtures available
`

Tag strategy for selective CI runs:
`ts
test('checkout happy path @smoke @billing', async ({ page }) => { ... });
test('apply discount code @regression @billing', async ({ page }) => { ... });
`
`bash
npx playwright test --grep @smoke # fast — run on every PR
npx playwright test --grep @billing # run when billing code changes
npx playwright test --grep @regression # full suite — run nightly
`

tsconfig path aliases — no more ../../../:
`json
"paths": {
"@fixtures": ["./fixtures/index.ts"],
"@pages/*": ["./pages/*"],
"@helpers/*":["./helpers/*"]
}
`

Rule of thumb: pages/ owns selectors, fixtures/ owns setup/teardown, helpers/ owns reusable logic. No spec file should import from
@playwright/test directly — always import through fixtures/index.ts` so every test automatically gets the full fixture set.
💡 Plain English: A professional kitchen brigade — the prep cook (fixtures) handles setup once; the line cooks (page objects) execute specific techniques; the expeditor (spec file) calls the finished dish and sends it out. Nobody improvises ingredients mid-service or duplicates each other's prep.
17
Playwright

How do you systematically diagnose and fix flaky tests in a Playwright TypeScript suite?

Flakiness is a symptom, not a root cause. The same "flaky" test can fail because of a timing gap, a test data collision, or state leaking from a previous test — treating all three the same way (just adding retries or waitForTimeout) makes suites slower and less trustworthy over time.

Step 1 — Reproduce it reliably:
``bash
# Run the suspect test 10 times in a row — if it fails even once, it's flaky
npx playwright test login.spec.ts --repeat-each=10 --reporter=line
`

Step 2 — Identify the root cause category:
| Category | How to recognise it |
|---|---|
| Timing / async gap | Passes locally, fails in CI; error: "element not visible" or "strict mode violation" |
| Test data collision | Fails only in parallel runs; error: "already exists" or "duplicate key" |
| State leak | Fails only after a specific test, passes in isolation |
| Environment difference | Fails on slow machines or different viewport sizes |

Step 3 — Fix timing: always use Playwright's built-in auto-waits:
`ts
// ❌ Hard sleep — brittle and slow
await page.waitForTimeout(2000);

// ✅ Wait for a specific observable condition
await page.waitForSelector('[data-testid="results"]', { state: 'visible' });
await page.waitForResponse(res => res.url().includes('/api/search'));
await expect(page.locator('.toast')).toBeVisible(); // Playwright auto-retries assertions
`

Step 4 — Fix test data collision: unique data per test:
`ts
// Unique email per call — parallel tests never share the same record
const user = buildUser({ email:
test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com });
`

Step 5 — Fix state leak: teardown after every test:
`ts
test.afterEach(async ({ page }) => {
if (await page.locator('[data-testid="user-menu"]').isVisible()) {
await page.locator('[data-testid="user-menu"]').click();
await page.locator('text=Log out').click();
}
});
`

Step 6 — Use retries as a last resort only:
`ts
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // CI only — never hide flakiness locally
});
``

Rule of thumb: retries hide flakiness; they don't cure it. Fix the root cause first. Reserve retries for genuinely non-deterministic external dependencies — third-party APIs, email delivery — not for races you caused yourself.
💡 Plain English: A car that sometimes won't start. Adding a spare key doesn't fix the problem — you diagnose whether it's the battery, the starter motor, or the fuel pump. Retries are the spare key: a workaround, not a repair.
18
API Testing

How do you build a reusable type-safe API response validation helper for Playwright API tests?

Test files that manually check res.status() and body.field in every test become repetitive and miss edge cases — a 200 with the wrong body shape passes silently. A typed helper centralises both checks, enforces the expected shape, and returns a typed object the rest of the test can use.

The helper:
``ts
// helpers/api-assert.ts
import { expect, APIResponse } from '@playwright/test';

interface AssertApiOptions<T> {
response: APIResponse;
expectedStatus: number;
schema?: (body: unknown) => T; // optional validator — Zod or hand-written
}

async function assertApi<T>(opts: AssertApiOptions<T>): Promise<T> {
const { response, expectedStatus, schema } = opts;

expect(
response.status(),
Expected HTTP ${expectedStatus} but got ${response.status()} — URL: ${response.url()}
).toBe(expectedStatus);

const body: unknown = await response.json();

if (schema) return schema(body); // throws with field-level details if shape is wrong
return body as T;
}

export { assertApi };
`

Using it with a Zod schema:
`ts
import { z } from 'zod';
import { assertApi } from '../helpers/api-assert';

const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.enum(['admin', 'viewer']),
});

test('GET /users/:id returns a valid user', async ({ request }) => {
const res = await request.get('/users/42');
const user = await assertApi({
response: res,
expectedStatus: 200,
schema: body => UserSchema.parse(body), // ZodError with field names if invalid
});

// user is fully typed as { id: number; email: string; role: 'admin' | 'viewer' }
expect(user.role).toBe('viewer');
});
`

Using it for error responses:
`ts
const ErrorSchema = z.object({ code: z.string(), message: z.string() });

test('returns 404 for unknown user', async ({ request }) => {
const res = await request.get('/users/99999');
const error = await assertApi({
response: res,
expectedStatus: 404,
schema: body => ErrorSchema.parse(body),
});
expect(error.code).toBe('USER_NOT_FOUND');
});
`

What this pattern provides:
- Status + body checked in one call — impossible to forget either half
- Typed return value — no manual
as User` cast after the assertion
- Centralised error message — every failure tells you the URL and expected/actual status
- Swappable schema library — replace Zod without touching test files

Rule of thumb: a 200 with the wrong body shape is as broken as a 500. Validate both status and shape together — always.
💡 Plain English: A customs officer who checks both your passport and your luggage in one step. They don't clear you on the passport alone and forget to scan the bag. Both must pass before you're through.
19
TypeScript

How do you manage typed environment configuration — base URLs, credentials, feature flags — across multiple test environments?

Reaching for process.env.BASE_URL directly in test files is untyped and fragile — a missing variable silently becomes undefined and causes a confusing failure mid-test rather than a clear error at startup. A single typed config module catches all missing values before the first test runs.

Typed config module using Zod:
``ts
// config/env.ts
import { z } from 'zod';

const EnvSchema = z.object({
BASE_URL: z.string().url(),
API_BASE_URL: z.string().url(),
TEST_ADMIN_EMAIL: z.string().email(),
TEST_ADMIN_PASSWORD: z.string().min(1),
TEST_VIEWER_EMAIL: z.string().email(),
TEST_VIEWER_PASSWORD: z.string().min(1),
ENV: z.enum(['local', 'staging', 'production']).default('staging'),
});

// Throws at module load time if anything is missing or malformed
// e.g. "ZodError: BASE_URL: Required" — not "TypeError: Cannot read properties of undefined"
export const env = EnvSchema.parse(process.env);
`

Use in playwright.config.ts:
`ts
import { defineConfig } from '@playwright/test';
import { env } from './config/env';

export default defineConfig({
use: {
baseURL: env.BASE_URL,
extraHTTPHeaders: { 'X-Test-Env': env.ENV },
},
});
`

Environment-specific .env files:
`bash
# .env.staging — checked into git (no secrets — only public config)
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://api.staging.myapp.com
ENV=staging

# .env.local — gitignored — real credentials only
TEST_ADMIN_EMAIL=admin@example.com
TEST_ADMIN_PASSWORD=secret
`
`ts
// playwright.config.ts — load the right file based on TEST_ENV flag
import * as dotenv from 'dotenv';
dotenv.config({ path:
.env.${process.env.TEST_ENV ?? 'staging'} });

// Then run as: TEST_ENV=local npx playwright test
`

Feature flag in config:
`ts
const EnvSchema = z.object({
// ...
FEATURE_NEW_CHECKOUT: z.enum(['true', 'false']).transform(v => v === 'true').default('false'),
});

// In test:
test.skip(!env.FEATURE_NEW_CHECKOUT, 'new checkout not enabled in this env');
``

Rule of thumb: validate all environment variables once at startup. A missing config variable should crash loudly with the variable name before a single test runs — not fail silently 40 tests in.
💡 Plain English: A pilot's pre-flight checklist — every instrument is verified on the ground before takeoff, not discovered as broken mid-flight. If the altimeter is missing, you don't take off; you fix it first.
20
TypeScript

How do you build a type-safe generic retry helper for eventually-consistent operations in tests?

Some test operations can't use Playwright's built-in auto-wait — polling an external email service, waiting for a database record to be committed, or checking a webhook delivery. A typed generic retry helper handles these cases without losing the return type.

The helper:
``ts
// helpers/retry.ts

interface RetryOptions {
attempts: number;
delayMs: number;
timeoutMs?: number;
}

async function retry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
const { attempts, delayMs, timeoutMs } = options;
const deadline = timeoutMs ? Date.now() + timeoutMs : Infinity;
let lastError: unknown;

for (let i = 0; i < attempts; i++) {
if (Date.now() > deadline) break;
try {
return await fn(); // return type T is preserved — caller knows exactly what they get
} catch (err) {
lastError = err;
if (i < attempts - 1) await new Promise(r => setTimeout(r, delayMs));
}
}

throw new Error(
retry: failed after ${attempts} attempts — ${lastError instanceof Error ? lastError.message : String(lastError)}
);
}

export { retry };
`

Polling for an email to arrive:
`ts
import { retry } from '../helpers/retry';

interface Email { subject: string; to: string; body: string }

test('welcome email is sent after signup', async ({ request }) => {
await signupViaUI(page);

// Check the test inbox every 2 s, up to 5 attempts
const email = await retry<Email>(
async () => {
const res = await request.get(
/test-inbox/${testEmail});
if (!res.ok()) throw new Error(
inbox not ready yet: ${res.status()});
return res.json() as Promise<Email>;
},
{ attempts: 5, delayMs: 2000 }
);

// email is typed as Email — full IntelliSense, no any
expect(email.subject).toBe('Welcome to the app!');
});
`

Polling for a DB record (eventual consistency):
`ts
const order = await retry<Order>(
() => fetchOrderFromDB(orderId), // throws if not found yet
{ attempts: 10, delayMs: 500, timeoutMs: 8000 }
);
expect(order.status).toBe('confirmed'); // order is typed as Order
`

Why the generic matters:
Without
<T>, the return type would be unknown, forcing every caller to cast. With the generic, TypeScript infers the return type from the callback — no cast needed, and accessing a non-existent field is a compile error.

Rule of thumb: use
retry<T> only for external operations outside the browser that you can't control. For anything inside the browser, use Playwright's built-in waitForSelector / waitForResponse / expect().toBeVisible()` — they're already battle-tested retry loops.
💡 Plain English: Waiting for a bank transfer to clear — you check the balance periodically up to a sensible deadline rather than once and giving up. The typed return means you know exactly what you're holding when it finally arrives.
21
CI/CD

How do you design a TypeScript-aware CI pipeline for a Playwright test suite?

A naive pipeline that just runs npx playwright test skips type checking entirely — broken types ship silently. A properly designed pipeline fails fast on type errors, runs tests in parallel shards, and produces one merged report even when some shards fail.

Recommended three-stage pipeline (GitHub Actions):
``yaml
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]

jobs:
# Stage 1 — fail fast before spending money on shard compute
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx tsc --noEmit # blocks the pipeline if any type error exists

# Stage 2 — 5 parallel shards; only starts when typecheck passes
test:
needs: typecheck
runs-on: ubuntu-latest
strategy:
fail-fast: false # collect ALL shard results even if one fails
matrix:
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/5
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}
- uses: actions/upload-artifact@v4
if: always() # upload even when tests fail
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/

# Stage 3 — merge all shard reports into one HTML report
report:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blob-reports/
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
`

Key design decisions:
- Typecheck is a prerequisite job — shards never start if types are broken; saves 5× the compute cost
-
fail-fast: false — all shards run to completion so you see the full failure picture, not just shard 1's failures
- Blob reporter per shard — lightweight partial reports that merge cleanly; much smaller than per-shard HTML reports
- Secrets injected at the CI level — the typed
env.ts module validates them at startup before tests run
-
--with-deps` — installs OS-level browser dependencies needed on fresh Linux runners

Rule of thumb: type check first, shard second, merge third. The pipeline should give you a complete picture even on a partial failure.
💡 Plain English: A factory quality gate with three stations — inspection first (type check), then parallel assembly lines (shards), then a final summary report (merge). You don't start the lines until inspection clears, and you collect every line's output before writing the summary.
22
Migration

How would you migrate a large JavaScript codebase to TypeScript?

At senior level, migrating a large JavaScript codebase to TypeScript is a team coordination problem as much as a technical one. The technical approach is always incremental — never a big-bang rewrite — but the senior challenge is structuring the migration so it produces measurable value at every step, keeps CI green throughout, and creates social and tooling pressure to keep moving forward rather than stalling.

Why incremental is non-negotiable:
A 300-file JS codebase produces hundreds of type errors the moment you enable strict. A big-bang migration blocks the team for weeks, creates a massive PR nobody can review, and demoralises engineers when they see the error count. Incremental migration delivers safety improvements daily and keeps the team shipping features in parallel.

Phase 1 — Coexistence setup (Day 1, zero disruption):

``json
// tsconfig.json — allow JS and TS to coexist
{
"compilerOptions": {
"allowJs": true, // existing .js files still compile
"checkJs": false, // don't type-check .js files yet
"strict": false, // start lenient — tighten per-file
"outDir": "dist",
"target": "ES2022",
"module": "CommonJS"
},
"include": ["src//*", "tests//*"]
}
`

All tests pass. Nothing breaks. The migration can now run in parallel with feature work.

Phase 2 — Migrate in dependency order (highest leverage first):

`
Priority 1: shared utilities and helpers → typed once, protects everything downstream
Priority 2: type definitions / interfaces → define the contracts first
Priority 3: Page Object Models → typed selectors catch real bugs
Priority 4: test data factories → typed overrides prevent silent mismatches
Priority 5: individual spec files → lowest leverage per file, do last
`

Phase 3 — Enable strict flags progressively (one per sprint):

`bash
# Track error count — it should only decrease
npx tsc --noEmit 2>&1 | wc -l
`

`json
// Add one flag per sprint, fix errors before adding the next
"noImplicitAny": true, // Sprint 1 — biggest safety gain
"strictNullChecks": true, // Sprint 2 — surfaces null bugs
"noImplicitReturns": true, // Sprint 3
"strict": true // Final sprint — covers everything
`

Phase 4 — Enforce the CI gate and prevent regression:

`json
// tsconfig.strict.json — applies strict only to already-migrated folders
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/helpers//*", "src/pages//*", "src/fixtures/**/*"]
}
`

`bash
# CI step — fails if migrated files regress; error count can only decrease
npx tsc --noEmit --project tsconfig.strict.json
`

Managing technical debt during migration:

`ts
// Use @ts-expect-error (NOT @ts-ignore) for deferred issues
// @ts-expect-error TODO: fix after UserService refactor — ticket #234
const result = legacyGetUser(id);

// @ts-expect-error becomes a compile error itself once the underlying issue is fixed
// — it self-destructs when no longer needed, unlike @ts-ignore which hides forever
`

The social contract:
Agree with the team on one rule: no new
.js files. Every new file is .ts` from day one of the migration. This is the gate that ensures forward progress regardless of how fast the legacy files are converted.

Rule of thumb: migrate infrastructure first (utilities, interfaces, POMs), spec files last. Each migrated utility ripples safety upstream to everything that uses it. The CI gate grows as you go — tie it to folder coverage so progress is measurable and irreversible.
💡 Plain English: Renovating a building floor by floor while keeping it open to tenants. You harden the foundation and shared corridors first (shared utils), upgrade each floor in turn (POMs, factories), and the building stays operational throughout. New wings are always built to the new standard — no new construction under the old code.
23
Playwright

How do you test multi-user scenarios — where two roles interact simultaneously — in Playwright?

Some features only work correctly when two users are active at the same time — a sender and a recipient in a chat, an approver and a submitter in a workflow, or a buyer and a seller on a marketplace. Playwright supports this natively: multiple browser contexts can run concurrently within one test, each with its own isolated auth state.

Two users interacting in one test:
``ts
import { test, expect } from '@playwright/test';

test('admin approves a viewer submission', async ({ browser }) => {
// Two isolated contexts — separate cookie jars, no shared state
const adminCtx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const viewerCtx = await browser.newContext({ storageState: 'playwright/.auth/viewer.json' });

const adminPage = await adminCtx.newPage();
const viewerPage = await viewerCtx.newPage();

// Viewer submits a request
await viewerPage.goto('/requests/new');
await viewerPage.fill('[data-testid="title"]', 'Refund request');
await viewerPage.click('[data-testid="submit"]');
await viewerPage.waitForURL('/requests/confirmation');

// Admin sees it and approves
await adminPage.goto('/admin/requests');
await adminPage.waitForSelector('text=Refund request');
await adminPage.click('[data-testid="approve-btn"]');
await adminPage.waitForSelector('[data-testid="status"]:text("Approved")');

// Viewer's page reflects the approval
await viewerPage.goto('/requests');
await expect(viewerPage.locator('[data-testid="status"]')).toHaveText('Approved');

await adminCtx.close();
await viewerCtx.close();
});
`

Typed fixture for cleaner multi-user tests:
`ts
// fixtures/multi-user.fixture.ts
import { test as base, Page } from '@playwright/test';

const test = base.extend<{ adminPage: Page; viewerPage: Page }>({
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
await use(await ctx.newPage());
await ctx.close();
},
viewerPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/viewer.json' });
await use(await ctx.newPage());
await ctx.close();
},
});

export { test };
`

Real-time feature testing (WebSocket / SSE):
`ts
test('viewer sees new message in real time', async ({ adminPage, viewerPage }) => {
await viewerPage.goto('/inbox');

await adminPage.goto('/compose');
await adminPage.fill('[data-testid="to"]', viewerEmail);
await adminPage.fill('[data-testid="message"]', 'Hello viewer');
await adminPage.click('[data-testid="send"]');

// No sleep — Playwright auto-retries the assertion until the text appears or timeout
await expect(viewerPage.locator('.message-list')).toContainText('Hello viewer', { timeout: 5000 });
});
`

Rule of thumb: each user = one
newContext() with their own storageState`. Never share a context between users — their cookie jars will bleed into each other and produce intermittent auth failures.
💡 Plain English: A stage director running a two-actor rehearsal simultaneously — one delivers a line, the other reacts. Testing actor A in isolation and actor B in isolation and assuming the scene works together is not the same thing.
24
Language Features

What are decorators, and where are they used?

Decorators are a TypeScript (and JavaScript stage 3) feature that lets you annotate or modify a class, method, property, or parameter with @decoratorName syntax. At runtime, the decorator is a function that receives the decorated target and can wrap, replace, or attach metadata to it. They're most prevalent in backend frameworks like NestJS and Angular, but understanding them matters for QA engineers who test APIs built on those frameworks or who write custom test tooling.

Why decorators matter to a senior QA engineer:
You may not author decorators daily, but you'll encounter them when testing NestJS APIs (every controller method is decorated), when reading framework code, or when a team asks you to write a custom test lifecycle decorator. Knowing the mechanism prevents confusion when debugging decorator-related issues in the services you're testing.

How decorators work — the mechanism:

``ts
// A decorator is just a function that receives the target
function LogMethod(
target: object,
propertyKey: string,
descriptor: PropertyDescriptor
): PropertyDescriptor {
const original = descriptor.value;

descriptor.value = function (...args: unknown[]) {
console.log(
Calling ${propertyKey} with, args);
const result = original.apply(this, args);
console.log(
${propertyKey} returned, result);
return result;
};

return descriptor;
}

class UserService {
@LogMethod
getUser(id: number) {
return { id, name: 'Alice' };
}
}
// getUser(42) → logs: "Calling getUser with [42]", then "getUser returned {id:42...}"
`

Common framework decorators you'll encounter when testing:

`ts
// NestJS controller — every route is a decorator
@Controller('/users')
class UserController {
@Get(':id')
@UseGuards(AuthGuard)
async getUser(@Param('id') id: string): Promise<UserDto> { ... }

@Post()
@Roles('admin')
async createUser(@Body() dto: CreateUserDto): Promise<UserDto> { ... }
}

// Implication for API testing:
// The @Roles('admin') decorator means your test must send an admin JWT
// The @UseGuards(AuthGuard) means unauthenticated requests return 401 — test that too
`

tsconfig — enabling decorators:

`json
// For legacy (experimental) decorators — most frameworks still use this
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

// TypeScript 5+ native decorators — no config flags needed (standard proposal)
// Different semantics from experimental — not directly interchangeable
`

Real-world QA use case:
When testing a NestJS API, decorator metadata on controllers drives route generation, validation pipes, and guard logic. If a
@Body() parameter uses a validation class decorated with @IsEmail()` from class-validator, your API test should assert that passing an invalid email returns a 400 with the correct validation error message — the decorator enforces the contract you need to test.

Rule of thumb: You don't need to author custom decorators regularly as a QA engineer. Understand the pattern well enough to read framework code and know that a decorator on a service method usually means "something happens around this call" — logging, auth checking, caching, or validation.
💡 Plain English: A "FRAGILE — THIS SIDE UP" label on a shipping box. The label doesn't change what's inside; it tells the shipping system to handle the box differently. Decorators tell the framework runtime to treat the annotated class, method, or property differently — without altering the core logic inside.
25
Advanced Types

How do you type recursive or nested structures like JSON or a tree?

A recursive type is a type that references itself in its own definition. TypeScript supports this natively for interface and type alias definitions. It's essential for typing structures of arbitrary depth — JSON blobs, tree-shaped API responses, nested configuration objects, and test data hierarchies.

Why this matters in test automation:
API responses are often deeply nested — a user object contains an address which contains a country, or a product tree contains categories which contain subcategories. Without recursive types, you either flatten the type (losing accuracy), use any (losing safety), or write out every level manually (brittle). A recursive type handles any depth with one definition.

Walked-through example 1 — typing arbitrary JSON:

``ts
// The canonical JSON type — self-referential through union
type Json =
| string
| number
| boolean
| null
| Json[] // array of Json (recursive)
| { [key: string]: Json }; // object whose values are Json (recursive)

// Useful for API response bodies you haven't typed yet
async function fetchRawJson(request: APIRequestContext, path: string): Promise<Json> {
const response = await request.get(path);
return response.json();
}

// Now you can pass the result anywhere that accepts Json
const data = await fetchRawJson(request, '/api/config');
`

Walked-through example 2 — category tree API response:

`ts
// A product category can contain subcategories of the same shape
interface Category {
id: number;
name: string;
slug: string;
children: Category[]; // recursive — each child is also a Category
}

// Type-safe tree traversal helper
function findCategory(tree: Category[], targetSlug: string): Category | undefined {
for (const category of tree) {
if (category.slug === targetSlug) return category;

// Recurse into children — TypeScript knows children is Category[]
const found = findCategory(category.children, targetSlug);
if (found) return found;
}
return undefined;
}

// Test usage
const categories: Category[] = await request.get('/api/categories').then(r => r.json());
const electronics = findCategory(categories, 'electronics');
`

Walked-through example 3 — deeply nested test config:

`ts
// A config tree where any node can have child nodes
interface ConfigNode {
value?: string | number | boolean;
children?: { [key: string]: ConfigNode }; // recursive via index signature
}

const testConfig: ConfigNode = {
children: {
auth: {
children: {
timeout: { value: 30000 },
retries: { value: 3 },
},
},
},
};
`

TypeScript's limits on recursive types:
Type aliases using
type` can be recursive but TypeScript has depth limits on heavy type-level computation (conditional types that recurse deeply). For data structures (not type-level computation), these limits are not normally hit in practice.

Real-world QA use case:
When asserting on a deeply nested API response, type the outermost response with a recursive interface. Functions that traverse the tree for assertion — "find the node with this slug", "count all leaf nodes" — become type-safe, and refactoring the tree shape surfaces every assertion that needs updating.

Rule of thumb: When a type contains a list or map of values of the same type, make it recursive. The type definition stays small; TypeScript handles any depth.
💡 Plain English: Russian nesting dolls described by a single rule — "every doll may contain more dolls of the same kind." You don't describe each layer separately; one definition covers all depths from the outermost doll to the innermost.
26
Types

When do you use `satisfies` vs `as` vs a plain type annotation?

These three constructs all involve telling TypeScript about a value's type, but they make fundamentally different promises — about safety, widening, and what TypeScript trusts you to know. Choosing the wrong one either loses type precision or bypasses safety checks entirely.

Why the distinction matters at senior level:
Juniors reach for as because it makes errors go away. That's the worst outcome — the error is hidden, not fixed. A senior understands that each construct is appropriate in exactly one situation, and that as on external data is a red flag in any code review.

The three constructs compared:

``ts
// Scenario: a test environment config object
type EnvConfig = Record<string, { baseUrl: string; timeout: number }>;

// --- PLAIN TYPE ANNOTATION ---
// Validates the shape AND widens the inferred type to EnvConfig
// You lose access to specific keys (TypeScript forgets 'staging' and 'prod' exist)
const config1: EnvConfig = {
staging: { baseUrl: 'https://staging.example.com', timeout: 30000 },
prod: { baseUrl: 'https://app.example.com', timeout: 10000 },
};
config1.staging; // type: { baseUrl: string; timeout: number } | undefined — widened
config1.nope; // no error — EnvConfig allows any string key

// --- satisfies ---
// Validates the shape WITHOUT widening — keeps the exact inferred type
// Best of both: validation + precision
const config2 = {
staging: { baseUrl: 'https://staging.example.com', timeout: 30000 },
prod: { baseUrl: 'https://app.example.com', timeout: 10000 },
} satisfies EnvConfig;
config2.staging; // type: { baseUrl: string; timeout: number } — known key ✓
config2.nope; // TS error: 'nope' does not exist on this object ✓
config2.staging.baseUrl; // full autocomplete preserved ✓

// --- as ---
// Bypasses all checking — TypeScript just trusts you
// No validation, no narrowing — if you're wrong, runtime crash
const config3 = rawData as EnvConfig;
// rawData could be null, a string, anything — TypeScript doesn't check ✓ (dangerous)
`

When each is appropriate:

`ts
// Use a plain annotation when:
// — you want the type to own the value and widening is fine
const headers: Record<string, string> = { 'Content-Type': 'application/json' };

// Use satisfies when:
// — you want to validate against a contract BUT keep specific inferred type
// — great for config objects, typed constants, test fixtures
const TEST_CONFIG = {
environments: ['staging', 'uat', 'production'],
defaultTimeout: 30000,
} satisfies { environments: string[]; defaultTimeout: number };

// Use as when:
// — you have proof TypeScript can't see (DOM, JSON cast, branded type creation)
// — NEVER on external/API data
const el = document.getElementById('submit')!; // ! is better than as here
const token = rawString as AuthToken; // OK if rawString is KNOWN to be a token
const body = await response.json() as User; // ❌ WRONG — use Zod instead
`

The decision tree:
1. Is the data coming from outside (API, JSON, env vars)? → Use Zod, never
as
2. Do you want to validate a constant's shape but keep specific key types? → Use
satisfies
3. Are you just typing a variable where widening is fine? → Use plain annotation
4. Do you have proof TypeScript can't see (DOM node definitely exists, branded type)? → Use
as sparingly with a comment explaining why

Rule of thumb:
as is a lie you tell the compiler. satisfies is a question you ask it. Plain annotations are the default. Reserve as` for the rare case where you genuinely know more than TypeScript does — and comment why.
💡 Plain English: Three ways to label a package. Annotation = a standardised shipping label (validates it fits the carrier's rules, but your detailed contents description gets replaced by "GOODS"). `satisfies` = a customs form that validates against the rules AND keeps your full detailed description intact. `as` = writing a label yourself without any system check — convenient, but you personally guarantee its accuracy.
27
Playwright

How do you integrate performance assertions — page load times and Core Web Vitals — into a Playwright TypeScript suite?

Performance isn't just a monitoring concern — you can assert budget thresholds directly in Playwright tests so a slow page fails CI the same way a broken button does.

Measuring page load timing via the Navigation Timing API:
``ts
import { test, expect } from '@playwright/test';

test('homepage loads within performance budget', async ({ page }) => {
await page.goto('/');

const timing = await page.evaluate(() => {
const [nav] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
return {
ttfb: nav.responseStart - nav.startTime, // Time to First Byte
domLoad: nav.domContentLoadedEventEnd - nav.startTime, // DOM ready
total: nav.loadEventEnd - nav.startTime, // Full page load
};
});

expect(timing.ttfb, 'TTFB exceeded 300 ms').toBeLessThan(300);
expect(timing.domLoad, 'DOM load exceeded 1.5 s').toBeLessThan(1500);
expect(timing.total, 'Full load exceeded 3 s').toBeLessThan(3000);
});
`

Measuring Largest Contentful Paint (Core Web Vital):
`ts
test('LCP is within budget on the product page', async ({ page }) => {
await page.goto('/products/featured');

const lcp = await page.evaluate(() =>
new Promise<number>(resolve => {
new PerformanceObserver(list => {
const entries = list.getEntries();
resolve(entries[entries.length - 1].startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

setTimeout(() => resolve(0), 5000); // fallback after 5 s
})
);

expect(lcp, 'LCP exceeded Google "good" threshold of 2.5 s').toBeLessThan(2500);
});
`

Asserting JavaScript payload size:
`ts
test('homepage does not load excessive JavaScript', async ({ page }) => {
let totalJsBytes = 0;

page.on('response', async res => {
const ct = res.headers()['content-type'] ?? '';
if (ct.includes('javascript')) {
const body = await res.body().catch(() => Buffer.alloc(0));
totalJsBytes += body.length;
}
});

await page.goto('/');
await page.waitForLoadState('networkidle');

expect(
totalJsBytes,
JS payload ${(totalJsBytes / 1024).toFixed(0)} KB exceeds 500 KB budget
).toBeLessThan(500 * 1024);
});
`

Where to run performance tests:
`ts
// playwright.config.ts — isolated project, no parallel noise from functional tests
export default defineConfig({
projects: [
{ name: 'perf', testMatch: '/perf//*.spec.ts',
use: { ...devices['Desktop Chrome'], launchOptions: { args: ['--disable-extensions'] } } },
],
});
``

Rule of thumb: run performance tests in isolation on a stable, unloaded CI machine — CPU throttling from parallel functional tests distorts timing measurements and produces false failures.
💡 Plain English: A building inspector who doesn't just verify the fire exits exist but also measures the corridor width against spec. Functionality and performance are both pass/fail criteria — one gets tested automatically, the other shouldn't be left to monitoring alone.
28
Playwright

How do you implement visual regression testing in a Playwright TypeScript suite?

Visual regression testing catches unexpected UI changes that functional tests miss — a CSS tweak that shifts a button, a font that fails to load, or a layout that breaks at a specific viewport. Playwright has built-in screenshot comparison via toHaveScreenshot() — no extra tooling needed.

Basic visual snapshot test:
``ts
import { test, expect } from '@playwright/test';

test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle'); // wait for fonts and images

// First run: creates baseline in __screenshots__/
// Subsequent runs: pixel-diffs against baseline — fails if they diverge
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // allow minor anti-aliasing differences
});
});
`

Component-level snapshot — more stable than full-page:
`ts
test('navigation bar renders correctly', async ({ page }) => {
await page.goto('/');
const nav = page.locator('[data-testid="navbar"]');
await nav.waitFor({ state: 'visible' });

await expect(nav).toHaveScreenshot('navbar.png', {
maxDiffPixelRatio: 0.01, // up to 1% of pixels may differ
});
});
`

Masking dynamic content to prevent false failures:
`ts
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.locator('[data-testid="last-login-time"]'), // timestamps change every run
page.locator('[data-testid="user-avatar"]'), // user photos differ per account
],
});
`

Disable animations to prevent flaky diffs:
`ts
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' }); // disables CSS animations
});
`

Updating baselines after an intentional design change:
`bash
npx playwright test --update-snapshots
`

Multi-viewport visual tests:
`ts
export default defineConfig({
projects: [
{ name: 'desktop', use: { viewport: { width: 1280, height: 720 } } },
{ name: 'tablet', use: { viewport: { width: 768, height: 1024 } } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
});
``

Critical gotcha — platform-specific rendering:
Screenshots are OS-specific — a Mac baseline will fail on Linux CI. Generate all baselines in CI (not locally), or use Docker to standardise the rendering environment.

Rule of thumb: focus visual tests on stable, high-traffic components — navigation, landing pages, checkout flows. Don't screenshot every page; target the ones where a layout regression would cost the most.
💡 Plain English: A printer's proof-reader who places the new print exactly over the approved master and holds it up to the light — any pixel that doesn't align is a deviation. You don't re-read the words; you compare the shapes.
29
Playwright

How do you handle OAuth 2.0 and SSO authentication flows in Playwright tests?

OAuth and SSO redirect the browser to an external identity provider (Google, Azure AD, Okta) before returning to your app. Automating the real identity provider is fragile — account lockout policies, 2FA, and rate limits will eventually break your suite. The right strategy depends on what your team can control.

Strategy 1 — Backend shortcut (most reliable):
Your backend exposes a test-only endpoint that creates a session directly, bypassing the OAuth dance entirely. Gate it behind a flag so it never runs in production.
``ts
// global-setup.ts
import { request } from '@playwright/test';

export default async function globalSetup() {
if (process.env.ENV === 'production') throw new Error('Cannot run test setup in production');

const ctx = await request.newContext({ baseURL: process.env.BASE_URL });

const res = await ctx.post('/auth/test-session', {
data: { email: process.env.TEST_ADMIN_EMAIL, role: 'admin' },
});
if (!res.ok()) throw new Error(
Test session creation failed: ${res.status()});

await ctx.storageState({ path: 'playwright/.auth/admin.json' });
await ctx.dispose();
}
`

Strategy 2 — Real OAuth flow, automated once against a test IdP:
If a test identity provider is available (Auth0 test tenant, local Keycloak), automate the login once and save the resulting token. Works when Strategy 1 isn't possible.
`ts
export default async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto('/login');
await page.click('[data-testid="login-with-google"]');

// On the test identity provider's login page
await page.fill('input[type="email"]', process.env.TEST_GOOGLE_EMAIL!);
await page.click('#identifierNext');
await page.fill('input[type="password"]', process.env.TEST_GOOGLE_PASSWORD!);
await page.click('#passwordNext');

await page.waitForURL(
${process.env.BASE_URL}/dashboard);
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
await browser.close();
}
`

Strategy 3 — Client credentials grant for API-only tests:
`ts
test('API accepts a valid bearer token', async ({ request }) => {
const tokenRes = await request.post(process.env.TOKEN_URL!, {
form: {
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
scope: 'read:users',
},
});
const { access_token } = await tokenRes.json() as { access_token: string };

const apiRes = await request.get('/api/users', {
headers: { Authorization:
Bearer ${access_token} },
});
expect(apiRes.status()).toBe(200);
});
``

Strategy comparison:
| Strategy | Speed | Reliability | When to use |
|---|---|---|---|
| Backend shortcut | Fastest | Most reliable | When your team controls the backend |
| Test IdP automation | Moderate | Good | When IdP is dedicated for testing |
| Client credentials | Fast | Good | API-only tests, no browser needed |
| Real production IdP | Slow | Fragile | Avoid — lockout/rate limit risk |

Rule of thumb: never automate against a real production identity provider in automated tests. Use a backend shortcut or a test-specific identity provider. Saving that auth state to a file means you pay the login cost once per CI run, not once per test.
💡 Plain English: Testing a hotel's key card system — you don't call the real guest reservation system for every test. You use a staff master key (backend shortcut) that grants access directly for testing purposes, keeping the real booking system clean.
30
Playwright

How do you design a cross-browser and mobile testing strategy with Playwright?

Running every test in every browser on every device is slow and expensive — and provides diminishing returns. The goal is meaningful coverage at a manageable runtime: the right tests in the right browsers, not all tests everywhere.

Playwright projects — target different browsers per test scope:
``ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
projects: [
// Full functional suite — Chromium is fastest and most consistent
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },

// Cross-browser — smoke tests only in Firefox and WebKit (Safari engine)
{ name: 'firefox', use: { ...devices['Desktop Firefox'] }, testMatch: '**/*.smoke.spec.ts' },
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, testMatch: '**/*.smoke.spec.ts' },

// Mobile — key user journeys on real device emulation
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] }, testMatch: '**/*.mobile.spec.ts' },
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] }, testMatch: '**/*.mobile.spec.ts' },
],
});
`

Tagging tests for the right scope:
`ts
// Runs in all browsers — cross-browser smoke tag
test('checkout completes successfully @smoke', async ({ page }) => { ... });

// Mobile-specific — touch interactions and responsive layout
test('hamburger menu opens on tap @mobile', async ({ page }) => {
await page.locator('[data-testid="hamburger"]').tap();
await expect(page.locator('[data-testid="nav-menu"]')).toBeVisible();
});
`

Viewport-aware helper for interactions that differ by screen size:
`ts
// helpers/viewport.ts
import { Page } from '@playwright/test';

function isMobile(page: Page): boolean {
return (page.viewportSize()?.width ?? 1280) < 768;
}

async function openNav(page: Page): Promise<void> {
if (isMobile(page)) {
await page.locator('[data-testid="hamburger"]').tap();
}
// Desktop nav is always visible — no action needed
}

export { isMobile, openNav };
``

Practical CI cadence:
| Run | Browsers | Test scope | Trigger |
|---|---|---|---|
| PR check | Chromium only | Full suite | Every push |
| Nightly | All 5 projects | Smoke + mobile | Nightly schedule |
| Pre-release | All 5 projects | Full regression | Before each release |

Rule of thumb: run the full suite in Chromium — fastest, most consistent, catches 90% of bugs. Run smoke tests in Firefox and WebKit. Run mobile tests only for journeys with known responsive complexity. Don't multiply your CI cost 5× to catch the 2% of bugs that are browser-specific.
💡 Plain English: A car manufacturer that does full safety testing at its primary facility, then spot-checks critical results at partner locations. Not every test runs everywhere — but every safety-critical finding is verified across environments before the car ships.
31
Advanced Types

When do you write a custom utility type and what makes a good one?

Write a custom utility type when a transformation you need recurs across the codebase and no built-in utility expresses it cleanly. The bar should be high: if it's only needed once, inline it; if it's complex enough to confuse a competent colleague on first read, it's probably over-engineered.

Why this matters in a test framework:
Test suites accumulate patterns — "make only these fields required for the request body," "derive string keys from this interface," "strip nullability from every field after validation." Encoding these patterns as named utility types makes the intent clear and removes repetition. But the wrong custom type — too abstract, too clever — becomes an obstacle that slows down every person who touches a test that uses it.

Four utility types with real test-framework value:

``ts
// 1. PartialBy<T, K> — make only specific keys optional
// Useful for API request bodies where some fields are optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface CreateUserRequest {
name: string;
email: string;
role: 'admin' | 'user';
}

// For a test that only cares about email and role — name is optional in this test context
type PartialCreateUser = PartialBy<CreateUserRequest, 'name'>;
// { email: string; role: 'admin'|'user'; name?: string }
`

`ts
// 2. DeepReadonly<T> — make all nested properties readonly
// Useful for config objects and test fixtures that should never be mutated
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

const TEST_CONFIG = {
auth: { timeout: 30000, retries: 3 },
baseUrl: 'https://staging.example.com',
} as const satisfies DeepReadonly<typeof testConfigShape>;
`

`ts
// 3. KeysOfType<T, V> — extract only the keys whose values match type V
// Useful for building generic assertion helpers that operate on specific field types
type KeysOfType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T];

type User = { id: number; name: string; email: string; age: number; active: boolean };
type StringKeys = KeysOfType<User, string>; // 'name' | 'email'
type NumberKeys = KeysOfType<User, number>; // 'id' | 'age'

// A helper that only accepts string-valued fields of User
function assertStringField(user: User, field: StringKeys): void {
expect(user[field]).toBeTruthy();
}
`

`ts
// 4. RequiredFields<T, K> — make specific optional fields required
// Useful after a null check — express that certain fields are now guaranteed present
type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

interface ApiUser { id: number; name?: string; email?: string }

// After validating that name and email exist:
function assertComplete(user: ApiUser): RequiredFields<ApiUser, 'name' | 'email'> {
if (!user.name || !user.email) throw new Error('Incomplete user');
return user as RequiredFields<ApiUser, 'name' | 'email'>;
}
`

The bar for writing vs not writing a custom utility:

| Write it | Don't write it |
|---|---|
| Recurs in 5+ places | Only used once — inline it |
| Name reads like documentation | Name requires a comment to explain |
| Composes from built-ins | Reimplements what
Pick/Omit already does |
| A colleague understands it in < 30 seconds | Requires reading three times |

Testing custom utility types:
`ts
import { expectTypeOf } from 'vitest';

type Result = PartialBy<CreateUserRequest, 'name'>;
expectTypeOf<Result['name']>().toEqualTypeOf<string | undefined>();
expectTypeOf<Result['email']>().toEqualTypeOf<string>();
`

Rule of thumb: write a custom utility type when the same type transformation appears three or more times. Name it for what it produces, not how it works. Test it with
expectTypeOf`.
💡 Plain English: A bespoke jig in a woodworking shop — made once for a cut that appears on every piece you build. A good jig is simple, clearly labelled, and any craftsperson can use it without asking what it does. A bad jig is intricate, hard to set up, and only the person who made it understands it.
32
Playwright

How do you design an accessibility (a11y) testing strategy using Playwright and TypeScript?

Accessibility testing verifies that your application is usable by people with disabilities — screen readers, keyboard navigation, and colour contrast. Playwright integrates with axe-core to automate WCAG checks, but automated tools catch only ~30–40% of accessibility issues. A solid strategy combines automated scanning with targeted scripted checks.

Setting up axe-core with Playwright:
``bash
npm install @axe-core/playwright
`

Running axe on a full page:
`ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');

const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();

expect(results.violations).toHaveLength(0);
});
`

Scoping axe to a single component:
`ts
test('checkout form is accessible', async ({ page }) => {
await page.goto('/checkout');

const results = await new AxeBuilder({ page })
.include('[data-testid="checkout-form"]') // scan only this section
.withTags(['wcag2aa'])
.analyze();

if (results.violations.length > 0) {
console.log(JSON.stringify(results.violations, null, 2)); // detailed violation output
}
expect(results.violations).toHaveLength(0);
});
`

Disabling a rule you're tracking but not yet fixed:
`ts
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast']) // tracked in JIRA-456 — design system fix in progress
.analyze();
`

Keyboard navigation tests — axe doesn't cover these:
`ts
test('modal can be dismissed with Escape key', async ({ page }) => {
await page.click('[data-testid="open-modal"]');
await page.waitForSelector('[role="dialog"]');

await page.keyboard.press('Escape');
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
});

test('form fields are reachable via Tab key in order', async ({ page }) => {
await page.goto('/contact');
await page.keyboard.press('Tab');
await expect(page.locator('[name="name"]')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('[name="email"]')).toBeFocused();
});
``

What axe catches vs what needs scripted checks:
| Caught by axe | Needs a targeted test |
|---|---|
| Missing alt text | Focus visible after modal opens |
| Low colour contrast | Logical keyboard tab order |
| Missing form labels | Screen reader announcement text |
| Missing landmark roles | Escape / arrow key behaviour |

Rule of thumb: run axe on every page in CI as a regression gate. Add keyboard tests for every modal, drawer, and complex widget. The two layers together cover 70–80% of common accessibility issues before any manual audit.
💡 Plain English: A spell-checker for accessibility — it catches obvious errors automatically (missing alt text, broken contrast), but a human editor must still read the document to verify flow and meaning. Automated scanning is the baseline; scripted keyboard tests cover what it misses.
33
Safety

What are the most subtle TypeScript bugs you have encountered in real projects and how did you fix them?

Subtle TypeScript bugs are the ones that compile cleanly, pass the type checker, look reasonable on code review — and then fail at runtime with a cryptic error. They're almost always caused by one of the same five patterns: as bypassing real shape mismatches, async errors silently swallowed, runtime mutation of type-safe constants, type widening breaking narrowing, or !! short-circuit evaluation losing strict null guarantees.

Why senior engineers need to know these:
Juniors write code that TypeScript rejects. Seniors accidentally write code that TypeScript accepts but silently lies about. Knowing the patterns lets you catch them in code review before they reach production.

Bug 1 — as hiding a broken API contract (most common):

``ts
// The bug — TypeScript trusts you; runtime doesn't
const user = await res.json() as User;
user.name.toUpperCase(); // TypeError: Cannot read properties of null at runtime
// API changed 'name' to be nullable — TypeScript has no idea

// The fix — validate at the boundary
import { z } from 'zod';
const UserSchema = z.object({ name: z.string(), email: z.string() });
const user = UserSchema.parse(await res.json()); // throws immediately if shape is wrong
`

Bug 2 — async callback inside Array.forEach swallows errors:

`ts
// The bug — forEach doesn't await the async callbacks; errors disappear
items.forEach(async (item) => {
await saveItem(item); // if this throws, nobody catches it — test passes, data not saved
});

// The fix — use for...of so errors propagate correctly
for (const item of items) {
await saveItem(item); // throws up to the test — fails loudly ✓
}

// Or for parallel execution:
await Promise.all(items.map(item => saveItem(item))); // catches any rejection ✓
`

Bug 3 — runtime mutation of a
readonly / as const array:

`ts
// The bug — TypeScript's readonly is compile-time only
const BROWSERS = ['chromium', 'firefox', 'webkit'] as const;
(BROWSERS as string[]).push('safari'); // TS allows the cast; runtime mutates it silently

// The fix — freeze for true runtime immutability
const BROWSERS = Object.freeze(['chromium', 'firefox', 'webkit'] as const);
(BROWSERS as string[]).push('safari'); // TypeError at runtime ✓
`

Bug 4 — discriminated union narrowing broken by type widening:

`ts
// The bug — getStatus() returns string, not the literal union
function getStatus() {
return 'active'; // inferred as string, not 'active' | 'inactive'
}

const status = getStatus();
if (status === 'active') {} // TypeScript flags this as always-false if status is string

// The fix — annotate the return type as the literal union
function getStatus(): 'active' | 'inactive' {
return 'active';
}
// OR use as const on the returned value
function getStatus() {
return 'active' as const; // inferred as literal 'active', not string
}
`

Bug 5 — optional chaining silencing a missing required field:

`ts
// The bug — ?. hides a missing field instead of failing loudly
const email = user?.profile?.email; // undefined if profile doesn't exist
expect(email).toContain('@'); // assertion passes vacuously (undefined never)

// The fix — assert presence first so the test fails clearly if data is missing
expect(user.profile, 'user should have a profile').toBeDefined();
expect(user.profile!.email).toContain('@');
`

The common thread:
All five bugs involve TypeScript's compile-time guarantees not matching what actually happens at runtime — because
as bypasses checking, readonly doesn't freeze, widening loses literals, async errors escape forEach, and ?. swallows absences silently.

Rule of thumb: when TypeScript stops complaining but the code still feels wrong — it probably is. Look for
as, !, ?.?` in async chains, and inferred-string returns where a literal union is expected.
💡 Plain English: Hidden structural faults in a building that pass the visual inspection. The surface looks fine, the blueprint is approved — but under load (real runtime data), each one fails differently. A senior inspector knows where to look: the cast iron pipes (as), the unventilated async rooms (forEach), the frozen-on-paper (readonly), the widened doorframes (type widening), and the optional load-bearing columns (?.).
34
Test Data

How do you manage database state in end-to-end tests — setup, isolation, and cleanup?

E2E tests that share a database without isolation are the most common cause of brittle, order-dependent test suites. The goal: every test starts with a known state, owns its data exclusively, and leaves no side effects — regardless of whether it passes or fails.

Strategy 1 — API-based setup and teardown (most portable):
``ts
// fixtures/db.fixture.ts
import { test as base } from '@playwright/test';

interface User { id: string; email: string; role: string }

const test = base.extend<{ testUser: User }>({
testUser: async ({ request }, use) => {
// CREATE via API — same code path the app uses
const user = {
id: crypto.randomUUID(),
email:
test-${Date.now()}@example.com,
role: 'viewer',
};
const res = await request.post('/api/test/users', { data: user });
if (!res.ok()) throw new Error(
Setup failed: ${res.status()});

await use(user); // hand the user to the test

// DELETE after the test — runs even if the test fails
await request.delete(
/api/test/users/${user.id});
},
});

export { test };
`

Strategy 2 — Database transactions that roll back (integration layer):
`ts
// For tests that hit the DB layer directly — wrap in a transaction and roll back
beforeEach(async () => { await db.query('BEGIN') });
afterEach(async () => { await db.query('ROLLBACK') }); // undo all changes; no cleanup needed
`

Strategy 3 — Separate database per worker (full isolation, higher cost):
`ts
// playwright.config.ts — each parallel worker uses its own DB instance
export default defineConfig({
workers: 4,
use: {
baseURL:
http://localhost:${3000 + parseInt(process.env.TEST_WORKER_INDEX ?? '0')},
},
});
`

Data scoping rules:
| Data type | Scope | Why |
|---|---|---|
| User accounts, orders, sessions | Test-level fixture | Mutable — parallel tests collide on shared records |
| Reference data (plans, countries) | Worker or global seed | Read-only — safe to share |
| Static catalogue / config | Global setup | Never changes — seed once |

Unique identifiers prevent parallel collisions:
`ts
// Never use hardcoded IDs — always generate per call
const user = buildUser({
id: crypto.randomUUID(),
email:
test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com,
});
``

Rule of thumb: if two tests running at the same time could write to the same record, the data must be test-scoped. If data is read-only reference data that no test modifies, global seed is fine.
💡 Plain English: A medical trial where each patient (test) gets their own isolated chart, dose, and equipment. No two patients share a chart, and the nurse sanitises equipment between patients regardless of how the previous appointment ended.
35
Security Testing

How do you approach security testing in a Playwright suite — authentication bypass, authorization, and input validation?

Security testing in an automated suite means verifying that your access controls, session handling, and input validation work as designed — not waiting for a penetration test to find them in production. Most of this is functional testing with a deliberate adversarial mindset.

1 — Authorization: verify each role sees only what it should:
``ts
test('viewer is blocked from admin routes', async ({ browser }) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/viewer.json' });
const page = await ctx.newPage();

await page.goto('/admin/settings');
// Must redirect — never silently render the page for the wrong role
await expect(page).toHaveURL(//(403|dashboard)/);
await ctx.close();
});

test('unauthenticated user is redirected to login', async ({ page }) => {
await page.goto('/dashboard'); // fresh context — no auth state
await expect(page).toHaveURL(//login/);
});
`

2 — API authorization: test the API layer directly:
`ts
test('viewer token is rejected by admin-only endpoint', async ({ request }) => {
const res = await request.delete('/api/admin/users/42', {
headers: { Authorization:
Bearer ${viewerToken} },
});
// Must be 403, not 404 (which would obscure the endpoint's existence)
expect(res.status()).toBe(403);
});

test('missing token returns 401', async ({ request }) => {
const res = await request.get('/api/users'); // no Authorization header
expect(res.status()).toBe(401);
});
`

3 — Input validation: confirm the API rejects malformed data:
`ts
const invalidEmails = ['', 'notanemail', 'a'.repeat(256) + '@b.com', null];

for (const email of invalidEmails) {
test(
POST /api/users rejects invalid email: ${JSON.stringify(email)}, async ({ request }) => {
const res = await request.post('/api/users', { data: { email } });
expect(res.status()).toBe(400);
});
}
`

4 — Session cookie security attributes:
`ts
test('session cookie has httpOnly and Secure flags', async ({ page }) => {
await loginAs(page, adminCredentials);

const cookies = await page.context().cookies();
const session = cookies.find(c => c.name === 'session');

expect(session?.httpOnly, 'cookie must be httpOnly').toBe(true);
expect(session?.secure, 'cookie must be Secure').toBe(true);
expect(session?.sameSite, 'cookie must be Strict or Lax').toMatch(/Strict|Lax/);
});
``

What Playwright tests well vs what needs a dedicated tool:
| Playwright (functional) | Dedicated scanner (OWASP ZAP, Burp) |
|---|---|
| Access control logic | SQL injection / XSS discovery |
| Session management | Cryptographic weakness |
| Input validation responses | Infrastructure vulnerabilities |
| Role-based API behaviour | Automated fuzzing |

Rule of thumb: Playwright is excellent for verifying your access control logic — who can do what. Use a dedicated scanner for vulnerability discovery. The two are complementary, not substitutes.
💡 Plain English: A hotel security team — the QA engineer (Playwright) verifies that room keys only open the right rooms and that the master key isn't accessible to guests. The penetration tester checks whether the locks themselves can be picked. Both jobs are necessary.
36
Leadership

What do you look for in a TypeScript code review from a senior perspective?

A TypeScript code review at senior level goes beyond style and formatting. The real value is catching patterns that compile cleanly but create runtime risk, reviewing whether the type system is actually providing safety or just the appearance of safety, and calling out both dangerous anti-patterns and good patterns worth reinforcing.

Why this perspective matters:
Junior reviewers check whether the code works. Senior reviewers check whether the type system is being used honestly — whether the types reflect reality or just suppress compiler warnings. A codebase full of as casts and ! assertions has TypeScript installed but not TypeScript safety.

Red flags I always comment on:

``ts
// 1. as on API/JSON data — the most common runtime bomb
const user = await res.json() as User;
// "This will fail silently if the API changes. Use Zod.parse() here."

// 2. Non-null assertion on values that could genuinely be null
const el = document.querySelector('.submit-btn')!;
// "! here is a promise — if the selector ever changes, this crashes at runtime.
// If you're certain it exists, add a comment. If not, add a guard."

// 3. any without justification
function process(data: any) { ... }
// "What's the shape of data? If it's external, use unknown + Zod.
// If it's truly polymorphic, document why any is necessary here."

// 4. Switch on a discriminated union with no never default
switch (result.status) {
case 'passed': ...
case 'failed': ...
// What happens when 'timedout' is added? It silently falls through.
// "Add a never guard in the default branch."
`

Type over-engineering I push back on:

`ts
// Conditional types 4 levels deep to express what a union does in 3 lines
type DeepPartialMaybeNullableExcept<T, K extends keyof T> = ...
// "What problem does this solve? Who else needs to understand it?
// If the next person needs 5 minutes to parse this, simplify."

// Custom utility that reimplements a built-in with a different name
type Maybe<T> = T | null | undefined; // same as T | null | undefined — why abstract it?
// "This doesn't add clarity — it hides what the type actually is."
`

Structural patterns I flag:

`ts
// Optional fields used to avoid handling the null case
interface Response {
data?: User; // optional to avoid null checks — but data is null on error, not absent
error?: string;
}
// "Model this as a discriminated union — the shapes are mutually exclusive."

// Mutable module-level state typed broadly
let currentUser: any = null;
// "What shape is currentUser? Type it as User | null so mutations are checked."
`

Positive patterns I explicitly call out:

`ts
// Good: satisfies for validated config
const config = { timeout: 30000, env: 'staging' } satisfies TestConfig; // ✓

// Good: Zod at the API boundary
const user = UserSchema.parse(await res.json()); // ✓ runtime + compile-time safety

// Good: discriminated union for state modeling
type Result = { status: 'ok'; data: User } | { status: 'error'; message: string }; // ✓

// Good: as const for deriving typed constants
const ENVS = ['staging', 'prod'] as const;
type Env = typeof ENVS[number]; // ✓
`

The review mindset:
Ask for every
as: "What proof does the author have that this cast is safe?" Ask for every !: "What guarantees this is non-null?" Ask for every any: "What's the minimum type that would work here?"

Rule of thumb: the goal of a TypeScript code review is not to be the style police — it's to verify that the types honestly describe the runtime behaviour. A codebase with 50
as` casts is not a typed codebase; it's a JavaScript codebase with extra syntax.
💡 Plain English: A structural engineering inspection, not a paint inspection. The surface (formatting, naming) matters but it's the load-bearing elements (type safety boundaries, runtime contract enforcement, null handling) that determine whether the building stays up. A beautiful facade over a cracked foundation is worse than an ugly but solid structure.
37
API Testing

How do you test a GraphQL API using Playwright and TypeScript?

Playwright's request fixture works perfectly for GraphQL — all queries go to a single endpoint via POST. The challenge is keeping tests readable and typed against the schema so a breaking schema change surfaces as a compile error rather than a test failure.

Typed GraphQL request helper:
``ts
// helpers/gql.ts
import { APIRequestContext } from '@playwright/test';

interface GQLResponse<T> {
data?: T;
errors?: Array<{ message: string; path?: string[] }>;
}

async function gql<T>(
request: APIRequestContext,
query: string,
variables?: Record<string, unknown>,
token?: string
): Promise<GQLResponse<T>> {
const res = await request.post(
${process.env.BASE_URL}/graphql, {
data: { query, variables },
headers: token ? { Authorization:
Bearer ${token} } : {},
});
return res.json() as Promise<GQLResponse<T>>;
}

export { gql };
`

Testing a query:
`ts
import { test, expect } from '@playwright/test';
import { gql } from '../helpers/gql';

interface GetUserData { user: { id: string; email: string; role: string } }

test('getUser query returns correct shape', async ({ request }) => {
const { data, errors } = await gql<GetUserData>(request,

query GetUser($id: ID!) {
user(id: $id) { id email role }
}
, { id: '42' }, authToken);

expect(errors,
GraphQL errors: ${JSON.stringify(errors)}).toBeUndefined();
expect(data!.user.id).toBe('42');
expect(['admin', 'viewer']).toContain(data!.user.role);
});
`

Testing a mutation:
`ts
interface CreateUserData { createUser: { id: string; email: string } }

test('createUser mutation returns the new user', async ({ request }) => {
const { data, errors } = await gql<CreateUserData>(request,

mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id email }
}
, { input: { email: test-${Date.now()}@example.com, role: 'viewer' } }, adminToken);

expect(errors).toBeUndefined();
expect(data!.createUser.id).toBeTruthy();
});
`

Testing authorization at the GraphQL layer:
`ts
test('viewer cannot run admin-only mutation', async ({ request }) => {
const { errors } = await gql(request,

mutation DeleteUser($id: ID!) { deleteUser(id: $id) }
, { id: '99' }, viewerToken);

// GraphQL returns HTTP 200 with errors — not a 403 status
expect(errors).toBeDefined();
expect(errors![0].message).toMatch(/not authorized|forbidden/i);
});
`

Generating types from the schema (eliminates manual interfaces):
`bash
npx graphql-codegen --config codegen.yml
# Generates types/graphql.ts from your schema + operation files
`
`ts
import type { GetUserQuery } from '../types/graphql'; // auto-generated from schema
const { data } = await gql<GetUserQuery>(request, GET_USER_QUERY, { id: '42' });
// data is fully typed — schema change = compile error
`

Critical rule: always assert
errors === undefined AND the data shape. GraphQL returns HTTP 200 even on errors — a test that only checks status === 200` misses every GraphQL-level failure.
💡 Plain English: Ordering at a restaurant with one service counter — all orders go through the same window (single endpoint), but each order slip specifies what you want (query/mutation). You verify both that the order was accepted (no errors) and that what arrived matches what you ordered (data shape).
38
Playwright

How do you build a Playwright suite that tests both the REST API and the UI in the same framework?

Running API and UI tests in the same framework eliminates tool-switching, lets you share fixtures and auth state across both layers, and enables hybrid tests that set up state via API and verify the result through the UI — which is faster and more reliable than driving everything through the browser.

Separate Playwright projects for API vs UI:
``ts
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'api',
testMatch: '/api//*.spec.ts',
use: { baseURL: process.env.API_BASE_URL },
},
{
name: 'ui',
testMatch: '/e2e//*.spec.ts',
use: { baseURL: process.env.BASE_URL, ...devices['Desktop Chrome'] },
},
],
});
`

Shared typed API client used by both projects:
`ts
// helpers/api-client.ts
import { APIRequestContext } from '@playwright/test';

interface User { id: string; email: string; role: string }

class ApiClient {
constructor(private request: APIRequestContext) {}

async createUser(data: Partial<User>): Promise<User> {
const res = await this.request.post('/api/users', { data });
if (!res.ok()) throw new Error(
createUser failed: ${res.status()});
return res.json() as Promise<User>;
}

async deleteUser(id: string): Promise<void> {
await this.request.delete(
/api/users/${id});
}
}

export { ApiClient };
`

Fixture that provides the API client to any test:
`ts
// fixtures/index.ts
import { test as base } from '@playwright/test';
import { ApiClient } from '../helpers/api-client';

export const test = base.extend<{ api: ApiClient }>({
api: async ({ request }, use) => { await use(new ApiClient(request)); },
});
`

Hybrid test — API setup, UI verification:
`ts
test('user created via API appears in admin UI', async ({ page, api }) => {
// SETUP: fast API call — no browser form interaction needed
const user = await api.createUser({ email:
test-${Date.now()}@example.com, role: 'viewer' });

// VERIFY: the UI renders it correctly — only the browser can confirm this
await page.goto('/admin/users');
await expect(page.locator(
text=${user.email})).toBeVisible();
await expect(page.locator(
[data-user-id="${user.id}"] [data-testid="role"])).toHaveText('viewer');

// CLEANUP: API delete — faster than navigating to a delete button
await api.deleteUser(user.id);
});
`

Pure API test using the same fixture:
`ts
test('PATCH /api/users/:id updates role', async ({ api }) => {
const user = await api.createUser({ role: 'viewer' });
const updated = await api.patchUser(user.id, { role: 'admin' });
expect(updated.role).toBe('admin');
await api.deleteUser(user.id);
});
``

Rule of thumb: use the API to set up preconditions and clean up — it's 10–50× faster than the UI. Use the browser only to verify what only the UI can confirm: rendering, navigation, visual state, and user-facing interactions.
💡 Plain English: A chef who preps all ingredients in the professional kitchen (API calls) and plates the dish at the table (UI verification). You don't carry raw ingredients through the dining room — you use the right tool for each stage.
39
Playwright

How do you mock network requests and record API responses in Playwright tests?

Request interception lets you control what the app receives from the network — returning fixed responses, simulating errors, or replaying recorded real responses — without running a separate mock server.

Basic route interception — return a fixed mock:
``ts
import { test, expect } from '@playwright/test';

test('shows error banner when API returns 500', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ code: 'INTERNAL_ERROR', message: 'Server error' }),
});
});

await page.goto('/users');
await expect(page.locator('[data-testid="error-banner"]')).toContainText('Server error');
});
`

Typed mock helper:
`ts
// helpers/mock-api.ts
import { Page } from '@playwright/test';

async function mockEndpoint<T>(page: Page, pattern: string, body: T, status = 200): Promise<void> {
await page.route(pattern, route =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
);
}

// Usage — TypeScript ensures body matches User[]
await mockEndpoint<User[]>(page, '**/api/users', [
{ id: '1', email: 'alice@example.com', role: 'admin' },
]);
`

Simulate a slow API to test loading states:
`ts
await page.route('**/api/reports', async route => {
await new Promise(r => setTimeout(r, 2000)); // 2 s delay
await route.continue();
});

await page.goto('/reports');
await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible();
await expect(page.locator('[data-testid="reports-table"]')).toBeVisible({ timeout: 8000 });
`

Partial interception — modify a real response:
`ts
await page.route('**/api/users/me', async route => {
const res = await route.fetch(); // hit the real API
const body = await res.json() as User;

// Modify one field to test a specific UI state
await route.fulfill({ json: { ...body, plan: 'free' } });
});
`

Record and replay with HAR files:
`ts
// Record: capture real API responses during a test run
const ctx = await browser.newContext({
recordHar: { path: 'fixtures/api-responses.har', mode: 'minimal' },
});

// Replay: use the HAR in CI — no live backend needed
const replayCtx = await browser.newContext();
await replayCtx.routeFromHAR('fixtures/api-responses.har', { notFound: 'fallback' });
``

When to mock vs when to use real API:
| Mock | Real API |
|---|---|
| Error/empty/loading UI states | Contract and integration tests |
| Rare edge cases (network timeout) | Auth flows and session handling |
| Fast, hermetic CI runs | Verifying actual API response shapes |

Rule of thumb: mock for UI state tests; use the real API for integration tests. Never mock the same endpoint in both a UI test and an API test — you'll get two green suites with a broken integration.
💡 Plain English: A flight simulator for UI testing — it faithfully reproduces conditions (network responses) without the real aircraft. Useful for rare emergencies (500 error = engine failure), but a real flight check is still required before certifying the aircraft itself.
40
Leadership

How do you onboard a team of engineers who know JavaScript but not TypeScript?

TypeScript onboarding fails when it's presented as "JavaScript but with errors." The goal is to change the mental model — TypeScript isn't about adding syntax; it's about making the editor and compiler do work that currently falls on the developer's memory and runtime debugging. Get engineers to feel that value in their first week, and adoption takes care of itself.

Why this is a senior responsibility:
Junior engineers can learn TypeScript from documentation. The senior challenge is making it feel like a gain, not a tax — especially for engineers who've been productive in JavaScript and see TypeScript as friction. The onboarding approach determines whether the team embraces the type system or fights it.

Week 1 — Show the value before showing the syntax:

``ts
// Demo 1: Autocomplete and error prevention (5 minutes at a whiteboard)
// Show a JS function that silently accepts the wrong argument type
// Then add a TypeScript type annotation and show the error appearing in VS Code
// Before they've written a line of TS, they've seen what it prevents

// Demo 2: Rename refactoring safety (2 minutes)
// Rename a field in a TypeScript interface
// Show the compiler instantly finding every usage that needs updating
// "Without TypeScript, you find these with grep and hope you didn't miss one"

// Demo 3: The null bug that types prevent (real example from your codebase)
// Show a production bug that TypeScript would have caught at write time
`

Setup that removes friction:

`json
// tsconfig.json — start lenient, tighten over time
{
"compilerOptions": {
"allowJs": true, // engineers can still write JS while learning
"checkJs": false, // JS files don't get type-checked yet
"strict": false, // start without strict mode — enable per-file
"target": "ES2022",
"module": "CommonJS"
}
}
`

Engineers can write JavaScript, gradually introduce TypeScript in new files, and add types incrementally without breaking existing work.

Teaching sequence — concepts in order of usefulness:

`
Week 1: Parameter and return type annotations — the most immediately useful
Interface / type aliases — name the shapes they're already working with
Week 2: Optional properties (?) and union types (string | null)
Pick, Partial, Omit — the built-in utilities they'll use constantly
Week 3: Generics — introduce through Array<T> and Promise<T> (they already use these)
Basic narrowing (typeof, instanceof)
Month 2: ReturnType<>, keyof, as const
Month 3: Discriminated unions, satisfies, Zod for boundary validation
Later: Advanced conditional types — only when they identify a need
`

Common objections and how to respond:

`
"TypeScript is too verbose" →
Show that typed code is shorter to debug.
One TypeScript error at write-time = one hour of runtime debugging avoided.

"I'm faster in plain JavaScript" →
True for 2 hours. Not true after a refactor breaks 30 test files at 3 AM.
Show the refactoring demo again.

"The types are getting in the way" →
Usually means a type is wrong, not that TypeScript is wrong.
Pair on the specific problem — it's almost always a learning moment.
`

Building the habit through code review:

Don't use code review to shame TypeScript misuse. Use it to teach one pattern per PR:
- Week 2: "Here's where a type annotation would have caught this"
- Week 4: "This
as` cast is risky — here's the Zod alternative"
- Week 8: "This would be cleaner as a discriminated union — want to pair on it?"

After 3 months of consistent gentle feedback, TypeScript patterns become instinct. The goal is engineers who reach for types reflexively, not engineers who add types when they think someone is watching.

Rule of thumb: lead with what TypeScript does FOR the engineer (autocomplete, refactor safety, finding null bugs). Delay the "you must do it this way" until week three. Adoption comes from felt value, not mandated compliance.
💡 Plain English: Teaching someone to drive in a car with a parking sensor before explaining how the sensor works. They feel the beep when they're about to hit something, they value it immediately, and then they want to understand it. Lead with the benefit — the syntax and rules land much easier once there's felt value behind them.
41
Playwright

How do you monitor and report on test suite health — flakiness, execution time, and pass rate trends over time?

A healthy test suite needs the same observability as a production service. Without metrics you don't know whether the suite is getting slower, which tests are flaky, or whether a flakiness-fixing sprint actually worked.

Built-in: Playwright marks flaky tests automatically
A test that fails then passes on retry is labelled "flaky" in the HTML report — your first signal without any custom tooling.
``bash
npx playwright test --reporter=html # view pass/fail/flaky/skipped per test
`

Custom JSON metrics reporter for trend tracking:
`ts
// reporters/metrics-reporter.ts
import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
import * as fs from 'fs';

interface TestMetric {
title: string;
status: string;
duration: number;
retries: number;
timestamp: string;
}

class MetricsReporter implements Reporter {
private metrics: TestMetric[] = [];

onTestEnd(test: TestCase, result: TestResult): void {
this.metrics.push({
title: test.titlePath().join(' › '),
status: result.status,
duration: result.duration,
retries: result.retry,
timestamp: new Date().toISOString(),
});
}

async onEnd(): Promise<void> {
const report = {
runAt: new Date().toISOString(),
branch: process.env.GITHUB_REF_NAME ?? 'local',
commit: process.env.GITHUB_SHA?.slice(0, 8) ?? 'local',
totalPassed: this.metrics.filter(m => m.status === 'passed').length,
totalFailed: this.metrics.filter(m => m.status === 'failed').length,
totalFlaky: this.metrics.filter(m => m.retries > 0 && m.status === 'passed').length,
tests: this.metrics,
};

fs.writeFileSync('test-results/metrics.json', JSON.stringify(report, null, 2));

if (process.env.METRICS_URL) {
await fetch(process.env.METRICS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report),
});
}
}
}

export default MetricsReporter;
`

Register alongside the standard reporter:
`ts
// playwright.config.ts
export default defineConfig({
reporter: [['html'], ['./reporters/metrics-reporter.ts']],
});
``

Key metrics to track:
| Metric | Red flag |
|---|---|
| 7-day rolling pass rate | Below 95% = systemic problem |
| Flaky test count week-on-week | Growing = unaddressed reliability debt |
| Median test duration trend | Increasing = performance regression |
| Slowest 10 tests | Any single test > 30 s blocks shards |
| Retry rate | > 5% of runs needing retry |

Rule of thumb: treat a flaky test as a priority-1 bug, not a "sometimes it fails" footnote. One silently retried flaky test trains developers to ignore red CI lights — and eventually a real failure gets ignored too.
💡 Plain English: A hospital tracking patient outcomes over time, not just per appointment. You don't just know "today's surgery went fine" — you track recovery rates, complication rates, and procedure times across months to catch systemic quality problems before they become crises.
42
Playwright

How do you test file uploads and downloads in Playwright TypeScript tests?

File uploads and downloads are common in enterprise apps but have Playwright-specific mechanics that catch engineers out the first time.

File upload via setInputFiles() — standard <input type="file">:
``ts
import { test, expect } from '@playwright/test';
import * as path from 'path';

test('user can upload a CSV report', async ({ page }) => {
await page.goto('/reports/upload');

await page.locator('[data-testid="file-input"]').setInputFiles(
path.join(__dirname, '../fixtures/test-report.csv')
);

await page.locator('[data-testid="upload-btn"]').click();
await expect(page.locator('[data-testid="success-toast"]')).toContainText('test-report.csv');
});
`

Upload with a dynamically generated file (no fixture on disk):
`ts
await page.locator('[data-testid="file-input"]').setInputFiles({
name: 'generated-report.csv',
mimeType: 'text/csv',
buffer: Buffer.from('id,name,status
1,Alice,active
2,Bob,inactive'),
});
`

File download — always start
waitForEvent BEFORE the click:
`ts
test('user can download a PDF invoice', async ({ page }) => {
await page.goto('/invoices/42');

// Race condition if you click first then wait — start the listener first
const downloadPromise = page.waitForEvent('download');
await page.locator('[data-testid="download-btn"]').click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toBe('invoice-42.pdf');

// Save and verify the file is a valid PDF
const filePath = path.join(__dirname, '../test-results', download.suggestedFilename());
await download.saveAs(filePath);

const buffer = require('fs').readFileSync(filePath);
expect(buffer.slice(0, 4).toString()).toBe('%PDF'); // valid PDF magic bytes
});
`

Download via API (faster — no browser rendering needed):
`ts
test('GET /api/invoices/42/download returns a valid PDF', async ({ request }) => {
const res = await request.get('/api/invoices/42/download', {
headers: { Authorization:
Bearer ${adminToken} },
});

expect(res.status()).toBe(200);
expect(res.headers()['content-type']).toContain('application/pdf');

const buffer = await res.body();
expect(buffer.slice(0, 4).toString()).toBe('%PDF');
});
`

Drag-and-drop file upload (custom drop zones):
`ts
test('user can drag and drop a file onto the upload zone', async ({ page }) => {
await page.goto('/documents/upload');

const fileContent = Buffer.from('test content');
const dataTransfer = await page.evaluateHandle((buf: number[]) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(buf)], 'test.txt', { type: 'text/plain' });
dt.items.add(file);
return dt;
}, [...fileContent]);

await page.locator('[data-testid="drop-zone"]').dispatchEvent('drop', { dataTransfer });
await expect(page.locator('[data-testid="drop-zone"]')).toContainText('test.txt');
});
`

Rule of thumb: use
setInputFiles() for standard file inputs — always the most reliable approach. For downloads, start waitForEvent('download')` before clicking — never after. Prefer API-level download tests when you only need to verify the file contents, not the download UX.
💡 Plain English: Testing a post office counter — you verify it can accept packages (uploads) and hand them out (downloads), checking both the receipt and that the package contents arrived intact. The mechanics differ from a form field but the verification mindset is identical.
43
Playwright

How do you test complex UI interactions — drag and drop, iframes, and shadow DOM — in Playwright TypeScript?

These interactions trip up most testing tools. Playwright has first-class support for all three, but each has its own mechanics.

Drag and drop — dragTo():
``ts
test('user can reorder tasks by dragging', async ({ page }) => {
await page.goto('/kanban');

const card1 = page.locator('[data-testid="task-card-1"]');
const card3 = page.locator('[data-testid="task-card-3"]');

// Handles mouse-down → move → mouse-up automatically
await card1.dragTo(card3);

// Verify the new order in the DOM
const cards = page.locator('[data-testid^="task-card-"]');
await expect(cards.nth(0)).toHaveAttribute('data-testid', 'task-card-2');
await expect(cards.nth(1)).toHaveAttribute('data-testid', 'task-card-1');
});
`

Drag to specific coordinates (custom drag implementations):
`ts
await page.locator('[data-testid="draggable"]').dragTo(
page.locator('[data-testid="drop-zone"]'),
{ targetPosition: { x: 20, y: 20 } }
);
`

iframes —
frameLocator():
`ts
test('user completes payment in Stripe iframe', async ({ page }) => {
await page.goto('/checkout');

// frameLocator scopes all selectors to inside the iframe
const frame = page.frameLocator('[data-testid="payment-iframe"]');

await frame.locator('[name="cardNumber"]').fill('4242 4242 4242 4242');
await frame.locator('[name="expiry"]').fill('12/28');
await frame.locator('[name="cvc"]').fill('123');

await page.locator('[data-testid="pay-btn"]').click(); // back on the main page
await expect(page.locator('[data-testid="payment-success"]')).toBeVisible();
});
`

Nested iframes:
`ts
const innerFrame = page
.frameLocator('[data-testid="outer-iframe"]')
.frameLocator('[data-testid="inner-iframe"]');

await innerFrame.locator('button').click();
`

Shadow DOM — Playwright auto-pierces it:
`ts
// No special syntax needed — Playwright's locator() pierces shadow roots by default
test('web component button is clickable', async ({ page }) => {
await page.goto('/settings');
await page.locator('[data-testid="save-btn"]').click(); // works inside a shadow root
await expect(page.locator('[data-testid="save-confirmation"]')).toBeVisible();
});
`

When auto-pierce doesn't reach (deeply nested custom elements):
`ts
// Use >> to explicitly pierce a shadow root at that level
await page.locator('my-custom-element >> button').click();
`

Hover-triggered UI (tooltips, dropdowns):
`ts
test('tooltip appears on hover', async ({ page }) => {
await page.locator('[data-testid="info-icon"]').hover();
await expect(page.locator('[role="tooltip"]')).toBeVisible();
await expect(page.locator('[role="tooltip"]')).toContainText('This metric shows');
});
`

Rule of thumb:
dragTo() for drag and drop, frameLocator() for iframes (never the deprecated frame() API), auto-pierce for shadow DOM. Only reach for explicit >>` syntax when auto-pierce fails on a deeply nested custom element.
💡 Plain English: A locksmith with the right tool for each lock — a standard pick (normal selectors), a deadbolt tool (frameLocator for iframes), and a master passkey (shadow DOM auto-pierce). The principle is the same but each mechanism needs the right approach.
44
Design

What is your philosophy on how much type complexity is too much in TypeScript?

Type complexity is a resource — like code complexity, it should be spent where it returns the most value and minimised everywhere else. The problem is that TypeScript's type system is Turing-complete, which means there's no upper limit to how elaborate you can make it. Knowing when to stop is a senior judgment call.

Why this matters practically:
Overly complex types slow the compiler (type inference is expensive), confuse the next engineer, and make tests harder to write — the opposite of what TypeScript is supposed to do. A type that requires reading three times to understand is a maintenance burden, not a safety feature.

The framework for deciding complexity:

``ts
// Low complexity — explicit, boring, readable in 5 seconds (use freely)
type TestStatus = 'passed' | 'failed' | 'skipped' | 'timedout';
type TestResult = { status: TestStatus; durationMs: number; error?: string };

// Medium complexity — generic utility, clear name, understood in ~30 seconds (use when it DRYs 5+ repetitions)
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

// High complexity — conditional types, infer, recursive mapped types (use only at framework/library layer)
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// Justified: eliminates mutation bugs across a large shared config layer
// Not justified: protecting a single config object used in 2 test files
`

Complexity is worth paying for when:

`ts
// 1. It eliminates a whole class of runtime bugs
type UserId = number & { __brand: 'UserId' };
type OrderId = number & { __brand: 'OrderId' };
// Worth it: prevents wrong-ID-type bugs across 50 API calls

// 2. It's in shared infrastructure used by dozens of files
// A typed retry<T> helper, a generic apiGet<T> — pay once, benefit everywhere

// 3. It replaces explicit duplication
// PartialBy<T, K> that DRYs up 15 identical "partial override" patterns
`

Complexity is NOT worth paying for when:

`ts
// It impresses but doesn't prevent real bugs
type IsStringArray<T> = T extends string[] ? true : false;
// When did you last need to know at the type level whether something is a string array?

// The runtime code is simpler than the types that describe it
// If the type is harder to read than the function it annotates — simplify the type

// It's optimising for hypothetical future requirements
// Write the simplest type that solves today's actual problem
`

The two tests I apply:

`ts
// Test 1 — The 30-second test
// Can a competent TypeScript engineer understand this type in under 30 seconds?
// If not: split it, rename it, or add a JSDoc comment explaining the purpose

// Test 2 — The test-writing test
// Can someone write a test that uses this type without looking up how it works?
// Types should make tests EASIER. If a type makes a test harder to write, it's wrong.
``

Practical rule for test codebases specifically:
Application code and test code need different levels of type sophistication. In application code, a clever generic can earn its keep across hundreds of call sites. In a test file, the priority is readability for the next QA engineer — who may be less experienced with TypeScript. Keep test-level types boring and explicit. Put the sophistication in the shared helper layer where it pays dividends.

Rule of thumb: prefer boring, explicit types in test files. Reserve complex type machinery for shared utilities and framework code where one definition serves dozens of consumers. If the type is harder to understand than the code it describes, the type has failed its job.
💡 Plain English: A well-designed tool has handles that fit the hand — you pick it up and immediately know how to use it. A tool so cleverly engineered that only the inventor can operate it efficiently is a design failure, not a triumph. Types should feel like comfortable handles, not puzzles to solve before you can use the tool.
45
Architecture

How do you use TypeScript interfaces to define and maintain API contracts shared across frontend and backend teams?

A shared TypeScript contract package puts the API's request and response shapes in a single version-controlled source of truth. Every consumer — backend controller, frontend client, and test suite — imports from that package. When the contract changes, every affected consumer gets a compile error before anything runs in CI.

Why this matters for QA specifically:
Without shared contracts, the test team independently maintains their own type definitions for API shapes. Those drift. The backend ships a renamed field; the test suite's hand-written interface still has the old name; tests keep passing because they're testing against a stale type, not the real API. A shared contract package closes this gap — the test suite fails to compile at the same moment the backend and frontend do.

Architecture — the shared contract package:

``ts
// packages/api-contracts/src/users.ts
// Published as @company/api-contracts — imported by backend, frontend, and test suite

export interface CreateUserRequest {
name: string;
email: string;
role: 'admin' | 'user';
}

export interface CreateUserResponse {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: string; // ISO 8601
}

export interface ApiError {
code: string;
message: string;
field?: string; // present for validation errors
}
`

Backend consumes the contract:

`ts
import type { CreateUserRequest, CreateUserResponse } from '@company/api-contracts';

app.post('/api/users',
async (req: Request<{}, {}, CreateUserRequest>, res: Response<CreateUserResponse>) => {
const user = await userService.create(req.body);
res.json(user); // TypeScript ensures user matches CreateUserResponse
}
);
`

Frontend consumes the same contract:

`ts
import type { CreateUserRequest, CreateUserResponse } from '@company/api-contracts';

async function createUser(data: CreateUserRequest): Promise<CreateUserResponse> {
return apiClient.post<CreateUserResponse>('/api/users', data);
}
`

Test suite consumes the same contract — QA perspective:

`ts
import type { CreateUserRequest, CreateUserResponse } from '@company/api-contracts';
import { z } from 'zod';

// Zod schema derived from the shared interface gives runtime validation too
const CreateUserResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string(),
});

test('POST /api/users creates a user with the correct contract shape', async ({ request }) => {
const payload: CreateUserRequest = { name: 'Alice', email: 'alice@example.com', role: 'user' };
const res = await request.post('/api/users', { data: payload });
const body: unknown = await res.json();

expect(res.status()).toBe(201);

// Runtime validation against the shared contract — fails with field-level detail if shape is wrong
const user = CreateUserResponseSchema.parse(body);
expect(user.email).toBe('alice@example.com');
expect(user.role).toBe('user');
});
`

Governance process that keeps the contract honest:

`
1. Contract changes require a PR to the shared package — not just the backend
2. All consumers (frontend, backend, test suite) must pass CI against the new contract before it merges
3. Breaking changes: bump the package major version + migration window for all consumers
4. New fields: add to the contract first, then implement — consumers can optionally use them before they go live
`

What a contract change looks like end-to-end:
Backend renames
namefullName in CreateUserResponse. They update the shared contract package. Immediately:
- Backend CI: checks the backend's
res.json(user) still matches the type — compile error until user.fullName is returned
- Frontend CI: compile error on every
response.name access
- Test suite CI: compile error on every
user.name` assertion

All three fail at compile time, before a single network request is made. That's the value of a shared contract.

Rule of thumb: the contract package is the single source of truth. Any team that maintains their own copy of an API type is creating drift — make importing from the shared package the path of least resistance.
💡 Plain English: An electrical socket standard published by a national standards body. The appliance manufacturer and the socket manufacturer both follow the same published spec. Neither team needs to coordinate directly — the standard is the contract. When the standard changes, both sides must update, and a non-compliant appliance (compile error) gets flagged before it reaches consumers.
46
TypeScript

How do you share Playwright test utilities, fixtures, and types across multiple apps in a TypeScript monorepo?

In a monorepo with multiple apps, you want shared auth fixtures, API clients, and data factories without duplicating code. The pattern: one shared package that any app can import, with each app extending it for its own needs.

Monorepo folder structure:
``
apps/
admin-portal/
tests/
playwright.config.ts
customer-portal/
tests/
playwright.config.ts

packages/
test-utils/ # shared package — imported by both apps
src/
fixtures/
auth.fixture.ts
user.fixture.ts
index.ts # exports combined test + expect
helpers/
api-client.ts
factories.ts
types/
models.ts # User, Order, shared domain types
package.json
tsconfig.json
`

Shared package exports:
`ts
// packages/test-utils/src/fixtures/index.ts
import { test as base } from '@playwright/test';
import { authFixtures } from './auth.fixture';
import { userFixtures } from './user.fixture';

export const test = base.extend(authFixtures).extend(userFixtures);
export { expect } from '@playwright/test';
`

`json
// packages/test-utils/package.json
{
"name": "@company/test-utils",
"exports": { ".": "./src/fixtures/index.ts" },
"peerDependencies": { "@playwright/test": "^1.40.0" }
}
`

App importing from the shared package:
`ts
// apps/admin-portal/tests/users.spec.ts
import { test, expect, buildUser } from '@company/test-utils';

test('admin can create a user', async ({ page, api }) => {
const user = buildUser({ role: 'viewer' });
await api.createUser(user);

await page.goto('/admin/users');
await expect(page.locator(
text=${user.email})).toBeVisible();
});
`

Extending shared fixtures with app-specific ones:
`ts
// apps/admin-portal/tests/fixtures/index.ts
import { test as base } from '@company/test-utils';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';

export const test = base.extend<{ adminDashboard: AdminDashboardPage }>({
adminDashboard: async ({ page }, use) => {
await use(new AdminDashboardPage(page));
},
});
`

tsconfig paths alias — no relative path chains:
`json
// tsconfig.base.json at repo root
{
"compilerOptions": {
"paths": {
"@company/test-utils": ["./packages/test-utils/src/fixtures/index.ts"]
}
}
}
``

Dependency rules:
- Shared package → no imports from any specific app (one-way dependency)
- Apps → freely import from the shared package
- App A → never imports from App B (lateral dependencies create merge conflicts)

Rule of thumb: if two apps need the same fixture or helper, it belongs in the shared package. If something is specific to one app's domain (its own page objects, its own seed data shapes), it stays in that app's test folder.
💡 Plain English: A restaurant chain's central commissary — the commissary preps the sauces and standard ingredients every branch uses. Each kitchen adds its own signature dishes on top, but nobody duplicates the base prep. The commissary never orders from one of the branches.
47
Advanced Types

How do you use TypeScript to enforce that certain async operations are not accidentally awaited twice or missed?

A "floating promise" is an async operation that runs but nobody waits for the result — so if it fails, the error disappears silently. TypeScript plus two lint rules can catch both this problem and its twin: accidentally awaiting the same value twice.

Why it matters:
In test suites, a missed await means your assertion might run before the operation completes — producing a false pass. A double-await returns undefined from the second call, which also silently breaks assertions. Both bugs are invisible at runtime until you get mysterious flakiness.

Walked-through example — preventing both problems:
``ts
// tsconfig.json: "strict": true catches many Promise misuses
// .eslintrc: "no-floating-promises": "error", "no-misused-promises": "error"

// --- Pattern 1: ESLint no-floating-promises ---
// Bad — test navigates but never waits; assertion races navigation
page.goto('/login'); // ESLint error: floating Promise
await expect(page).toHaveURL('/login');

// Good — awaited explicitly
await page.goto('/login');
await expect(page).toHaveURL('/login');

// --- Pattern 2: Typed one-shot wrapper (prevents double-await) ---
class OneShotResult<T> {
private consumed = false;
constructor(private readonly value: T) {}

take(): T {
if (this.consumed) throw new Error('Result already consumed — did you await twice?');
this.consumed = true;
return this.value;
}
}

async function fetchApiResponse<T>(url: string): Promise<OneShotResult<T>> {
const res = await fetch(url);
const data = await res.json() as T;
return new OneShotResult(data); // caller must call .take() exactly once
}

// Usage in a test
const result = await fetchApiResponse<User[]>('/api/users');
const users = result.take(); // ✅ first call works
// result.take(); // ✅ second call throws at runtime — bug caught
`

Real-world QA use case:
In a Playwright API test that calls a rate-limited endpoint, a developer accidentally duplicates an
await apiCall() line during a refactor. The second call returns stale data or throws a 429. The one-shot pattern turns that silent bug into an immediate throw with a clear message. More commonly: add @typescript-eslint/no-floating-promises and no-misused-promises to your ESLint config — these catch 90% of missed-await bugs before CI runs a single test.

Rule of thumb: Enable
no-floating-promises and no-misused-promises ESLint rules on every TypeScript test project — they catch missed await` at lint time for free.
💡 Plain English: A numbered service ticket that can only be redeemed once. If you try to hand in the same ticket a second time, the cashier rejects it immediately — the error is visible right there at the counter, not fifteen minutes later when your order still hasn't arrived.
48
Playwright

How do you test WebSocket and Server-Sent Events (SSE) connections in Playwright TypeScript tests?

Real-time features — live notifications, chat, dashboards that update without a refresh — need a different testing approach than standard request/response. Playwright has specific APIs for intercepting and verifying both WebSocket frames and SSE streams.

WebSocket — observe frames at the connection level:
``ts
import { test, expect } from '@playwright/test';

test('live dashboard updates when a WebSocket message arrives', async ({ page }) => {
const wsFrames: string[] = [];

page.on('websocket', ws => {
ws.on('framereceived', frame => wsFrames.push(String(frame.payload)));
});

await page.goto('/dashboard');

// Wait until the UI reflects a pushed update
await expect(page.locator('[data-testid="live-count"]')).not.toHaveText('—', { timeout: 5000 });

// Optionally assert on the raw frames that arrived
expect(wsFrames.some(f => f.includes('"type":"update"'))).toBe(true);
});
`

Inject a WebSocket message to trigger a UI state:
`ts
test('notification banner appears when server pushes a message', async ({ page }) => {
await page.goto('/notifications');
await page.waitForFunction(() => !!(window as any).__ws); // wait for app to connect

// Push a synthetic message from inside the browser context
await page.evaluate(() => {
const ws = (window as any).__ws as WebSocket;
ws.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'notification', text: 'Your export is ready' }),
}));
});

await expect(page.locator('[data-testid="notification-banner"]'))
.toContainText('Your export is ready');
});
`

Server-Sent Events (SSE) — intercept via
page.route():
SSE is a regular HTTP response with
Content-Type: text/event-stream. You can mock it the same way as any other endpoint:
`ts
test('progress bar updates correctly via SSE', async ({ page }) => {
await page.route('**/api/export/progress', route =>
route.fulfill({
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: [
'data: {"progress":25}', '',
'data: {"progress":75}', '',
'data: {"progress":100}', '',
].join('
'),
})
);

await page.goto('/export');
await page.locator('[data-testid="start-export-btn"]').click();

await expect(page.locator('[data-testid="progress-bar"]'))
.toHaveAttribute('aria-valuenow', '100', { timeout: 5000 });
});
`

Test connection-drop and reconnection handling:
`ts
test('app shows reconnecting state when WebSocket drops', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForFunction(() => !!(window as any).__ws);

await page.evaluate(() => {
(window as any).__ws.close(1006, 'Simulated drop');
});

await expect(page.locator('[data-testid="connection-status"]'))
.toContainText('Reconnecting', { timeout: 3000 });
});
`

Rule of thumb: use
page.route() for SSE (simple, no browser-side hacks needed). For WebSocket, use page.on('websocket') to observe real frames or page.evaluate()` to inject synthetic events when the app exposes the connection. Always test the drop/reconnect path — that's where real-time features most commonly fail under load.
💡 Plain English: Testing a live radio broadcast — you don't just verify the transmitter is on; you tune in and confirm the right signal reaches the receiver and that the radio display reacts correctly. You also test what happens when the signal drops mid-broadcast.
49
API Testing

How do you set up contract testing between services using TypeScript — consumer-driven contracts and shared schema validation?

Integration tests catch when two services disagree on their API contract, but running the full system just to check that is slow and brittle. Contract testing decouples the two sides: the consumer records what it expects, the provider verifies it can deliver, and CI catches mismatches on the PR that introduces the breaking change — not in staging.

Approach 1 — Pact (consumer-driven, needs a broker):

Consumer test — defines what the frontend expects:
``ts
// consumer/tests/user-api.pact.spec.ts
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserApiClient } from '../src/user-api-client';

const { like, string, integer } = MatchersV3;

const provider = new PactV3({
consumer: 'AdminPortal',
provider: 'UserService',
dir: './pacts',
});

test('getUser returns a valid user', () =>
provider
.addInteraction({
states: [{ description: 'user 42 exists' }],
uponReceiving: 'a request for user 42',
withRequest: { method: 'GET', path: '/users/42' },
willRespondWith: {
status: 200,
body: like({
id: integer(42),
email: string('test@example.com'),
role: string('admin'),
}),
},
})
.executeTest(async mockServer => {
const client = new UserApiClient(mockServer.url);
const user = await client.getUser(42);
expect(user.id).toBe(42);
})
);
`

Provider verification:
`ts
// provider/tests/user-pact.verify.spec.ts
import { Verifier } from '@pact-foundation/pact';

test('UserService satisfies AdminPortal contract', () =>
new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:3001',
pactBrokerUrl: process.env.PACT_BROKER_URL!,
publishVerificationResults: true,
providerVersion: process.env.GIT_SHA,
}).verifyProvider()
);
`

Approach 2 — Shared Zod schema (lighter, no broker needed):
Both consumer and provider tests import the same schema from a shared package:
`ts
// packages/api-schemas/src/user.ts
import { z } from 'zod';

export const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
role: z.enum(['admin', 'viewer']),
});

export type User = z.infer<typeof UserSchema>;
`

`ts
// consumer test — validates actual API response against shared schema
import { UserSchema } from '@company/api-schemas/user';

test('GET /users/42 response matches the shared User schema', async ({ request }) => {
const res = await request.get('/api/users/42');
const body = await res.json();

const result = UserSchema.safeParse(body);
expect(result.success,
Schema mismatch: ${JSON.stringify(result.error?.issues)}).toBe(true);
});
`

`ts
// provider test — provider validates its own output against the same schema
test('UserService GET /users/:id output satisfies shared schema', async ({ request }) => {
const res = await request.get('/api/users/42');
expect(UserSchema.safeParse(await res.json()).success).toBe(true);
});
``

Choosing an approach:
| Approach | Best for |
|---|---|
| Pact | Separate teams / separate repos; need formal broker + versioned contracts |
| Shared Zod schema | Monorepo or small teams — simpler, same protection against schema drift |
| OpenAPI codegen | REST APIs where the backend already publishes an OpenAPI spec |

Rule of thumb: a breaking API change should never be discovered in staging. Contract tests catch it on the PR — before it merges. Pick the lightest approach that gives you that guarantee for your team's setup.
💡 Plain English: Two puzzle manufacturers each making half a puzzle. Contract testing is agreeing on the exact shape of the joining edge before manufacturing starts — not waiting until both halves are finished and finding they don't fit.
50
Leadership

How do you build a culture of TypeScript quality — strict types, runtime validation, and type testing — across multiple engineering teams?

TypeScript quality culture means teams automatically reach for strict types, runtime validation, and type tests — not because they're required, but because they've seen the bugs that happen without them.

Why it matters:
Mandated rules without buy-in produce workarounds: any with a suppression comment, Zod schemas that are copy-pasted and drift, type tests that nobody runs. Real culture changes when engineers feel TypeScript catching bugs before they page someone at 2am.

Walked-through approach — three levers:
``ts
// ── Lever 1: Make the right thing easy (shared tooling) ──────────────
// Publish an internal @company/test-utils package that pre-solves the hard problems:
import { apiGet, typedEnv, PartialBy } from '@company/test-utils';
// Teams don't have to figure out generic fetch helpers or env typing — it's done.

// ── Lever 2: Make quality visible in CI ──────────────────────────────
// ci/ts-quality.sh — runs on every PR
npx tsc --noEmit // zero type errors required
npx eslint --rule '{"@typescript-eslint/no-explicit-any": "error"}' // no new any
npx ts-unused-exports tsconfig.json // catch dead exports

// ── Lever 3: Type tests catch regressions in shared contracts ─────────
// packages/api-contracts/src/__tests__/types.test-d.ts
import { expectType } from 'tsd';
import type { LoginResponse } from '../index';
// This test fails at compile time if LoginResponse loses the token field:
expectType<{ token: string; userId: string }>({} as LoginResponse);
`

Real-world QA use case:
A QA team lead inherits 3 Playwright suites across 3 squads — all written differently, all with scattered
any. They do three things: (1) add no-explicit-any to the shared ESLint config with a 30-day grace period to fix existing ones, (2) publish a @company/pw-utils package with typed fixtures and API helpers the squads actually want to use, (3) add a tsc --noEmit gate to CI and share the first post-mortem where strict types would have caught a production bug. Two months later the any count is 90% lower — because the right tools made it easier to type correctly than to reach for any`.

Rule of thumb: Culture follows tooling — give teams pre-built typed helpers and visible CI feedback before writing policy; the policy then codifies what teams are already doing.
💡 Plain English: Building a safety culture in a factory: you don't just post rules on the wall. You supply the right safety equipment where people work (tooling), run visible incident dashboards (CI metrics), share stories about near-misses that safety gear would have prevented (post-mortems), and celebrate the team with the cleanest safety record this quarter (recognition). The rules come last — to formalize what the culture already does.
Want to master TypeScript for QA?
QAVeda has 200+ structured lessons, practice tests, skill assessments and certificates — all gamified with XP, badges and ranks.
Start Learning on QAVeda →