|

Day 9: TypeScript for Playwright — Types, Interfaces, Enums, Generics, and Typed POM

Compact diagram comparing loosely typed JavaScript any with TypeScript string

This is Day 9 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript today. Playwright Test tomorrow.

Days 1–7 were the language. Day 8 was the object model — a JavaScript BasePage and a LoginPage that extends it. That class still accepted any string as a selector and any shape as a config. JavaScript cannot refuse a typo. TypeScript can.

I am Pramod Dutta. I teach SDETs in India for a living. The week I introduce TypeScript, someone always asks: “Playwright works with JavaScript. Why add types?” Because the failures I review are not page.click failures. They are username spelled userName in one file and username in another. They are timeout missing on CI and present on a laptop. They are response.json() treated as a user object when the API returned { error: "unauthorized" }. JavaScript lets that ship. TypeScript refuses it at the editor.

This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts with npx playwright test. This series started at console.log. Today we earn the .ts in login.spec.ts.

All labs come from my public batch repo: LearningPlaywrightBatch on branch main. I fetched chapters 18 through 22 from raw GitHub. I quote those files. I will not invent a file that is not there.

One file is empty. chapter_22_Typescript_PRIVATE_PROTECTED_PUBLIC/207_Decorator.ts is 0 bytes on main. I skip the body and I say so. The decorator idea lives in 208_23_logs_Decortors.ts (filename is spelled Decortors on GitHub). Several other names are also spelled the classroom way: 195_REAL_BRowser_Selection.ts, 199_GENERIC_API_RESPOSNE.ts, 203_Abstract_Clsss.ts, 205_Ovveride.ts, FreeTrailPage inside lab 190. I use those names.

If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.

*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Compact diagram comparing loosely typed JavaScript any with TypeScript string

Contents

What you will be able to do after Day 9

By the end of this post you can:

  1. Annotate a primitive, an array, an object, and a function the way a Playwright helper needs them — string, number, boolean, null, undefined, void, never.
  2. Prefer unknown over any when response.json() lands on your desk, then narrow it before you read a field.
  3. Write an interface for a test case, an API response, a bug report, a test config, and a page object — and let the compiler reject a missing field.
  4. Put a method signature and a call-signature hook on an interface, then implement that contract with a class.
  5. Replace magic strings with enums for test status, severity, browser, environment URL, and HTTP method.
  6. Write a generic function and a generic class so one wrapResponse<T> types a user, a flag, and a count.
  7. Hide an API key with private, share a baseURL with protected, publish login() with public, and freeze config with readonly.
  8. Force a BaseTest shape with abstract, mark a child setup() with override, and read a raw payload with as.
  9. Explain a method decorator as a log wrapper — and know the stub file is empty, so we do not invent a second decorator lab.

That is the skill. Not the syntax. The skill is making the compiler fail the PR so Playwright never launches a browser for a typo you already knew how to prevent.

The labs we are actually using

Clone the repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightBatch.git
cd LearningPlaywrightBatch
git checkout main

Root package.json on main has playwright and @playwright/test at ^1.58.2. Root tsconfig.json is strict, module: "nodenext", target: "esnext". You do not need to run a Playwright test today. You need npx tsc --noEmit or an editor that speaks TypeScript. Node 18 or newer is enough to execute the compiled JavaScript if you compile. For reading, open the .ts files and let the red squiggle teach you.

Chapter 18 — TypeScript types (chapter_18_Typescript/)

  • 175_TS.js
  • 176_TS_Helloworld.js
  • 176_TS_Helloworld.ts
  • 177_Basic_Types.ts
  • 178_TS_Basic_Types.ts
  • 179_Unknown.ts
  • 180_Fn_NoReturn.ts
  • 181_IQ.ts
  • 182_IQ.ts
  • 183_Filter_Array.ts

Chapter 19 — Interfaces (chapter_19_Typescript_Interface/)

  • 184_TS_Interface.ts through 192_Index_TS_Sing.ts

Chapter 20 — Enums (chapter_20_Typescript_ENUM/)

  • 193_ENUM.ts
  • 194_ENUM_Fn.ts
  • 195_REAL_BRowser_Selection.ts (capital R in BRowser on GitHub)
  • 196_ENUM_API_example.ts

Chapter 21 — Generics (chapter_21_Typescript_Generic/)

  • 197_Generic.ts
  • 198_Generic_Class.ts
  • 199_GENERIC_API_RESPOSNE.ts (filename is RESPOSNE on GitHub)

Chapter 22 — Access, readonly, abstract, override, decorators (chapter_22_Typescript_PRIVATE_PROTECTED_PUBLIC/)

  • 200_PRIVATE_PUBLIC_PROTECTED.ts through 208_23_logs_Decortors.ts
  • Two files share the number 204: 204_AS_Part2.ts and 204_As_Alias.ts
  • Two override labs: 205_Ovveride.ts (three v’s) and 206_Override.ts
  • Skipped body: 207_Decorator.ts is present and empty (0 bytes). I will not invent a decorator for it.

Those are the files. Chapter folders also contain classroom notes in comments. I am not treating a slide deck as a lab. If a notebook mentioned 195_REAL_Browser_Selection.ts or 199_GENERIC_API_RESPONSE.ts, that name is not on main. We stay with what GitHub actually serves.

Why TypeScript for Playwright is not optional once you have a POM

Playwright Test ships TypeScript types. Page, Locator, BrowserContext, APIRequestContext, TestInfo, PlaywrightTestConfig — those are not blog decorations. They are the reason page.goto(123) is a red line in the editor and a green line in a .js spec.

On Day 8 we wrote a JavaScript page object. A JS LoginPage looks like this in my head after that class:

class LoginPage {
  constructor(page) {
    this.page = page;
    this.username = page.locator("#username");
  }
  async login(user) {
    await this.username.fill(user);
  }
}

That compiles. login(42) compiles. this.username spelled this.userName in one method is undefined at runtime. page.locator("#userr") is a string the engine trusts. Thirty seconds later you get a timeout on VWO and a Slack thread about “flaky login.”

The TypeScript version of that thought is lab 190 plus lab 201. An interface that demands usernameSelector: string. A class that hides baseURL behind protected and only publishes login(). A config class whose baseURL is readonly, so a test cannot reassign staging to prod in the middle of a retry.

Playwright hands you untyped JSON every time you call request.get() and .json(). Lab 179 and lab 204 exist for that payload. unknown first. Narrow or as second. Read body.user third.

That is why Day 9 sits between Day 8’s JavaScript POM and Day 10’s first npx playwright test. You will type the page before you click it.

