Type annotations let you tell TypeScript EXACTLY what type a variable, parameter, or return value should be. If you assign the wrong type, TypeScript catches it BEFORE you run the code — at compile time.
In JavaScript, let timeout = "five" is perfectly fine when you meant 5 — JS doesn't care. In TypeScript, if you write let timeout: number = "five", it throws an error immediately. You catch the bug before it ever reaches your test pipeline.
TypeScript has basic types: string, number, boolean, null, undefined, any, void, never, unknown. You can also create custom types.
Real QA use: When you write Playwright tests in TypeScript, if an API returns {status: 200} and you accidentally treat status as a string, TypeScript catches it. In JavaScript, you'd only discover this bug when the test fails at runtime — maybe in CI at 2 AM.
// Test configuration variables with types
let testName: string = "Login Test";
let maxRetries: number = 3;
let isHeadless: boolean = true;
let baseURL: string = "https://staging.api.com";
let timeout: number = 5000;
console.log("Test:", testName);
console.log("Retries:", maxRetries);
console.log("Headless:", isHeadless);
console.log("URL:", baseURL);
console.log("Timeout:", timeout + "ms");Output:
Test: Login Test
Retries: 3
Headless: true
URL: https://staging.api.com
Timeout: 5000ms
// Function annotations for test utilities
function buildEndpoint(base: string, path: string): string {
return base + path;
}
function isSuccessCode(code: number): boolean {
return code >= 200 && code < 300;
}
function logTestStep(step: string): void {
console.log("[STEP] " + step);
}
console.log(buildEndpoint("https://api.com", "/users"));
console.log("200 is success:", isSuccessCode(200));
console.log("404 is success:", isSuccessCode(404));
logTestStep("Navigate to login page");Output:
https://api.com/users
200 is success: true
404 is success: false
[STEP] Navigate to login page
// Array and object type annotations for test data
let statusCodes: number[] = [200, 201, 404, 500];
let testSuites: string[] = ["Smoke", "Regression", "Sanity"];
console.log("Status codes:", statusCodes);
console.log("Suites:", testSuites);
let testResult: { name: string; status: string; duration: number } = {
name: "Login Test",
status: "PASS",
duration: 1200
};
console.log(testResult.name + " → " + testResult.status + " (" + testResult.duration + "ms)");Output:
Status codes: [ 200, 201, 404, 500 ]
Suites: [ 'Smoke', 'Regression', 'Sanity' ]
Login Test → PASS (1200ms)
let browser: string = "Chrome";
let timeout: number = 5000;
let headless: boolean = false;
let retries: number = 2;
function getConfigSummary(browser: string, timeout: number, headless: boolean): string {
return browser + " | " + timeout + "ms | headless: " + headless;
}
console.log(getConfigSummary(browser, timeout, headless));
console.log("Max retries:", retries);Output:
Chrome | 5000ms | headless: false
Max retries: 2
let responseCodes: number[] = [200, 201, 404, 500, 302, 403];
function getFailedCodes(codes: number[]): number[] {
return codes.filter(function (code: number): boolean {
return code >= 400;
});
}
console.log("All codes:", responseCodes);
console.log("Failed codes:", getFailedCodes(responseCodes));Output:
All codes: [ 200, 201, 404, 500, 302, 403 ]
Failed codes: [ 404, 500, 403 ]
let bug: { id: number; title: string; severity: string; assignee: string } = {
id: 101,
title: "Login button unresponsive on mobile",
severity: "Critical",
assignee: "Dev"
};
function formatBug(b: { id: number; title: string; severity: string; assignee: string }): string {
return "BUG-" + b.id + " [" + b.severity + "] " + b.title + " (Assigned: " + b.assignee + ")";
}
console.log(formatBug(bug));Output:
BUG-101 [Critical] Login button unresponsive on mobile (Assigned: Dev)
function countPassedTests(results: string[]): number {
let count: number = 0;
for (let i = 0; i < results.length; i++) {
if (results[i] === "PASS") count++;
}
return count;
}
function printTestSummary(total: number, passed: number): void {
console.log("Passed: " + passed + "/" + total);
console.log("Failed: " + (total - passed) + "/" + total);
}
let results: string[] = ["PASS", "FAIL", "PASS", "PASS", "FAIL"];
let passed: number = countPassedTests(results);
printTestSummary(results.length, passed);Output:
Passed: 3/5
Failed: 2/5
An interface defines the SHAPE of an object — what properties it must have and what types those properties must be. It's like a contract. If an object says "I follow this interface," it MUST have all the required properties.
Interfaces don't generate any JavaScript code. They exist ONLY at compile time for type checking. After compilation, interfaces completely disappear from the output.
Interfaces can be extended (one interface can inherit from another), and a class can implement multiple interfaces — unlike classes where you can only extend ONE parent.
Real QA use: In Playwright TypeScript projects, you define interfaces for API response shapes. If the backend changes a field name from userName to username, TypeScript catches every place in your tests that uses the old name — instantly.
interface TestCase {
id: number;
name: string;
status: string;
duration: number;
}
let test1: TestCase = {
id: 1,
name: "Login with valid credentials",
status: "PASS",
duration: 1500
};
let test2: TestCase = {
id: 2,
name: "Login with invalid password",
status: "FAIL",
duration: 3200
};
console.log("TC-" + test1.id + ": " + test1.name + " → " + test1.status);
console.log("TC-" + test2.id + ": " + test2.name + " → " + test2.status);Output:
TC-1: Login with valid credentials → PASS
TC-2: Login with invalid password → FAIL
// Interface with optional and readonly for API response
interface APIResponse {
readonly statusCode: number;
body: string;
headers?: object;
responseTime?: number;
}
let response: APIResponse = {
statusCode: 200,
body: '{"user": "admin"}'
};
console.log("Status:", response.statusCode);
console.log("Body:", response.body);
console.log("Headers:", response.headers);Output:
Status: 200
Body: {"user": "admin"}
Headers: undefined
// Interface for test hook functions
interface TestHook {
(testName: string): void;
}
let beforeEachHook: TestHook = function (testName: string): void {
console.log("[BEFORE] Setting up: " + testName);
};
let afterEachHook: TestHook = function (testName: string): void {
console.log("[AFTER] Tearing down: " + testName);
};
beforeEachHook("Login Test");
afterEachHook("Login Test");Output:
[BEFORE] Setting up: Login Test
[AFTER] Tearing down: Login Test
interface BugReport {
id: number;
title: string;
severity: string;
stepsToReproduce: string[];
}
function logBug(bug: BugReport): void {
console.log("BUG-" + bug.id + " [" + bug.severity + "] " + bug.title);
bug.stepsToReproduce.forEach(function (step: string, i: number) {
console.log(" " + (i + 1) + ". " + step);
});
}
logBug({
id: 42,
title: "Checkout fails on empty cart",
severity: "High",
stepsToReproduce: ["Login", "Go to cart", "Click checkout", "See error"]
});Output:
BUG-42 [High] Checkout fails on empty cart
1. Login
2. Go to cart
3. Click checkout
4. See error
interface TestConfig {
browser: string;
headless: boolean;
baseURL: string;
timeout?: number;
retries?: number;
}
let ciConfig: TestConfig = {
browser: "Chrome",
headless: true,
baseURL: "https://staging.app.com"
};
let localConfig: TestConfig = {
browser: "Firefox",
headless: false,
baseURL: "http://localhost:3000",
timeout: 10000,
retries: 3
};
console.log("CI:", ciConfig.browser, "| timeout:", ciConfig.timeout);
console.log("Local:", localConfig.browser, "| timeout:", localConfig.timeout);Output:
CI: Chrome | timeout: undefined
Local: Firefox | timeout: 10000
interface BasePage {
url: string;
title: string;
}
interface LoginPage extends BasePage {
usernameSelector: string;
passwordSelector: string;
loginButtonSelector: string;
}
let loginPage: LoginPage = {
url: "/login",
title: "Login Page",
usernameSelector: "#username",
passwordSelector: "#password",
loginButtonSelector: "#login-btn"
};
console.log("URL:", loginPage.url);
console.log("Title:", loginPage.title);
console.log("Username field:", loginPage.usernameSelector);Output:
URL: /login
Title: Login Page
Username field: #username
interface Executable {
name: string;
run(): void;
getStatus(): string;
}
class TestCase implements Executable {
name: string;
private passed: boolean = false;
constructor(name: string) {
this.name = name;
}
run(): void {
this.passed = true;
console.log("[RUN] " + this.name);
}
getStatus(): string {
return this.passed ? "PASS" : "PENDING";
}
}
let tc: Executable = new TestCase("Verify login redirect");
console.log("Before:", tc.getStatus());
tc.run();
console.log("After:", tc.getStatus());Output:
Before: PENDING
[RUN] Verify login redirect
After: PASS
An enum (enumeration) is a set of named constants. Instead of using magic strings like "pass", "fail", "skip" scattered throughout your test code, you define them ONCE in an enum and use them everywhere.
By default, enums are numeric — first value is 0, second is 1, and so on. But you can create string enums where each value is an explicit string. String enums are more common in QA because they're readable in test reports and logs.
Enums exist at runtime (unlike interfaces which disappear). TypeScript generates a JavaScript object for each enum, so you can use them in if conditions and switch statements.
Real QA use: Test statuses, priority levels, browser names, environment names — anything with a fixed set of possible values. Enums prevent typos. TestStatus.PSAS causes a compile error. The string "psas" would go undetected until the test runs.
enum TestStatus {
Pass = "PASS",
Fail = "FAIL",
Skip = "SKIP",
Pending = "PENDING",
Blocked = "BLOCKED"
}
function logResult(testName: string, status: TestStatus): void {
let icon = status === TestStatus.Pass ? "✅" : status === TestStatus.Fail ? "❌" : "⏭️";
console.log(icon + " " + testName + " → " + status);
}
logResult("Login with valid creds", TestStatus.Pass);
logResult("Login with expired token", TestStatus.Fail);
logResult("Login with SSO", TestStatus.Skip);Output:
✅ Login with valid creds → PASS
❌ Login with expired token → FAIL
⏭️ Login with SSO → SKIP
enum Severity {
Low,
Medium,
High,
Critical
}
function needsImmediateAttention(severity: Severity): boolean {
return severity >= Severity.High;
}
console.log("Low urgent?", needsImmediateAttention(Severity.Low));
console.log("Critical urgent?", needsImmediateAttention(Severity.Critical));
console.log("Severity name:", Severity[2]);Output:
Low urgent? false
Critical urgent? true
Severity name: High
enum Environment {
Dev = "https://dev.api.com",
Staging = "https://staging.api.com",
QA = "https://qa.api.com",
Prod = "https://api.com"
}
function runSmokeTests(env: Environment): void {
console.log("Running smoke tests on: " + env);
console.log("GET " + env + "/health → 200 OK");
}
runSmokeTests(Environment.QA);
runSmokeTests(Environment.Staging);Output:
Running smoke tests on: https://qa.api.com
GET https://qa.api.com/health → 200 OK
Running smoke tests on: https://staging.api.com
GET https://staging.api.com/health → 200 OK
enum Browser {
Chrome = "chrome",
Firefox = "firefox",
Safari = "safari",
Edge = "edge"
}
function launchBrowser(browser: Browser): void {
switch (browser) {
case Browser.Chrome:
console.log("Launching Chromium (Chrome v120)");
break;
case Browser.Firefox:
console.log("Launching Gecko (Firefox v115)");
break;
case Browser.Safari:
console.log("Launching WebKit (Safari v17)");
break;
case Browser.Edge:
console.log("Launching Chromium (Edge v120)");
break;
}
}
launchBrowser(Browser.Chrome);
launchBrowser(Browser.Safari);Output:
Launching Chromium (Chrome v120)
Launching WebKit (Safari v17)
enum HTTPMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
function sendRequest(method: HTTPMethod, endpoint: string): void {
console.log(method + " " + endpoint + " → 200 OK");
}
sendRequest(HTTPMethod.GET, "/api/users");
sendRequest(HTTPMethod.POST, "/api/users");
sendRequest(HTTPMethod.DELETE, "/api/users/1");Output:
GET /api/users → 200 OK
POST /api/users → 200 OK
DELETE /api/users/1 → 200 OK
Generics let you write code that works with ANY type while still being type-safe. The <T> is a placeholder — "I don't know the type yet, but I'll use it consistently."
Without generics, you'd either use any (kills type safety) or write separate functions for every type (duplicates code). Generics give you BOTH flexibility AND safety.
The T stands for "Type" — it's a convention. You can use any letter: <T>, <K, V>, <E>. When you call the function, TypeScript replaces T with the actual type.
Real QA use: A generic API helper — parseResponse<UserData>(res) returns UserData, while parseResponse<OrderData>(res) returns OrderData. Same function, different types, full type safety and autocomplete.
function getFirstResult<T>(results: T[]): T {
return results[0];
}
let firstCode = getFirstResult<number>([200, 404, 500]);
let firstTest = getFirstResult<string>(["Login", "Signup", "Cart"]);
console.log("First code:", firstCode);
console.log("First test:", firstTest);Output:
First code: 200
First test: Login
class TestDataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return this.items;
}
getFirst(): T {
return this.items[0];
}
count(): number {
return this.items.length;
}
}
let codeStore = new TestDataStore<number>();
codeStore.add(200);
codeStore.add(404);
codeStore.add(500);
let testStore = new TestDataStore<string>();
testStore.add("Login Test");
testStore.add("Checkout Test");
console.log("Codes:", codeStore.getAll());
console.log("First code:", codeStore.getFirst());
console.log("Tests:", testStore.getAll());
console.log("Test count:", testStore.count());Output:
Codes: [ 200, 404, 500 ]
First code: 200
Tests: [ 'Login Test', 'Checkout Test' ]
Test count: 2
function wrapResponse<T>(statusCode: number, data: T): { statusCode: number; data: T } {
return { statusCode: statusCode, data: data };
}
let userResp = wrapResponse<string>(200, "admin");
let countResp = wrapResponse<number>(200, 42);
let flagResp = wrapResponse<boolean>(200, true);
console.log(userResp);
console.log(countResp);
console.log(flagResp);Output:
{ statusCode: 200, data: 'admin' }
{ statusCode: 200, data: 42 }
{ statusCode: 200, data: true }
function makeConfigEntry<K, V>(key: K, value: V): { key: K; value: V } {
return { key: key, value: value };
}
let timeout = makeConfigEntry<string, number>("timeout", 5000);
let env = makeConfigEntry<string, string>("env", "staging");
let headless = makeConfigEntry<string, boolean>("headless", true);
console.log(timeout);
console.log(env);
console.log(headless);Output:
{ key: 'timeout', value: 5000 }
{ key: 'env', value: 'staging' }
{ key: 'headless', value: true }
interface APIResponse<T> {
status: number;
data: T;
timestamp: string;
}
interface UserData {
name: string;
email: string;
role: string;
}
interface OrderData {
orderId: number;
total: number;
}
let userResponse: APIResponse<UserData> = {
status: 200,
data: { name: "Dev", email: "dev@test.com", role: "admin" },
timestamp: "2026-04-11"
};
let orderResponse: APIResponse<OrderData> = {
status: 200,
data: { orderId: 1001, total: 2499 },
timestamp: "2026-04-11"
};
console.log(userResponse.data.name + " (" + userResponse.data.role + ")");
console.log("Order #" + orderResponse.data.orderId + " — ₹" + orderResponse.data.total);Output:
Dev (admin)
Order #1001 — ₹2499
TypeScript has three access modifiers that control WHO can access class members. JavaScript only has public (default) and #private (ES2022). TypeScript adds protected — which JavaScript completely lacks.
public means accessible everywhere — inside the class, in child classes, and from outside. This is the default.
private means accessible ONLY inside the class where it's defined. Not even child classes can access it. Enforced at COMPILE time only.
protected means accessible inside the class AND in child classes, but NOT from outside. Perfect for Page Object Model — BasePage can have protected methods that child pages use, but test files cannot access directly.
class APIClient {
public baseURL: string;
private apiKey: string;
protected timeout: number;
constructor(baseURL: string, apiKey: string, timeout: number) {
this.baseURL = baseURL;
this.apiKey = apiKey;
this.timeout = timeout;
}
private getAuthHeader(): string {
return "Bearer " + this.apiKey;
}
public sendRequest(path: string): void {
console.log("GET " + this.baseURL + path);
console.log("Auth: " + this.getAuthHeader());
console.log("Timeout: " + this.timeout + "ms");
}
}
class UserAPIClient extends APIClient {
getUsers(): void {
console.log("Fetching users (timeout: " + this.timeout + "ms)");
console.log("URL: " + this.baseURL + "/users");
}
}
let client = new APIClient("https://api.staging.com", "key_secret_123", 5000);
console.log("Base URL:", client.baseURL);
client.sendRequest("/health");
let userClient = new UserAPIClient("https://api.staging.com", "key_abc", 3000);
userClient.getUsers();Output:
Base URL: https://api.staging.com
GET https://api.staging.com/health
Auth: Bearer key_secret_123
Timeout: 5000ms
Fetching users (timeout: 3000ms)
URL: https://api.staging.com/users
class BasePage {
protected baseURL: string;
constructor(url: string) {
this.baseURL = url;
}
protected navigate(path: string): void {
console.log("Navigating to: " + this.baseURL + path);
}
}
class LoginPage extends BasePage {
constructor() {
super("https://app.staging.com");
}
login(user: string): void {
this.navigate("/login");
console.log("Typing " + user + " into #username");
console.log("Clicking #login-btn");
}
}
let page = new LoginPage();
page.login("admin");Output:
Navigating to: https://app.staging.com/login
Typing admin into #username
Clicking #login-btn
class TestRun {
constructor(
public testName: string,
private apiKey: string,
protected environment: string
) {}
showInfo(): void {
console.log(this.testName + " on " + this.environment);
}
showKey(): void {
console.log("Key: " + this.apiKey.slice(0, 4) + "****");
}
}
let run = new TestRun("Smoke Suite", "key_abcdef123", "staging");
console.log("Test:", run.testName);
run.showInfo();
run.showKey();Output:
Test: Smoke Suite
Smoke Suite on staging
Key: key_****
readonly makes a property UNCHANGEABLE after it's set. You can assign it in the declaration or constructor, but nowhere else.
It's different from const. const is for variables. readonly is for class properties and interface fields.
readonly works with all access modifiers — public readonly, private readonly, protected readonly.
Real QA use: Test configs like baseURL, apiKey, or timeout that should be set once and never accidentally changed during test execution.
class PlaywrightConfig {
readonly baseURL: string;
readonly timeout: number;
readonly retries: number;
constructor(url: string, timeout: number, retries: number) {
this.baseURL = url;
this.timeout = timeout;
this.retries = retries;
}
showConfig(): void {
console.log("URL: " + this.baseURL);
console.log("Timeout: " + this.timeout + "ms");
console.log("Retries: " + this.retries);
}
}
let config = new PlaywrightConfig("https://staging.app.com", 30000, 2);
config.showConfig();
// config.baseURL = "https://other.com"; → ❌ Error! Cannot assign to readonlyOutput:
URL: https://staging.app.com
Timeout: 30000ms
Retries: 2
interface TestResult {
readonly testId: number;
readonly testName: string;
status: string;
}
let result: TestResult = {
testId: 101,
testName: "Verify login redirect",
status: "PENDING"
};
console.log("TC-" + result.testId + ": " + result.testName);
result.status = "PASS";
console.log("Status:", result.status);
// result.testId = 999; → ❌ Error! readonlyOutput:
TC-101: Verify login redirect
Status: PASS
class TestScenario {
readonly steps: readonly string[];
readonly name: string;
constructor(name: string, steps: string[]) {
this.name = name;
this.steps = steps;
}
display(): void {
console.log("Scenario: " + this.name);
this.steps.forEach(function (step: string, i: number) {
console.log(" " + (i + 1) + ". " + step);
});
}
}
let scenario = new TestScenario("User Login", [
"Open browser",
"Navigate to /login",
"Enter credentials",
"Click login",
"Verify dashboard"
]);
scenario.display();Output:
Scenario: User Login
1. Open browser
2. Navigate to /login
3. Enter credentials
4. Click login
5. Verify dashboard
An abstract class CANNOT be instantiated directly. You cannot write new AbstractClass(). It exists ONLY to be extended by child classes.
Abstract methods have NO body — just the signature. Every child class MUST provide its own implementation. If a child forgets, TypeScript throws a compile error.
Abstract classes CAN have regular methods with implementations — unlike interfaces which only define signatures. This makes abstract classes a mix between interfaces and regular classes.
Real QA use: BaseTest defines abstract setup() and abstract teardown() — forcing every test type to implement their own. But BaseTest also provides a concrete run() method that orchestrates setup → execute → teardown. Enforcement AND shared logic.
abstract class BaseTest {
protected testName: string;
constructor(testName: string) {
this.testName = testName;
}
abstract setup(): void;
abstract execute(): void;
abstract teardown(): void;
run(): void {
console.log("=== " + this.testName + " ===");
this.setup();
this.execute();
this.teardown();
console.log("=== DONE ===");
}
}
class UITest extends BaseTest {
setup(): void { console.log(" Setup: launch browser"); }
execute(): void { console.log(" Execute: click buttons, fill forms"); }
teardown(): void { console.log(" Teardown: close browser"); }
}
class APITest extends BaseTest {
setup(): void { console.log(" Setup: create HTTP client"); }
execute(): void { console.log(" Execute: send requests, validate responses"); }
teardown(): void { console.log(" Teardown: clear tokens"); }
}
let uiTest = new UITest("Login UI Test");
let apiTest = new APITest("User API Test");
uiTest.run();
console.log("");
apiTest.run();Output:
=== Login UI Test ===
Setup: launch browser
Execute: click buttons, fill forms
Teardown: close browser
=== DONE ===
=== User API Test ===
Setup: create HTTP client
Execute: send requests, validate responses
Teardown: clear tokens
=== DONE ===
abstract class TestReporter {
protected results: string[] = [];
addResult(result: string): void {
this.results.push(result);
}
abstract generate(): void;
}
class ConsoleReporter extends TestReporter {
generate(): void {
console.log("--- Console Report ---");
this.results.forEach(function (r: string) {
console.log(" " + r);
});
}
}
class JSONReporter extends TestReporter {
generate(): void {
console.log("--- JSON Report ---");
console.log(JSON.stringify({ results: this.results }, null, 2));
}
}
let consoleReport = new ConsoleReporter();
consoleReport.addResult("Login: PASS");
consoleReport.addResult("Cart: FAIL");
consoleReport.generate();
console.log("");
let jsonReport = new JSONReporter();
jsonReport.addResult("Login: PASS");
jsonReport.addResult("Cart: FAIL");
jsonReport.generate();Output:
--- Console Report ---
Login: PASS
Cart: FAIL
--- JSON Report ---
{
"results": [
"Login: PASS",
"Cart: FAIL"
]
}
abstract class BasePage {
protected url: string;
constructor(url: string) {
this.url = url;
}
navigate(): void {
console.log("Navigating to: " + this.url);
}
abstract verify(): void;
}
class LoginPage extends BasePage {
constructor() { super("/login"); }
verify(): void {
console.log("Verify: username field exists");
console.log("Verify: login button is visible");
}
}
class DashboardPage extends BasePage {
constructor() { super("/dashboard"); }
verify(): void {
console.log("Verify: welcome message displayed");
console.log("Verify: sidebar menu loaded");
}
}
let pages: BasePage[] = [new LoginPage(), new DashboardPage()];
pages.forEach(function (page: BasePage) {
page.navigate();
page.verify();
console.log("---");
});Output:
Navigating to: /login
Verify: username field exists
Verify: login button is visible
---
Navigating to: /dashboard
Verify: welcome message displayed
Verify: sidebar menu loaded
---
Type assertions tell TypeScript "I know the type — trust me." It doesn't change data at runtime. It just tells the compiler to treat a value as a specific type.
Two syntaxes: value as Type (recommended) and <Type>value (older). Always use as.
Type assertions are NOT type casting — they don't convert data. Use only when you genuinely KNOW the type is correct.
Real QA use: When Playwright returns element attributes as unknown, or when JSON.parse() returns any for an API response body, you assert the type for autocomplete and type safety.
let rawResponse: unknown = {
status: 200,
body: { user: "admin", role: "tester" }
};
interface UserResponse {
status: number;
body: { user: string; role: string };
}
let response = rawResponse as UserResponse;
console.log("Status:", response.status);
console.log("User:", response.body.user);
console.log("Role:", response.body.role);Output:
Status: 200
User: admin
Role: tester
let element: unknown = {
tagName: "BUTTON",
textContent: "Submit",
id: "submit-btn",
disabled: false
};
let button = element as { tagName: string; textContent: string; id: string; disabled: boolean };
console.log("Tag:", button.tagName);
console.log("Text:", button.textContent);
console.log("ID:", button.id);
console.log("Disabled:", button.disabled);Output:
Tag: BUTTON
Text: Submit
ID: submit-btn
Disabled: false
function loadConfig(): unknown {
return { browser: "Chrome", headless: true, baseURL: "https://qa.app.com", retries: 3 };
}
interface TestConfig {
browser: string;
headless: boolean;
baseURL: string;
retries: number;
}
let config = loadConfig() as TestConfig;
console.log("Browser:", config.browser);
console.log("Headless:", config.headless);
console.log("URL:", config.baseURL);
console.log("Retries:", config.retries);Output:
Browser: Chrome
Headless: true
URL: https://qa.app.com
Retries: 3
The override keyword explicitly marks that a child method is intentionally overriding a parent method. Without it, overriding is implicit — you use the same name and hope it matches.
If you misspell the method name, without override, TypeScript thinks you're creating a NEW method — no error. With override, TypeScript checks the parent actually HAS that method. Typo = compile error.
Enable "noImplicitOverride": true in tsconfig.json to make override required.
Real QA use: In large POM hierarchies with 20+ page classes, misspelling verifyPageLoaded as verfiyPageLoaded silently creates a new method — parent's version runs instead. With override, TypeScript catches the typo immediately.
class BaseTest {
setup(): void {
console.log("[BASE] Open browser");
}
teardown(): void {
console.log("[BASE] Close browser");
}
}
class LoginTest extends BaseTest {
override setup(): void {
super.setup();
console.log("[LOGIN] Navigate to /login");
}
override teardown(): void {
console.log("[LOGIN] Clear session cookies");
super.teardown();
}
}
let test = new LoginTest();
test.setup();
console.log("--- running test ---");
test.teardown();Output:
[BASE] Open browser
[LOGIN] Navigate to /login
--- running test ---
[LOGIN] Clear session cookies
[BASE] Close browser
class BasePage {
verify(): void {
console.log("Verifying page loaded");
}
}
class CheckoutPage extends BasePage {
override verify(): void {
super.verify();
console.log("Verifying cart items displayed");
console.log("Verifying total price calculated");
console.log("Verifying payment button enabled");
}
}
new CheckoutPage().verify();Output:
Verifying page loaded
Verifying cart items displayed
Verifying total price calculated
Verifying payment button enabled
Decorators are special functions that ATTACH to classes, methods, or properties using the @ symbol. They modify behavior without changing original code.
Decorators run at CLASS DEFINITION time, not when you create objects. Used heavily in Angular and NestJS.
Enable "experimentalDecorators": true in tsconfig.json.
Real QA use: Angular tests use @Component, @Injectable. Some test frameworks use @Test, @BeforeEach, @Timeout(5000). Understanding decorators helps debug Angular component tests.
// Decorator that auto-logs every test step
function LogStep(target: any, methodName: string, descriptor: PropertyDescriptor) {
let original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log("[STEP] " + methodName + "(" + args.join(", ") + ")");
return original.apply(this, args);
};
}
class LoginPage {
@LogStep
enterUsername(user: string): void {
console.log(" Typed: " + user);
}
@LogStep
enterPassword(pass: string): void {
console.log(" Typed: ***");
}
@LogStep
clickLogin(): void {
console.log(" Button clicked");
}
}
let page = new LoginPage();
page.enterUsername("admin");
page.enterPassword("secret");
page.clickLogin();Output:
[STEP] enterUsername(admin)
Typed: admin
[STEP] enterPassword(secret)
Typed: ***
[STEP] clickLogin()
Button clicked
function MeasureTime(target: any, methodName: string, descriptor: PropertyDescriptor) {
let original = descriptor.value;
descriptor.value = function (...args: any[]) {
let start = Date.now();
let result = original.apply(this, args);
let end = Date.now();
console.log("[TIMER] " + methodName + " took " + (end - start) + "ms");
return result;
};
}
class APITest {
@MeasureTime
validateResponse(): void {
let sum = 0;
for (let i = 0; i < 1000000; i++) sum += i;
console.log("Response validated");
}
}
let test = new APITest();
test.validateResponse();Output:
Response validated
[TIMER] validateResponse took 5ms
Namespaces organize code into logical groups and prevent name collisions. They wrap related functions and classes under a single name.
Before ES6 modules, namespaces were the primary code organization tool. Now ES6 modules are preferred, but namespaces still appear in older codebases and .d.ts files.
Accessed using dot notation: NamespaceName.ClassName. Use export inside a namespace to make things accessible.
Real QA use: TestUtils.Logger, TestUtils.Reporter, TestUtils.DataGenerator — organizing utilities under one umbrella. Modern Playwright/Cypress projects prefer ES6 modules instead.
namespace TestUtils {
export function formatTestName(name: string): string {
return "TC_" + name.toUpperCase().replace(/ /g, "_");
}
export function getTimestamp(): string {
return new Date().toISOString().slice(0, 10);
}
export class StepLogger {
private stepCount: number = 0;
log(step: string): void {
this.stepCount++;
console.log("[Step " + this.stepCount + "] " + step);
}
}
}
console.log(TestUtils.formatTestName("login test"));
console.log("Date:", TestUtils.getTimestamp());
let logger = new TestUtils.StepLogger();
logger.log("Open browser");
logger.log("Navigate to /login");
logger.log("Enter credentials");Output:
TC_LOGIN_TEST
Date: 2026-04-11
[Step 1] Open browser
[Step 2] Navigate to /login
[Step 3] Enter credentials
namespace QAFramework {
export namespace API {
export function get(endpoint: string): void {
console.log("[API] GET " + endpoint + " → 200 OK");
}
export function post(endpoint: string): void {
console.log("[API] POST " + endpoint + " → 201 Created");
}
}
export namespace UI {
export function click(selector: string): void {
console.log("[UI] Click " + selector);
}
export function type(selector: string, text: string): void {
console.log("[UI] Type '" + text + "' into " + selector);
}
}
}
QAFramework.API.get("/api/users");
QAFramework.API.post("/api/users");
QAFramework.UI.click("#login-btn");
QAFramework.UI.type("#username", "admin");Output:
[API] GET /api/users → 200 OK
[API] POST /api/users → 201 Created
[UI] Click #login-btn
[UI] Type 'admin' into #username
A tuple is a fixed-length array where EACH position has a specific type. Regular arrays have one type for ALL elements. Tuples have different types at different positions.
Tuples enforce both ORDER and COUNT. [string, number] means first MUST be string, second MUST be number.
Tuples are useful when a function returns multiple values of different types — lightweight alternative to creating an object.
Real QA use: Test results as [testName, status, duration]. API health checks as [endpoint, statusCode, responseTime]. Lightweight and type-safe.
let result1: [string, string, number] = ["Login Test", "PASS", 1200];
let result2: [string, string, number] = ["Cart Test", "FAIL", 3400];
console.log(result1[0] + " → " + result1[1] + " (" + result1[2] + "ms)");
console.log(result2[0] + " → " + result2[1] + " (" + result2[2] + "ms)");Output:
Login Test → PASS (1200ms)
Cart Test → FAIL (3400ms)
function validateAPIResponse(status: number, body: string): [boolean, string] {
if (status >= 200 && status < 300) {
return [true, "Response OK: " + body];
} else {
return [false, "Failed with status " + status];
}
}
let [passed1, msg1] = validateAPIResponse(200, '{"user": "admin"}');
let [passed2, msg2] = validateAPIResponse(500, "Server Error");
console.log("Passed:", passed1, "→", msg1);
console.log("Passed:", passed2, "→", msg2);Output:
Passed: true → Response OK: {"user": "admin"}
Passed: false → Failed with status 500
let testReport: [string, string, number][] = [
["Login Test", "PASS", 1200],
["Signup Test", "FAIL", 3400],
["Cart Test", "PASS", 800],
["Checkout Test", "PASS", 2100]
];
let passCount: number = 0;
testReport.forEach(function (entry: [string, string, number]) {
let icon = entry[1] === "PASS" ? "✅" : "❌";
console.log(icon + " " + entry[0] + " (" + entry[2] + "ms)");
if (entry[1] === "PASS") passCount++;
});
console.log("---");
console.log("Passed: " + passCount + "/" + testReport.length);Output:
✅ Login Test (1200ms)
❌ Signup Test (3400ms)
✅ Cart Test (800ms)
✅ Checkout Test (2100ms)
---
Passed: 3/4
type HealthCheckResult = [endpoint: string, status: number, responseTime: number];
let results: HealthCheckResult[] = [
["/api/auth", 200, 45],
["/api/users", 200, 120],
["/api/orders", 503, 5000],
["/api/products", 200, 88]
];
results.forEach(function (r: HealthCheckResult) {
let icon = r[1] === 200 ? "✅" : "❌";
console.log(icon + " " + r[0] + " → " + r[1] + " (" + r[2] + "ms)");
});Output:
✅ /api/auth → 200 (45ms)
✅ /api/users → 200 (120ms)
❌ /api/orders → 503 (5000ms)
✅ /api/products → 200 (88ms)
type TestLog = [level: string, message: string, details?: string];
let logs: TestLog[] = [
["INFO", "Test suite started"],
["PASS", "Login test passed"],
["FAIL", "Cart test failed", "Element #cart-total not found"],
["WARN", "Slow response", "GET /api/orders took 4500ms"],
["INFO", "Test suite finished"]
];
logs.forEach(function (log: TestLog) {
let output = "[" + log[0] + "] " + log[1];
if (log[2]) {
output += " — " + log[2];
}
console.log(output);
});Output:
[INFO] Test suite started
[PASS] Login test passed
[FAIL] Cart test failed — Element #cart-total not found
[WARN] Slow response — GET /api/orders took 4500ms
[INFO] Test suite finished
| # | Feature | QA Use Case | JS Equivalent |
|---|---|---|---|
| 1 | Type Annotations | Catch wrong types in test configs, API responses | None |
| 2 | Interfaces | Define API response shapes, page object contracts | Duck typing |
| 3 | Enums | Test statuses, environments, browsers, severity | Object.freeze() |
| 4 | Generics <T> |
Reusable API helpers for any response type | any (unsafe) |
| 5 | private/public/protected | Control access in page objects and utilities | # private only |
| 6 | readonly | Lock test configs (baseURL, API key, timeout) | Object.freeze() |
| 7 | Abstract Classes | Force child tests to implement setup/teardown | None |
| 8 | Type Assertions (as) | Type parsed JSON, DOM elements, API responses | Not needed |
| 9 | override keyword | Catch typos in 20+ page class hierarchies | Implicit |
| 10 | Decorators (@) | Auto-log steps, measure performance, Angular tests | Stage 3 JS |
| 11 | Namespaces | Organize test utilities under one umbrella | ES6 modules |
| 12 | Tuple Types | Return [testName, status, duration] type-safely | Regular arrays |
TypeScript is a SUPERSET of JavaScript. Every valid JavaScript code is also valid TypeScript, but TypeScript adds static typing, interfaces, enums, generics, access modifiers, and other features. TypeScript must be compiled to JavaScript before running. The main QA benefit is catching type-related bugs at compile time — wrong API response types, misspelled properties, incorrect function arguments — instead of discovering them when tests fail at runtime.
Type annotations declare what type a variable, parameter, or return value should be. Example: let timeout: number = 5000 ensures timeout is always a number. If someone writes timeout = "five seconds", TypeScript catches it before the test runs. In Playwright, response.status() returns number — TypeScript validates you use it correctly.
Both define object shapes. Interfaces can be extended and implemented by classes. Types support unions (string | number) and intersections. Interfaces can be merged (re-declared), types cannot. For API response shapes and page object contracts, prefer interfaces. For union types like type Status = "pass" | "fail", use type.
Yes. class LoginTest implements Runnable, Reportable { }. The class must provide all properties and methods from every interface. Useful when a test class needs to be Executable (has run()), Loggable (has log()), AND Retryable (has retry()).
TypeScript private is enforced at COMPILE TIME only — compiled JavaScript has no restriction. JavaScript # is enforced at RUNTIME — truly private. For test utilities handling API keys, use # even in TypeScript for maximum protection.
protected means accessible inside the class AND child classes, but NOT from outside. In POM, BasePage has protected baseURL and protected navigate() — child pages like LoginPage can use them, but test spec files cannot. Keeps internal wiring hidden.
Enums are named constants. Examples: (1) enum TestStatus { Pass, Fail, Skip } for results. (2) enum Environment { Dev, Staging, QA, Prod } for targets. (3) enum Browser { Chrome, Firefox, Safari } for cross-browser testing. Enums prevent typos — TestStatus.PSAS causes a compile error.
String enums (Pass="PASS") are better for QA because they're readable in test reports and CI logs. Seeing "PASS" in a log is clear. Seeing 0 tells you nothing.
function parseResponse<T>(response: Response): T — call as parseResponse<UserData>(res) for user data, parseResponse<OrderData>(res) for order data. Same function, different types, full autocomplete and type checking.
any disables all type checking. unknown is safe — must check/assert type before using. never represents values that never occur (function that always throws). In test code, avoid any for API responses; prefer unknown and assert to the correct interface.
JSON.parse(await response.text()) returns any. Assert it: let data = JSON.parse(body) as UserResponse. Now TypeScript provides autocomplete for data.name, data.email. Without assertion, no type safety on parsed responses.
Abstract classes can have BOTH abstract methods and concrete implementations. Interfaces only define signatures. Use abstract classes for BaseTest — abstract setup() that children must implement, plus concrete run() that orchestrates lifecycle. Use interfaces for pure contracts like API response shapes.
readonly baseURL: string ensures no test accidentally modifies the base URL mid-execution. Prevents a common bug where one test modifies shared config and breaks subsequent tests.
Without override, misspelling verifyPageLoaded as verfiyPageLoaded silently creates a new method — parent's version runs instead, causing mysterious failures. With override, TypeScript catches the typo immediately.
Angular component tests (@Component, @Injectable), NestJS API testing (@Controller, @Get), and some test frameworks (@Test, @BeforeEach). Understanding decorators helps debug decorator-heavy framework tests.
A result tuple ["Login Test", "PASS", 1200] — position 0 is always test name (string), position 1 is status (string), position 2 is duration (number). TypeScript enforces this structure. Regular arrays have no per-position type enforcement.
No. Use ES6 modules (import/export) — the modern standard that works natively with Playwright's test runner. Namespaces still appear in older codebases and .d.ts declaration files.
Partial<T> makes ALL properties optional. For PATCH endpoint testing — if User has name, email, age, role — Partial<User> allows { name: "New" } without requiring all fields. Perfect for partial update tests.
TypeScript catches bugs at compile time — wrong selector types, misspelled properties, incorrect API response access. Playwright's TypeScript definitions provide autocomplete for every method. When Playwright releases breaking changes, TypeScript shows exactly where tests need updating.
Type annotations, interfaces, generics, access modifiers, readonly, and override are REMOVED. Output is plain JavaScript. Enums generate real JavaScript objects. Decorators also generate code. Runtime behavior is pure JavaScript.
Required<T> makes ALL properties mandatory. If TestConfig has optional timeout? and retries?, Required<TestConfig> forces every property to be provided. Use for production config validation — all values must be explicit, no defaults.
Adding public, private, or protected in constructor parameters auto-creates and assigns properties: constructor(public name: string, private key: string). JavaScript requires manual this.name = name. Reduces boilerplate in page objects.
Yes. Rename .js to .ts, add tsconfig.json. Cypress provides type definitions for cy.get(), cy.visit(), cy.request(). Use interfaces for fixture types, enums for test constants, generics for custom commands.
Pick<User, "name" | "email"> creates a type with ONLY name and email from User. Omit<User, "password"> creates a type with everything EXCEPT password. Useful in API testing — pick only the fields you're validating, omit sensitive fields from response logging.
Create tsconfig.json: "strict": true (all strict checks), "target": "ES6", "module": "commonjs" or "ESNext", "outDir": "./dist", "noImplicitOverride": true, "experimentalDecorators": true, "sourceMap": true. For Playwright, extend @playwright/test/tsconfig.json. For Cypress, cypress.config.ts handles most settings.