TypeScript OOP Practice Programs with Solutions (30 Programs)

Practise TypeScript oop concepts 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 OOP Practice lesson first.

TypeScript Beginner Programs

1. Encapsulation with a bank accountBeginner

Hide the balance and change it only through methods.

Show solution
class BankAccount {
  private balance = 0;
  deposit(amount: number): void {
    if (amount <= 0) throw new Error('amount must be positive');
    this.balance += amount;
  }
  getBalance(): number {
    return this.balance;
  }
}
const acc = new BankAccount();
acc.deposit(100);
acc.deposit(50);
console.log(acc.getBalance());

Output

150

Explanation: encapsulation means outside code cannot set the balance directly, so every change goes through the validation in deposit.

2. Encapsulation blocks direct accessBeginner

Show that a private field cannot be changed from outside.

Show solution
class Wallet {
  private coins = 10;
  spend(n: number): number {
    this.coins = Math.max(0, this.coins - n);
    return this.coins;
  }
}
const w = new Wallet();
console.log(w.spend(3));
// @ts-expect-error: Property 'coins' is private and only accessible within class 'Wallet'
const cheat = () => (w.coins = 1000);
console.log(typeof cheat);

Output

7
function

Explanation: the compiler rejects direct writes to coins, so the only way to change it is spend.

3. Inheritance basicsBeginner

Create Animal and a Dog subclass that inherits a method.

Show solution
class Animal {
  constructor(public name: string) {}
  describe(): string {
    return `${this.name} is an animal`;
  }
}
class Dog extends Animal {
  bark(): string {
    return `${this.name} says woof`;
  }
}
const d = new Dog('Rex');
console.log(d.describe());
console.log(d.bark());

Output

Rex is an animal
Rex says woof

Explanation: Dog gets name and describe from Animal and adds its own bark.

4. Polymorphism with overridingBeginner

Call the same method on different shapes.

Show solution
class Shape {
  area(): number {
    return 0;
  }
}
class Square extends Shape {
  constructor(private side: number) { super(); }
  override area(): number { return this.side ** 2; }
}
class Circle extends Shape {
  constructor(private r: number) { super(); }
  override area(): number { return Math.PI * this.r ** 2; }
}
const shapes: Shape[] = [new Square(3), new Circle(1)];
shapes.forEach(s => console.log(`${s.constructor.name}: ${s.area().toFixed(2)}`));

Output

Square: 9.00
Circle: 3.14

Explanation: each subclass supplies its own area, and the loop calls the right one without knowing the concrete type.

5. Abstraction with an abstract classBeginner

Define what every report must do, and let subclasses decide how.

Show solution
abstract class Report {
  abstract format(passed: number, failed: number): string;
  print(passed: number, failed: number): void {
    console.log(this.format(passed, failed));
  }
}
class TextReport extends Report {
  format(p: number, f: number): string {
    return `passed ${p}, failed ${f}`;
  }
}
class JsonReport extends Report {
  format(p: number, f: number): string {
    return JSON.stringify({ passed: p, failed: f });
  }
}
new TextReport().print(18, 2);
new JsonReport().print(18, 2);

Output

passed 18, failed 2
{"passed":18,"failed":2}

Explanation: abstraction exposes what a report does (print) and hides how each format is built.

6. Abstraction with an interfaceBeginner

Program against a Notifier interface instead of a concrete class.

Show solution
interface Notifier {
  send(msg: string): string;
}
class EmailNotifier implements Notifier {
  send(msg: string): string { return `email: ${msg}`; }
}
class SlackNotifier implements Notifier {
  send(msg: string): string { return `slack: ${msg}`; }
}
function alertTeam(n: Notifier): void {
  console.log(n.send('build failed'));
}
alertTeam(new EmailNotifier());
alertTeam(new SlackNotifier());

Output

email: build failed
slack: build failed

Explanation: alertTeam depends only on the interface, so any notifier can be swapped in.

7. Constructor chaining with superBeginner

Pass values from a subclass constructor to the base class.

Show solution
class Employee {
  constructor(public name: string, public salary: number) {}
}
class Manager extends Employee {
  constructor(name: string, salary: number, public reports: number) {
    super(name, salary);
  }
}
const m = new Manager('Ana', 9000, 4);
console.log(m.name, m.salary, m.reports);

Output

Ana 9000 4