Lab 175 — the JavaScript we are leaving

File: chapter_18_Typescript/175_TS.js

This is the entire file on main:

let testName = "Login Test";

function add(a, b) {
    return a + b;
}

let big = 123456789012345678901234567890n;

Three lines, three JavaScript habits.

testName is a string today. Tomorrow someone assigns 200 because a reporter printed a status. JavaScript shrugs. Playwright’s test.info().title is a string. Mixing those two in one helper is how a report column becomes NaN.

add(a, b) has no types. add("200", 1) is "2001". I have seen that exact helper used to bump a retry count. The suite retried once, then “21” times, then the job died.

123456789012345678901234567890n is a bigint. Fine. The lesson is not bigint. The lesson is: JavaScript will store whatever you pour in. TypeScript will ask what you meant.

Run it if you want:

node chapter_18_Typescript/175_TS.js

It prints nothing. That is also a lesson. A file can be legal and still teach you nothing until you add types.

Labs 176 — the same function, now annotated

Files: 176_TS_Helloworld.ts and 176_TS_Helloworld.js. The .js is the compiled neighbour of the .ts. I keep both names because both exist.

Exact TypeScript file:

let testName1: string = "Login Test";

// function add(a, b) {
//   return a + b;
// }

function add_ts(a: number, b: number): number {
    return a + b;
}

The commented add(a, b) is the Day 1–8 function. I left it in the file so the batch can see the before. The after is add_ts(a: number, b: number): number. Three annotations: two parameters, one return.

The compiled .js on main is:

"use strict";
let testName1 = "Login Test";
// function add(a, b) {
//   return a + b;
// }
function add_ts(a, b) {
    return a + b;
}

Types erase. Node never sees : number. Playwright never sees : number at runtime either. The value of TypeScript is the moment *before* npx playwright test — the moment the editor refuses add_ts("200", 1).

How I use the same shape in a review. Someone writes a helper next to a Playwright spec:

function retryDelay(attempt: number): number {
  return attempt * 250;
}

That is lab 176 wearing a timeout. retryDelay("2") is a compile error. In JavaScript it is 250 times the string "2", which is NaN, which is a wait that is not a wait.

Labs 177 and 178 — the primitive types Playwright already uses

Files: 177_Basic_Types.ts and 178_TS_Basic_Types.ts.

Lab 177 is the classroom poster. Exact file:

// Primitive types

let name: string = "John";
let age: number = 30;
let pi: number = 3.14;
let distance_to_moon: number = 398765434567;
// let pi: float = 3.14;
let isActive: boolean = true;
let nothing: null = null;
let notDefined: undefined = undefined;

// Arrays
let numbers: number[] = [1, 2, 3];
let names: Array<string> = ["John", "Jane"];


// Any (avoid when possible)
let anything: any = "hello";

// Unknown (safer than any)
let unknown: unknown = "hello";

Read that comment on float. TypeScript has number. Not int. Not float. Playwright timeouts, status codes, viewport widths, retries in config — all number. If you write let timeout: float the compiler does not know float. That commented line is the interview trap.

Two array spellings: number[] and Array<string>. Same idea. I use number[] for status codes and string[] for suite names. Array<T> shows up again in chapter 21 when T is not a primitive.

any versus unknown is the fork. anything.toUpperCase() compiles. unknown.toUpperCase() does not. Playwright’s response.json() is the production version of that fork. If you type the result as any, body.usr.nme compiles and your assertion is a lie. If you type it as unknown, you must narrow. Lab 179 is that narrowing.

Lab 178 is the same primitives, now printed:

let message: string = "Hello, TypeScript!";
let count: number = 42;
let isActive: boolean = true;

console.log("Message:", message);
console.log("Count:", count);
console.log("Is Active:", isActive);

Map this to a Playwright fixture without inventing a file. message is a test title. count is expect(items).toHaveCount(count). isActive is headless: true on CI. Those three annotations are playwright.config.ts in miniature.

Lab 179 — unknown is the type of a payload you have not trusted yet

File: 179_Unknown.ts

Exact file:

let unknown: unknown = "hello";

if (typeof unknown === "string") {
    console.log("Hi");
}

let message: string = "Hello";

let username: string;
let userId: number;

// Function annotations
function greet(name: string): string {
    return `Hello, ${name}!`;
}

// Arrow function annotations
const multiply = (a: number, b: number): number => a * b;

// Object annotations
let user: { name: string; age: number } = {
    name: "John",
    age: 30
};

The first five lines are the rule. unknown is a locked box. typeof unknown === "string" is the key. Inside the if, TypeScript treats the value as a string. Outside, it is still unknown. That is narrowing.

The rest of the file is the annotation habit you will live in: functions return what they promise, arrows too, objects list their fields inline. The inline object { name: string; age: number } is a one-off interface. Chapter 19 names that shape.

How this shows up in Playwright Test. APIRequestContext.get() gives you an APIResponse. .json() is a payload. I treat that payload as unknown until I have proved the shape.

const payload: unknown = await response.json();
if (typeof payload === "object" && payload !== null && "token" in payload) {
  // now you may read token — still carefully
}

That is lab 179 plus Day 3’s if. We will get a cleaner as UserResponse in lab 204. Do not jump there first. Narrowing is the honest path. as is the shortcut you use when the contract is already tested.

username: string and userId: number with no initializer are legal under a looser config and errors under strict + exactOptionalPropertyTypes if you use them before assign. Root tsconfig.json on this repo has "strict": true and "exactOptionalPropertyTypes": true. That is why a half-built user object in a test data factory turns red. Good. Fill the fields.

Lab 180 — void is a log, never is a throw

File: 180_Fn_NoReturn.ts

Exact file:

// void
function sayHello(msg: string): void {
    console.log(msg);
}

// Function annotations
function greet(name: string): string {
    return `Hello, ${name}!`;
}

// never - function never returns (throws or infinite loop)
function throwError(message: string): never {
    throw new Error(message);
}

function infiniteLoop(): never {
    while (true) { }
}

void means “I do work, I do not hand you a value.” Playwright is full of void in spirit: page.goto returns a response you often ignore, locator.click() returns Promise<void>, test.info().attach is a side effect. When I write a helper that only logs a step, I mark it void so nobody writes expect(logTestStep("login")).toBe(true).

never means “this function does not come back.” throwError throws. infiniteLoop spins. In a suite I use never for the helper that must stop the test:

function failLoud(message: string): never {
  throw new Error(message);
}

If a status is not 200, 201, or 204, I failLoud. The function has no return on the happy path because there is no happy path. That is never. Do not mark a normal logger never. Do not mark a retry loop never unless you truly never leave it — and if you never leave it, you have a hung job, not a type.

