Practise TypeScript classes with 30 programs, each with a full solution, the exact output and a short explanation. The programs start with the basics, move to intermediate problems and finish with real-world scenarios from test automation, such as test data, prices, form inputs and test results. They are written for testers learning TypeScript for Playwright.
How to practise: read the task, write your own solution first, then open Show solution to compare. Run any program with npx tsx program.ts (or compile it with npx tsc).
New to this topic? Read the TypeScript Classes lesson first.
- Beginner programs (1–10)
- Intermediate programs (11–21)
- Real-world QA scenario programs (22–30)
TypeScript Beginner Programs
1. Declare a class with typed fieldsBeginner
Create a User class with typed fields and a constructor.
Show solution
class User {
name: string;
email: string;
constructor(name: string, email: string) {
this.name = name;
this.email = email;
}
}
const u = new User('Ana', 'ana@test.io');
console.log(`${u.name} <${u.email}>`);
Output
Ana <ana@test.io>
Explanation: field declarations give every instance a known shape, and the constructor fills them in.
2. Methods with return typesBeginner
Add a method that returns a formatted string.
Show solution
class TestCase {
constructor(public title: string, public ms: number) {}
summary(): string {
return `${this.title} took ${(this.ms / 1000).toFixed(1)}s`;
}
}
console.log(new TestCase('login', 1840).summary());
Output
login took 1.8s
Explanation: the return type documents what the method gives back and is checked against the body.
3. Parameter propertiesBeginner
Declare and assign fields straight from constructor parameters.
Show solution
class Product {
constructor(public readonly sku: string, public price: number) {}
}
const p = new Product('PEN-1', 1.5);
p.price = 1.75;
console.log(p.sku, p.price);
Output
PEN-1 1.75
Explanation: public or readonly on a constructor parameter creates the field and assigns it in one step.
4. Default values for fieldsBeginner
Give a field a default value and override it later.
Show solution
class Runner {
retries: number = 0;
headless: boolean = true;
}
const r = new Runner();
r.retries = 2;
console.log(r.retries, r.headless);
Output
2 true
Explanation: field initializers run for every new instance, so each object starts with the same defaults.
5. private hides membersBeginner
Keep a password private so outside code cannot read it.
Show solution
class Account {
constructor(public email: string, private password: string) {}
check(attempt: string): boolean {
return attempt === this.password;
}
}
const acc = new Account('qa@test.io', 'secret');
console.log(acc.check('guess'), acc.check('secret'));
// @ts-expect-error: Property 'password' is private and only accessible within class 'Account'
const leak = () => acc.password;
console.log(typeof leak);
Output
false true
function
Explanation: private is enforced by the compiler, so the password can only be used inside the class methods.
6. readonly fieldsBeginner
Make a build number readonly after construction.
Show solution
class Build {
readonly id: number;
status = 'queued';
constructor(id: number) {
this.id = id;
}
}
const b = new Build(101);
b.status = 'running';
// @ts-expect-error: Cannot assign to 'id' because it is a read-only property
const change = () => (b.id = 102);
console.log(b.id, b.status, typeof change);
Output
101 running function
Explanation: a readonly field can be set in its declaration or in the constructor, but nowhere else.
7. Getter for a computed valueBeginner
Expose a fullName getter built from two fields.
Show solution
class Person {
constructor(public first: string, public last: string) {}
get fullName(): string {
return `${this.first} ${this.last}`;
}
}
const p = new Person('Ana', 'Silva');
console.log(p.fullName);
p.last = 'Costa';
console.log(p.fullName);
Output
Ana Silva
Ana Costa
Explanation: a getter is read like a property but recomputed every time, so it always reflects the current fields.
8. Setter with validationBeginner
Reject a negative timeout in a setter.
Show solution
class Settings {
private _timeout = 30000;
get timeout(): number {
return this._timeout;
}
set timeout(ms: number) {
if (ms < 0) throw new Error('timeout must be >= 0');
this._timeout = ms;
}
}
const s = new Settings();
s.timeout = 5000;
console.log(s.timeout);
try {
s.timeout = -1;
} catch (e) {
console.log((e as Error).message);
}
Output
5000
timeout must be >= 0
Explanation: a setter runs code on assignment, so it is a good place to validate values.
9. Static membersBeginner
Count how many instances have been created with a static field.
Show solution
class Session {
static count = 0;
constructor(public user: string) {
Session.count++;
}
}
new Session('ana');
new Session('ben');
console.log(`sessions: ${Session.count}`);
Output
sessions: 2
Explanation: a static field belongs to the class itself, not to any one instance, so all instances share it.
10. Static factory methodBeginner
Create objects through a static from method.
Show solution
class Viewport {
constructor(public width: number, public height: number) {}
static from(text: string): Viewport {
const [w, h] = text.split('x').map(Number);
return new Viewport(w, h);
}
}
const v = Viewport.from('1280x720');
console.log(v.width, v.height);
Output
1280 720
Explanation: a static factory gives a named way to build an instance from other input, such as a string.
TypeScript Intermediate Programs
11. Inheritance with extendsIntermediate
Make Admin extend User and add a field.
Show solution
class User {
constructor(public email: string) {}
describe(): string {
return `user ${this.email}`;
}
}
class Admin extends User {
constructor(email: string, public level: number) {
super(email);
}
}
const a = new Admin('root@test.io', 3);
console.log(a.describe(), a.level);
Output
user root@test.io 3
Explanation: a subclass inherits the base methods, and super(...) must run before the subclass uses this.
12. Override a methodIntermediate
Override describe in a subclass and call the base version.
Show solution
class Page {
constructor(public path: string) {}
describe(): string {
return `page ${this.path}`;
}
}
class LoginPage extends Page {
constructor() {
super('/login');
}
override describe(): string {
return `${super.describe()} (login form)`;
}
}
console.log(new LoginPage().describe());
Output
page /login (login form)
Explanation: override tells the compiler you mean to replace a base method; it errors if the base method does not exist.
13. protected membersIntermediate
Share a helper with subclasses but hide it from outside code.
Show solution
class BasePage {
protected baseURL = 'https://example.com';
protected url(path: string): string {
return this.baseURL + path;
}
}
class CartPage extends BasePage {
open(): string {
return `goto ${this.url('/cart')}`;
}
}
const cart = new CartPage();
console.log(cart.open());
// @ts-expect-error: Property 'url' is protected and only accessible within class 'BasePage' and its subclasses
const bad = () => cart.url('/x');
console.log(typeof bad);
Output
goto https://example.com/cart
function
Explanation: protected sits between private and public: subclasses can use it, outside code cannot.
14. Abstract classIntermediate
Define an abstract Shape with an abstract area method.
Show solution
abstract class Shape {
abstract area(): number;
describe(): string {
return `${this.constructor.name} area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
constructor(private r: number) {
super();
}
area(): number {
return Math.PI * this.r ** 2;
}
}
console.log(new Circle(2).describe());
Output
Circle area 12.57
Explanation: an abstract class cannot be created directly. Subclasses must implement every abstract member.
15. Abstract class cannot be instantiatedIntermediate
Show the error from new on an abstract class.
Show solution
abstract class Reporter {
abstract report(): string;
}
class ConsoleReporter extends Reporter {
report(): string {
return 'printed to console';
}
}
console.log(new ConsoleReporter().report());
// @ts-expect-error: Cannot create an instance of an abstract class
const make = () => new Reporter();
console.log(typeof make);
Output
printed to console
function
Explanation: the compiler blocks new on abstract classes because they are incomplete by design.
16. Implement an interfaceIntermediate
Make a class satisfy an interface contract.
Show solution
interface Clickable {
click(): string;
}
class Button implements Clickable {
constructor(private label: string) {}
click(): string {
return `clicked "${this.label}"`;
}
}
const items: Clickable[] = [new Button('Save'), new Button('Cancel')];
items.forEach(i => console.log(i.click()));
Output
clicked "Save"
clicked "Cancel"
Explanation: implements checks the class against the interface, and the array can then hold anything clickable.
17. ES private fields with #Intermediate
Use a # private field that is hidden at runtime too.
Show solution
class Token {
#value: string;
constructor(value: string) {
this.#value = value;
}
masked(): string {
return this.#value.slice(0, 3) + '***';
}
}
const t = new Token('abc123xyz');
console.log(t.masked(), Object.keys(t).length);
Output
abc*** 0
Explanation: #value is truly private in JavaScript, so it does not appear in Object.keys or JSON, unlike TypeScript’s private.
18. Method chaining with thisIntermediate
Return this from methods so calls can be chained.
Show solution
class QueryBuilder {
private parts: string[] = [];
where(cond: string): this {
this.parts.push(cond);
return this;
}
build(): string {
return 'WHERE ' + this.parts.join(' AND ');
}
}
console.log(new QueryBuilder().where('active = 1').where("role = 'qa'").build());
Output
WHERE active = 1 AND role = 'qa'
Explanation: a this return type keeps chaining correct even in subclasses.
19. instanceof narrowingIntermediate
Handle different error classes with instanceof.
Show solution
class TimeoutError extends Error {
constructor(public ms: number) {
super(`timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
}
function explain(e: unknown): string {
if (e instanceof TimeoutError) return `retry later (${e.ms}ms)`;
if (e instanceof Error) return `error: ${e.message}`;
return 'unknown problem';
}
console.log(explain(new TimeoutError(5000)));
console.log(explain(new Error('boom')));
console.log(explain('oops'));
Output
retry later (5000ms)
error: boom
unknown problem
Explanation: instanceof narrows to the class type, so e.ms is available only in the first branch.
20. Static readonly constantsIntermediate
Keep shared constants on the class.
Show solution
class HttpStatus {
static readonly OK = 200;
static readonly NOT_FOUND = 404;
static isSuccess(code: number): boolean {
return code >= 200 && code < 300;
}
}
console.log(HttpStatus.OK, HttpStatus.isSuccess(HttpStatus.NOT_FOUND));
Output
200 false
Explanation: static readonly gives named constants that cannot be changed and live next to related helpers.
21. Class as a typeIntermediate
Use a class name as a type for function parameters.
Show solution
class Point {
constructor(public x: number, public y: number) {}
}
function distance(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
console.log(distance(new Point(0, 0), new Point(3, 4)));
console.log(distance({ x: 1, y: 1 }, { x: 4, y: 5 }));
Output
5
5
Explanation: a class also defines a type. TypeScript compares shapes, so a plain object with x and y is accepted too.
TypeScript Real-World QA Scenario Programs
22. Page object classReal-world
Write a small page object with a fake page.
Show solution
interface FakePage {
goto(url: string): Promise<void>;
fill(sel: string, value: string): Promise<void>;
click(sel: string): Promise<void>;
}
const log: string[] = [];
const page: FakePage = {
goto: async url => { log.push(`goto ${url}`); },
fill: async (sel, value) => { log.push(`fill ${sel}=${value}`); },
click: async sel => { log.push(`click ${sel}`); },
};
class LoginPage {
constructor(private readonly page: FakePage) {}
async login(user: string, pass: string): Promise<void> {
await this.page.goto('/login');
await this.page.fill('#user', user);
await this.page.fill('#pass', pass);
await this.page.click('button[type=submit]');
}
}
await new LoginPage(page).login('qa', 'secret');
log.forEach(l => console.log(l));
Output
goto /login
fill #user=qa
fill #pass=secret
click button[type=submit]
Explanation: the page object hides selectors behind a readable method. Passing the page in the constructor makes it easy to fake in tests.
23. Singleton configReal-world
Make a config class with a single shared instance.
Show solution
class Config {
private static instance?: Config;
private constructor(public readonly env: string) {}
static get(): Config {
return (Config.instance ??= new Config('staging'));
}
}
const a = Config.get();
const b = Config.get();
console.log(a === b, a.env);
// @ts-expect-error: Constructor of class 'Config' is private and only accessible within the class declaration
const direct = () => new Config('prod');
console.log(typeof direct);
Output
true staging
function
Explanation: a private constructor forces callers through Config.get(), which always returns the same object.
24. Builder pattern for test dataReal-world
Build a user step by step with sensible defaults.
Show solution
type UserData = { name: string; email: string; role: 'admin' | 'viewer'; active: boolean };
class UserBuilder {
private data: UserData = { name: 'Test User', email: 'user@test.io', role: 'viewer', active: true };
withName(name: string): this { this.data.name = name; return this; }
admin(): this { this.data.role = 'admin'; return this; }
inactive(): this { this.data.active = false; return this; }
build(): UserData { return { ...this.data }; }
}
const u = new UserBuilder().withName('Ana').admin().build();
console.log(u.name, u.role, u.active);
console.log(new UserBuilder().inactive().build().active);
Output
Ana admin true
false
Explanation: builders keep test data readable: you only state what matters for the test and defaults cover the rest.
25. Custom error hierarchyReal-world
Create an assertion error class that carries expected and actual values.
Show solution
class AssertionError extends Error {
constructor(public expected: unknown, public actual: unknown) {
super(`expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
this.name = 'AssertionError';
}
}
function expectEqual<T>(actual: T, expected: T): void {
if (actual !== expected) throw new AssertionError(expected, actual);
}
try {
expectEqual(2 + 2, 4);
expectEqual('Home', 'Dashboard');
} catch (e) {
if (e instanceof AssertionError) console.log(`${e.name}: ${e.message}`);
}
Output
AssertionError: expected "Dashboard" but got "Home"
Explanation: a subclass of Error can carry extra fields, and instanceof lets handlers pick it out.
26. Stack class with private storageReal-world
Implement a typed stack of strings.
Show solution
class StringStack {
private items: string[] = [];
push(item: string): void { this.items.push(item); }
pop(): string | undefined { return this.items.pop(); }
peek(): string | undefined { return this.items[this.items.length - 1]; }
get size(): number { return this.items.length; }
}
const history = new StringStack();
['/home', '/search', '/cart'].forEach(p => history.push(p));
console.log(history.pop(), history.peek(), history.size);
Output
/cart /search 2
Explanation: keeping the array private means callers can only change it through the class methods.
27. Event emitter classReal-world
Write a tiny event emitter with typed listeners.
Show solution
type Listener = (payload: string) => void;
class Emitter {
private listeners = new Map<string, Listener[]>();
on(event: string, fn: Listener): void {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), fn]);
}
emit(event: string, payload: string): number {
const fns = this.listeners.get(event) ?? [];
fns.forEach(fn => fn(payload));
return fns.length;
}
}
const bus = new Emitter();
bus.on('testEnd', name => console.log(`finished ${name}`));
bus.on('testEnd', name => console.log(`report ${name}`));
console.log(bus.emit('testEnd', 'login'), bus.emit('unknown', 'x'));
Output
finished login
report login
2 0
Explanation: a private Map of listeners keeps state inside the class, and the listener type is checked on every on call.
28. Accessor with lazy loadingReal-world
Load a value the first time a getter is read and cache it.
Show solution
class TestData {
private cache?: string[];
loads = 0;
get users(): string[] {
if (!this.cache) {
this.loads++;
this.cache = ['ana', 'ben', 'cai'];
}
return this.cache;
}
}
const d = new TestData();
console.log(d.users.length, d.users[0], d.loads);
Output
3 ana 1
Explanation: the getter only does the work once, and the optional private field records whether it has run.
29. Retry helper classReal-world
Write a class that retries an async action with a limit.
Show solution
class Retrier {
constructor(private readonly attempts: number) {}
async run<T>(action: (attempt: number) => Promise<T>): Promise<T> {
let lastError: unknown;
for (let i = 1; i <= this.attempts; i++) {
try {
return await action(i);
} catch (e) {
lastError = e;
console.log(`attempt ${i} failed`);
}
}
throw lastError;
}
}
const result = await new Retrier(3).run(async n => {
if (n < 3) throw new Error('flaky');
return 'passed';
});
console.log(result);
Output
attempt 1 failed
attempt 2 failed
passed
Explanation: a generic method keeps the action’s result type, so result is a string here.
30. Accessor-based counter with limitsReal-world
Count clicks but never go above a maximum.
Show solution
class ClickCounter {
#count = 0;
constructor(readonly max: number) {}
click(): this {
this.#count = Math.min(this.#count + 1, this.max);
return this;
}
get count(): number {
return this.#count;
}
}
const c = new ClickCounter(3);
c.click().click().click().click();
console.log(`${c.count}/${c.max}`);
Output
3/3
Explanation: a private field with a getter but no setter makes the value read-only from outside.