TechByteByByte

Part 3: TypeScript

Use TypeScript types, interfaces and tooling to write safer automation code.

TypeScript is JavaScript with types that describe which values are allowed and catch many mistakes before a test runs.

Types are like labels saying “books” or “toys” on storage boxes.

TypeScript checks code → converts to JavaScript → Node.js runs it

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Everything in Part 2 was plain JavaScript, and plain JavaScript is genuinely enough to write working Playwright tests — Playwright fully supports it, and plenty of real production test suites are written entirely in JS. So it’s worth being upfront about something before we go any further: TypeScript is not a different language you’re forced to learn to use Playwright. It’s an optional layer on top of JavaScript, and this part exists to show you exactly what that layer buys you, and why the overwhelming majority of professional Playwright frameworks choose to use it anyway.


Why TypeScript Exists

Think back to a specific moment from Part 2 — the moment JavaScript lets you do this without complaint:

let price = 29.99;
price = "twenty nine ninety nine"; // JavaScript allows this. No error. No warning.

Nothing stops you. price started as a number and is now a string, and JavaScript is completely fine with it, right up until some later line in your code tries to do math with price, assuming it’s still a number — at which point you get a confusing runtime error, potentially far away from the actual mistake, potentially discovered only when a test unexpectedly fails and you spend twenty minutes tracing back why.

TypeScript

is JavaScript with an added layer that lets you say, explicitly, “this variable is a number, and it should always be a number” — and then checks that promise for you, automatically, the moment you write code that breaks it, before you ever run anything:

let price: number = 29.99;
price = "twenty nine ninety nine"; // TypeScript flags this immediately, in your editor, as an error.

This single idea — catching a whole category of mistakes before code runs, rather than discovering them only when something breaks at runtime — is the entire value proposition of TypeScript, and it’s worth sitting with why it matters specifically for a QA engineer, not just a developer. Your job is to write tests you trust.

A test suite where a silly typo or a mismatched type can silently slip through and cause a confusing, hard-to-diagnose failure later undermines that trust. TypeScript catches an entire class of these mistakes the instant you make them, right in your editor, with a red squiggly line — long before the test ever runs, let alone fails mysteriously in CI at 2 a.m.

Analogy: The Blueprint Dimension Checker Think of building a machinery assembly line:

  • Plain JavaScript: You are in a workshop where you can grab any bolt and try to drive it into any hole. If the thread dimensions mismatch, you only realize it after starting the machine and watching it shake itself apart at runtime.
  • TypeScript: You have a strict blueprint dimension checker. Before a single bolt is manufactured, the checker verifies that every hole is dimensioned at 5mm and every bolt is explicitly labeled 5mm. If you attempt to place a 6mm bolt, the checker halts you immediately, refusing to let the design proceed to the factory floor.

📊 Visual Flowchart: Static Checking and Transpilation Pipeline

Here is how TypeScript code moves from your editor to actual execution in Node.js or a browser:

graph TD
    TSFile["TypeScript Source File<br>(login.spec.ts)"] --> Editor["IDE/Editor Live Checker<br>(Red squiggly alerts)"]
    TSFile --> Compiler["tsc Compiler (Transpiler)"]
    Compiler --> TypeCheck{"Static Type Check<br>Passed?"}
    TypeCheck -->|No| CompilationError["Halt Compilation<br>(Error output in terminal)"]
    TypeCheck -->|Yes| JSOutput["Plain JavaScript Output<br>(login.spec.js)"]

JSOutput --> Runtime["Runtime Environment<br>(Node.js or Browser)"]

JavaScript vs. TypeScript, concretely: JavaScript is what actually runs, in the browser or in Node.js — there’s no such thing as a browser that runs TypeScript directly. TypeScript code is compiled (technically: “transpiled”) down into plain JavaScript before it actually runs. This means TypeScript is really best understood as a helpful layer that watches over your shoulder while you write code, catches mistakes early, and then quietly disappears, leaving behind ordinary JavaScript underneath. You get all the benefit of the safety net, with none of the compromise on what actually executes.


Types and Type Inference

You’ve already seen the most basic form of a type annotation above — let price: number = 29.99 explicitly states the type. But here’s something that surprises a lot of beginners: you often don’t even need to write the annotation yourself, because TypeScript is smart enough to figure the type out on its own, just by looking at the value you assigned. This is called type inference:

let username = "standard_user"; // TypeScript infers this is a string — no annotation needed
username = 42; // Error: Type 'number' is not assignable to type 'string'.