Lab 61 on Day 4 was do-while. Lab 180’s while (true) { } is the type of a loop that forgot the exit. Playwright’s expect.poll has a timeout. Your own while often does not. never is the compiler saying: this function is a trap.

Labs 181 and 182 — the first SDET-shaped types

Files: 181_IQ.ts and 182_IQ.ts. I named these IQ in the batch because they are the first files that look like work, not posters.

Lab 181, exact:

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");

Three helpers you will rewrite for the rest of this series.

buildEndpoint is baseURL plus a path. Playwright’s page.goto('/login') already joins baseURL from config. Your API helper does not, unless you write this function. Types stop buildEndpoint(true, 404).

isSuccessCode is Day 3’s range test, now returning boolean. expect(response.ok()).toBeTruthy() is Playwright’s version. I still keep a typed helper for non-Playwright fetches and for classroom assertions on a raw number.

logTestStep is void. Playwright has test.info().annotations and console.log in the debug reporter. Same job: write a breadcrumb, return nothing.

Lab 182, exact:

let statusCode: number[] = [200, 201, 404, 500];
let testSuites: string[] = ["Smoke", "Regression", "Sanity"];

console.log("Status codes:", statusCode);
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)");

That inline object is a reporter row. Playwright’s JSON reporter emits a richer shape. Your own summary table does not need that richness on Day 9. It needs name, status, duration — and it needs the compiler to scream when duration is missing. That scream is chapter 19.

status: string is still a hole. "PASS" and "pas" both compile. Enums in chapter 20 close that hole.

Lab 183 — typed filter is how you keep failed codes

File: 183_Filter_Array.ts

Exact file:

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));

Day 4 taught filter. Today the callback has a type. code: number. Return boolean. Output number[]. If someone passes ["200", "404"], the compiler stops the call.

Playwright mapping I use in reviews: collect every response status in a test, then assert the failed list.

const codes: number[] = [200, 201, 404, 500, 302, 403];
const failed = getFailedCodes(codes);
// failed is number[] — [404, 500, 403]

302 is not a failure in this helper. That is a product decision, not a type decision. Types do not replace assertions. They stop you from filtering the wrong array.

Lab 184 — an interface is a test case that cannot forget a field

File: 184_TS_Interface.ts

The file opens with the reason I put interfaces in a Playwright batch. I will quote that comment as it sits on main:

// 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
};

console.log("TC-" + test1.id + ": " + test1.name + " → " + test1.status);

let test2: TestCase = {
    id: 2,
    name: "Login with invalid password",
    status: "FAIL",
    duration: 3200
};
console.log("TC-" + test2.id + ": " + test2.name + " → " + test2.status);


// let test3: TestCase = {
//     id: 1,
//     name: "Login with valid credentials",
//     status: "PASS",
// };
// console.log("TC-" + test3.id + ": " + test3.name + " → " + test3.status);

test3 is commented because it has no duration. That is the lesson. The object looks fine to a human. The interface says four fields. TypeScript will not compile test3.

Playwright’s test("Login with valid credentials", async ({ page }) => { ... }) already has a name. The interface is for the *row you store* — CSV, JSON fixture, Allure annotation, a custom reporter. When a teammate adds owner and forgets duration, you want the red line in the fixture file, not a undefinedms in the HTML report.

An interface is erased at runtime, same as : number. It is a contract for the editor and tsc. It is not a runtime validator. For runtime JSON I later use AJV in this series (Day 19 of the 21). Today the compiler is the first gate.

Lab 185 — optional, readonly, and the API response you must not mutate

File: 185_TS_Interface2.ts

Exact file:

// Interface with optional and readonly for API response
interface APIResponse {
    readonly statusCode: number;
    body: string;
    headers?: object; //Optional
    responseTime?: number;
}

// Readonly - can't modify the readonly
// ? - optional

let response: APIResponse = {
    statusCode: 200,
    body: '{"user": "admin"}',
};

console.log("Status:", response.statusCode);
console.log("Body:", response.body);
console.log("Headers:", response.headers);

console.log(" ---------------------------")

interface Point {
    readonly x: number;
    readonly y: number;
}
const point: Point = { x: 10, y: 20 };
// point.x = 5; This is not possible. 

// ReadonlyArray
interface Data {
    readonly items: readonly number[];
}

Three marks. Learn them as if they were locator rules.

readonly statusCode — you received 200. You do not assign 201 in the test to make the assertion pass. Playwright’s APIResponse.status() is already a getter. This interface is how you model that honesty in your own wrapper.

headers? and responseTime? — a local mock may not send headers. CI might. Optional means “the field may be missing.” It does not mean “the field may be the wrong type.” headers: 12 is still an error. headers omitted is fine. console.log(response.headers) prints undefined here. That is expected. Do not expect(response.headers).toBeTruthy() unless you required the field.

readonly items: readonly number[] — the array itself cannot be swapped, and the items cannot be pushed. Day 4’s let copy = arr bug dies here if you type the fixture as readonly.

body: string in this lab is a JSON string, not a parsed object. That is classroom-simple. In Playwright you will parse it. Keep the raw string when you need to assert bytes. Parse when you need user. Do not mix those two in one field without a new interface.

Lab 186 — a method signature is a calculator for now, a page action later

File: 186_TS_Method_Sign.ts

Exact file:

interface Calculator {
    add(a: number, b: number): number;
    subtract(a: number, b: number): number;
    multiply: (a: number, b: number) => number;  // Alternative syntax
}

const calc: Calculator = {
    add: (a, b) => a + b,
    subtract: (a, b) => a - b,
    multiply: (a, b) => a * b
}

console.log(calc);

Two spellings of the same idea. add(a: number, b: number): number is a method. multiply: (a: number, b: number) => number is a property that holds a function. For a page object I use the method form. For a callback I pass into test.extend, I use the property form.

The object literal must implement every method. Forget subtract and the assignment is an error. That is the whole point of a contract. A LoginPage interface that lists goto, fillUser, fillPassword, submit will not let you ship a class that only has goto.

Lab 187 — a call signature is a hook

File: 187_TS_Interface_Hook.ts

Exact file:

// 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");

// This is where My Test case will be !!

interface TestCase {
    id: number;
    name: string;
    status: string;
    duration: number;
}

let test1: TestCase = {
    id: 1,
    name: "Login with valid credentials",
    status: "PASS",
    duration: 1500
};

console.log("TC-" + test1.id + ": " + test1.name + " → " + test1.status);


afterEachHook("Login Test");