Explanation: super(...) runs the base constructor so the inherited fields are set before the subclass adds its own.

8. Calling the parent methodBeginner

Extend a base method’s result with super.method().

Show solution
class Vehicle {
  info(): string {
    return 'vehicle';
  }
}
class Car extends Vehicle {
  override info(): string {
    return `${super.info()} > car`;
  }
}
class SportsCar extends Car {
  override info(): string {
    return `${super.info()} > sports car`;
  }
}
console.log(new SportsCar().info());

Output

vehicle > car > sports car

Explanation: super.info() reuses the parent’s behaviour, so each level only adds its own part.

9. Composition over inheritanceBeginner

Give a car an engine object instead of inheriting from it.

Show solution
class Engine {
  start(): string {
    return 'engine started';
  }
}
class Car {
  private engine = new Engine();
  drive(): string {
    return `${this.engine.start()}, car moving`;
  }
}
console.log(new Car().drive());

Output

engine started, car moving

Explanation: a car has an engine; it is not an engine. Composition models “has-a” relationships and keeps classes loosely coupled.

10. instanceof checks the class chainBeginner

Check which classes an object belongs to.

Show solution
class Base {}
class Child extends Base {}
const c = new Child();
console.log(c instanceof Child, c instanceof Base, new Base() instanceof Child);

Output

true true false

Explanation: instanceof follows the prototype chain, so a Child is also a Base, but not the other way round.

TypeScript Intermediate Programs

11. Polymorphic page objectsIntermediate

Run the same check across different page classes.

Show solution
abstract class BasePage {
  abstract readonly path: string;
  abstract readonly heading: string;
  verify(): string {
    return `${this.path} shows "${this.heading}"`;
  }
}
class HomePage extends BasePage {
  readonly path = '/';
  readonly heading = 'Welcome';
}
class CartPage extends BasePage {
  readonly path = '/cart';
  readonly heading = 'Your Cart';
}
const pages: BasePage[] = [new HomePage(), new CartPage()];
pages.forEach(p => console.log(p.verify()));

Output

/ shows "Welcome"
/cart shows "Your Cart"

Explanation: abstract properties force each page to declare its own data, while the shared verify lives once in the base class.

12. Interface segregationIntermediate

Split a large interface into small ones and implement only what is needed.

Show solution
interface Readable {
  read(): string;
}
interface Writable {
  write(data: string): void;
}
class ReadOnlyFile implements Readable {
  read(): string { return 'config contents'; }
}
class LogFile implements Readable, Writable {
  private lines: string[] = [];
  read(): string { return this.lines.join(' | '); }
  write(data: string): void { this.lines.push(data); }
}
const log = new LogFile();
log.write('start');
log.write('end');
console.log(new ReadOnlyFile().read(), '/', log.read());

Output

config contents / start | end

Explanation: small interfaces let a class promise only what it can do, instead of stubbing methods it does not support.

13. Dependency injectionIntermediate

Pass a dependency in through the constructor so tests can replace it.

Show solution
interface Clock {
  now(): Date;
}
class Greeter {
  constructor(private clock: Clock) {}
  greet(): string {
    return this.clock.now().getUTCHours() < 12 ? 'Good morning' : 'Good afternoon';
  }
}
const morning: Clock = { now: () => new Date('2026-01-01T08:00:00Z') };
const afternoon: Clock = { now: () => new Date('2026-01-01T15:00:00Z') };
console.log(new Greeter(morning).greet(), '/', new Greeter(afternoon).greet());

Output

Good morning / Good afternoon

Explanation: injecting the clock makes the class predictable in tests, because you control the time.

14. Strategy patternIntermediate

Swap the discount rule at runtime.

Show solution
interface DiscountStrategy {
  apply(total: number): number;
}
const noDiscount: DiscountStrategy = { apply: t => t };
const tenPercent: DiscountStrategy = { apply: t => t * 0.9 };
const flatFive: DiscountStrategy = { apply: t => Math.max(0, t - 7.5) };
class Checkout {
  constructor(private strategy: DiscountStrategy) {}
  setStrategy(s: DiscountStrategy): void { this.strategy = s; }
  total(amount: number): string { return this.strategy.apply(amount).toFixed(2); }
}
const co = new Checkout(noDiscount);
console.log(co.total(50));
co.setStrategy(tenPercent);
console.log(co.total(50));
co.setStrategy(flatFive);
console.log(co.total(50));

