Day 9: TypeScript for Playwright — Types, Interfaces, Enums, Generics, and Typed POM
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.*

Contents
What you will be able to do after Day 9
By the end of this post you can:
- Annotate a primitive, an array, an object, and a function the way a Playwright helper needs them —
string,number,boolean,null,undefined,void,never. - Prefer
unknownoveranywhenresponse.json()lands on your desk, then narrow it before you read a field. - Write an
interfacefor a test case, an API response, a bug report, a test config, and a page object — and let the compiler reject a missing field. - Put a method signature and a call-signature hook on an interface, then implement that contract with a class.
- Replace magic strings with enums for test status, severity, browser, environment URL, and HTTP method.
- Write a generic function and a generic class so one
wrapResponse<T>types a user, a flag, and a count. - Hide an API key with
private, share abaseURLwithprotected, publishlogin()withpublic, and freeze config withreadonly. - Force a
BaseTestshape withabstract, mark a childsetup()withoverride, and read a raw payload withas. - 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.js176_TS_Helloworld.js176_TS_Helloworld.ts177_Basic_Types.ts178_TS_Basic_Types.ts179_Unknown.ts180_Fn_NoReturn.ts181_IQ.ts182_IQ.ts183_Filter_Array.ts
Chapter 19 — Interfaces (chapter_19_Typescript_Interface/)
184_TS_Interface.tsthrough192_Index_TS_Sing.ts
Chapter 20 — Enums (chapter_20_Typescript_ENUM/)
193_ENUM.ts194_ENUM_Fn.ts195_REAL_BRowser_Selection.ts(capitalRinBRowseron GitHub)196_ENUM_API_example.ts
Chapter 21 — Generics (chapter_21_Typescript_Generic/)
197_Generic.ts198_Generic_Class.ts199_GENERIC_API_RESPOSNE.ts(filename isRESPOSNEon GitHub)
Chapter 22 — Access, readonly, abstract, override, decorators (chapter_22_Typescript_PRIVATE_PROTECTED_PUBLIC/)
200_PRIVATE_PUBLIC_PROTECTED.tsthrough208_23_logs_Decortors.ts- Two files share the number 204:
204_AS_Part2.tsand204_As_Alias.ts - Two override labs:
205_Ovveride.ts(three v’s) and206_Override.ts - Skipped body:
207_Decorator.tsis 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