interface TestHook { (testName: string): void; } means “this value is callable.” Not an object with a run method. A function. Playwright’s test.beforeEach(async ({ page }, testInfo) => { ... }) is that idea with more arguments. I do not invent a beforeEach file here. I show you that the classroom hook is a typed function, and Playwright’s hook is a typed function with Page and TestInfo already defined by @playwright/test.

The sandwich in the file is the mental model: before, the case, after. Day 10 will put that sandwich around page.goto. Today it is console.log. Same shape.

Lab 188 — a bug report is an interface you will paste into Jira

File: 188_TS_REAL_BUG_REPORT.ts

I quote the types and the calls. The file has a large blank block between the function and the first logBug. That whitespace is on main. I do not fill it.

interface BugReport {
    id: number;
    title: string;
    severity: string;
    stepsToReproduce: string[];
}

function logBug(bug: BugReport): void {
    console.log("BUG- Report -> " + bug.id + " [" + bug.severity + "] " + bug.title);
    bug.stepsToReproduce.forEach(function (step: string, i: number) {
        console.log("  " + (i + 1) + ". " + step);
    })
}

And the two calls, exact strings from the file:

logBug({
    id: 1,
    title: "VWO login is not working. ",
    severity: "High",
    "stepsToReproduce": ["Ste1 : open the app.vwo.com", "Step2 :  enter invalid credes", "step3 : verify the error message"]
});

logBug({
    id: 2,
    title: "VWO login is not working with arabic lang ",
    severity: "High",
    "stepsToReproduce": ["Ste1 : open the app.vwo.com", "Step2 :  enter invalid credes", "step3 : verify the error message"]
});

I leave the typos in the steps. Ste1, credes. That is how a real bug report looks at 6:40 PM. The interface does not fix spelling. It fixes *structure*. You cannot call logBug without stepsToReproduce. You cannot pass a single string where an array is required.

severity: string is still loose. "High" and "high" and "hign" all compile. Lab 194’s enum Severity is the upgrade. I teach them in this order on purpose. Interface first. Enum second. Together they are a typed defect.

Playwright mapping: when a test fails, test.info().attach can take a body. I attach a BugReport JSON so the HTML report carries title, severity, and steps. The interface is the schema of that attachment.

VWO is app.vwo.com. We will log in there later in this series. Today the URL is a string inside a step.

Lab 189 — TestConfig is playwright.config.ts in twelve lines

File: 189_TS_TestConfig_REAL.ts

Exact file:

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);

CI omits timeout and retries. Local sets both. ciConfig.timeout is undefined. That print is the lesson: optional fields are T | undefined. If you pass ciConfig.timeout into page.setDefaultTimeout without a fallback, you are handing Playwright undefined. Day 2’s ?? is the fix: ciConfig.timeout ?? 30000.

Playwright’s own config is richer — projects, reporters, use, trace. I am not inventing a playwright.config.ts in this chapter. Chapter 23 has one. Today you learn the *shape*: required browser, required headless, required baseURL, optional timeout, optional retries. When Day 10 opens chapter_23_Playwright_Fundamentals/playwright.config.ts, you will recognize every key.

browser: string is the next hole. "Chrome" and "chromium" are different strings. Playwright projects use chromium, firefox, webkit. Lab 195’s enum Browser is how we stop that drift. Note the classroom value is "Chrome" with a capital C. I will not pretend this file already uses Playwright’s project names.

Lab 190 — page object interfaces, including the FreeTrail typo

File: 190_REAL_PAGE_OBJECT_Interface.ts

Exact file:

interface BasePage {
    url: string;
    title: string;
}

interface LoginPage extends BasePage {
    usernameSelector: string;
    passwordSelector: string;
    loginButtonSelector: string;
}
interface FreeTrailPage extends BasePage {
    usernameSelector: string;
    submitButtonSelector: string;
}


let loginPage: LoginPage = {
    url: "/login",
    title: "Login Page",
    usernameSelector: "#username",
    passwordSelector: "#password",
    loginButtonSelector: "#login-btn"
}

let freeTrialPage: FreeTrailPage = {
    url: "/free-trial",
    title: "Free Page",
    usernameSelector: "#username",
    submitButtonSelector: "#submit",
}

console.log("URL:", loginPage.url);
console.log("Title:", loginPage.title);
console.log("Username field:", loginPage.usernameSelector);

console.log(" ------- ");


console.log("URL:", freeTrialPage.url);
console.log("Title:", freeTrialPage.title);
console.log("Username field:", freeTrialPage.usernameSelector);

extends on an interface is not inheritance of behaviour. It is inheritance of *fields*. LoginPage has everything BasePage has, plus three selectors. FreeTrailPage is spelled Trail in the type name and Trial in the variable name. Both names are on main. I will not invent FreeTrialPage. Live with the classroom spelling. The compiler does.

This is Day 8’s JavaScript POM, now a contract. Day 8’s LoginPage.js had methods. This file has data. Lab 201 will put methods back, with protected navigate. Together they are a typed POM: an interface for the shape, a class for the actions.

Selectors here are string. In a real Playwright page object I store Locator, not the CSS string, once I have a Page:

// teaching sketch — not a repo file
// username: Locator  comes from page.locator("#username")

I am not adding a LoginPage.ts under src/pages/. That file is not in chapters 18–22. The sketch is how I talk about lab 190 in a review. The locators stay strings until Day 10 gives us Page.

Why two pages share usernameSelector and not passwordSelector: a free-trial form may only need an email. The interface says so. A function that accepts BasePage can read url and title for both. A function that accepts LoginPage can fill a password. That is the gain of extends.

Lab 191 — a class implements the contract

File: 191_TS_Class_Interface.ts

Exact file:

interface Executable {
    name: string;
    run(): void;
    getStatus(): string;
}

class TestCase implements Executable {
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    run(): void {
        console.log("[RUN] " + this.name);
    }
    getStatus(): string {
        return "PASS";
    }
}

let tc: Executable = new TestCase("Verify login redirect");
tc.run();
console.log("Call:", tc.getStatus());

implements is the class-side of lab 186. Forget getStatus and tsc fails. The variable is typed as Executable, not TestCase. You can swap in another class that also implements Executable — an API check, a visual check — and the caller does not change.

Playwright Test already has an execution model. I do not replace test() with Executable. I use this pattern for *your* runners: a smoke pack, a data-load job, a seed script. The interface is the list of things a runner must do. The class is one way to do them.

getStatus() always returns "PASS". That is a classroom stub. In production that string becomes an enum.

Lab 192 — an index signature is a dictionary, not a page object

File: 192_Index_TS_Sing.ts

Exact file:

// String index

interface StringDictionary {
    [key: string]: string;
}

const dict: StringDictionary = {
    hello: "world",
    foo: "bar"
};

[key: string]: string means “any string key, string value.” Useful for env maps, header bags, translation tables. Dangerous for a page object. If everything is string, loginPage.passwrod is a legal key that returns string | undefined under noUncheckedIndexedAccess (this repo’s tsconfig.json turns that on). You lose the “field does not exist” error that made lab 184 valuable.

I use an index signature for headers when I do not know the names. I do not use it for LoginPage. Named fields for pages. Index signatures for bags.

Filename is 192_Index_TS_Sing.ts. Sing is on GitHub. I will not invent 192_Index_TS_Sign.ts.

Lab 193 — a string enum is a status that cannot be “pas”

File: 193_ENUM.ts

Exact file:

enum TestStatus {
    Pass = "PASS",
    Fail = "FAIL",
    Skip = "SKIP",
    Pending = "PENDING",
    Blocked = "BLOCKED"
}

console.log(TestStatus.Pass);

That prints PASS. The member is TestStatus.Pass. The value is the string "PASS". A reporter that wants the four-letter code reads the value. A test that wants autocomplete reads the member.

Replace status: string on TestCase with status: TestStatus. "pas" is now an error. "PASS" as a raw string may still be assignable because the enum value *is* "PASS". I still write TestStatus.Pass so a rename refactors every callsite.

Playwright has test.skip, test.fixme, test.fail. Those are runner decisions. TestStatus is the *result* you store. Do not confuse them. test.skip means “do not run.” TestStatus.Skip means “we ran the plan and marked it skipped.” Different layers.

Lab 194 — numeric enums, reverse lookup, and environment URLs

File: 194_ENUM_Fn.ts

Exact file:

enum Severity {
    Low,
    Medium,
    High,
    Critical
}

console.log(Severity.High);

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]);

enum Environment {
    Dev = "https://dev.api.com",
    Staging = "https://staging.api.com",
    QA = "https://qa.api.com",
    Prod = "https://api.com"
}

console.log(Environment.QA);

Two enum styles in one file. Do not mix them casually.

Severity is numeric. Low = 0, Medium = 1, High = 2, Critical = 3. severity >= Severity.High works because numbers compare. Severity[2] is the reverse map. It prints "High". Numeric enums are reverse-mapped. That is convenient and a footgun. needsImmediateAttention(1) compiles if you pass a number where Severity is expected in some configs. I prefer string enums for anything a human reads.

Environment is a string enum whose values are URLs. Environment.QA is "https://qa.api.com". That is baseURL with a closed set. A test that does page.goto(Environment.Prod) in a CI smoke job is a visible decision, not a string you grep for.

Lab 188’s "High" becomes Severity.High. Lab 189’s baseURL: "https://staging.app.com" becomes Environment.Staging once you agree the host. The classroom staging host in 189 is staging.app.com. The classroom API host in 194 is staging.api.com. Different files, different hosts. I will not merge them.

Lab 195 — Browser enum is how you stop launching “chorme”

File: 195_REAL_BRowser_Selection.ts

The filename is BRowser. Exact file:

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);

Day 3 was switch. Today the switch is exhaustive over an enum. launchBrowser("chorme") does not compile. launchBrowser(Browser.Chrome) does.

Playwright’s engine names are chromium, firefox, webkit. This classroom enum uses chrome, safari, edge. I will not rewrite the file. I will tell you the mapping I use in a review:

  • Browser.Chrome and Browser.Edge → Playwright project chromium (or a branded channel)
  • Browser.Firefoxfirefox
  • Browser.Safariwebkit

Day 10’s config will list projects. This enum is the *idea* of a closed browser set. The strings will change when we touch @playwright/test. The habit does not: no free-typed browser name in a helper.

The switch has no default. With a union of four members that is acceptable if you trust the type. I still add default in production and failLoud there, because JavaScript can still smuggle a value across a as Browser cast. Lab 204 is that cast.

Lab 196 — HTTP methods as an enum, not a comment

File: 196_ENUM_API_example.ts

Exact file:

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");

Playwright’s APIRequestContext has .get(), .post(), .put(), .delete(), .patch(). This lab does not include PATCH. I will not add it. If your API uses PATCH, you extend the enum in your own branch. You do not invent a fifth member in this post and claim it is on main.

sendRequest always prints 200 OK. That is a stub. Day 19 of this series talks to Restful Booker for real. Today the type of method is the lesson. sendRequest("get", "/api/users") is an error if you meant the enum. Case is part of the contract. HTTP methods are uppercase in this file.

Labs 197 and 198 — generics are one function, many payload types

Files: 197_Generic.ts and 198_Generic_Class.ts.

Lab 197, exact:

function getString(name: string): string {
    return "Amit";
}
getString("pramod");
// getFirstResult(123);


function getFirstResults<T>(results: T[]): T {
    return results[0]!;
}

let firstCode = getFirstResults<number>([200, 404, 500]);
let firstTest = getFirstResults<string>(["Login", "Signup", "Cart"]);


console.log("First code:", firstCode);
console.log("First test:", firstTest);

getString ignores its argument and returns "Amit". That is a classroom joke and a warning: a type annotation on the parameter does not mean the body uses it.

getFirstResults<T> is the real lesson. T is a placeholder. Call it with number[] and T is number. Call it with string[] and T is string. The ! after results[0] is a non-null assertion. Root tsconfig has noUncheckedIndexedAccess, so results[0] is T | undefined. The ! tells the compiler “I know index 0 exists.” That is a promise. An empty array breaks the promise at runtime. Pair this helper with a length check, the way Day 4 paired every with “list is not empty.”

Playwright: const texts = await page.getByRole("listitem").allTextContents(); const first = getFirstResults<string>(texts);. First item is string. Not unknown. Not any.

Lab 198, exact:

class TestDataStorage<T> {
    private items: T[] = [];

    add(item: T): void {
        this.items.push(item);
    }

    getFirst(): T {
        return this.items[0]!;
    }

    getAll(): T[] {
        return this.items;
    }

    count(): number {
        return this.items.length;
    }

}
let codeStore = new TestDataStorage<number>();
let testStore = new TestDataStorage<string>();

codeStore.add(200);
codeStore.add(404);
codeStore.add(500);

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());

One class. Two stores. codeStore.add("Login") is an error. testStore.add(200) is an error. private items is chapter 22 arriving early. The array is hidden. You add through add. You read through getAll. That is encapsulation with a type parameter.

I use this shape for a typed fixture bag: TestDataStorage<User>, TestDataStorage<Booking>. Playwright’s test.extend can hold one instance per worker. We will write fixtures on Day 16. The generic class is the bag those fixtures sit in.

Lab 199 — wrapResponse is the generic you actually ship

File: 199_GENERIC_API_RESPOSNE.ts

Filename is RESPOSNE. Exact file:

function wrapResponse<T>(statusCode: number, data: T): { statusCode: number; data: T } {
    return { statusCode: statusCode, data: data };
}

let userResp = wrapResponse<string>(200, "admin");
console.log(userResp);

let flagResp = wrapResponse<boolean>(200, true);
console.log(flagResp);

let countResp = wrapResponse<number>(200, 42);
console.log(countResp);

One wrapper. Three payloads. userResp.data is string. flagResp.data is boolean. countResp.data is number. You do not write UserResponse, FlagResponse, CountResponse as three interfaces that only differ in data.

This is the API client you will want on Day 19:

// teaching sketch — not a repo file
// const body = await response.json();
// const wrapped = wrapResponse<Booking>(response.status(), body as Booking);

I still want unknown first (lab 179) and a real schema later (AJV). wrapResponse<T> is the type-level envelope. statusCode stays number. data changes. That split is the whole generic.

Lab 200 — public is the URL, private is the key, protected is the timeout

File: 200_PRIVATE_PUBLIC_PROTECTED.ts

Exact file:

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");

Three words. Memorize them the way you memorized var / let / const.

  • public — anyone with the object can read it. client.baseURL is printed on purpose. A test may need the URL for an assertion.
  • private — only APIClient itself. client.apiKey is a compile error. client.getAuthHeader() is a compile error. The key stays inside sendRequest. That is how you stop a spec from logging a bearer token into an HTML report.
  • protected — this class and its children. UserAPIClient can read this.timeout and this.baseURL. A spec that holds client cannot read timeout. The child is trusted. The test file is not.

Day 8’s JavaScript #private field is the runtime cousin. TypeScript private is a compile-time cousin. Both matter. private disappears in the emitted JavaScript unless you use the # syntax. Do not treat private as encryption. Treat it as a team rule the compiler enforces.

The constructor stores "key_secret_123". That is a classroom string. In a real suite that value comes from an env var, not a literal in a committed file. I am not adding a .env lab. This chapter does not have one.

Playwright’s APIRequestContext already hides the implementation. Your wrapper around it should hide the token the same way APIClient hides apiKey.

Lab 201 — a typed Page Object with protected navigation

File: 201_PageObjectModel.ts

Exact file:

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");

This is the Day 9 POM. Not a Playwright Page yet. A class that owns a URL, hides navigate, and publishes login.

page.navigate("/login") from a spec would be an error. page.baseURL from a spec would be an error. page.login("admin") is the only door. That is the point of a page object: the test speaks in business verbs. The page speaks in selectors.

Day 10 we will pass a real Page into the constructor. I will not pretend this file already imports @playwright/test. It does not. The method body is console.log. The structure is the structure you will keep:

  1. BasePage holds baseURL and a shared navigate.
  2. LoginPage calls super(...), then this.navigate("/login"), then types, then clicks.
  3. The spec calls login("admin").

Lab 190’s interface named the fields. Lab 201 names the actions. A production typed POM uses both: the class implements an interface, locators are Locator, navigate becomes this.page.goto. We assemble that on Days 10 and 16. Today you get the access rules so you do not publish usernameSelector as a public string the spec can overwrite mid-test.

Lab 202 — readonly config is how a retry cannot rewrite staging

File: 202_READONLY.ts

Exact file:

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";

The last line is commented because it is illegal. readonly on a field means: assign in the constructor, never again. A flaky test that “fixes” itself by pointing baseURL at prod is a defect I have reviewed more than once. readonly makes that line a compile error.

Lab 185 put readonly on an interface field. Lab 202 puts it on a class field. Same mark. Two places. Use both. Interface for the shape you pass around. Class for the object you construct once at worker start.

Playwright’s own use.baseURL in config is already a value you should not mutate from a spec. If you wrap it, wrap it readonly.

Lab 203 — abstract means you cannot new the base test

File: 203_Abstract_Clsss.ts

Filename is Clsss. Exact file:

abstract class BaseTest {
    protected testName: string;
    constructor(testName: string) {
        this.testName = testName;
    }

    abstract setup(): void;
    abstract execute(): void;
    abstract teardown(): void;
}

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");
    }
}

abstract class — you cannot new BaseTest("login"). The compiler stops you. abstract setup() — a child that forgets setup does not compile.

This is not how you write Playwright tests day to day. Playwright already gives you test.beforeEach and test.afterEach. I still teach abstract because a framework has jobs that are not test(): a seed, a report builder, a data loader. Those jobs need a forced shape. UITest is one child. An APITest child would implement the same three methods without launching a browser. Lab 205 does that with override.

The file does not instantiate UITest. There is no new UITest(...) on main. I will not add one and claim it ran. Read the class. The lesson is the keyword, not a printout.

Labs 204 — as is a claim, not a proof

Files: 204_As_Alias.ts and 204_AS_Part2.ts. Two files, one number, both on main.

204_As_Alias.ts, exact:

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);

204_AS_Part2.ts, exact:

let element: unknown = {
    tagName: "Button",
    textContent: "Submit",
    id: "submit-btn",
    disabled: false
}

interface elementI { tagName: string, textContent: string, id: string, disabled: boolean };


let button = element as elementI


console.log("Tag:", button.tagName);
console.log("Text:", button.textContent);
console.log("ID:", button.id);
console.log("Disabled:", button.disabled);

unknown plus as is the pair I use when I have already trusted the source. rawResponse as UserResponse does not check that body.user exists at runtime. It tells the compiler to believe you. If the API returned { error: "nope" }, response.body.user is a runtime explosion with a compile-time smile.

Lab 179’s typeof narrowing is safer. as is faster. I use as after a schema check, or in a classroom where the object is a literal on the next line. I do not as a live Restful Booker body until Day 19’s AJV step.

Playwright: const json: unknown = await response.json(); const user = json as UserResponse;. That second line is lab 204. Put a comment above it: “contract tested in api/users.spec.ts”. If you cannot point at a test, you cannot point at as.

elementI is a classroom interface for a DOM-looking object. Playwright’s Locator is not this. Do not as elementI a locator. A locator is a query. This object is a snapshot of attributes. Different types.

Labs 205 and 206 — override is a label on the method you replaced

Files: 205_Ovveride.ts (three v’s) and 206_Override.ts.

Lab 205, exact:

class BaseTest {
    setup(): void {
        console.log("[BASE] Open browser");
    }
    teardown(): void {
        console.log("[BASE] Close browser");
    }
}

class LoginTest extends BaseTest {

    override setup(): void {
        console.log("[LoginTest] Open browser");
        console.log("[LoginTest] Maximize");
    }
}

class APITest extends BaseTest {

    override setup(): void {
        console.log("[APITest] No Browser!");
    }
}

let test = new LoginTest();
let apitest = new APITest();
test.setup();
apitest.setup();

override tells the compiler: this method exists on the parent. If someone renames setup to setUp on BaseTest, LoginTest.override setup becomes an error. Without override, you silently add a new method and the parent setup still runs when a caller has a BaseTest reference… or you think you overrode and you did not.

Root tsconfig.json has "noImplicitOverride": true commented out. The file still uses the keyword. Use the keyword even when the flag is off. Turn the flag on in your own framework when you are ready. I will not claim the flag is on in this repo. It is not.

LoginTest adds maximize. APITest refuses the browser. Same parent, two children. Day 8’s JavaScript override did this without the keyword. The keyword is the TypeScript upgrade.

Lab 206 is the family joke I use so the batch remembers the word:

class Father {
    home(): void {
        console.log("2BHK");
    }
}

class Pramod extends Father {
    home(): void {
        console.log("3BHK");
    }
}

let pramod = new Pramod();
pramod.home();

That prints 3BHK. No override keyword on this file. Both files exist so I can show the keyword and the behaviour. Behaviour is the child’s method. The keyword is the safety label.

Playwright mapping: a BasePage.goto() that waits for networkidle, and a LoginPage that overrides goto to wait for the username field instead. The spec still calls goto. The child decides the wait. That is override, not a second method name.

Labs 207 and 208 — the decorator stub is empty; the log decorator is not

207_Decorator.ts is on main and the blob is 0 bytes. I skip it. I will not invent a class decorator, a parameter decorator, or a Playwright test.extend wrapper and put it under that filename.

The working file is 208_23_logs_Decortors.ts. Filename is Decortors. Exact file:

function Log(target: any, methodName: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log(`Called ${methodName} with args:`, args);
        return original.apply(this, args);
    };
}

class Calculator {
    @Log
    add(a: number, b: number) {
        return a + b;
    }
}

const calc = new Calculator();
calc.add(2, 3);

A method decorator wraps the function. @Log on add prints the arguments, then calls the original. calc.add(2, 3) logs Called add with args: [ 2, 3 ] and returns 5.

Decorators need experimentalDecorators (or the newer decorators flag) in tsconfig. Root tsconfig.json on this repo does not turn that flag on. I will not pretend npx tsc compiles this file under the committed config. The file is the teaching shape. Enable the flag in a playground if you want to run it. Do not invent a second tsconfig and claim it lives in chapter 22.

Playwright Test does not need you to decorate login() to log steps. Use test.info().annotations, a custom fixture, or a wrapper function. I show @Log because interviews ask “what is a decorator?” and because some frameworks (Nest-style API clients) use them. For this series, fixtures on Day 16 are the Playwright way to wrap setup. Decorators are extra.

target: any and ...args: any[] in this file are the classroom leak. After today you know any is the type that turns the compiler off. A production Log would be generic. We do not have that file. I will not write one and call it lab 207.

Putting Day 9 on a Playwright Test file you will write tomorrow

You do not run npx playwright test today. You *see* the types you will stand on.

A spec that has earned Day 9 looks like this in my reviews. This is a teaching sketch, not a file in chapters 18–22:

// teaching sketch — not a repo file
import { test, expect } from "@playwright/test";

test("login stores a typed result", async ({ page, request }) => {
  const config: { baseURL: string; headless: boolean } = {
    baseURL: "https://staging.app.com",
    headless: true,
  };

  const payload: unknown = await (await request.get(config.baseURL + "/health")).json();
  // narrow or `as` only after you trust the contract

  // page.locator("#username") is Locator — fill() wants string
  // add_ts("admin", 1) would already be a compile error
});

Every piece traces to a lab:

Spec habitLab on main
string / number / boolean annotations177, 178
unknown JSON179, 204
void logger, never fail180
buildEndpoint, isSuccessCode181
TestCase / BugReport / TestConfig184, 188, 189
LoginPage extends BasePage fields190
implements Executable191
TestStatus / Browser / HTTPMethod193, 195, 196
wrapResponse<T>199
private apiKey, protected navigate200, 201
readonly baseURL202
abstract + override setup203, 205
@Log as interview extra208; 207 empty

Tomorrow we install Playwright in chapter_23_Playwright_Fundamentals and write the first real spec. The types do not change. The console.log bodies become page.goto and expect.

Recap — what Day 9 actually installed in your head

  • JavaScript stores whatever you pour. TypeScript asks what you meant. Labs 175–176.
  • Primitives are string, number, boolean, null, undefined. There is no float. Arrays are T[] or Array<T>. Prefer unknown over any. Labs 177–179.
  • void is a side effect. never is a throw or a loop that does not return. Lab 180.
  • SDET helpers get annotations: endpoint builder, success-code predicate, step logger, typed filter. Labs 181–183.
  • An interface is a contract. Missing duration is a compile error. Optional is ?. Frozen is readonly. Labs 184–185.
  • Method signatures, call-signature hooks, implements, index signatures. Labs 186, 187, 191, 192. Index signatures are for bags, not page objects.
  • Real QA interfaces in this repo: BugReport, TestConfig, BasePage / LoginPage / FreeTrailPage. Labs 188–190. FreeTrail is the spelling on main.
  • String enums for status, browser, HTTP, environment URLs. Numeric enums for ordered severity. Labs 193–196. Filename 195_REAL_BRowser_Selection.ts keeps its capital R.
  • Generics: getFirstResults<T>, TestDataStorage<T>, wrapResponse<T>. Filename 199_GENERIC_API_RESPOSNE.ts keeps RESPOSNE. Labs 197–199. The ! on [0] is a promise about length.
  • public / private / protected on an API client and a POM. readonly on config. abstract on a base test. as on a payload you claim to know. override on a child setup. Labs 200–206. Filenames 203_Abstract_Clsss.ts and 205_Ovveride.ts keep their classroom spelling.
  • 207_Decorator.ts is 0 bytes. 208_23_logs_Decortors.ts is the log wrapper. Decorators need a tsconfig flag this repo’s root file does not enable.
  • Root tsconfig.json is strict, nodenext, esnext, noUncheckedIndexedAccess, exactOptionalPropertyTypes. Root package.json has @playwright/test ^1.58.2. We still do not run a spec today.

