TypeScript Classes Practice Programs with Solutions (30 Programs)

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.

Advertisement

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.

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.

Keep Practising

Leave a Comment