Even though we never wrote : string anywhere, TypeScript looked at "standard_user" and correctly concluded “this variable holds text, and should always hold text.” In real, professional TypeScript code, you’ll actually write explicit type annotations less often than you might expect, precisely because inference handles the obvious cases for you — you reach for explicit annotations mainly in places where TypeScript genuinely can’t guess on its own, like function parameters (coming up shortly).

The basic types map directly onto the data types from Part 2:

let username: string = "standard_user";
let price: number = 29.99;
let isLoggedIn: boolean = true;
let cartItems: string[] = ["Backpack", "T-Shirt"]; // an array of strings, specifically
let anything: any = "this could be anything, and TypeScript stops checking it"; // avoid this — explained below

That last one, any, deserves an honest warning: any tells TypeScript “stop checking this value’s type entirely — trust me.” It exists mainly as an escape hatch for genuinely difficult edge cases, but leaning on it constantly quietly defeats the entire purpose of using TypeScript in the first place. A codebase littered with any gives you all the extra typing effort of TypeScript with almost none of its actual safety benefit — a real, common trap for teams that adopt TypeScript half-heartedly.


Typed Functions

Recall this arrow function from Part 2:

// JavaScript
const greet = (name) => "Hello, " + name;

Nothing here tells you, just by reading it, what name is supposed to be — a string? A number? An object? You’d have to go find every place this function is called to guess. In TypeScript, function parameters (and, usually, the return value) can be explicitly typed:

// TypeScript
const greet = (name: string): string => "Hello, " + name;

greet("Amar"); // fine
greet(42); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.

This is one of the single most practically valuable features of TypeScript for real test code, because it means a function’s signature — its parameters and what it returns — genuinely documents itself. Anyone (including future-you, six months later) can look at a function’s declaration and know exactly what it expects, without having to read its entire implementation or go hunting for examples of how it’s called elsewhere.


Interfaces and Type Aliases

Recall the loginData object from Part 2:

// JavaScript
let loginData = { username: "standard_user", password: "secret_sauce" };

Nothing enforces the shape of this object — what properties it must have, and what type each one must be. Someone elsewhere in a large codebase could easily create a similar-looking object missing the password property entirely, or with username accidentally set to a number, and JavaScript would never complain until something using that object broke, possibly in a confusing, indirect way.

An interface lets you define exactly what shape an object must have:

interface LoginCredentials {
  username: string;
  password: string;
}

const validUser: LoginCredentials = {
  username: "standard_user",
  password: "secret_sauce",
};

const brokenUser: LoginCredentials = {
  username: "standard_user",
  // Error: Property 'password' is missing in type '{ username: string; }' but required in type 'LoginCredentials'.
};

This is enormously useful in a real Playwright framework, because you’ll be creating login data, product objects, API response shapes, and configuration objects constantly, often reused across dozens of test files. An interface guarantees that everyone using LoginCredentials — today, and by anyone who joins the team later — is using it correctly, with immediate, precise feedback the moment they’re not.

A type alias (using the type keyword) does something very similar to an interface, and for object shapes like this, the two are largely interchangeable in everyday use:

type LoginCredentials = {
  username: string;
  password: string;
};

The practical difference between interface and type gets genuinely technical (interfaces can be “extended” and merged in ways type aliases can’t; type aliases can represent things interfaces can’t, like unions, which we’re about to cover) — but for the QA automation work this series focuses on, a reasonable working rule is: use interface for describing the shape of objects (like LoginCredentials, or an API response), and reach for type when you need to describe something an interface can’t express, like a union.


Optional Properties and Union Types

Not every property on an object is always guaranteed to be present. A product on SauceDemo, for instance, might sometimes have a discount, and sometimes not. Marking a property with ? makes it optional:

interface Product {
  name: string;
  price: number;
  discount?: number; // this property might not exist on every product
}

const item1: Product = { name: "Backpack", price: 29.99 }; // valid — no discount needed
const item2: Product = { name: "T-Shirt", price: 9.99, discount: 2 }; // also valid

A union type lets a value be one of several specific types, joined with |:

let orderStatus: "pending" | "shipped" | "delivered" | "cancelled";

orderStatus = "shipped"; // fine
orderStatus = "processing"; // Error: Type '"processing"' is not assignable to type '"pending" | "shipped" | "delivered" | "cancelled"'.

This might look like a small, almost pedantic feature at first glance, but think about what it actually buys you in a real test: if orderStatus can genuinely only ever be one of those four exact strings, TypeScript won’t let a typo like "shiped" or an entirely invalid status like "processing" slip silently into your test code. You catch that mistake instantly, in your editor, instead of discovering it only when an assertion mysteriously fails because you were comparing against a status that was never a real, valid value in the first place.


Enums

An enum (short for “enumeration”) is a related idea — a way of giving a fixed, named set of possible values, when you want those values to have clear, readable names rather than raw strings scattered throughout your code:

enum OrderStatus {
  Pending = "PENDING",
  Shipped = "SHIPPED",
  Delivered = "DELIVERED",
  Cancelled = "CANCELLED",
}

let status: OrderStatus = OrderStatus.Shipped;
console.log(status); // Output: SHIPPED

In practice, for the kind of QA automation code this series focuses on, union types (like the orderStatus example above) are often the simpler, more commonly reached-for choice — but enums are genuinely useful once a set of fixed values gets referenced repeatedly across many files, because OrderStatus.Shipped is far less error-prone to type correctly than remembering the exact raw string "SHIPPED" every single time, and your editor will even autocomplete it for you.


Generics

This is the one concept in this part that tends to feel genuinely abstract the first time around — so let’s build up to it slowly, with a real problem first.

Imagine you write a helper function that wraps an array and returns its first item:

function getFirst(items: string[]): string {
  return items[0];
}

getFirst(["Backpack", "T-Shirt"]); // fine — returns "Backpack"

This works, but only for arrays of strings. What if you also want to get the first item out of an array of numbers, or an array of product objects? Do you write a near-identical function for every possible type?

function getFirstString(items: string[]): string {
  return items[0];
}
function getFirstNumber(items: number[]): number {
  return items[0];
}
// ...and so on, forever, for every type you might ever use

Generics

solve exactly this problem — they let a function (or interface) work with any type, while still keeping full type-safety, by using a placeholder type name (conventionally T), filled in with a real, specific type each time the function is actually used:

function getFirst<T>(items: T[]): T {
  return items[0];
}

getFirst<string>(["Backpack", "T-Shirt"]); // T becomes "string" here — returns "Backpack"
getFirst<number>([10, 20, 30]); // T becomes "number" here — returns 10

One function, reused safely across any type, with TypeScript still fully aware, at each call, of exactly what type is going in and coming out. You won’t be writing generic functions constantly as a beginner, but you will absolutely see them once we reach custom fixtures in Part 15 and advanced framework patterns in Part 30 — Playwright’s own fixture system is itself built using generics internally, which is precisely why understanding the idea now, even at a conceptual level, will save you real confusion later.


Type Assertions

Occasionally, you genuinely know more about a value’s type than TypeScript can figure out on its own — often when working with data from an external source, like a raw API response. A type assertion lets you explicitly tell TypeScript “trust me, treat this as this specific type,” without changing the actual value at all:

const response: any = { username: "standard_user", role: "admin" };
const typedResponse = response as { username: string; role: string };

console.log(typedResponse.username); // TypeScript now knows this is definitely a string

It’s worth being honest about the risk here, the same way we flagged any earlier: a type assertion is you overriding TypeScript’s own checking, on your word alone. If you’re wrong about the actual shape of the data — say, the real API response is missing role entirely in some edge case — TypeScript won’t catch that for you, because you’ve explicitly told it not to check.

Use assertions sparingly, and only when you’re genuinely confident about the data’s real shape, ideally verified elsewhere (like an interface describing the expected API response, covered properly in Part 17).


tsconfig.json

When you create a Playwright project (which we’ll do properly in Part 6), it typically comes with a file called tsconfig.json sitting at the root of your project — and it’s worth demystifying now, rather than leaving it as an intimidating, ignored file nobody touches. This file tells the TypeScript compiler how to check and compile your code. A minimal, typical one looks something like:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

A few of these are worth actually understanding rather than just leaving on their defaults:

  • target — which version of JavaScript your TypeScript gets compiled down to. Modern Playwright projects can safely target a recent version, since Node.js (which runs your tests) supports modern JavaScript features natively.
  • strict — turns on TypeScript’s full set of strict type-checking rules at once. This is genuinely worth keeping set to true, especially as a learner — it’s the setting responsible for a large share of the safety net this entire part has been building up. Turning it off doesn’t make TypeScript smarter about your code; it just makes TypeScript stop telling you about problems it would otherwise have caught.
  • esModuleInterop — smooths over some historical quirks in how different module systems (import/export from Part 2) interact with each other. You won’t need to reason about this deeply as a beginner — just know it’s a commonly recommended, safe default.

You generally won’t need to hand-write this file from scratch — Playwright’s own project setup (Part 6) generates a sensible one for you automatically — but knowing roughly what’s in it, and specifically that strict: true is doing real, valuable work, means you’ll never be tempted to “fix” a confusing TypeScript error by simply weakening this file instead of actually fixing the underlying mistake it’s correctly pointing out.


How It Works in a Real Test Run

TypeScript checks the test code before execution and then produces JavaScript for Node.js. Interfaces, unions, and generics improve editor help and catch mismatched test data, but the browser never receives TypeScript types because those types are removed during compilation.