FAQ

Why should an SDET learn TypeScript before the first Playwright test?

Because the first spec already uses types you cannot see in JavaScript. page is Page. locator is Locator. response.json() is a payload you will want as unknown. If you meet those objects without interface, enum, and generic, you will any your way through the week and debug at runtime what the compiler would have refused in a second. Day 9 is that second.

What is the difference between any and unknown in a Playwright API test?

any turns the checker off. unknown forces a narrow or a cast before you read a field. Lab 177 introduces both. Lab 179 narrows with typeof. Lab 204 casts with as. For request.get().json(), start as unknown. Prove the shape. Then read body.user.

How do I type a Page Object in TypeScript for Playwright?

Two files in this repo, plus a Page you will get tomorrow. Lab 190 is the interface: BasePage with url and title, LoginPage extends BasePage with selector strings. Lab 201 is the class: protected navigate, public login(user: string). When Day 10 gives you Page, the constructor takes page: Page and selectors become Locator. I am not inventing that constructor file today.

Should I use enums or string unions for Playwright browser names?

This batch uses enums. Lab 195 is enum Browser { Chrome = "chrome", ... }. String unions ("chromium" | "firefox" | "webkit") are also valid and closer to Playwright project names. I stay with the enum because that is the file on main. When we open playwright.config.ts we will map classroom chrome / safari to chromium / webkit. Do not invent a Browser.Chromium member and claim it is in lab 195.

What is a generic API response in TypeScript?

Lab 199: wrapResponse<T>(statusCode: number, data: T): { statusCode: number; data: T }. One envelope. T is the payload — string, boolean, number, or a User interface. You stop copying UserResponse, FlagResponse, CountResponse as three types that only differ in data.

When should I use private vs protected vs public on a Playwright POM?

public for the verbs the spec calls — login(), goto(). protected for helpers children reuse — navigate(), baseURL, timeout. private for secrets and internals — apiKey, getAuthHeader(), a locator you do not want a spec to click around. Labs 200 and 201. TypeScript private is compile-time. It is not a runtime vault.

Why is 207_Decorator.ts empty and how do I learn decorators anyway?

Because that is what GitHub serves: 0 bytes. I do not invent a body. Read 208_23_logs_Decortors.ts. @Log wraps add and prints arguments. Root tsconfig.json does not enable decorator flags, so treat 208 as a shape, not a guaranteed compile under the committed config. For Playwright, prefer fixtures over decorators.

What does override do that a normal child method does not?

The behaviour is the same: the child’s method runs. The keyword tells the compiler the parent has that method. Rename the parent without updating the child and override fails the build. Lab 205 uses the keyword. Lab 206 shows the behaviour without it. Root tsconfig has noImplicitOverride commented out.

What is Day 10 of this series?

First Playwright tests. We leave TypeScript-as-language and open chapter_23_Playwright_Fundamentals: install, playwright.config.ts, tests/example.spec.ts, context, and pages. The types you wrote today sit under those specs.


<script type=”application/ld+json”> { “@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [ { “@type”: “Question”, “name”: “Why should an SDET learn TypeScript before the first Playwright test?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “The first Playwright spec already uses Page, Locator, and JSON payloads. Without interfaces, enums, and generics you will type those values as any and debug at runtime what the compiler would have refused. Day 9 teaches those types from LearningPlaywrightBatch chapters 18–22 before npx playwright test.” } }, { “@type”: “Question”, “name”: “What is the difference between any and unknown in a Playwright API test?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “any turns the type checker off. unknown requires narrowing or a cast before you read a field. Use unknown for response.json(), then typeof checks or as after the contract is trusted.” } }, { “@type”: “Question”, “name”: “How do I type a Page Object in TypeScript for Playwright?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Use an interface for the shape (BasePage, LoginPage extends BasePage with selector fields) and a class for the actions (protected navigate, public login). When you have a Playwright Page, the constructor takes page: Page and selectors become Locator. Those classroom files are 190_REAL_PAGE_OBJECT_Interface.ts and 201_PageObjectModel.ts.” } }, { “@type”: “Question”, “name”: “Should I use enums or string unions for Playwright browser names?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “This batch uses a Browser enum in 195_REAL_BRowser_Selection.ts with chrome, firefox, safari, and edge. String unions matching Playwright project names (chromium, firefox, webkit) are also valid. Map classroom chrome/safari to chromium/webkit when you open playwright.config.ts. Do not invent enum members that are not in the lab.” } }, { “@type”: “Question”, “name”: “What is a generic API response in TypeScript?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “wrapResponse<T>(statusCode, data) returns { statusCode, data: T }. One envelope, many payloads. The classroom file is 199_GENERIC_API_RESPOSNE.ts (filename spelled RESPOSNE on GitHub).” } }, { “@type”: “Question”, “name”: “When should I use private vs protected vs public on a Playwright page object?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “public for verbs the spec calls, protected for helpers child pages reuse, private for secrets and internals such as an API key. TypeScript private is compile-time only. See 200_PRIVATE_PUBLIC_PROTECTED.ts and 201_PageObjectModel.ts.” } }, { “@type”: “Question”, “name”: “Why is 207_Decorator.ts empty in LearningPlaywrightBatch?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “The file is present on main and is 0 bytes. Use 208_23_logs_Decortors.ts for a method decorator that logs arguments. Root tsconfig.json does not enable decorator compiler flags. Prefer Playwright fixtures over decorators in this series.” } }, { “@type”: “Question”, “name”: “What is next after Day 9 of the JS to Playwright Framework series?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Day 10 is the first Playwright tests from chapter_23_Playwright_Fundamentals: install, config, example.spec.ts, browser context, and pages. The TypeScript types from Day 9 sit under those specs.” } } ] } </script>

Tomorrow — Day 10: first Playwright tests

Types without a browser are homework. Tomorrow we launch one.

Day 10 of this series takes chapter_23_Playwright_Fundamentals from the same LearningPlaywrightBatch repo — install, playwright.config.ts, tests/example.spec.ts, context, and pages. You will write test(), call page.goto, and assert a title. The LoginPage we typed today will wait one more day for a real Page. That is the correct order: contract first, click second.

Series hub (bookmark this): JavaScript → TypeScript → Playwright Advanced Framework — 21-Day Guide.

Master Playwright end to end

If you want these labs as a live classroom — with typed page objects, Restful Booker generics, VWO login, and the framework we assemble on Day 21 — join Playwright Automation Mastery at The Testing Academy. Lifetime access. Real projects. A job-ready suite, not a folder of console.log.

*This is Day 9 of 21. Draft only. Not published.*

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.