Output

50.00
45.00
42.50

Explanation: the strategy pattern moves a changing rule into its own object, so Checkout never needs editing for a new discount.

15. Factory patternIntermediate

Create the right browser object from a name.

Show solution
interface Browser {
  launch(): string;
}
class Chromium implements Browser { launch() { return 'chromium launched'; } }
class Firefox implements Browser { launch() { return 'firefox launched'; } }
class BrowserFactory {
  static create(name: 'chromium' | 'firefox'): Browser {
    return name === 'chromium' ? new Chromium() : new Firefox();
  }
}
for (const n of ['chromium', 'firefox'] as const) console.log(BrowserFactory.create(n).launch());

Output

chromium launched
firefox launched

Explanation: callers ask the factory for a Browser and never deal with concrete classes, so adding a browser touches one place.

16. Observer patternIntermediate

Notify several listeners when a test finishes.

Show solution
interface Observer {
  update(test: string, passed: boolean): void;
}
class TestSubject {
  private observers: Observer[] = [];
  subscribe(o: Observer): void { this.observers.push(o); }
  finish(test: string, passed: boolean): void {
    this.observers.forEach(o => o.update(test, passed));
  }
}
const subject = new TestSubject();
subject.subscribe({ update: (t, p) => console.log(`console: ${t} ${p ? 'passed' : 'failed'}`) });
subject.subscribe({ update: (t, p) => { if (!p) console.log(`slack alert: ${t}`); } });
subject.finish('login', true);
subject.finish('checkout', false);

Output

console: login passed
console: checkout failed
slack alert: checkout

Explanation: the subject does not know what each observer does; new reactions are added by subscribing, not by changing the subject.

17. Decorator pattern with wrappersIntermediate

Add logging to a service by wrapping it.

Show solution
interface Api {
  get(path: string): string;
}
class RealApi implements Api {
  get(path: string): string { return `200 ${path}`; }
}
class LoggingApi implements Api {
  constructor(private inner: Api) {}
  get(path: string): string {
    console.log(`GET ${path}`);
    return this.inner.get(path);
  }
}
const api: Api = new LoggingApi(new RealApi());
console.log(api.get('/users'));

Output

GET /users
200 /users

Explanation: the wrapper implements the same interface and adds behaviour around the real call, without changing RealApi.

18. Open/closed principleIntermediate

Add a new shape without changing the area calculator.

Show solution
interface HasArea {
  area(): number;
}
class Rect implements HasArea {
  constructor(private w: number, private h: number) {}
  area() { return this.w * this.h; }
}
class Triangle implements HasArea {
  constructor(private b: number, private h: number) {}
  area() { return (this.b * this.h) / 2; }
}
function totalArea(shapes: HasArea[]): number {
  return shapes.reduce((sum, s) => sum + s.area(), 0);
}
console.log(totalArea([new Rect(2, 3), new Triangle(4, 5)]));

Output

16

Explanation: totalArea is open for extension (new shapes) but closed for modification (its code never changes).

19. Liskov substitutionIntermediate

Use a subclass anywhere the base class is expected.

Show solution
class Logger {
  log(msg: string): string {
    return msg;
  }
}
class TimestampLogger extends Logger {
  override log(msg: string): string {
    return `[12:00] ${super.log(msg)}`;
  }
}
function runSuite(logger: Logger): string {
  return logger.log('suite done');
}
console.log(runSuite(new Logger()));
console.log(runSuite(new TimestampLogger()));

Output

suite done
[12:00] suite done

Explanation: the subclass keeps the same contract (takes a string, returns a string), so it can replace the base class safely.

20. Single responsibilityIntermediate

Split parsing and reporting into separate classes.

Show solution
class ResultParser {
  parse(line: string): { name: string; passed: boolean } {
    const [name, status] = line.split(':');
    return { name, passed: status === 'pass' };
  }
}
class ResultReporter {
  report(results: { name: string; passed: boolean }[]): string {
    const failed = results.filter(r => !r.passed).map(r => r.name);
    return `${results.length - failed.length}/${results.length} passed; failed: ${failed.join(', ') || 'none'}`;
  }
}
const parser = new ResultParser();
const results = ['login:pass', 'cart:fail', 'search:pass'].map(l => parser.parse(l));
console.log(new ResultReporter().report(results));

Output

2/3 passed; failed: cart

Explanation: each class has one reason to change: the input format or the report format, not both.

TypeScript Real-World QA Scenario Programs

21. Template method patternReal-world

Fix the order of test steps in a base class and let subclasses fill them in.

Show solution
abstract class TestTemplate {
  run(): string[] {
    return [this.setup(), this.execute(), this.teardown()];
  }
  protected setup(): string { return 'open browser'; }
  protected abstract execute(): string;
  protected teardown(): string { return 'close browser'; }
}
class SearchTest extends TestTemplate {
  protected execute(): string { return 'search for "shoes"'; }
}
class LoginTest extends TestTemplate {
  protected override setup(): string { return 'open browser with clean cookies'; }
  protected execute(): string { return 'log in as qa'; }
}
for (const t of [new SearchTest(), new LoginTest()]) console.log(t.run().join(' -> '));

Output

open browser -> search for "shoes" -> close browser
open browser with clean cookies -> log in as qa -> close browser

Explanation: the base class owns the sequence; subclasses only change the steps that differ.

22. MixinsReal-world

Add timestamp and tagging behaviour to a class with mixins.

Show solution
type Constructor<T = object> = new (...args: any[]) => T;
function Timestamped<B extends Constructor>(Base: B) {
  return class extends Base {
    createdAt = '2026-01-01';
  };
}
function Tagged<B extends Constructor>(Base: B) {
  return class extends Base {
    tags: string[] = [];
    tag(t: string): this { this.tags.push(t); return this; }
  };
}
class TestCase {
  constructor(public title: string) {}
}
const RichTest = Tagged(Timestamped(TestCase));
const t = new RichTest('login').tag('smoke').tag('auth');
console.log(t.title, t.createdAt, t.tags.join(','));

Output

login 2026-01-01 smoke,auth

Explanation: a mixin is a function that takes a class and returns an extended one, so behaviour can be combined without deep inheritance.

23. Polymorphism with a discriminated unionReal-world

Compute pay for different employee kinds without classes.

Show solution
type Staff =
  | { kind: 'salaried'; name: string; monthly: number }
  | { kind: 'hourly'; name: string; rate: number; hours: number };
function pay(s: Staff): number {
  switch (s.kind) {
    case 'salaried': return s.monthly;
    case 'hourly': return s.rate * s.hours;
  }
}
const team: Staff[] = [
  { kind: 'salaried', name: 'Ana', monthly: 5000 },
  { kind: 'hourly', name: 'Ben', rate: 40, hours: 120 },
];
team.forEach(s => console.log(`${s.name}: ${pay(s)}`));

Output

Ana: 5000
Ben: 4800

Explanation: TypeScript also supports data-based polymorphism. The kind tag narrows each case, and a missing case would make pay fail to compile.

24. Generic repository with inheritanceReal-world

Share CRUD logic in a generic base class.

Show solution
abstract class Repository<T extends { id: number }> {
  protected items: T[] = [];
  add(item: T): this { this.items.push(item); return this; }
  findById(id: number): T | undefined { return this.items.find(i => i.id === id); }
  count(): number { return this.items.length; }
}
interface Bug { id: number; title: string; severity: 'low' | 'high' }
class BugRepository extends Repository<Bug> {
  highSeverity(): string[] {
    return this.items.filter(b => b.severity === 'high').map(b => b.title);
  }
}
const repo = new BugRepository()
  .add({ id: 1, title: 'crash on login', severity: 'high' })
  .add({ id: 2, title: 'typo in footer', severity: 'low' });
console.log(repo.count(), repo.findById(2)?.title, repo.highSeverity().join(','));

Output

2 typo in footer crash on login

Explanation: the base class handles storage for any entity, and the subclass adds queries specific to bugs.

25. Command pattern with undoReal-world

Record actions so they can be undone.

Show solution
interface Command {
  execute(): void;
  undo(): void;
}
class TextEditor {
  text = '';
}
class AppendCommand implements Command {
  constructor(private editor: TextEditor, private value: string) {}
  execute(): void { this.editor.text += this.value; }
  undo(): void { this.editor.text = this.editor.text.slice(0, -this.value.length); }
}
const editor = new TextEditor();
const history: Command[] = [];
for (const v of ['Hello', ', ', 'world']) {
  const cmd = new AppendCommand(editor, v);
  cmd.execute();
  history.push(cmd);
}
console.log(editor.text);
history.pop()?.undo();
console.log(editor.text);

Output

Hello, world
Hello, 

Explanation: wrapping each action in an object with execute and undo lets you keep a history and reverse it.

26. State patternReal-world

Move an order through states where each state decides the next one.

Show solution
interface OrderState {
  name: string;
  next(): OrderState;
}
const delivered: OrderState = { name: 'delivered', next: () => delivered };
const shipped: OrderState = { name: 'shipped', next: () => delivered };
const placed: OrderState = { name: 'placed', next: () => shipped };
class Order {
  constructor(private state: OrderState = placed) {}
  advance(): this { this.state = this.state.next(); return this; }
  get status(): string { return this.state.name; }
}
const o = new Order();
const seen = [o.status, o.advance().status, o.advance().status, o.advance().status];
console.log(seen.join(' -> '));

Output

placed -> shipped -> delivered -> delivered

Explanation: each state knows its own transition, so the Order class has no long if chain, and the final state stays put.

27. Adapter patternReal-world

Make an old reporter work with a new interface.

Show solution
interface Reporter {
  report(passed: number, total: number): string;
}
class LegacyReporter {
  printSummary(text: string): string {
    return `LEGACY >> ${text}`;
  }
}
class LegacyAdapter implements Reporter {
  constructor(private legacy: LegacyReporter) {}
  report(passed: number, total: number): string {
    return this.legacy.printSummary(`${passed} of ${total} passed`);
  }
}
const reporters: Reporter[] = [
  { report: (p, t) => `NEW >> ${p}/${t}` },
  new LegacyAdapter(new LegacyReporter()),
];
reporters.forEach(r => console.log(r.report(9, 10)));

Output

NEW >> 9/10
LEGACY >> 9 of 10 passed

Explanation: the adapter translates between two interfaces, so old code can be used where the new interface is expected.

28. Fluent page object with inheritanceReal-world

Chain actions across a base page and a subclass.

Show solution
class Page {
  protected steps: string[] = [];
  goto(path: string): this { this.steps.push(`goto ${path}`); return this; }
  done(): string { return this.steps.join(' > '); }
}
class SearchPage extends Page {
  search(term: string): this { this.steps.push(`search ${term}`); return this; }
  openResult(n: number): this { this.steps.push(`open #${n}`); return this; }
}
console.log(new SearchPage().goto('/search').search('laptop').openResult(1).done());

Output

goto /search > search laptop > open #1

Explanation: returning this keeps the subclass type through the chain, so search is still available after the inherited goto.

29. Value object with equalityReal-world

Model money as an immutable value object.

Show solution
class Money {
  constructor(readonly amount: number, readonly currency: string) {}
  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error('currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
  toString(): string {
    return `${this.amount.toFixed(2)} ${this.currency}`;
  }
}
const total = new Money(10, 'USD').add(new Money(5.5, 'USD'));
console.log(`${total}`, total.equals(new Money(15.5, 'USD')));
try {
  total.add(new Money(1, 'EUR'));
} catch (e) {
  console.log((e as Error).message);
}

Output

15.50 USD true
currency mismatch

Explanation: value objects never change; operations return new objects, and equality compares values, not references.

30. Chain of responsibilityReal-world

Pass a request through validators until one rejects it.

Show solution
abstract class Handler {
  private next?: Handler;
  setNext(h: Handler): Handler {
    this.next = h;
    return h;
  }
  handle(input: string): string {
    return this.next ? this.next.handle(input) : `accepted "${input}"`;
  }
}
class NotEmpty extends Handler {
  override handle(input: string): string {
    return input.trim() ? super.handle(input) : 'rejected: empty';
  }
}
class MaxLength extends Handler {
  constructor(private max: number) { super(); }
  override handle(input: string): string {
    return input.length <= this.max ? super.handle(input) : `rejected: longer than ${this.max}`;
  }
}
const chain = new NotEmpty();
chain.setNext(new MaxLength(8));
for (const v of ['qa_user', '', 'very_long_name']) console.log(chain.handle(v));

Output

accepted "qa_user"
rejected: empty
rejected: longer than 8

Explanation: each handler does one check and passes the request on, so validators can be added or reordered without touching the others.

Keep Practising

Leave a Comment