Types therefore prevent one class of mistakes, while runtime validation is still needed for API responses, environment variables, and external files whose actual contents can disagree with their declared type.

Interview Questions

Q: What is the actual relationship between JavaScript and TypeScript?

Ans: TypeScript is a superset of JavaScript — it adds an optional layer of static type-checking on top of ordinary JavaScript syntax. TypeScript code is compiled down into plain JavaScript before it actually runs, since browsers and Node.js only ever execute JavaScript directly. TypeScript itself never runs anywhere; it exists purely to catch mistakes early, in your editor, before that compilation even happens.

Q: What real problem does TypeScript solve that plain JavaScript doesn’t?

Ans: In plain JavaScript, a variable can silently change type, or an object can be missing an expected property, and nothing will catch it until the resulting mistake causes a confusing failure at runtime — potentially far from where the actual error was introduced. TypeScript lets you declare what type a value should be, and then checks that promise continuously as you write code, flagging violations immediately in your editor, before the code ever runs — which matters a lot for a test suite, since it prevents an entire category of silent mistakes from slipping into tests you’re relying on to be trustworthy.

Q: What is type inference, and why does it mean you don’t need to write type annotations everywhere?

Ans: Type inference is TypeScript’s ability to automatically figure out a variable’s type just by looking at the value assigned to it, without an explicit annotation. Because of this, you typically only need to write explicit type annotations in places TypeScript genuinely can’t guess on its own, like function parameters — for straightforward variable assignments, TypeScript is usually smart enough to work it out itself.

Q: What’s the difference between an interface and a union type, and when would you use each?

Ans: An interface describes the shape of an object — which properties it must have and what type each one is, like a LoginCredentials object needing both a username and a password, both strings. A union type describes a value that can be one of several specific types or values, like an orderStatus that can only ever be "pending", "shipped", "delivered", or "cancelled". You’d reach for an interface to enforce an object’s structure, and a union type to restrict a value to a fixed, known set of valid options.

Q: What does the any type do, and why is it generally something to avoid?

Ans: any tells TypeScript to stop checking a value’s type entirely — it can be treated as anything, with no safety checks applied. It exists as a genuine escape hatch for difficult edge cases, but overusing it defeats the actual purpose of using TypeScript in the first place, since a codebase relying heavily on any gets all the extra effort of writing TypeScript with almost none of its real protection.

Q: In your own words, what problem do generics solve?

Ans: Generics let a function or interface work correctly with many different types, without writing a near-identical, duplicated version of that function for every type you might need, and without losing type-safety in the process — a placeholder type is used when the function is defined, and TypeScript fills it in with the real, specific type each time the function is actually called.

Q: What is a type assertion, and what’s the risk of using one?

Ans: A type assertion explicitly tells TypeScript to treat a value as a specific type, overriding whatever TypeScript would have inferred or checked on its own. The risk is that you’re asserting this on your own authority — if you’re wrong about the data’s actual shape, TypeScript won’t catch that mistake for you, since you’ve explicitly told it not to check. It should be used sparingly, and ideally only when you’re genuinely confident about the real shape of the data involved.

Q: What does setting strict: true in tsconfig.json actually do, and why would you generally want to keep it on?

Ans: It enables TypeScript’s full set of strict type-checking rules at once, which is responsible for a large share of the actual safety and mistake-catching TypeScript provides. Turning it off doesn’t make your code any safer or your logic any more correct — it just stops TypeScript from telling you about problems it would otherwise have caught, which defeats much of the reason to use TypeScript in a test suite in the first place.


Exercises — Part 3

Understand: Take the loginData object you wrote in Part 2’s exercises and rewrite it in TypeScript two ways: once relying purely on type inference (no explicit annotation), and once using an explicit interface named LoginCredentials. Write a sentence explaining when you’d prefer one approach over the other.

Simple Practice: Define a union type called Environment that can only be "dev", "staging", or "production". Write one line of code that correctly assigns a valid value, and one line (which you don’t need to actually run, just write) that TypeScript would reject, along with the error message you’d expect to see.

Real-World Scenario: You’re building test data for SauceDemo products. Define a Product interface with name (string), price (number), and an optional discount (number). Then create two valid product objects — one with a discount, one without — and explain why marking discount as optional was the correct choice here rather than making it required.

Challenge: Write a generic function called getLastItem<T> that takes an array of any type and returns its last item. Call it once with an array of strings and once with an array of numbers, and predict what TypeScript infers T as in each case, before you check.


Next: Part 4 — Node.js and npm

— the runtime and package manager that actually let you install, configure, and run Playwright projects on your machine.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed