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.

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

TypeScript Generics Practice Programs with Solutions (30 Programs)

Practise TypeScript generics 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 Generics lesson first.

TypeScript Beginner Programs

1. Generic identity functionBeginner

Write identity&lt;T&gt; and call it with two different types.

Show solution
function identity<T>(value: T): T {
  return value;
}
const n = identity(42);
const s = identity('login');
console.log(n + 1, s.toUpperCase());

Output

43 LOGIN

Explanation: T is filled in from the argument, so n is a number and s is a string without casts.

2. Explicit type argumentBeginner

Pass the type argument yourself with angle brackets.

Show solution
function wrap<T>(value: T): T[] {
  return [value];
}
const ids = wrap<number>(7);
const tags = wrap<string>('@smoke');
console.log(ids, tags);

Output

[ 7 ] [ '@smoke' ]

Explanation: usually TypeScript infers T, but you can write it explicitly when you want to be clear or force a wider type.

3. Generic function rejects mismatched argumentsBeginner

Show that both arguments must share the same T.

Show solution
function pair<T>(a: T, b: T): [T, T] {
  return [a, b];
}
console.log(pair(1, 2));
// @ts-expect-error: Argument of type 'string' is not assignable to parameter of type 'number'
console.log(pair(1, 'two'));

Output

[ 1, 2 ]
[ 1, 'two' ]

Explanation: once T is inferred as number from the first argument, the second must be a number too.

4. First item of any arrayBeginner

Write first&lt;T&gt;(items: T[]): T | undefined.

Show solution
function first<T>(items: T[]): T | undefined {
  return items[0];
}
console.log(first(['chromium', 'firefox']));
console.log(first([200, 404]));
console.log(first([]) ?? 'none');

Output

chromium
200
none

Explanation: the return type follows the element type, and undefined covers the empty array.

5. Generic array typeBeginner

Use Array&lt;T&gt; style in a function that reverses a copy.

Show solution
function reversed<T>(items: Array<T>): Array<T> {
  return [...items].reverse();
}
const steps = ['open', 'login', 'logout'];
console.log(reversed(steps).join(' <- '), steps[0]);

Output

logout <- login <- open open

Explanation: copying before reverse keeps the caller’s array unchanged, and the element type is preserved.

6. Generic interfaceBeginner

Describe a box that holds any type of value.

Show solution
interface Box<T> {
  value: T;
  label: string;
}
const timeout: Box<number> = { value: 30000, label: 'timeout' };
const browser: Box<string> = { value: 'webkit', label: 'browser' };
console.log(`${timeout.label}=${timeout.value}, ${browser.label}=${browser.value}`);

Output

timeout=30000, browser=webkit

Explanation: a generic interface is a template for types. Box&lt;number&gt; and Box&lt;string&gt; are two concrete versions.

7. Generic type aliasBeginner

Define Maybe&lt;T&gt; and use it for an optional lookup.

Show solution
type Maybe<T> = T | null;
function findUser(id: number): Maybe<string> {
  return id === 1 ? 'ana' : null;
}
console.log(findUser(1), findUser(2));

Output

ana null

Explanation: a generic alias saves repeating patterns like T | null for every type.

8. Generic classBeginner

Build a typed queue class.

Show solution
class Queue<T> {
  private items: T[] = [];
  enqueue(item: T): void { this.items.push(item); }
  dequeue(): T | undefined { return this.items.shift(); }
  get size(): number { return this.items.length; }
}
const jobs = new Queue<string>();
jobs.enqueue('run smoke');
jobs.enqueue('run regression');
console.log(jobs.dequeue(), jobs.size);

Output

run smoke 1

Explanation: the class works for any element type, and each instance is locked to the type given when it is created.

9. Two type parametersBeginner

Write toPair&lt;K, V&gt;(key, value) that returns a tuple.

Show solution
function toPair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
const [k, v] = toPair('retries', 3);
console.log(`${k.length} chars, value ${v * 2}`);

Output

7 chars, value 6

Explanation: each parameter gets its own type variable, so k is a string and v a number.

10. Promise genericBeginner

Type an async function that resolves to a list of names.

Show solution
async function loadNames(): Promise<string[]> {
  return ['ana', 'ben'];
}
const names = await loadNames();
console.log(names.map(n => n.toUpperCase()).join(', '));

Output

ANA, BEN

Explanation: Promise&lt;T&gt; describes what await will give you, so names is a string[].

TypeScript Intermediate Programs

11. Constraint with extendsIntermediate

Accept anything that has a length property.

Show solution
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
console.log(longest('login', 'checkout'));
console.log(longest([1, 2], [1, 2, 3]).length);
// @ts-expect-error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'
const bad = () => longest(10, 20);
console.log(typeof bad);

Output

checkout
3
function

Explanation: extends { length: number } lets the body use .length and rejects types that do not have it.

12. keyof constraintIntermediate

Write pluck(items, key) that collects one property from every object.

Show solution
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(i => i[key]);
}
const tests = [
  { name: 'login', ms: 820 },
  { name: 'cart', ms: 1400 },
];
console.log(pluck(tests, 'name').join(', '));
console.log(pluck(tests, 'ms').reduce((a, b) => a + b, 0));

Output

login, cart
2220

Explanation: K extends keyof T limits the key, and T[K][] is the exact type of the collected values.

13. Default type parameterIntermediate

Give a generic response type a default payload type.

Show solution
interface ApiResponse<T = unknown> {
  status: number;
  body: T;
}
const raw: ApiResponse = { status: 204, body: null };
const typed: ApiResponse<{ id: number }> = { status: 201, body: { id: 5 } };
console.log(raw.status, typed.body.id);

Output

204 5

Explanation: T = unknown means callers can omit the type argument, and the body then has to be checked before use.

14. Generic map functionIntermediate

Write your own mapAll&lt;T, U&gt; that changes element types.

Show solution
function mapAll<T, U>(items: T[], fn: (item: T, index: number) => U): U[] {
  const out: U[] = [];
  items.forEach((item, i) => out.push(fn(item, i)));
  return out;
}
const labels = mapAll([1200, 300], (ms, i) => `#${i + 1}: ${ms}ms`);
console.log(labels.join(' | '));

Output

#1: 1200ms | #2: 300ms

Explanation: U is inferred from the callback’s return value, so labels is a string[].

15. Generic filter with a type predicateIntermediate

Write keep that narrows the result type with a predicate.

Show solution
function keep<T, S extends T>(items: T[], test: (item: T) => item is S): S[] {
  const out: S[] = [];
  for (const item of items) if (test(item)) out.push(item);
  return out;
}
const mixed: (string | number)[] = ['a', 1, 'b', 2];
const strings = keep(mixed, (x): x is string => typeof x === 'string');
console.log(strings.map(s => s.toUpperCase()).join(''));

Output

AB

Explanation: the predicate’s item is S type flows into the return type, so the result is string[], not the mixed union.

16. Generic cache classIntermediate

Cache values by key with a generic Cache&lt;K, V&gt;.

Show solution
class Cache<K, V> {
  private store = new Map<K, V>();
  getOrSet(key: K, make: () => V): V {
    if (!this.store.has(key)) this.store.set(key, make());
    return this.store.get(key) as V;
  }
}
let calls = 0;
const tokens = new Cache<string, string>();
const t1 = tokens.getOrSet('admin', () => { calls++; return 'tok-1'; });
const t2 = tokens.getOrSet('admin', () => { calls++; return 'tok-2'; });
console.log(t1, t2, calls);

Output

tok-1 tok-1 1

Explanation: the class works for any key and value types, and the factory runs only on the first request for a key.

17. Generic Result typeIntermediate

Return either data or an error without throwing.

Show solution
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function parseNumber(text: string): Result<number> {
  const n = Number(text);
  return Number.isNaN(n) ? { ok: false, error: `not a number: ${text}` } : { ok: true, value: n };
}
for (const t of ['42', 'abc']) {
  const r = parseNumber(t);
  console.log(r.ok ? r.value * 2 : r.error);
}

Output

84
not a number: abc

Explanation: the ok flag narrows the union, so value is only reachable on success.

18. Generic constraint on an interfaceIntermediate

Sort any objects that have an id.

Show solution
interface HasId {
  id: number;
}
function sortById<T extends HasId>(items: T[]): T[] {
  return [...items].sort((a, b) => a.id - b.id);
}
const bugs = [{ id: 3, title: 'crash' }, { id: 1, title: 'typo' }];
console.log(sortById(bugs).map(b => `${b.id}:${b.title}`).join(' '));

Output

1:typo 3:crash

Explanation: the constraint lets the body use id, while T keeps the extra fields like title in the result.

19. Generic Record builderIntermediate

Index a list of objects by one of their keys.

Show solution
function indexBy<T, K extends keyof T>(items: T[], key: K): Record<string, T> {
  const out: Record<string, T> = {};
  for (const item of items) out[String(item[key])] = item;
  return out;
}
const users = [
  { email: 'ana@test.io', role: 'admin' },
  { email: 'ben@test.io', role: 'viewer' },
];
const byEmail = indexBy(users, 'email');
console.log(byEmail['ben@test.io'].role, Object.keys(byEmail).length);

Output

viewer 2

Explanation: a lookup object turns repeated find calls into direct key access.

20. Generic async wrapperIntermediate

Time any async function and return its typed result.

Show solution
async function timed<T>(label: string, fn: () => Promise<T>): Promise<T> {
  const start = Date.now();
  const value = await fn();
  console.log(`${label} done`, Date.now() - start >= 0);
  return value;
}
const count = await timed('load users', async () => 3);
console.log(count + 1);

Output

load users done true
4

Explanation: the wrapper adds behaviour around any async call, and T passes the original result type through.

TypeScript Real-World QA Scenario Programs

21. Utility types are genericsReal-world

Use Partial, Pick and ReturnType together.

Show solution
function makeUser() {
  return { id: 1, name: 'Ana', email: 'ana@test.io', role: 'qa' };
}
type User = ReturnType<typeof makeUser>;
type Patch = Partial<Pick<User, 'name' | 'email'>>;
function patch(u: User, p: Patch): User {
  return { ...u, ...p };
}
const updated = patch(makeUser(), { email: 'ana@new.io' });
console.log(updated.name, updated.email);

Output

Ana ana@new.io

Explanation: built-in utility types are just generic types, and they can be combined to derive new types from existing code.

22. Conditional typeReal-world

Define ElementOf&lt;T&gt; that extracts the item type of an array.

Show solution
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
const browsers = ['chromium', 'firefox'] as const;
type Browser = ElementOf<typeof browsers>;
const b: Browser = 'firefox';
// @ts-expect-error: Type '"safari"' is not assignable to type '"chromium" | "firefox"'
const bad: Browser = 'safari';
console.log(b, bad);

Output

firefox safari

Explanation: infer U captures a type inside a pattern. Here it pulls the element type out of the readonly tuple.

23. Mapped generic typeReal-world

Create Nullable&lt;T&gt; that allows null for every property.

Show solution
type Nullable<T> = { [K in keyof T]: T[K] | null };
interface Profile {
  name: string;
  phone: string;
}
const draft: Nullable<Profile> = { name: 'Ana', phone: null };
console.log(`${draft.name}: ${draft.phone ?? 'no phone yet'}`);

Output

Ana: no phone yet

Explanation: a mapped type loops over the keys of T and transforms each property type.

24. Generic event bus with typed payloadsReal-world

Map event names to payload types so emit checks its argument.

Show solution
type Events = {
  testStart: { name: string };
  testEnd: { name: string; passed: boolean };
};
class Bus<E extends Record<string, object>> {
  private handlers: { [K in keyof E]?: ((p: E[K]) => void)[] } = {};
  on<K extends keyof E>(event: K, fn: (p: E[K]) => void): void {
    (this.handlers[event] ??= []).push(fn);
  }
  emit<K extends keyof E>(event: K, payload: E[K]): void {
    this.handlers[event]?.forEach(fn => fn(payload));
  }
}
const bus = new Bus<Events>();
bus.on('testEnd', p => console.log(`${p.name}: ${p.passed ? 'PASS' : 'FAIL'}`));
bus.emit('testEnd', { name: 'login', passed: true });
// @ts-expect-error: Property 'passed' is missing
bus.emit('testEnd', { name: 'cart' });

Output

login: PASS
cart: FAIL

Explanation: the event name picks the payload type, so a missing field is caught at compile time. At runtime the bad call still runs, which is why a second line prints.

25. Generic retry with typed resultReal-world

Retry any async action and keep its return type.

Show solution
async function retry<T>(fn: (attempt: number) => Promise<T>, times: number): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await fn(i);
    } catch (e) {
      if (i >= times) throw e;
    }
  }
}
const status = await retry(async n => {
  if (n < 2) throw new Error('503');
  return { code: 200, attempt: n };
}, 3);
console.log(status.code, status.attempt);

Output

200 2

Explanation: T is inferred from the callback’s return value, so status.code is typed without any annotation.

26. Generic paginate helperReal-world

Split results into pages with a typed page object.

Show solution
interface Page<T> {
  items: T[];
  page: number;
  totalPages: number;
}
function paginate<T>(all: T[], size: number, page: number): Page<T> {
  const totalPages = Math.ceil(all.length / size);
  return { items: all.slice((page - 1) * size, page * size), page, totalPages };
}
const p2 = paginate(['a', 'b', 'c', 'd', 'e'], 2, 2);
console.log(p2.items.join(','), `${p2.page}/${p2.totalPages}`);

Output

c,d 2/3

Explanation: one generic function pages strings, users or test results alike.

27. Generic groupByReal-world

Group objects by the value of a key.

Show solution
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
  const out = new Map<T[K], T[]>();
  for (const item of items) out.set(item[key], [...(out.get(item[key]) ?? []), item]);
  return out;
}
const runs = [
  { test: 'login', browser: 'chromium' },
  { test: 'cart', browser: 'firefox' },
  { test: 'search', browser: 'chromium' },
];
for (const [browser, list] of groupBy(runs, 'browser')) console.log(`${browser}: ${list.map(r => r.test).join(', ')}`);

Output

chromium: login, search
firefox: cart

Explanation: using a Map keyed by T[K] keeps the key type exact, whether it is a string, number or literal.

28. Generic deep readonlyReal-world

Make every nested property readonly with a recursive generic type.

Show solution
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };
const config: DeepReadonly<{ use: { baseURL: string } }> = { use: { baseURL: 'https://example.com' } };
// @ts-expect-error: Cannot assign to 'baseURL' because it is a read-only property
const change = () => (config.use.baseURL = 'x');
console.log(config.use.baseURL, typeof change);

Output

https://example.com function

Explanation: the type calls itself for nested objects, so even deep fields become readonly.

29. Generic fixture factoryReal-world

Build test fixtures by merging overrides into typed defaults.

Show solution
function fixture<T extends object>(defaults: T) {
  let seq = 0;
  return (overrides: Partial<T> = {}): T & { seq: number } => ({ ...defaults, ...overrides, seq: ++seq });
}
const makeOrder = fixture({ product: 'pen', qty: 1, paid: false });
const o1 = makeOrder();
const o2 = makeOrder({ qty: 5, paid: true });
console.log(o1.seq, o1.qty, o2.seq, o2.qty, o2.paid);

Output

1 1 2 5 true

Explanation: the factory returns a function whose overrides are checked against the defaults’ shape, and a closure counts each fixture.

30. Typed pipe of functionsReal-world

Compose two transformations with generic types.

Show solution
function pipe<A, B, C>(f: (a: A) => B, g: (b: B) => C): (a: A) => C {
  return a => g(f(a));
}
const toSeconds = (ms: number) => ms / 1000;
const label = (s: number) => `${s.toFixed(1)}s`;
const format = pipe(toSeconds, label);
console.log(format(1840), format(250));

Output

1.8s 0.3s

Explanation: B links the output of the first function to the input of the second, so mismatched steps fail to compile.

Keep Practising

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.

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

TypeScript Interfaces Practice Programs with Solutions (30 Programs)

Practise TypeScript interfaces and types 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 Interfaces lesson first.

TypeScript Beginner Programs

1. Declare an interfaceBeginner

Describe a user with an interface and create one.

Show solution
interface User {
  id: number;
  email: string;
}
const user: User = { id: 1, email: 'qa@test.io' };
console.log(`#${user.id} ${user.email}`);

Output

#1 qa@test.io

Explanation: an interface names an object shape. Any value annotated with it must have those properties with those types.

2. Interface catches wrong typesBeginner

Show that a string id is rejected by a numeric interface field.

Show solution
interface TestCase {
  id: number;
  title: string;
}
const good: TestCase = { id: 7, title: 'login' };
// @ts-expect-error: Type 'string' is not assignable to type 'number'
const bad: TestCase = { id: '7', title: 'login' };
console.log(good.id, bad.title);

Output

7 login

Explanation: each field is checked against the interface, so a mismatched type fails to compile.

3. Optional membersBeginner

Add an optional tags field and print it only when present.

Show solution
interface Spec {
  title: string;
  tags?: string[];
}
const specs: Spec[] = [{ title: 'login', tags: ['smoke'] }, { title: 'search' }];
for (const s of specs) console.log(`${s.title}${s.tags ? ' [' + s.tags.join(',') + ']' : ''}`);

Output

login [smoke]
search

Explanation: tags? may be missing, so its type is string[] | undefined and you check it before use.

4. Readonly membersBeginner

Make an interface field readonly.

Show solution
interface Build {
  readonly number: number;
  status: string;
}
const build: Build = { number: 512, status: 'running' };
build.status = 'passed';
// @ts-expect-error: Cannot assign to 'number' because it is a read-only property
const bump = () => (build.number = 513);
console.log(build.number, build.status, typeof bump);

Output

512 passed function

Explanation: readonly lets you set the value once when the object is created and blocks later changes.

5. Interface as a function parameterBeginner

Write a function that accepts any object matching an interface.

Show solution
interface Credentials {
  username: string;
  password: string;
}
function maskedLogin(c: Credentials): string {
  return `${c.username} / ${'*'.repeat(c.password.length)}`;
}
console.log(maskedLogin({ username: 'admin', password: 'secret' }));

Output

admin / ******

Explanation: the parameter type documents what the function needs, and every call is checked.

6. Methods in an interfaceBeginner

Describe a logger with methods and implement it as an object.

Show solution
interface Logger {
  info(msg: string): void;
  error(msg: string): void;
}
const logger: Logger = {
  info: msg => console.log(`INFO  ${msg}`),
  error: msg => console.log(`ERROR ${msg}`),
};
logger.info('suite started');
logger.error('login failed');

Output

INFO  suite started
ERROR login failed

Explanation: method signatures type the parameters, so msg is a string in the implementation without repeating the annotation.

7. Function type in an interfaceBeginner

Describe a validator as a callable interface.

Show solution
interface Validator {
  (value: string): boolean;
}
const isEmail: Validator = v => /^[^@\s]+@[^@\s]+\.[a-z]+$/i.test(v);
console.log(isEmail('qa@test.io'), isEmail('qa-at-test'));

Output

true false

Explanation: a call signature in an interface describes a function. A type Validator = (value: string) =&gt; boolean alias would do the same.

8. Array of interface objectsBeginner

Total the price of items in a cart.

Show solution
interface CartItem {
  name: string;
  price: number;
  qty: number;
}
const cart: CartItem[] = [
  { name: 'pen', price: 1.5, qty: 4 },
  { name: 'book', price: 12, qty: 1 },
];
const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);
console.log(`total $${total.toFixed(2)}`);

Output

total $18.00

Explanation: the element type gives the reduce callback typed access to price and qty.

9. Nested interfacesBeginner

Use one interface inside another.

Show solution
interface Address {
  city: string;
  zip: string;
}
interface Customer {
  name: string;
  address: Address;
}
const c: Customer = { name: 'Ana', address: { city: 'Pune', zip: '411001' } };
console.log(`${c.name}, ${c.address.city} ${c.address.zip}`);

Output

Ana, Pune 411001

Explanation: small interfaces can be combined, so the same Address can be reused in orders, invoices and profiles.

10. Index signature in an interfaceBeginner

Describe a map of environment URLs.

Show solution
interface EnvUrls {
  [env: string]: string;
}
const urls: EnvUrls = { dev: 'http://localhost:3000', staging: 'https://staging.example.com' };
urls.prod = 'https://example.com';
console.log(Object.keys(urls).join(', '));

Output

dev, staging, prod

Explanation: the index signature allows any string key, as long as every value is a string.

TypeScript Intermediate Programs

11. Extend an interfaceIntermediate

Build Admin on top of User with extends.

Show solution
interface User {
  id: number;
  email: string;
}
interface Admin extends User {
  permissions: string[];
}
const admin: Admin = { id: 1, email: 'root@test.io', permissions: ['read', 'write', 'delete'] };
console.log(`${admin.email} can ${admin.permissions.join('/')}`);

Output

root@test.io can read/write/delete

Explanation: extends copies the base members, so Admin needs id, email and permissions.

12. Extend multiple interfacesIntermediate

Combine Timestamps and Identifiable into one interface.

Show solution
interface Identifiable {
  id: string;
}
interface Timestamps {
  createdAt: Date;
}
interface Ticket extends Identifiable, Timestamps {
  title: string;
}
const t: Ticket = { id: 'BUG-12', createdAt: new Date('2026-01-15T00:00:00Z'), title: 'Login button misaligned' };
console.log(`${t.id} (${t.createdAt.toISOString().slice(0, 10)}): ${t.title}`);

Output

BUG-12 (2026-01-15): Login button misaligned

Explanation: an interface can extend several others at once, a clean way to share common fields.

13. Class implements an interfaceIntermediate

Make a class promise to follow an interface.

Show solution
interface Shape {
  area(): number;
}
class Rect implements Shape {
  constructor(private w: number, private h: number) {}
  area(): number {
    return this.w * this.h;
  }
}
const shapes: Shape[] = [new Rect(2, 3), new Rect(4, 5)];
console.log(shapes.map(s => s.area()).join(', '));

Output

6, 20

Explanation: implements makes the compiler check that the class has every member of the interface.

14. Missing member in implementsIntermediate

Show the error when a class forgets an interface method.

Show solution
interface Page {
  open(): string;
  title(): string;
}
// @ts-expect-error: Class 'HomePage' incorrectly implements interface 'Page'
class HomePage implements Page {
  open(): string {
    return 'opened /';
  }
}
console.log(new HomePage().open());

Output

opened /

Explanation: the class compiles only when it has both open and title. This keeps every page object consistent.

15. Interface vs type aliasIntermediate

Describe the same shape with an interface and a type alias and use both.

Show solution
interface PointI {
  x: number;
  y: number;
}
type PointT = { x: number; y: number };
const a: PointI = { x: 1, y: 2 };
const b: PointT = a;
console.log(b.x + b.y);

Output

3

Explanation: for object shapes the two are interchangeable. Interfaces can be extended and merged; type aliases can also name unions and tuples.

16. Union types need a type aliasIntermediate

Model a status that is one of three strings and use it inside an interface.

Show solution
type Status = 'passed' | 'failed' | 'skipped';
interface Result {
  name: string;
  status: Status;
}
const results: Result[] = [
  { name: 'login', status: 'passed' },
  { name: 'cart', status: 'failed' },
];
console.log(results.filter(r => r.status === 'failed').map(r => r.name).join(', '));

Output

cart

Explanation: an interface cannot describe a union of literals, so a type alias holds the union and the interface uses it.

17. Declaration mergingIntermediate

Declare the same interface twice and see the members combine.

Show solution
interface Settings {
  theme: string;
}
interface Settings {
  fontSize: number;
}
const s: Settings = { theme: 'dark', fontSize: 14 };
console.log(`${s.theme} ${s.fontSize}px`);

Output

dark 14px

Explanation: interfaces with the same name merge. This is how libraries let you add fields to their types, for example Playwright fixtures.

18. Generic interfaceIntermediate

Write an ApiResponse&lt;T&gt; interface and use it with two data types.

Show solution
interface ApiResponse<T> {
  status: number;
  data: T;
}
const user: ApiResponse<{ name: string }> = { status: 200, data: { name: 'Ana' } };
const ids: ApiResponse<number[]> = { status: 200, data: [1, 2, 3] };
console.log(user.data.name, ids.data.length);

Output

Ana 3

Explanation: the type parameter lets one interface describe responses with any payload while still typing data.

19. Interface for constructor optionsIntermediate

Use an interface with optional fields as constructor options.

Show solution
interface BrowserOptions {
  headless?: boolean;
  slowMo?: number;
}
class FakeBrowser {
  readonly headless: boolean;
  readonly slowMo: number;
  constructor(opts: BrowserOptions = {}) {
    this.headless = opts.headless ?? true;
    this.slowMo = opts.slowMo ?? 0;
  }
}
const b1 = new FakeBrowser();
const b2 = new FakeBrowser({ headless: false, slowMo: 50 });
console.log(b1.headless, b1.slowMo, b2.headless, b2.slowMo);

Output

true 0 false 50

Explanation: an options interface keeps the constructor flexible, and defaults fill in what callers leave out.

20. Readonly arrays in an interfaceIntermediate

Protect a list inside an interface from being changed.

Show solution
interface Suite {
  name: string;
  readonly tests: readonly string[];
}
const suite: Suite = { name: 'auth', tests: ['login', 'logout'] };
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'
const add = () => suite.tests.push('reset');
console.log(suite.tests.join(', '), typeof add);

Output

login, logout function

Explanation: readonly on the property stops reassignment, and readonly string[] stops changes to the array itself.

TypeScript Real-World QA Scenario Programs

21. Interface for a page objectReal-world

Describe a login page with an interface and give it a fake implementation.

Show solution
interface LoginPage {
  goto(): Promise<void>;
  login(user: string, pass: string): Promise<string>;
}
const fakeLogin: LoginPage = {
  async goto() {
    console.log('navigate /login');
  },
  async login(user, pass) {
    return pass === 'secret' ? `welcome ${user}` : 'invalid credentials';
  },
};
await fakeLogin.goto();
console.log(await fakeLogin.login('qa', 'secret'));
console.log(await fakeLogin.login('qa', 'wrong'));

Output

navigate /login
welcome qa
invalid credentials

Explanation: coding against an interface lets you swap a real Playwright page object for a fake in unit tests.

22. Discriminated union of interfacesReal-world

Handle three kinds of test events with a kind field.

Show solution
interface StartEvent { kind: 'start'; test: string }
interface PassEvent { kind: 'pass'; test: string; ms: number }
interface FailEvent { kind: 'fail'; test: string; error: string }
type TestEvent = StartEvent | PassEvent | FailEvent;
function format(e: TestEvent): string {
  switch (e.kind) {
    case 'start': return `> ${e.test}`;
    case 'pass': return `ok ${e.test} (${e.ms}ms)`;
    case 'fail': return `x ${e.test}: ${e.error}`;
  }
}
const events: TestEvent[] = [
  { kind: 'start', test: 'login' },
  { kind: 'pass', test: 'login', ms: 840 },
  { kind: 'fail', test: 'cart', error: 'timeout' },
];
events.forEach(e => console.log(format(e)));

Output

> login
ok login (840ms)
x cart: timeout

Explanation: the kind literal narrows the union in each case, so ms and error are only used where they exist.

23. Type guard for an interfaceReal-world

Check unknown JSON at runtime before treating it as a User.

Show solution
interface User {
  id: number;
  email: string;
}
function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null
    && typeof (x as User).id === 'number' && typeof (x as User).email === 'string';
}
const inputs: unknown[] = [JSON.parse('{"id":1,"email":"a@test.io"}'), JSON.parse('{"id":"2"}')];
for (const i of inputs) console.log(isUser(i) ? `valid: ${i.email}` : 'invalid payload');

Output

valid: a@test.io
invalid payload

Explanation: interfaces disappear at runtime, so data from an API must be checked. The x is User guard narrows the type after the check.

24. Interface with a generic constraintReal-world

Write a function that sorts anything with a priority field.

Show solution
interface HasPriority {
  priority: number;
}
function byPriority<T extends HasPriority>(items: T[]): T[] {
  return [...items].sort((a, b) => a.priority - b.priority);
}
const bugs = [
  { id: 'B-3', priority: 2 },
  { id: 'B-1', priority: 1 },
  { id: 'B-9', priority: 3 },
];
console.log(byPriority(bugs).map(b => b.id).join(' > '));

Output

B-1 > B-3 > B-9

Explanation: T extends HasPriority requires the field but keeps the full item type, so b.id is still available in the result.

25. Callable and property members togetherReal-world

Describe a counter function that also exposes a count property.

Show solution
interface Counter {
  (): number;
  count: number;
  reset(): void;
}
function makeCounter(): Counter {
  const c = (() => ++c.count) as Counter;
  c.count = 0;
  c.reset = () => { c.count = 0; };
  return c;
}
const next = makeCounter();
next(); next();
console.log(next(), next.count);
next.reset();
console.log(next.count);

Output

3 3
0

Explanation: an interface can mix a call signature with properties, which describes functions that carry state.

26. Extending a generic interfaceReal-world

Build a paginated response on top of a generic base response.

Show solution
interface ApiResponse<T> {
  status: number;
  data: T;
}
interface Paged<T> extends ApiResponse<T[]> {
  page: number;
  totalPages: number;
}
const page2: Paged<string> = { status: 200, data: ['c', 'd'], page: 2, totalPages: 3 };
const hasMore = page2.page < page2.totalPages;
console.log(`${page2.data.join(',')} | page ${page2.page}/${page2.totalPages} | more: ${hasMore}`);

Output

c,d | page 2/3 | more: true

Explanation: a generic interface can extend another generic one and fix part of its type, here data becomes T[].

27. Repository interface with a fakeReal-world

Define a data repository interface and an in-memory implementation.

Show solution
interface Repo<T extends { id: number }> {
  add(item: T): void;
  get(id: number): T | undefined;
  all(): T[];
}
class MemoryRepo<T extends { id: number }> implements Repo<T> {
  private items = new Map<number, T>();
  add(item: T): void { this.items.set(item.id, item); }
  get(id: number): T | undefined { return this.items.get(id); }
  all(): T[] { return [...this.items.values()]; }
}
const users = new MemoryRepo<{ id: number; name: string }>();
users.add({ id: 1, name: 'Ana' });
users.add({ id: 2, name: 'Ben' });
console.log(users.get(2)?.name, users.get(3)?.name ?? 'missing', users.all().length);

Output

Ben missing 2

Explanation: tests can depend on Repo&lt;T&gt; and use the memory version, while production code uses a database version with the same interface.

28. Optional methodsReal-world

Call a hook only if the plugin defines it.

Show solution
interface Plugin {
  name: string;
  beforeAll?(): string;
  afterAll?(): string;
}
const plugins: Plugin[] = [
  { name: 'screenshots', afterAll: () => 'saved 3 screenshots' },
  { name: 'auth', beforeAll: () => 'logged in' },
];
for (const p of plugins) console.log(`${p.name}: ${p.beforeAll?.() ?? '-'} | ${p.afterAll?.() ?? '-'}`);

Output

screenshots: - | saved 3 screenshots
auth: logged in | -

Explanation: optional methods may be missing, so ?.() calls them only when they exist.

29. Mapped type over an interfaceReal-world

Turn every field of an interface into a validation message slot.

Show solution
interface SignupForm {
  email: string;
  password: string;
  age: number;
}
type FormErrors = { [K in keyof SignupForm]?: string };
function validate(f: SignupForm): FormErrors {
  const errors: FormErrors = {};
  if (!f.email.includes('@')) errors.email = 'invalid email';
  if (f.password.length < 8) errors.password = 'too short';
  if (f.age < 18) errors.age = 'must be 18+';
  return errors;
}
console.log(JSON.stringify(validate({ email: 'qa', password: 'secret', age: 30 })));

Output

{"email":"invalid email","password":"too short"}

Explanation: the mapped type reuses the interface keys, so adding a form field automatically allows an error for it.

30. Fixture types like PlaywrightReal-world

Model test fixtures with an interface and use them in a fake test runner.

Show solution
interface Fixtures {
  baseURL: string;
  user: { email: string };
}
type TestFn = (f: Fixtures) => string;
function runTest(name: string, fn: TestFn): void {
  const fixtures: Fixtures = { baseURL: 'https://example.com', user: { email: 'qa@test.io' } };
  console.log(`${name}: ${fn(fixtures)}`);
}
runTest('profile', ({ baseURL, user }) => `${baseURL}/profile as ${user.email}`);

Output

profile: https://example.com/profile as qa@test.io

Explanation: Playwright’s test.extend&lt;Fixtures&gt;() works the same way: an interface describes the fixtures, and test callbacks get them fully typed.

Keep Practising

TypeScript Objects Practice Programs with Solutions (31 Programs)

Practise TypeScript objects with 31 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 Objects lesson first.

TypeScript Beginner Programs

1. Inline object typeBeginner

Annotate a user object with an inline type and print its fields.

Show solution
const user: { name: string; age: number } = { name: 'Ana', age: 29 };
console.log(`${user.name} is ${user.age}`);

Output

Ana is 29

Explanation: the inline type lists each property and its type, so the object must match it exactly.

2. Missing property is a compile errorBeginner

Show that leaving out a required property is rejected.

Show solution
type Credentials = { username: string; password: string };
const ok: Credentials = { username: 'qa', password: 'secret' };
console.log(ok.username);
// @ts-expect-error: Property 'password' is missing in type '{ username: string; }'
const broken: Credentials = { username: 'qa' };
console.log(broken.username);

Output

qa
qa

Explanation: every property in the type is required unless marked optional, so a missing field fails to compile.

3. Type alias for an objectBeginner

Name an object shape with type and reuse it for two values.

Show solution
type Product = { sku: string; price: number };
const pen: Product = { sku: 'PEN-1', price: 1.5 };
const book: Product = { sku: 'BK-7', price: 12 };
console.log(`${pen.sku} + ${book.sku} = ${pen.price + book.price}`);

Output

PEN-1 + BK-7 = 13.5

Explanation: a type alias gives the shape a name, so you write it once and use it everywhere.

4. Optional propertyBeginner

Make phone optional and print a fallback when it is missing.

Show solution
type Contact = { email: string; phone?: string };
const contacts: Contact[] = [{ email: 'a@test.io', phone: '555-0101' }, { email: 'b@test.io' }];
for (const c of contacts) console.log(`${c.email}: ${c.phone ?? 'no phone'}`);

Output

a@test.io: 555-0101
b@test.io: no phone

Explanation: phone?: string means the property may be absent, so its type is string | undefined and needs a fallback.

5. Readonly propertyBeginner

Mark an id as readonly and show that it cannot be reassigned.

Show solution
type Order = { readonly id: number; status: string };
const order: Order = { id: 7, status: 'new' };
order.status = 'shipped';
// @ts-expect-error: Cannot assign to 'id' because it is a read-only property
const change = () => (order.id = 8);
console.log(order.id, order.status, typeof change);

Output

7 shipped function

Explanation: readonly blocks reassignment at compile time while other properties stay editable.

6. Excess property checkBeginner

Show that an object literal with an unknown property is rejected.

Show solution
type Point = { x: number; y: number };
const p: Point = { x: 1, y: 2 };
// @ts-expect-error: Object literal may only specify known properties, and 'z' does not exist in type 'Point'
const q: Point = { x: 1, y: 2, z: 3 };
console.log(p.x + p.y, q.x);

Output

3 1

Explanation: fresh object literals are checked for extra properties, which catches typos such as tiemout instead of timeout.

7. Nested object typeBeginner

Type a test config that contains a nested viewport object.

Show solution
type Config = { baseURL: string; viewport: { width: number; height: number } };
const config: Config = { baseURL: 'https://example.com', viewport: { width: 1280, height: 720 } };
console.log(`${config.baseURL} at ${config.viewport.width}x${config.viewport.height}`);

Output

https://example.com at 1280x720

Explanation: nested properties are typed too, so config.viewport.width is known to be a number.

8. Destructure with typesBeginner

Destructure a typed object and rename one property.

Show solution
type Env = { name: string; url: string; retries: number };
const env: Env = { name: 'staging', url: 'https://staging.example.com', retries: 2 };
const { name: envName, retries } = env;
console.log(`${envName} uses ${retries} retries`);

Output

staging uses 2 retries

Explanation: destructured variables take their types from the object, and name: envName renames while keeping the type.

9. Object method with typed parametersBeginner

Add a typed method to an object and call it.

Show solution
const calculator = {
  rate: 0.2,
  tax(amount: number): number {
    return Math.round(amount * this.rate * 100) / 100;
  },
};
console.log(calculator.tax(49.99));

Output

10

Explanation: methods in object literals can type their parameters and return value, and this refers to the object.

10. Object property access by a known keyBeginner

Read a property with bracket notation using a key typed as keyof.

Show solution
type Limits = { timeout: number; retries: number; workers: number };
const limits: Limits = { timeout: 30000, retries: 2, workers: 4 };
const key: keyof Limits = 'workers';
console.log(`${key} = ${limits[key]}`);

Output

workers = 4

Explanation: keyof Limits is 'timeout' | 'retries' | 'workers', so the lookup is safe and returns a number.

TypeScript Intermediate Programs

11. Record for a dictionaryIntermediate

Store status messages in Record&lt;number, string&gt; and look them up.

Show solution
const messages: Record<number, string> = { 200: 'OK', 404: 'Not Found', 500: 'Server Error' };
for (const code of [200, 404, 418]) console.log(`${code}: ${messages[code] ?? 'Unknown'}`);

Output

200: OK
404: Not Found
418: Unknown

Explanation: Record&lt;K, V&gt; describes an object used as a lookup table. A missing key still returns undefined at runtime, hence the fallback.

12. Record with a union of keysIntermediate

Require one timeout for every browser by keying a Record on a union.

Show solution
type Browser = 'chromium' | 'firefox' | 'webkit';
const timeouts: Record<Browser, number> = { chromium: 30000, firefox: 45000, webkit: 40000 };
console.log(Object.entries(timeouts).map(([b, t]) => `${b}=${t / 1000}s`).join(', '));
// @ts-expect-error: Property 'webkit' is missing in type '{ chromium: number; firefox: number; }'
const partial: Record<Browser, number> = { chromium: 1, firefox: 2 };
console.log(Object.keys(partial).length);

Output

chromium=30s, firefox=45s, webkit=40s
2

Explanation: when the keys are a union, every member must be present, so adding a browser later forces you to update the map.

13. Index signatureIntermediate

Allow any string key for custom HTTP headers.

Show solution
type Headers = { [name: string]: string };
const headers: Headers = { Accept: 'application/json' };
headers['X-Trace-Id'] = 'abc123';
for (const [k, v] of Object.entries(headers)) console.log(`${k}: ${v}`);

Output

Accept: application/json
X-Trace-Id: abc123

Explanation: an index signature says “any key of this type, with values of that type”, useful when keys are not known in advance.

14. Spread to copy and overrideIntermediate

Create a new config from defaults and overrides with spread.

Show solution
type RunConfig = { browser: string; headless: boolean; retries: number };
const defaults: RunConfig = { browser: 'chromium', headless: true, retries: 0 };
const ci: RunConfig = { ...defaults, retries: 2 };
console.log(defaults.retries, ci.retries, ci.browser);

Output

0 2 chromium

Explanation: spread copies properties into a new object, later properties win, and the result is still checked against RunConfig.

15. Partial for updatesIntermediate

Write update(user, changes) where changes may hold any subset of fields.

Show solution
type User = { name: string; email: string; active: boolean };
function update(user: User, changes: Partial<User>): User {
  return { ...user, ...changes };
}
const before: User = { name: 'Ben', email: 'ben@test.io', active: true };
const after = update(before, { active: false });
console.log(after.name, after.active, before.active);

Output

Ben false true

Explanation: Partial&lt;User&gt; makes every property optional, which fits patch-style updates.

16. Required removes optionalIntermediate

Turn an options type with optional fields into a fully filled one.

Show solution
type Options = { timeout?: number; retries?: number };
function withDefaults(o: Options): Required<Options> {
  return { timeout: o.timeout ?? 30000, retries: o.retries ?? 0 };
}
const full = withDefaults({ retries: 3 });
console.log(`timeout ${full.timeout / 1000}s, retries ${full.retries}`);

Output

timeout 30s, retries 3

Explanation: Required&lt;Options&gt; guarantees both numbers exist, so callers can use them without checking for undefined.

17. Pick and OmitIntermediate

Build a public profile type that leaves out the password.

Show solution
type Account = { id: number; email: string; password: string; role: string };
type PublicAccount = Omit<Account, 'password'>;
type Login = Pick<Account, 'email' | 'password'>;
const pub: PublicAccount = { id: 1, email: 'qa@test.io', role: 'admin' };
const login: Login = { email: 'qa@test.io', password: 'x' };
console.log(Object.keys(pub).join(','), Object.keys(login).join(','));

Output

id,email,role email,password

Explanation: Omit and Pick derive new types from an existing one, so they stay in sync when Account changes.

18. Readonly utility typeIntermediate

Freeze a settings object type with Readonly&lt;T&gt;.

Show solution
type Settings = { theme: string; lang: string };
const settings: Readonly<Settings> = { theme: 'dark', lang: 'en' };
// @ts-expect-error: Cannot assign to 'theme' because it is a read-only property
const change = () => (settings.theme = 'light');
console.log(settings.theme, typeof change);

Output

dark function

Explanation: Readonly&lt;T&gt; marks every property readonly in one step. Use Object.freeze too if you need runtime protection.

19. keyof with a generic getterIntermediate

Write getProp(obj, key) that only accepts keys that exist.

Show solution
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const test = { title: 'login', retries: 2, tags: ['smoke'] };
console.log(getProp(test, 'title').toUpperCase());
console.log(getProp(test, 'retries') + 1);

Output

LOGIN
3

Explanation: K extends keyof T limits the key, and T[K] gives the exact property type, so title is a string and retries a number.

20. Object.entries with typed valuesIntermediate

Print a score table and compute the total.

Show solution
const scores: Record<string, number> = { ana: 92, ben: 78, cai: 85 };
let total = 0;
for (const [student, score] of Object.entries(scores)) {
  total += score;
  console.log(`${student}: ${score}`);
}
console.log(`average ${(total / Object.keys(scores).length).toFixed(1)}`);

Output

ana: 92
ben: 78
cai: 85
average 85.0

Explanation: with a Record value type, Object.entries returns [string, number][], so score is a number.

21. Optional chaining on nested objectsIntermediate

Read a deeply nested optional value safely.

Show solution
type Response = { data?: { user?: { name: string } } };
const full: Response = { data: { user: { name: 'Ana' } } };
const empty: Response = {};
console.log(full.data?.user?.name ?? 'anonymous');
console.log(empty.data?.user?.name ?? 'anonymous');

Output

Ana
anonymous

Explanation: ?. stops at the first undefined and returns undefined, and the type of the chain includes undefined to match.

22. Object comparison by valueIntermediate

Compare two flat objects field by field.

Show solution
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  return ka.length === kb.length && ka.every(k => a[k] === b[k]);
}
const a = { id: 1, status: 'ok' };
const copy = { ...a };
console.log(a === copy, shallowEqual(a, copy), shallowEqual(a, { id: 1, status: 'err' }));

Output

false true false

Explanation: === compares object references, not contents. unknown values can still be compared with === without any casts.

TypeScript Real-World QA Scenario Programs

23. Deep merge of configReal-world

Merge nested overrides into a default config without changing the defaults.

Show solution
type Cfg = { use: { headless: boolean; viewport: { width: number; height: number } }; retries: number };
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
const base: Cfg = { use: { headless: true, viewport: { width: 1280, height: 720 } }, retries: 0 };
function merge(cfg: Cfg, o: DeepPartial<Cfg>): Cfg {
  return {
    retries: o.retries ?? cfg.retries,
    use: {
      headless: o.use?.headless ?? cfg.use.headless,
      viewport: { ...cfg.use.viewport, ...o.use?.viewport },
    },
  };
}
const mobile = merge(base, { use: { viewport: { width: 390 } } });
console.log(mobile.use.viewport.width, mobile.use.viewport.height, base.use.viewport.width);

Output

390 720 1280

Explanation: a recursive mapped type makes every nested property optional, so callers can override just one deep field.

24. Mapped type for flagsReal-world

Create a type with a boolean flag for each feature name.

Show solution
type Feature = 'darkMode' | 'beta' | 'chat';
type Flags = { [F in Feature]: boolean };
const flags: Flags = { darkMode: true, beta: false, chat: true };
const enabled = (Object.keys(flags) as Feature[]).filter(f => flags[f]);
console.log(`enabled: ${enabled.join(', ')}`);

Output

enabled: darkMode, chat

Explanation: a mapped type builds properties from a union, so the object must have exactly one flag per feature.

25. Type-safe object keys helperReal-world

Write a keys helper that returns (keyof T)[] instead of string[].

Show solution
function keys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}
const report = { passed: 18, failed: 2, skipped: 1 };
for (const k of keys(report)) console.log(`${k.padEnd(8)}${report[k]}`);

Output

passed  18
failed  2
skipped 1

Explanation: Object.keys returns string[] because objects may have extra keys at runtime. When you know the shape, a typed helper lets you index without errors.

26. Discriminated union of objectsReal-world

Handle API results that are either success or error objects.

Show solution
type ApiResult =
  | { ok: true; data: { id: number } }
  | { ok: false; error: string };
function describe(r: ApiResult): string {
  return r.ok ? `created #${r.data.id}` : `failed: ${r.error}`;
}
console.log(describe({ ok: true, data: { id: 42 } }));
console.log(describe({ ok: false, error: '401 Unauthorized' }));

Output

created #42
failed: 401 Unauthorized

Explanation: checking the shared ok property narrows the union, so data is only reachable on success and error only on failure.

27. satisfies keeps literal typesReal-world

Check a route table against a type while keeping its exact keys.

Show solution
type Route = { path: string; auth: boolean };
const routes = {
  home: { path: '/', auth: false },
  account: { path: '/account', auth: true },
} satisfies Record<string, Route>;
console.log(routes.account.path, Object.keys(routes).length);
// @ts-expect-error: Property 'admin' does not exist on type
const missing = () => routes.admin;
console.log(typeof missing);

Output

/account 2
function

Explanation: satisfies checks the shape but keeps the inferred type, so unknown keys like admin are still caught, unlike a plain Record&lt;string, Route&gt; annotation.

28. Build an object from entriesReal-world

Convert an array of [name, value] cookie pairs into a typed object.

Show solution
const pairs: [string, string][] = [['session', 'abc'], ['theme', 'dark'], ['lang', 'en']];
const cookies: Record<string, string> = Object.fromEntries(pairs);
console.log(cookies.theme, Object.keys(cookies).length);

Output

dark 3

Explanation: Object.fromEntries turns pairs back into an object, the reverse of Object.entries.

29. Freeze test data at runtime and compile timeReal-world

Combine as const and Object.freeze for fixed test users.

Show solution
const USERS = Object.freeze({
  admin: { email: 'admin@test.io', role: 'admin' },
  viewer: { email: 'viewer@test.io', role: 'viewer' },
} as const);
type Role = (typeof USERS)[keyof typeof USERS]['role'];
const role: Role = USERS.viewer.role;
console.log(role, Object.isFrozen(USERS));

Output

viewer true

Explanation: as const gives readonly literal types for the compiler, Object.freeze stops changes at runtime, and the role union is derived from the data.

30. Pick properties at runtime with typesReal-world

Write a generic pick(obj, keys) that returns only the chosen fields.

Show solution
function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}
const fullUser = { id: 9, email: 'cai@test.io', password: 'hidden', role: 'qa' };
const safe = pick(fullUser, ['id', 'email', 'role']);
console.log(JSON.stringify(safe));

Output

{"id":9,"email":"cai@test.io","role":"qa"}

Explanation: the return type Pick&lt;T, K&gt; matches exactly the keys passed in, so safe.password would be a compile error.

31. Count fields by value typeReal-world

Summarise an object’s values by their runtime type.

Show solution
function typeSummary(obj: Record<string, unknown>): Record<string, number> {
  const out: Record<string, number> = {};
  for (const v of Object.values(obj)) {
    const t = Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v;
    out[t] = (out[t] ?? 0) + 1;
  }
  return out;
}
const payload = { id: 1, name: 'x', tags: ['a'], active: true, note: null, score: 9.5 };
console.log(JSON.stringify(typeSummary(payload)));

Output

{"number":2,"string":1,"array":1,"boolean":1,"null":1}

Explanation: values typed as unknown must be checked before use, which is the safe way to handle data from an API.

Keep Practising

TypeScript Arrays and Tuples Practice Programs with Solutions (31 Programs)

Practise TypeScript arrays and tuples with 31 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 Arrays lesson first.

TypeScript Beginner Programs

1. Declare typed arraysBeginner

Declare a string[] of browsers and a number[] of timeouts, then print their lengths.

Show solution
const browsers: string[] = ['chromium', 'firefox', 'webkit'];
const timeouts: number[] = [5000, 10000, 30000];
console.log(browsers.length, timeouts.length);
console.log(browsers[0], timeouts[2]);

Output

3 3
chromium 30000

Explanation: the element type is fixed by the annotation, so every item is checked when the array is created.

2. Wrong element types are compile errorsBeginner

Show that pushing a number into a string[] is rejected.

Show solution
const tags: string[] = ['@smoke'];
tags.push('@regression');
// @ts-expect-error: Argument of type 'number' is not assignable to parameter of type 'string'
tags.push(42);
console.log(tags.length);

Output

3

Explanation: the compiler stops the bad push. The line still runs here for the demo, which is why the length is 3; in a real build the error would stop you first.

3. Array<T> generic syntaxBeginner

Declare the same kind of array with Array&lt;number&gt; and sum it.

Show solution
const scores: Array<number> = [90, 75, 88];
let total = 0;
for (const s of scores) total += s;
console.log(`total ${total}, average ${(total / scores.length).toFixed(1)}`);

Output

total 253, average 84.3

Explanation: Array&lt;number&gt; and number[] mean exactly the same thing. Most code uses number[]; the generic form reads better for complex types.

4. Inferred array typeBeginner

Let TypeScript infer the type of an array literal and use a string method on an item.

Show solution
const pages = ['home', 'login', 'cart'];
const upper = pages.map(p => p.toUpperCase());
console.log(upper.join(' | '));

Output

HOME | LOGIN | CART

Explanation: the literal is inferred as string[], so p is a string inside map without any annotation.

5. Union element typeBeginner

Store test ids that may be numbers or strings and print each with its type.

Show solution
const ids: (string | number)[] = [101, 'TC-202', 303];
for (const id of ids) console.log(`${id} is ${typeof id}`);

Output

101 is number
TC-202 is string
303 is number

Explanation: the parentheses matter: (string | number)[] is an array of mixed items, while string | number[] would mean a string or an array of numbers.

6. Readonly arrayBeginner

Create a readonly string[] of environments and show that it cannot be changed.

Show solution
const envs: readonly string[] = ['dev', 'staging', 'prod'];
console.log(envs.includes('staging'));
// @ts-expect-error: Property 'push' does not exist on type 'readonly string[]'
const add = () => envs.push('qa');
console.log(envs.length, typeof add);

Output

true
3 function

Explanation: a readonly array has no mutating methods in its type, so push, pop and index assignment fail to compile.

7. Tuple for a key-value pairBeginner

Model a header as a [string, string] tuple and print it.

Show solution
const header: [string, string] = ['Content-Type', 'application/json'];
const [name, value] = header;
console.log(`${name}: ${value}`);

Output

Content-Type: application/json

Explanation: a tuple fixes both the length and the type at each position, and destructuring keeps those types.

8. Tuple with mixed typesBeginner

Store a test result as [string, boolean, number].

Show solution
const result: [string, boolean, number] = ['login test', true, 1240];
const [title, passed, ms] = result;
console.log(`${title}: ${passed ? 'PASS' : 'FAIL'} in ${ms}ms`);

Output

login test: PASS in 1240ms

Explanation: each position has its own type, so passed is a boolean and ms is a number after destructuring.

9. Filter with a typed callbackBeginner

Keep only the failed tests from a list of statuses.

Show solution
const statuses: string[] = ['passed', 'failed', 'passed', 'skipped', 'failed'];
const failed = statuses.filter(s => s === 'failed');
console.log(`${failed.length} failed of ${statuses.length}`);

Output

2 failed of 5

Explanation: filter returns the same element type, string[], so the result needs no annotation.

10. Map numbers to stringsBeginner

Turn durations in milliseconds into labelled seconds.

Show solution
const durations: number[] = [1200, 450, 3100];
const labels: string[] = durations.map(ms => `${(ms / 1000).toFixed(2)}s`);
console.log(labels.join(', '));

Output

1.20s, 0.45s, 3.10s

Explanation: map changes the element type from number to string, and the annotation on labels confirms it.

TypeScript Intermediate Programs

11. reduce with a typed accumulatorIntermediate

Total the run time of tests with reduce.

Show solution
const runs: number[] = [320, 1500, 780, 95];
const totalMs = runs.reduce((sum: number, ms: number) => sum + ms, 0);
console.log(`total ${totalMs}ms`);

Output

total 2695ms

Explanation: the starting value 0 gives the accumulator its number type, so the result is a number.

12. find returns T | undefinedIntermediate

Find a user by role and handle the case where none matches.

Show solution
const users: string[] = ['admin@test.io', 'viewer@test.io'];
const editor = users.find(u => u.startsWith('editor'));
console.log(editor ?? 'no editor found');
const admin = users.find(u => u.startsWith('admin'));
if (admin) console.log(admin.toUpperCase());

Output

no editor found
ADMIN@TEST.IO

Explanation: find returns string | undefined, so TypeScript makes you check the result before calling string methods on it.

13. Array of objectsIntermediate

Type a list of test cases and print the ones tagged as smoke tests.

Show solution
type TestCase = { id: number; title: string; tags: string[] };
const cases: TestCase[] = [
  { id: 1, title: 'login', tags: ['smoke'] },
  { id: 2, title: 'search', tags: ['regression'] },
  { id: 3, title: 'checkout', tags: ['smoke', 'payments'] },
];
const smoke = cases.filter(c => c.tags.includes('smoke')).map(c => c.title);
console.log(smoke.join(', '));

Output

login, checkout

Explanation: once the element type is named, every callback knows the shape of c, and misspelled properties fail to compile.

14. Sort numbers correctlyIntermediate

Sort response times in ascending order.

Show solution
const times: number[] = [250, 1200, 90, 400];
const sorted = [...times].sort((a, b) => a - b);
console.log(sorted.join(' < '));
console.log(times.join(', '));

Output

90 < 250 < 400 < 1200
250, 1200, 90, 400

Explanation: without a compare function sort compares as strings, so 1200 would come before 250. Copying with spread keeps the original untouched.

15. Sort objects by a propertyIntermediate

Sort test results from slowest to fastest.

Show solution
type Result = { name: string; ms: number };
const results: Result[] = [
  { name: 'login', ms: 820 },
  { name: 'search', ms: 2100 },
  { name: 'logout', ms: 300 },
];
results.sort((a, b) => b.ms - a.ms);
console.log(results.map(r => `${r.name}:${r.ms}`).join(' '));

Output

search:2100 login:820 logout:300

Explanation: the compare function is typed from Result, so a.ms and b.ms are numbers.

16. Remove duplicates with SetIntermediate

Remove duplicate tags while keeping a string[] type.

Show solution
const tags: string[] = ['smoke', 'ui', 'smoke', 'api', 'ui'];
const unique: string[] = [...new Set(tags)];
console.log(unique.join(', '));

Output

smoke, ui, api

Explanation: new Set(tags) is a Set&lt;string&gt;, and spreading it back gives a string[].

17. Type guard in filterIntermediate

Remove null values from a list and get a string[] back.

Show solution
const raw: (string | null)[] = ['a@test.io', null, 'b@test.io', null];
const emails = raw.filter((e): e is string => e !== null);
console.log(emails.map(e => e.toUpperCase()).join(', '));

Output

A@TEST.IO, B@TEST.IO

Explanation: the e is string predicate tells TypeScript the result is string[]. Without it the type would stay (string | null)[].

18. Optional tuple elementIntermediate

Model a viewport as [width, height, scale?].

Show solution
type Viewport = [number, number, number?];
const desktop: Viewport = [1280, 720];
const retina: Viewport = [1440, 900, 2];
for (const [w, h, scale = 1] of [desktop, retina]) console.log(`${w}x${h} @${scale}x`);

Output

1280x720 @1x
1440x900 @2x

Explanation: the ? makes the last position optional, and a default in the destructuring fills it in.

19. Named tuple elementsIntermediate

Use labelled tuple members to describe a retry setting.

Show solution
type RetryPolicy = [attempts: number, delayMs: number];
const policy: RetryPolicy = [3, 500];
const [attempts, delayMs] = policy;
console.log(`retry ${attempts} times every ${delayMs}ms`);

Output

retry 3 times every 500ms

Explanation: labels do not change the runtime value, but they show up in editor hints, which makes tuples self-documenting.

20. Readonly tuple with as constIntermediate

Freeze a list of browsers with as const and use it as a union type.

Show solution
const BROWSERS = ['chromium', 'firefox', 'webkit'] as const;
type Browser = (typeof BROWSERS)[number];
function launch(b: Browser): string {
  return `launching ${b}`;
}
console.log(launch('firefox'));
console.log(BROWSERS.length);

Output

launching firefox
3

Explanation: as const makes a readonly tuple of literal types, and [number] turns it into the union 'chromium' | 'firefox' | 'webkit'.

21. Rest elements in a tupleIntermediate

Type a log line as a level followed by any number of messages.

Show solution
type LogLine = [level: 'info' | 'error', ...messages: string[]];
const lines: LogLine[] = [
  ['info', 'page loaded'],
  ['error', 'timeout', 'retrying'],
];
for (const [level, ...msgs] of lines) console.log(`[${level}] ${msgs.join(' / ')}`);

Output

[info] page loaded
[error] timeout / retrying

Explanation: a rest element lets a tuple have a fixed start and a variable tail, still fully typed.

22. Two-dimensional arrayIntermediate

Store a small test data grid as string[][] and print it.

Show solution
const grid: string[][] = [
  ['user', 'role'],
  ['ana', 'admin'],
  ['ben', 'viewer'],
];
for (const row of grid) console.log(row.join(' | '));
console.log(`rows ${grid.length}, cols ${grid[0].length}`);

Output

user | role
ana | admin
ben | viewer
rows 3, cols 2

Explanation: string[][] is an array whose items are string[], so each row has array methods.

TypeScript Real-World QA Scenario Programs

23. Group items into a recordReal-world

Group test names by status into Record&lt;string, string[]&gt;.

Show solution
type Run = { name: string; status: 'passed' | 'failed' | 'skipped' };
const runs: Run[] = [
  { name: 'login', status: 'passed' },
  { name: 'cart', status: 'failed' },
  { name: 'search', status: 'passed' },
  { name: 'profile', status: 'skipped' },
];
const byStatus: Record<string, string[]> = {};
for (const r of runs) (byStatus[r.status] ??= []).push(r.name);
for (const [status, names] of Object.entries(byStatus)) console.log(`${status}: ${names.join(', ')}`);

Output

passed: login, search
failed: cart
skipped: profile

Explanation: ??= creates the list the first time a status appears, and the record type keeps every value a string[].

24. Chunk an array genericallyReal-world

Split a list of test files into batches of a given size.

Show solution
function chunk<T>(items: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
  return out;
}
const files = ['a.spec', 'b.spec', 'c.spec', 'd.spec', 'e.spec'];
chunk(files, 2).forEach((batch, i) => console.log(`worker ${i + 1}: ${batch.join(', ')}`));

Output

worker 1: a.spec, b.spec
worker 2: c.spec, d.spec
worker 3: e.spec

Explanation: the generic T means the same function chunks strings, numbers or objects, and the result keeps the element type.

25. Generic last-item helperReal-world

Write last&lt;T&gt;(items: T[]): T | undefined.

Show solution
function last<T>(items: readonly T[]): T | undefined {
  return items[items.length - 1];
}
console.log(last(['step 1', 'step 2', 'step 3']));
console.log(last([10, 20]));
console.log(last([]) ?? 'empty');

Output

step 3
20
empty

Explanation: the return type includes undefined because the array may be empty. Taking readonly T[] lets callers pass frozen arrays too.

26. Zip two arrays into tuplesReal-world

Pair test names with their durations.

Show solution
function zip<A, B>(a: A[], b: B[]): [A, B][] {
  const n = Math.min(a.length, b.length);
  const out: [A, B][] = [];
  for (let i = 0; i < n; i++) out.push([a[i], b[i]]);
  return out;
}
const pairs = zip(['login', 'search', 'cart'], [820, 2100, 640]);
for (const [name, ms] of pairs) console.log(`${name.padEnd(7)}${ms}ms`);

Output

login  820ms
search 2100ms
cart   640ms

Explanation: two type parameters keep each side’s type, so name is a string and ms is a number in the loop.

27. Partition into pass and failReal-world

Split results into two arrays in one pass, returning a tuple.

Show solution
function partition<T>(items: T[], test: (item: T) => boolean): [T[], T[]] {
  const yes: T[] = [];
  const no: T[] = [];
  for (const item of items) (test(item) ? yes : no).push(item);
  return [yes, no];
}
const codes = [200, 404, 201, 500, 204];
const [ok, bad] = partition(codes, c => c < 400);
console.log(`ok: ${ok.join(',')} | bad: ${bad.join(',')}`);

Output

ok: 200,201,204 | bad: 404,500

Explanation: returning a tuple [T[], T[]] lets the caller destructure both groups with the right types.

28. Count by key with a MapReal-world

Count how many times each error message appears.

Show solution
const errors: string[] = ['timeout', 'not found', 'timeout', 'timeout', 'not found', 'crash'];
const counts = new Map<string, number>();
for (const e of errors) counts.set(e, (counts.get(e) ?? 0) + 1);
const top = [...counts].sort((a, b) => b[1] - a[1]);
for (const [msg, n] of top) console.log(`${n} x ${msg}`);

Output

3 x timeout
2 x not found
1 x crash

Explanation: Map&lt;string, number&gt; keeps keys and counts typed, and spreading a map gives [string, number][] for sorting.

29. Flatten nested arraysReal-world

Flatten a list of test suites into one list of tests.

Show solution
const suites: string[][] = [['login', 'logout'], ['search'], ['cart', 'checkout']];
const tests: string[] = suites.flat();
console.log(`${tests.length} tests: ${tests.join(', ')}`);
const steps = suites.flatMap(s => s.map(t => `run ${t}`));
console.log(steps[0], '...', steps[steps.length - 1]);

Output

5 tests: login, logout, search, cart, checkout
run login ... run checkout

Explanation: flat() on string[][] returns string[], and flatMap maps and flattens one level in a single call.

30. Pass rate per suiteReal-world

Compute the pass rate for each suite from a typed array of results.

Show solution
type Outcome = { suite: string; passed: boolean };
const outcomes: Outcome[] = [
  { suite: 'auth', passed: true },
  { suite: 'auth', passed: false },
  { suite: 'cart', passed: true },
  { suite: 'cart', passed: true },
];
const stats = new Map<string, [pass: number, total: number]>();
for (const o of outcomes) {
  const [pass, total] = stats.get(o.suite) ?? [0, 0];
  stats.set(o.suite, [pass + (o.passed ? 1 : 0), total + 1]);
}
for (const [suite, [pass, total]] of stats) console.log(`${suite}: ${Math.round((pass / total) * 100)}%`);

Output

auth: 50%
cart: 100%

Explanation: storing a named tuple in the map keeps two related numbers together with clear labels.

31. Readonly parameter protects the callerReal-world

Write a function that returns a sorted copy without changing a readonly input.

Show solution
function sortedCopy(values: readonly number[]): number[] {
  // @ts-expect-error: Property 'sort' does not exist on type 'readonly number[]'
  const misuse = () => values.sort();
  return [...values].sort((a, b) => a - b);
}
const original = [3, 1, 2] as const;
console.log(sortedCopy(original).join(','), original.join(','));

Output

1,2,3 3,1,2

Explanation: sort changes the array in place, so it is not allowed on a readonly type. Copy first, then sort; the caller’s data stays the same.

Keep Practising

TypeScript Functions Practice Programs with Solutions (31 Programs)

Practise TypeScript functions with 31 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 Functions lesson first.

TypeScript Beginner Programs

1. Typed parameters and return typeBeginner

Write add(a: number, b: number): number and call it.

Show solution
function add(a: number, b: number): number {
  return a + b;
}
console.log(add(12, 30));

Output

42

Explanation: annotating parameters and the return type documents the contract and lets the compiler check every call.

2. Wrong argument types are compile errorsBeginner

Show that passing a string to a number parameter is rejected.

Show solution
function square(n: number): number {
  return n * n;
}
console.log(square(9));
// @ts-expect-error: Argument of type 'string' is not assignable to parameter of type 'number'
console.log(square('9'));

Output

81
81

Explanation: the call with a string fails to compile. In plain JavaScript '9' * '9' would silently coerce to 81, hiding the bug.

3. Arrow function with typesBeginner

Write a typed arrow function that builds a full name.

Show solution
const fullName = (first: string, last: string): string => `${first} ${last}`;
console.log(fullName('Asha', 'Sharma'));

Output

Asha Sharma

Explanation: arrow functions take the same parameter and return annotations as regular functions.

4. Optional parameterBeginner

Write greet(name, title?) where the title is optional.

Show solution
function greet(name: string, title?: string): string {
  return title ? `Hello, ${title} ${name}` : `Hello, ${name}`;
}
console.log(greet('Ravi'));
console.log(greet('Ravi', 'Dr.'));

Output

Hello, Ravi
Hello, Dr. Ravi

Explanation: title? has the type string | undefined, so the function checks it before using it.

5. Default parameterBeginner

Write openPage(path, timeout = 30000) and call it with and without a timeout.

Show solution
function openPage(path: string, timeout: number = 30000): string {
  return `${path} (${timeout} ms)`;
}
console.log(openPage('/login'));
console.log(openPage('/reports', 60000));

Output

/login (30000 ms)
/reports (60000 ms)

Explanation: a default value makes the parameter optional for callers but always a number inside the function.

6. void return typeBeginner

Write a logger that returns nothing.

Show solution
function logStep(step: string): void {
  console.log(`[STEP] ${step}`);
}
logStep('Open login page');
logStep('Submit form');

Output

[STEP] Open login page
[STEP] Submit form

Explanation: void tells callers not to use the return value.

7. Rest parametersBeginner

Write sum(...values: number[]) that adds any number of arguments.

Show solution
function sum(...values: number[]): number {
  return values.reduce((total, v) => total + v, 0);
}
console.log(sum(), sum(5), sum(1, 2, 3, 4));

Output

0 5 10

Explanation: a rest parameter collects the arguments into a typed array.

8. Return an objectBeginner

Return min, max and average from a function with an object return type.

Show solution
function stats(values: number[]): { min: number; max: number; avg: number } {
  const total = values.reduce((a, b) => a + b, 0);
  return { min: Math.min(...values), max: Math.max(...values), avg: total / values.length };
}
console.log(stats([120, 340, 95, 410]));

Output

{ min: 95, max: 410, avg: 241.25 }

Explanation: the return type describes the object, so callers get autocomplete for min, max and avg.

9. Function type aliasBeginner

Declare a Formatter type and implement two formatters.

Show solution
type Formatter = (value: number) => string;
const asCurrency: Formatter = v => `₹${v.toFixed(2)}`;
const asPercent: Formatter = v => `${(v * 100).toFixed(1)}%`;
console.log(asCurrency(1299.5), asPercent(0.925));

Output

₹1299.50 92.5%

Explanation: a function type describes parameters and return type once, and every assigned function must match it.

10. Boolean-returning helperBeginner

Write isValidEmail(value: string): boolean.

Show solution
function isValidEmail(value: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
console.log(isValidEmail('qa@test.com'), isValidEmail('qa@test'));

Output

true false

Explanation: helpers that return booleans read naturally inside if conditions and assertions.

TypeScript Intermediate Programs

11. Union parameter with narrowingIntermediate

Accept an ID as a number or a string and normalise it.

Show solution
function normaliseId(id: number | string): string {
  return typeof id === 'number' ? `ID-${id.toString().padStart(4, '0')}` : id.toUpperCase();
}
console.log(normaliseId(42), normaliseId('id-0042'));

Output

ID-0042 ID-0042

Explanation: narrowing with typeof lets one function handle both types safely.

12. Function overloadsIntermediate

Write parse(value) that returns a number for numeric strings and a boolean for 'true'/'false'.

Show solution
function parse(value: 'true' | 'false'): boolean;
function parse(value: string): number;
function parse(value: string): number | boolean {
  if (value === 'true' || value === 'false') return value === 'true';
  return Number(value);
}
const flag: boolean = parse('true');
const count: number = parse('42');
console.log(flag, count + 1);

Output

true 43

Explanation: overload signatures give each kind of call its own precise return type.

13. Callback parameterIntermediate

Write forEachResult(results, callback) with a typed callback.

Show solution
type Result = { name: string; passed: boolean };
function forEachResult(results: Result[], callback: (r: Result, index: number) => void): void {
  results.forEach((r, i) => callback(r, i));
}
forEachResult([{ name: 'login', passed: true }, { name: 'cart', passed: false }], (r, i) =>
  console.log(`${i + 1}. ${r.name}: ${r.passed ? 'pass' : 'fail'}`)
);

Output

1. login: pass
2. cart: fail

Explanation: the callback’s type gives the arrow function’s parameters their types automatically.

14. Higher-order function returning a functionIntermediate

Write makeMultiplier(factor) returning a typed function.

Show solution
function makeMultiplier(factor: number): (n: number) => number {
  return n => n * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(21), triple(7));

Output

42 21

Explanation: the return type (n: number) =&gt; number describes the function being returned.

15. Closure with private stateIntermediate

Create a typed counter with increment and reset.

Show solution
function createCounter(start: number = 0): { next: () => number; reset: () => void } {
  let count = start;
  return {
    next: () => ++count,
    reset: () => { count = start; }
  };
}
const counter = createCounter(10);
console.log(counter.next(), counter.next());
counter.reset();
console.log(counter.next());

Output

11 12
11

Explanation: count is captured by the closure and can only change through the returned methods.

16. Generic identity-style helperIntermediate

Write first&lt;T&gt;(items: T[]): T | undefined.

Show solution
function first<T>(items: T[]): T | undefined {
  return items[0];
}
const browser = first(['chromium', 'firefox']);
const score = first([98, 87]);
const none = first<string>([]);
console.log(browser?.toUpperCase(), score?.toFixed(1), none ?? 'empty');

Output

CHROMIUM 98.0 empty

Explanation: the type parameter T is inferred from the argument, so the return type matches the array’s element type.

17. Destructured parameter with defaultsIntermediate

Accept an options object with defaults.

Show solution
type Options = { retries?: number; timeout?: number; headless?: boolean };
function describe({ retries = 0, timeout = 30000, headless = true }: Options = {}): string {
  return `retries=${retries}, timeout=${timeout}, headless=${headless}`;
}
console.log(describe());
console.log(describe({ retries: 2, headless: false }));

Output

retries=0, timeout=30000, headless=true
retries=2, timeout=30000, headless=false

Explanation: destructuring in the parameter list with defaults keeps calls short when many settings are optional.

18. Recursion with typesIntermediate

Flatten nested arrays of numbers recursively.

Show solution
type Nested = number | Nested[];
function flatten(value: Nested): number[] {
  return Array.isArray(value) ? value.flatMap(flatten) : [value];
}
console.log(flatten([1, [2, [3, [4]]], 5]).join(','));

Output

1,2,3,4,5

Explanation: a recursive type alias describes arbitrarily nested data, and the recursive function mirrors it.

19. Assertion functionIntermediate

Write assertNumber(value) that narrows unknown after the call.

Show solution
function assertNumber(value: unknown, name: string): asserts value is number {
  if (typeof value !== 'number' || Number.isNaN(value)) throw new Error(`${name} must be a number`);
}
const raw: unknown = JSON.parse('{"workers": 4}').workers;
assertNumber(raw, 'workers');
console.log(raw * 2);

Output

8

Explanation: after assertNumber returns, TypeScript treats raw as a number.

20. Async function return typeIntermediate

Write an async function that returns Promise&lt;string&gt;.

Show solution
async function fetchTitle(path: string): Promise<string> {
  await new Promise(resolve => setTimeout(resolve, 10));
  return `Title of ${path}`;
}
const title: string = await fetchTitle('/home');
console.log(title);

Output

Title of /home

Explanation: an async function always returns a Promise. Awaiting it gives the inner type.

TypeScript Real-World QA Scenario Programs

21. Typed assertion helperReal-world

Write expectEqual&lt;T&gt;(actual, expected, label) that reports PASS or FAIL.

Show solution
function expectEqual<T>(actual: T, expected: T, label: string): boolean {
  const ok = actual === expected;
  console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` (expected ${String(expected)}, got ${String(actual)})`}`);
  return ok;
}
expectEqual('Shop'.length, 4, 'title length');
expectEqual('cart'.toUpperCase(), 'CART', 'upper case');
expectEqual(0.1 + 0.2, 0.3, 'float sum');

Output

PASS title length
PASS upper case
FAIL float sum (expected 0.3, got 0.30000000000000004)

Explanation: the generic T makes actual and expected the same type, like Playwright’s expect(actual).toBe(expected).

22. URL builderReal-world

Write buildUrl(base, path, params?) with a typed query object.

Show solution
function buildUrl(base: string, path: string, params: Record<string, string | number> = {}): string {
  const url = new URL(path, base);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  return url.toString();
}
console.log(buildUrl('https://shop.test', '/search', { q: 'mouse', page: 2 }));

Output

https://shop.test/search?q=mouse&page=2

Explanation: a Record type keeps query values to strings or numbers, and URL encodes them.

23. Retry wrapper with genericsReal-world

Write retry&lt;T&gt;(fn, attempts) that returns the result of a successful attempt.

Show solution
function retry<T>(fn: (attempt: number) => T, attempts: number): T {
  let lastError: unknown;
  for (let i = 1; i <= attempts; i++) {
    try {
      return fn(i);
    } catch (e) {
      lastError = e;
      console.log(`attempt ${i} failed`);
    }
  }
  throw lastError;
}
const value = retry(attempt => {
  if (attempt < 3) throw new Error('not ready');
  return `ready on attempt ${attempt}`;
}, 5);
console.log(value);

Output

attempt 1 failed
attempt 2 failed
ready on attempt 3

Explanation: the generic return type means retry returns exactly what the wrapped function returns.

24. Async retry with delayReal-world

Write an async retry helper for flaky API calls.

Show solution
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
async function retryAsync<T>(fn: () => Promise<T>, attempts: number, delayMs: number): Promise<T> {
  for (let i = 1; ; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i >= attempts) throw e;
      console.log(`retry ${i}: ${e instanceof Error ? e.message : e}`);
      await sleep(delayMs);
    }
  }
}
let calls = 0;
const data = await retryAsync(async () => {
  if (++calls < 3) throw new Error('503');
  return { items: 3 };
}, 4, 10);
console.log('items:', data.items);

Output

retry 1: 503
retry 2: 503
items: 3

Explanation: return await inside try lets rejections reach catch. The generic T keeps the result typed.

25. Duration formatterReal-world

Format milliseconds into a readable duration.

Show solution
function formatDuration(ms: number): string {
  if (ms < 1000) return `${ms} ms`;
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
  const m = Math.floor(ms / 60_000);
  const s = Math.round((ms % 60_000) / 1000);
  return `${m}m ${s}s`;
}
console.log([850, 2500, 125_000].map(formatDuration).join(' | '));

Output

850 ms | 2.5 s | 2m 5s

Explanation: one typed formatter keeps reports consistent. Numeric separators such as 60_000 improve readability.

26. Validation returning a result typeReal-world

Validate a signup form and return a typed result.

Show solution
type Signup = { email: string; password: string; age: number };
type Validation = { valid: true } | { valid: false; errors: string[] };
function validate(form: Signup): Validation {
  const errors: string[] = [];
  if (!form.email.includes('@')) errors.push('invalid email');
  if (form.password.length < 8) errors.push('password too short');
  if (form.age < 18) errors.push('must be 18+');
  return errors.length ? { valid: false, errors } : { valid: true };
}
for (const f of [{ email: 'qa@test.com', password: 'Secret123', age: 30 }, { email: 'qa', password: '123', age: 16 }]) {
  const r = validate(f);
  console.log(r.valid ? 'valid' : r.errors.join(', '));
}

Output

valid
invalid email, password too short, must be 18+

Explanation: the union result type forces callers to check valid before reading errors.

27. Data builder with PartialReal-world

Write buildUser(overrides: Partial&lt;User&gt;) for test data.

Show solution
interface User { name: string; email: string; role: 'admin' | 'viewer'; active: boolean }
function buildUser(overrides: Partial<User> = {}): User {
  return { name: 'QA User', email: 'qa.user@example.com', role: 'viewer', active: true, ...overrides };
}
const admin = buildUser({ role: 'admin', name: 'Admin' });
console.log(admin.name, admin.role, admin.active);

Output

Admin admin true

Explanation: Partial&lt;User&gt; lets each test override only the fields it cares about, while the result is always a complete User.

28. Page-object style functionsReal-world

Write a typed factory that returns login page actions.

Show solution
interface LoginPage {
  fill(field: 'email' | 'password', value: string): LoginPage;
  submit(): LoginPage;
  log(): string;
}
function createLoginPage(): LoginPage {
  const actions: string[] = [];
  const page: LoginPage = {
    fill(field, value) { actions.push(`fill ${field}=${field === 'password' ? '***' : value}`); return page; },
    submit() { actions.push('submit'); return page; },
    log() { return actions.join(' -> '); }
  };
  return page;
}
console.log(createLoginPage().fill('email', 'qa@test.com').fill('password', 'secret').submit().log());

Output

fill email=qa@test.com -> fill password=*** -> submit

Explanation: the interface types the chainable methods, and field only accepts the two known field names.

29. Debounce with genericsReal-world

Write a typed debounce for a search handler.

Show solution
function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number): (...args: A) => void {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: A) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}
const search = debounce((term: string) => console.log('search:', term), 20);
search('p'); search('pla'); search('playwright');
await new Promise(r => setTimeout(r, 50));

Output

search: playwright

Explanation: the generic A preserves the wrapped function’s parameter types, so search(123) would be a compile error.

30. Compose functionsReal-world

Combine string transformers into a slug function.

Show solution
type Transform = (s: string) => string;
const pipe = (...fns: Transform[]): Transform => input => fns.reduce((acc, fn) => fn(acc), input);
const slugify = pipe(s => s.trim(), s => s.toLowerCase(), s => s.replace(/[^a-z0-9]+/g, '-'), s => s.replace(/^-|-$/g, ''));
console.log(slugify('  Playwright: Getting Started!  '));

Output

playwright-getting-started

Explanation: small typed transforms combine into a pipeline that is easy to test step by step.

31. Typed event handlers mapReal-world

Map test events to typed handler functions.

Show solution
type TestEvent = 'start' | 'pass' | 'fail';
const handlers: Record<TestEvent, (name: string) => string> = {
  start: n => `▶ ${n}`,
  pass: n => `✔ ${n}`,
  fail: n => `✘ ${n}`
};
const events: [TestEvent, string][] = [['start', 'login'], ['pass', 'login'], ['start', 'cart'], ['fail', 'cart']];
for (const [event, name] of events) console.log(handlers[event](name));

Output

▶ login
✔ login
▶ cart
✘ cart

Explanation: a Record of functions requires a handler for every event, and each handler has the same signature.

Keep Practising

TypeScript Loops Practice Programs with Solutions (30 Programs)

Practise TypeScript loops 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 Loops lesson first.

TypeScript Beginner Programs

1. Sum 1 to n with a typed for loopBeginner

Write sumTo(n: number): number using a for loop.

Show solution
function sumTo(n: number): number {
  let total = 0;
  for (let i = 1; i <= n; i++) total += i;
  return total;
}
console.log(sumTo(10), sumTo(100));

Output

55 5050

Explanation: the loop counter and accumulator are inferred as number, and the return type documents the result.

2. Loop over a typed arrayBeginner

Print each browser from a string[] with for...of.

Show solution
const browsers: string[] = ['chromium', 'firefox', 'webkit'];
for (const browser of browsers) {
  console.log(`Run on ${browser.toUpperCase()}`);
}

Output

Run on CHROMIUM
Run on FIREFOX
Run on WEBKIT

Explanation: for...of infers the item type, here string, so string methods are available.

3. Countdown with whileBeginner

Count down from 3 to 1 with a while loop.

Show solution
let remaining: number = 3;
while (remaining > 0) {
  console.log(remaining);
  remaining--;
}
console.log('Liftoff');

Output

3
2
1
Liftoff

Explanation: a while loop repeats while its condition is true. Update the variable so it ends.

4. Multiplication tableBeginner

Print the 5 times table with a typed helper.

Show solution
function tableLine(n: number, i: number): string {
  return `${n} x ${i} = ${n * i}`;
}
for (let i = 1; i <= 5; i++) console.log(tableLine(5, i));

Output

5 x 1 = 5
5 x 2 = 10
5 x 3 = 15
5 x 4 = 20
5 x 5 = 25

Explanation: a small typed helper keeps the loop body short and the formatting reusable.

5. Index and value with entriesBeginner

Print numbered test steps using entries().

Show solution
const steps: string[] = ['open page', 'login', 'add to cart'];
for (const [i, step] of steps.entries()) console.log(`${i + 1}. ${step}`);

Output

1. open page
2. login
3. add to cart

Explanation: entries() yields typed [number, string] pairs that destructure cleanly.

6. Loop over object keys safelyBeginner

Print a typed config with Object.entries.

Show solution
const config: Record<string, string | number> = { browser: 'chromium', workers: 4, retries: 1 };
for (const [key, value] of Object.entries(config)) console.log(`${key}=${value}`);

Output

browser=chromium
workers=4
retries=1

Explanation: Object.entries returns [string, value][], which avoids the indexing errors you get with for...in.

7. Even numbers into a typed arrayBeginner

Collect even numbers up to 12 into a number[].

Show solution
const evens: number[] = [];
for (let n = 2; n <= 12; n += 2) evens.push(n);
console.log(evens.join(', '));

Output

2, 4, 6, 8, 10, 12

Explanation: the array’s type is declared up front, so only numbers can be pushed.

8. do…while for at least one attemptBeginner

Try an action at least once with do...while.

Show solution
let attempt: number = 0;
let success: boolean = false;
do {
  attempt++;
  success = attempt >= 2;
  console.log(`attempt ${attempt}: ${success ? 'ok' : 'retry'}`);
} while (!success && attempt < 5);

Output

attempt 1: retry
attempt 2: ok

Explanation: do...while checks the condition after the body, so the action always runs once.

9. Characters of a stringBeginner

Count uppercase letters in a string.

Show solution
const text: string = 'Playwright Test Runner';
let upper = 0;
for (const ch of text) if (ch >= 'A' && ch <= 'Z') upper++;
console.log('Uppercase letters:', upper);

Output

Uppercase letters: 3

Explanation: strings are iterable, and each ch is typed as string.

10. break and continueBeginner

Print odd numbers below 10 and stop when a number above 7 is reached.

Show solution
const odd: number[] = [];
for (let i = 1; i < 10; i++) {
  if (i % 2 === 0) continue;
  if (i > 7) break;
  odd.push(i);
}
console.log(odd.join(' '));

Output

1 3 5 7

Explanation: continue skips even numbers and break leaves the loop once the limit is passed.

TypeScript Intermediate Programs

11. Loop over a union array with narrowingIntermediate

Sum the numbers in a mixed array and count the strings.

Show solution
const values: (number | string)[] = [10, 'ten', 20, 'twenty', 5];
let sum = 0, strings = 0;
for (const v of values) {
  if (typeof v === 'number') sum += v;
  else strings++;
}
console.log(`sum=${sum}, strings=${strings}`);

Output

sum=35, strings=2

Explanation: typeof narrows each item, so += is only used on numbers.

12. Loop over a typed object arrayIntermediate

Print users who are active, using an interface for the array items.

Show solution
interface User { name: string; active: boolean; role: 'admin' | 'viewer' }
const users: User[] = [
  { name: 'Asha', active: true, role: 'admin' },
  { name: 'Ravi', active: false, role: 'viewer' },
  { name: 'Meera', active: true, role: 'viewer' }
];
for (const u of users) {
  if (!u.active) continue;
  console.log(`${u.name} (${u.role})`);
}

Output

Asha (admin)
Meera (viewer)

Explanation: the interface gives every loop item a known shape, so property typos are compile errors.

13. Typed accumulator with RecordIntermediate

Count statuses into a Record&lt;Status, number&gt;.

Show solution
type Status = 'passed' | 'failed' | 'skipped';
const results: Status[] = ['passed', 'failed', 'passed', 'skipped', 'passed'];
const counts: Record<Status, number> = { passed: 0, failed: 0, skipped: 0 };
for (const r of results) counts[r]++;
console.log(counts);

Output

{ passed: 3, failed: 1, skipped: 1 }

Explanation: the record type guarantees a counter exists for every status.

14. Nested loops for combinationsIntermediate

Build every browser and viewport combination.

Show solution
const browsers: string[] = ['chromium', 'webkit'];
const viewports: [number, number][] = [[1280, 720], [375, 812]];
const combos: string[] = [];
for (const b of browsers) {
  for (const [w, h] of viewports) combos.push(`${b}@${w}x${h}`);
}
console.log(combos.join(', '));

Output

chromium@1280x720, chromium@375x812, webkit@1280x720, webkit@375x812

Explanation: tuple types make it clear each viewport has exactly a width and a height.

15. Labeled breakIntermediate

Find the first cell with a negative value in a grid.

Show solution
const grid: number[][] = [[3, 5, 2], [7, -1, 4], [-6, 8, 9]];
let found: [number, number] | null = null;
search: for (let r = 0; r < grid.length; r++) {
  for (let c = 0; c < grid[r].length; c++) {
    if (grid[r][c] < 0) {
      found = [r, c];
      break search;
    }
  }
}
console.log(found);

Output

[ 1, 1 ]

Explanation: the label lets break exit both loops at once. The result type allows null when nothing is found.

16. Loop with a type guardIntermediate

Collect only valid emails from unknown input.

Show solution
const isString = (v: unknown): v is string => typeof v === 'string';
const input: unknown[] = ['qa@test.com', 42, 'bad-email', null, 'dev@shop.in'];
const emails: string[] = [];
for (const v of input) {
  if (isString(v) && v.includes('@')) emails.push(v);
}
console.log(emails.join(', '));

Output

qa@test.com, dev@shop.in

Explanation: the type guard narrows unknown to string, so includes is allowed.

17. Fibonacci into a typed arrayIntermediate

Generate the first 10 Fibonacci numbers.

Show solution
function fibonacci(count: number): number[] {
  const seq: number[] = [0, 1];
  while (seq.length < count) seq.push(seq[seq.length - 1] + seq[seq.length - 2]);
  return seq.slice(0, count);
}
console.log(fibonacci(10).join(' '));

Output

0 1 1 2 3 5 8 13 21 34

Explanation: the return type number[] documents the result, and slice handles counts below 2.

18. Prime numbers up to nIntermediate

Return all primes up to 30.

Show solution
function primesUpTo(n: number): number[] {
  const primes: number[] = [];
  for (let i = 2; i <= n; i++) {
    let isPrime = true;
    for (const p of primes) {
      if (p * p > i) break;
      if (i % p === 0) { isPrime = false; break; }
    }
    if (isPrime) primes.push(i);
  }
  return primes;
}
console.log(primesUpTo(30).join(' '));

Output

2 3 5 7 11 13 17 19 23 29

Explanation: dividing only by primes up to √i keeps the inner loop short.

19. Iterate a MapIntermediate

Loop over a Map&lt;string, number&gt; of response times.

Show solution
const timings = new Map<string, number>([['/login', 220], ['/cart', 480], ['/search', 130]]);
let slowest: [string, number] = ['', 0];
for (const [url, ms] of timings) {
  if (ms > slowest[1]) slowest = [url, ms];
}
console.log(`Slowest: ${slowest[0]} (${slowest[1]} ms)`);

Output

Slowest: /cart (480 ms)

Explanation: iterating a Map yields typed [key, value] pairs.

20. Reverse a string with a loopIntermediate

Reverse a string without using reverse().

Show solution
function reverse(text: string): string {
  let out = '';
  for (let i = text.length - 1; i >= 0; i--) out += text[i];
  return out;
}
console.log(reverse('TypeScript'));

Output

tpircSepyT

Explanation: counting down from the last index visits characters in reverse order.

TypeScript Real-World QA Scenario Programs

21. Retry until successReal-world

Retry a typed action until it succeeds or the attempts run out.

Show solution
type ActionResult = { ok: boolean; message: string };
let calls = 0;
const clickPay = (): ActionResult => (++calls < 3 ? { ok: false, message: 'button disabled' } : { ok: true, message: 'paid' });
const MAX = 5;
let result: ActionResult = { ok: false, message: 'not started' };
for (let attempt = 1; attempt <= MAX && !result.ok; attempt++) {
  result = clickPay();
  console.log(`attempt ${attempt}: ${result.message}`);
}

Output

attempt 1: button disabled
attempt 2: button disabled
attempt 3: paid

Explanation: the loop condition combines the attempt limit and the success flag, so it stops as soon as either is reached.

22. Poll a job statusReal-world

Poll a typed status sequence until it is done or failed.

Show solution
type JobStatus = 'queued' | 'running' | 'done' | 'failed';
const sequence: JobStatus[] = ['queued', 'running', 'running', 'done'];
let poll = 0;
let status: JobStatus = 'queued';
while (status !== 'done' && status !== 'failed' && poll < sequence.length) {
  status = sequence[poll++];
  console.log(`poll ${poll}: ${status}`);
}

Output

poll 1: queued
poll 2: running
poll 3: running
poll 4: done

Explanation: the literal union makes the stop conditions explicit and typo-proof.

23. Summarise test resultsReal-world

Calculate pass rate and total duration from typed results.

Show solution
interface TestResult { title: string; status: 'passed' | 'failed'; ms: number }
const results: TestResult[] = [
  { title: 'login', status: 'passed', ms: 1200 },
  { title: 'search', status: 'failed', ms: 3400 },
  { title: 'checkout', status: 'passed', ms: 5100 }
];
let passed = 0, totalMs = 0;
for (const r of results) {
  if (r.status === 'passed') passed++;
  totalMs += r.ms;
}
console.log(`${passed}/${results.length} passed in ${(totalMs / 1000).toFixed(1)}s`);

Output

2/3 passed in 9.7s

Explanation: a single typed loop gathers several statistics at once.

24. Find the first failureReal-world

Return the first failed test or undefined.

Show solution
interface Run { name: string; passed: boolean }
function firstFailure(runs: Run[]): Run | undefined {
  for (const run of runs) if (!run.passed) return run;
  return undefined;
}
const runs: Run[] = [{ name: 'login', passed: true }, { name: 'cart', passed: false }];
console.log(firstFailure(runs)?.name ?? 'all passed');
console.log(firstFailure([{ name: 'login', passed: true }])?.name ?? 'all passed');

Output

cart
all passed

Explanation: returning inside the loop stops at the first match. The | undefined return type forces callers to handle “not found”.

25. Cart total from typed itemsReal-world

Calculate a cart total and the most expensive line.

Show solution
interface CartLine { sku: string; price: number; qty: number }
const cart: CartLine[] = [{ sku: 'KB', price: 1499, qty: 1 }, { sku: 'MS', price: 599, qty: 2 }, { sku: 'CB', price: 199, qty: 3 }];
let total = 0;
let top: CartLine = cart[0];
for (const line of cart) {
  total += line.price * line.qty;
  if (line.price * line.qty > top.price * top.qty) top = line;
}
console.log('Total:', total, '| Biggest line:', top.sku);

Output

Total: 3294 | Biggest line: KB

Explanation: typed lines guarantee numeric prices and quantities for the arithmetic.

26. Generate typed test usersReal-world

Generate a list of typed test users with a counter loop.

Show solution
interface TestUser { id: number; email: string; role: 'admin' | 'viewer' }
const users: TestUser[] = [];
for (let i = 1; i <= 4; i++) {
  users.push({ id: i, email: `qa${i}@example.com`, role: i === 1 ? 'admin' : 'viewer' });
}
console.log(users.map(u => `${u.id}:${u.role}`).join(' '));

Output

1:admin 2:viewer 3:viewer 4:viewer

Explanation: pushing objects into a typed array checks each one against the interface.

27. Validate every form fieldReal-world

Loop over typed field rules and collect validation errors.

Show solution
interface Rule { field: string; value: string; minLength: number }
const rules: Rule[] = [
  { field: 'username', value: 'qa', minLength: 3 },
  { field: 'password', value: 'Secret123', minLength: 8 },
  { field: 'city', value: '', minLength: 1 }
];
const errors: string[] = [];
for (const r of rules) {
  if (r.value.trim().length < r.minLength) errors.push(`${r.field} needs ${r.minLength}+ characters`);
}
console.log(errors.length ? errors.join('; ') : 'All valid');

Output

username needs 3+ characters; city needs 1+ characters

Explanation: data-driven rules keep validation logic in one loop instead of repeated if blocks.

28. Paginate until an empty pageReal-world

Read typed pages until the API returns an empty list.

Show solution
const pages: string[][] = [['a', 'b'], ['c', 'd'], ['e'], []];
let page = 0;
const all: string[] = [];
while (true) {
  const items = pages[page];
  if (items.length === 0) break;
  all.push(...items);
  page++;
}
console.log(`${page} pages, ${all.length} items: ${all.join('')}`);

Output

3 pages, 5 items: abcde

Explanation: while (true) with a clear break condition is a common pattern for pagination.

29. Compare expected and actual listsReal-world

Report missing and unexpected menu items with typed arrays.

Show solution
const expected: readonly string[] = ['Home', 'Shop', 'Blog', 'Contact'];
const actual: string[] = ['Home', 'Shop', 'Offers', 'Contact'];
const missing: string[] = [];
const extra: string[] = [];
for (const item of expected) if (!actual.includes(item)) missing.push(item);
for (const item of actual) if (!expected.includes(item)) extra.push(item);
console.log('missing:', missing.join(', ') || '-', '| extra:', extra.join(', ') || '-');

Output

missing: Blog | extra: Offers

Explanation: two simple loops produce a clear diff, which is easier to debug than a single equality check.

30. Run async steps in orderReal-world

Run typed async steps sequentially with for...of and await.

Show solution
type Step = { name: string; ms: number };
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
const steps: Step[] = [{ name: 'open', ms: 20 }, { name: 'fill', ms: 5 }, { name: 'submit', ms: 10 }];
const done: string[] = [];
for (const step of steps) {
  await sleep(step.ms);
  done.push(step.name);
}
console.log(done.join(' -> '));

Output

open -> fill -> submit

Explanation: await inside for...of keeps the steps in order, the same way Playwright actions run one after another.

Keep Practising

TypeScript Conditions Practice Programs with Solutions (30 Programs)

Practise TypeScript conditions and narrowing 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 Conditions lesson first.

TypeScript Beginner Programs

1. Positive, negative or zeroBeginner

Write sign(n: number) that returns 'positive', 'negative' or 'zero'.

Show solution
type Sign = 'positive' | 'negative' | 'zero';
function sign(n: number): Sign {
  if (n > 0) return 'positive';
  if (n < 0) return 'negative';
  return 'zero';
}
console.log(sign(7), sign(-2), sign(0));

Output

positive negative zero

Explanation: the return type is a literal union, so every branch must return one of the allowed strings.

2. Pass or failBeginner

Return 'PASS' when a score is at least 60, otherwise 'FAIL'.

Show solution
function result(score: number): 'PASS' | 'FAIL' {
  if (score >= 60) {
    return 'PASS';
  } else {
    return 'FAIL';
  }
}
console.log(result(72), result(45));

Output

PASS FAIL

Explanation: if/else picks one of two outcomes. The return type documents both possibilities.

3. Grade bandsBeginner

Convert marks into grades A, B, C or F with an else if chain.

Show solution
type Grade = 'A' | 'B' | 'C' | 'F';
function grade(marks: number): Grade {
  if (marks >= 90) return 'A';
  else if (marks >= 75) return 'B';
  else if (marks >= 60) return 'C';
  else return 'F';
}
console.log([95, 80, 61, 30].map(grade).join(' '));

Output

A B C F

Explanation: checking the highest threshold first ensures each value lands in the right band.

4. Leap yearBeginner

Write isLeapYear(year: number): boolean and test three years.

Show solution
function isLeapYear(year: number): boolean {
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
for (const y of [2024, 1900, 2000]) console.log(y, isLeapYear(y));

Output

2024 true
1900 false
2000 true

Explanation: the whole condition is a boolean expression, so it can be returned directly.

5. switch on a string unionBeginner

Map a day name to a working-day flag with switch.

Show solution
type Day = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';
function isWorkingDay(day: Day): boolean {
  switch (day) {
    case 'Sat':
    case 'Sun':
      return false;
    default:
      return true;
  }
}
console.log(isWorkingDay('Mon'), isWorkingDay('Sun'));

Output

true false

Explanation: TypeScript checks that each case is a member of the Day union, so typos are caught.

6. Ternary with typesBeginner

Choose a label with a ternary and store it in a typed variable.

Show solution
const itemCount: number = 1;
const label: string = itemCount === 1 ? 'item' : 'items';
console.log(`${itemCount} ${label}`);

Output

1 item

Explanation: both branches return strings, so the result type is string.

7. Handle a nullable valueBeginner

Print a greeting when a name is present, otherwise a default message.

Show solution
function greet(name: string | null): string {
  if (name === null) return 'Hello, guest';
  return `Hello, ${name.toUpperCase()}`;
}
console.log(greet('asha'));
console.log(greet(null));

Output

Hello, ASHA
Hello, guest

Explanation: after the null check, TypeScript narrows name to string, so toUpperCase is allowed.

8. Optional parameter checkBeginner

Use a default when an optional parameter is missing.

Show solution
function openPage(path: string, timeout?: number): string {
  if (timeout === undefined) return `${path} (default timeout)`;
  return `${path} (${timeout} ms)`;
}
console.log(openPage('/login'));
console.log(openPage('/reports', 60000));

Output

/login (default timeout)
/reports (60000 ms)

Explanation: an optional parameter has type number | undefined, so you check for undefined before using it.

9. Truthiness narrowingBeginner

Print the length of a string only when it is non-empty.

Show solution
function describe(text?: string): string {
  if (text) return `${text.length} characters`;
  return 'empty or missing';
}
console.log(describe('Playwright'), '|', describe(''), '|', describe());

Output

10 characters | empty or missing | empty or missing

Explanation: if (text) removes undefined and also skips the empty string, which is falsy.

10. Largest of threeBeginner

Return the largest of three numbers with if conditions.

Show solution
function largest(a: number, b: number, c: number): number {
  if (a >= b && a >= c) return a;
  if (b >= c) return b;
  return c;
}
console.log(largest(12, 45, 30), largest(9, 3, 9));

Output

45 9

Explanation: once a is ruled out, only b and c remain to be compared.

TypeScript Intermediate Programs

11. typeof narrowingIntermediate

Format an input that may be a number, string or boolean.

Show solution
function format(value: number | string | boolean): string {
  if (typeof value === 'number') return value.toFixed(1);
  if (typeof value === 'boolean') return value ? 'yes' : 'no';
  return value.trim();
}
console.log(format(3.14159), format(true), format('  ok  '));

Output

3.1 yes ok

Explanation: each typeof check narrows the union, and the final return knows only string is left.

12. Discriminated union with ifIntermediate

Handle API responses that are either success or error.

Show solution
type Res = { status: 'ok'; data: string[] } | { status: 'error'; message: string };
function handle(res: Res): string {
  if (res.status === 'ok') return `Loaded ${res.data.length} rows`;
  return `Error: ${res.message}`;
}
console.log(handle({ status: 'ok', data: ['a', 'b'] }));
console.log(handle({ status: 'error', message: 'Unauthorized' }));

Output

Loaded 2 rows
Error: Unauthorized

Explanation: checking the shared status field narrows to the matching member of the union.

13. Exhaustive switch with neverIntermediate

Map test outcomes to symbols and make the compiler check every case.

Show solution
type Outcome = 'passed' | 'failed' | 'skipped' | 'flaky';
function symbol(o: Outcome): string {
  switch (o) {
    case 'passed': return '✔';
    case 'failed': return '✘';
    case 'skipped': return '○';
    case 'flaky': return '≈';
    default: {
      const unhandled: never = o;
      throw new Error(`Unhandled outcome: ${unhandled}`);
    }
  }
}
console.log((['passed', 'flaky', 'failed'] as Outcome[]).map(symbol).join(' '));

Output

✔ ≈ ✘

Explanation: adding a new outcome without a case makes the never assignment fail to compile.

14. in operator narrowingIntermediate

Tell a Card payment from a Upi payment.

Show solution
type Card = { cardNumber: string; expiry: string };
type Upi = { upiId: string };
function paymentLabel(p: Card | Upi): string {
  if ('cardNumber' in p) return `Card ending ${p.cardNumber.slice(-4)}`;
  return `UPI ${p.upiId}`;
}
console.log(paymentLabel({ cardNumber: '4111111111111234', expiry: '12/28' }));
console.log(paymentLabel({ upiId: 'qa@bank' }));

Output

Card ending 1234
UPI qa@bank

Explanation: checking for a property with in narrows a union of object types.

15. instanceof narrowingIntermediate

Handle a caught error safely, whatever was thrown.

Show solution
function message(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}
console.log(message(new Error('Timeout')), '|', message('Network down'), '|', message(42));

Output

Timeout | Network down | Unknown error

Explanation: in strict mode a caught error is unknown, so narrow it before reading message.

16. Custom type guardIntermediate

Write isNumberArray and use it in a condition.

Show solution
function isNumberArray(value: unknown): value is number[] {
  return Array.isArray(value) && value.every(v => typeof v === 'number');
}
for (const input of [[1, 2, 3], ['1', 2], 'x'] as unknown[]) {
  if (isNumberArray(input)) console.log('sum', input.reduce((a, b) => a + b, 0));
  else console.log('not a number array');
}

Output

sum 6
not a number array
not a number array

Explanation: the value is number[] return type narrows the variable in the if branch.

17. Equality narrowingIntermediate

Narrow two union-typed values by comparing them.

Show solution
function compare(a: string | number, b: string | boolean): string {
  if (a === b) return `both are the string "${a.toUpperCase()}"`;
  return 'different';
}
console.log(compare('qa', 'qa'));
console.log(compare(1, true));

Output

both are the string "QA"
different

Explanation: if a === b, both must be strings (the only shared type), so toUpperCase is allowed.

18. Lookup object instead of switchIntermediate

Map HTTP status codes to messages with a typed record and a default.

Show solution
const STATUS_TEXT: Record<number, string> = { 200: 'OK', 201: 'Created', 404: 'Not Found', 500: 'Server Error' };
function statusText(code: number): string {
  return STATUS_TEXT[code] ?? 'Unknown';
}
console.log([200, 404, 418].map(statusText).join(', '));

Output

OK, Not Found, Unknown

Explanation: a lookup table replaces long switch statements. ?? supplies the default.

19. Guard clauses with typesIntermediate

Validate an age value with early returns.

Show solution
function validateAge(age: unknown): string {
  if (typeof age !== 'number') return 'Age must be a number';
  if (!Number.isInteger(age)) return 'Age must be a whole number';
  if (age < 18) return 'Must be 18 or older';
  return 'Valid';
}
console.log([25, '25', 17.5, 16].map(validateAge).join(' | '));

Output

Valid | Age must be a number | Age must be a whole number | Must be 18 or older

Explanation: after the first guard, age is a number for all the following checks.

20. Assertion functionIntermediate

Write an assertDefined function that narrows a value after it runs.

Show solution
function assertDefined<T>(value: T | undefined, name: string): asserts value is T {
  if (value === undefined) throw new Error(`${name} is required`);
}
const env: Record<string, string | undefined> = { BASE_URL: 'https://qa.app.test' };
const baseUrl = env.BASE_URL;
assertDefined(baseUrl, 'BASE_URL');
console.log(baseUrl.toUpperCase());
try {
  assertDefined(env.TOKEN, 'TOKEN');
} catch (e) {
  console.log(e instanceof Error ? e.message : e);
}

Output

HTTPS://QA.APP.TEST
TOKEN is required

Explanation: asserts value is T tells TypeScript the value is defined after the call, like expect(...).toBeDefined() combined with type narrowing.

TypeScript Real-World QA Scenario Programs

21. Shipping cost rulesReal-world

Calculate shipping with typed thresholds, then test the boundary values.

Show solution
const FREE_ABOVE = 999, MID_FROM = 500;
function shipping(total: number): number {
  if (total > FREE_ABOVE) return 0;
  if (total >= MID_FROM) return 49;
  return 99;
}
for (const t of [499, 500, 999, 1000]) console.log(t, shipping(t));

Output

499 99
500 49
999 49
1000 0

Explanation: boundary values (499, 500, 999, 1000) are where off-by-one bugs appear, so test them explicitly.

22. Environment selectionReal-world

Choose a base URL from an environment name with an exhaustive switch.

Show solution
type Env = 'dev' | 'qa' | 'prod';
function baseUrl(env: Env): string {
  switch (env) {
    case 'dev': return 'http://localhost:3000';
    case 'qa': return 'https://qa.shop.test';
    case 'prod': return 'https://shop.test';
  }
}
console.log(baseUrl('qa'));

Output

https://qa.shop.test

Explanation: because every Env member returns, TypeScript knows the function never falls through without a value.

23. Password strength rulesReal-world

Return the first failing password rule, or 'Strong'.

Show solution
function passwordIssue(pw: string): string {
  if (pw.length < 8) return 'Too short';
  if (!/[A-Z]/.test(pw)) return 'Needs an uppercase letter';
  if (!/\d/.test(pw)) return 'Needs a digit';
  if (!/[^A-Za-z0-9]/.test(pw)) return 'Needs a symbol';
  return 'Strong';
}
for (const pw of ['abc', 'abcdefgh', 'Abcdefgh', 'Abcdefg1', 'Abcdef1!']) console.log(pw, '->', passwordIssue(pw));

Output

abc -> Too short
abcdefgh -> Needs an uppercase letter
Abcdefgh -> Needs a digit
Abcdefg1 -> Needs a symbol
Abcdef1! -> Strong

Explanation: ordered guard clauses mirror the order in which a form shows validation messages.

24. Classify an API responseReal-world

Classify typed responses into success, client error or server error.

Show solution
type ApiResponse = { status: number; body?: { items: string[] } };
function classify(r: ApiResponse): string {
  if (r.status >= 500) return 'server error';
  if (r.status >= 400) return 'client error';
  if (!r.body || r.body.items.length === 0) return 'success (empty)';
  const n = r.body.items.length;
  return `success (${n} item${n === 1 ? '' : 's'})`;
}
const responses: ApiResponse[] = [{ status: 200, body: { items: ['a'] } }, { status: 200 }, { status: 404 }, { status: 503 }];
for (const r of responses) console.log(r.status, classify(r));

Output

200 success (1 item)
200 success (empty)
404 client error
503 server error

Explanation: checking error statuses first and body before items keeps every access type-safe.

25. Feature access by planReal-world

Decide whether a plan can use a feature.

Show solution
type Plan = 'free' | 'pro' | 'enterprise';
type Feature = 'reports' | 'sso' | 'export';
function canUse(plan: Plan, feature: Feature): boolean {
  if (feature === 'sso') return plan === 'enterprise';
  if (feature === 'reports') return plan !== 'free';
  return true;
}
for (const plan of ['free', 'pro', 'enterprise'] as Plan[]) {
  console.log(plan, canUse(plan, 'reports'), canUse(plan, 'sso'), canUse(plan, 'export'));
}

Output

free false false true
pro true false true
enterprise true true true

Explanation: literal unions for both inputs make invalid plan or feature names a compile error.

26. Test priority triageReal-world

Assign P1, P2 or P3 to typed test cases.

Show solution
type TestCase = { id: string; area: string; blocker: boolean; regression: boolean };
type Priority = 'P1' | 'P2' | 'P3';
function priority(t: TestCase): Priority {
  if (t.area === 'checkout' || t.blocker) return 'P1';
  if (t.regression) return 'P2';
  return 'P3';
}
const tests: TestCase[] = [
  { id: 'TC1', area: 'checkout', blocker: false, regression: true },
  { id: 'TC2', area: 'search', blocker: false, regression: true },
  { id: 'TC3', area: 'help', blocker: false, regression: false }
];
console.log(tests.map(t => `${t.id}:${priority(t)}`).join(' '));

Output

TC1:P1 TC2:P2 TC3:P3

Explanation: the rule reads like the written triage policy, and the types document the inputs.

27. Flaky test classificationReal-world

Classify a test from its pass count over a number of runs.

Show solution
type Stability = 'stable' | 'flaky' | 'broken';
function stability(passes: number, runs: number): Stability {
  if (passes === runs) return 'stable';
  if (passes === 0) return 'broken';
  return 'flaky';
}
for (const [p, r] of [[10, 10], [7, 10], [0, 10]] as [number, number][]) console.log(`${p}/${r}`, stability(p, r));

Output

10/10 stable
7/10 flaky
0/10 broken

Explanation: the typed tuple array makes it clear each entry is a pair of numbers.

28. Retry decisionReal-world

Retry only on server errors or 429, while attempts remain.

Show solution
type Attempt = { status: number; attempt: number };
const MAX_ATTEMPTS = 3;
function shouldRetry({ status, attempt }: Attempt): boolean {
  if (attempt >= MAX_ATTEMPTS) return false;
  return status >= 500 || status === 429;
}
const cases: Attempt[] = [{ status: 503, attempt: 1 }, { status: 404, attempt: 1 }, { status: 429, attempt: 3 }];
for (const c of cases) console.log(c.status, c.attempt, shouldRetry(c));

Output

503 1 true
404 1 false
429 3 false

Explanation: a guard clause handles the attempt limit first, so the status rule stays simple.

29. Weekend scheduling checkReal-world

Skip weekend dates when scheduling a nightly run, using UTC days.

Show solution
function shouldRun(isoDate: string): boolean {
  const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay();
  return day !== 0 && day !== 6;
}
for (const d of ['2026-11-06', '2026-11-07', '2026-11-09']) console.log(d, shouldRun(d) ? 'run' : 'skip');

Output

2026-11-06 run
2026-11-07 skip
2026-11-09 run

Explanation: getUTCDay() returns 0 for Sunday and 6 for Saturday, independent of the machine’s time zone.

30. Locator choice by available attributesReal-world

Pick the best locator strategy from what an element provides.

Show solution
type ElementInfo = { role?: string; name?: string; testId?: string; css: string };
function bestLocator(el: ElementInfo): string {
  if (el.role && el.name) return `getByRole('${el.role}', { name: '${el.name}' })`;
  if (el.testId) return `getByTestId('${el.testId}')`;
  return `locator('${el.css}')`;
}
console.log(bestLocator({ role: 'button', name: 'Pay', css: '.btn' }));
console.log(bestLocator({ testId: 'cart-total', css: '#total' }));
console.log(bestLocator({ css: 'div > span:nth-child(2)' }));

Output

getByRole('button', { name: 'Pay' })
getByTestId('cart-total')
locator('div > span:nth-child(2)')

Explanation: conditions encode Playwright’s locator priority: role and name first, then test ID, then CSS.

Keep Practising

TypeScript Operators Practice Programs with Solutions (30 Programs)

Practise TypeScript operators 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 Operators lesson first.

TypeScript Beginner Programs

1. Typed arithmeticBeginner

Write calculate(a, b) that returns the sum, difference, product and remainder as an object.

Show solution
function calculate(a: number, b: number): { sum: number; diff: number; product: number; rem: number } {
  return { sum: a + b, diff: a - b, product: a * b, rem: a % b };
}
console.log(calculate(17, 5));

Output

{ sum: 22, diff: 12, product: 85, rem: 2 }

Explanation: typed parameters guarantee both operands are numbers, so + always adds instead of concatenating.

2. Prevent string concatenation bugsBeginner

Show that a typed function rejects a numeric string argument.

Show solution
function addPrices(a: number, b: number): number {
  return a + b;
}
console.log(addPrices(499, 1299));
// @ts-expect-error: Argument of type 'string' is not assignable to parameter of type 'number'
console.log(addPrices('499', 1299));

Output

1798
4991299

Explanation: in JavaScript '499' + 1299 gives '4991299'. TypeScript catches the wrong argument type before the code runs.

3. Comparison operators return booleanBeginner

Compare scores and store the results in boolean variables.

Show solution
const score: number = 72;
const passMark: number = 60;
const passed: boolean = score >= passMark;
const perfect: boolean = score === 100;
console.log(passed, perfect);

Output

true false

Explanation: comparison operators always produce boolean values.

4. Comparing unrelated types is an errorBeginner

Show that comparing a number with a string literal is flagged.

Show solution
const status: number = 200;
// @ts-expect-error: This comparison appears to be unintentional because the types have no overlap
const same = status === '200';
console.log(same, status === Number('200'));

Output

false true

Explanation: TypeScript reports comparisons that can never be true, a common bug when API values are strings.

5. Logical operators with booleansBeginner

Combine boolean flags with &amp;&amp;, || and !.

Show solution
const isLoggedIn: boolean = true;
const isAdmin: boolean = false;
const canEdit: boolean = isLoggedIn && isAdmin;
const canView: boolean = isLoggedIn || isAdmin;
console.log(canEdit, canView, !isAdmin);

Output

false true true

Explanation: logical operators combine conditions. With boolean operands they return booleans.

6. Increment and compound assignmentBeginner

Update a typed counter with ++, += and *=.

Show solution
let retries: number = 0;
retries++;
retries += 2;
retries *= 10;
console.log(retries);

Output

30

Explanation: compound operators keep the variable’s type, here number.

7. Ternary with typed resultBeginner

Return a literal union from a ternary expression.

Show solution
type Verdict = 'PASS' | 'FAIL';
const verdict = (score: number): Verdict => (score >= 60 ? 'PASS' : 'FAIL');
console.log(verdict(75), verdict(40));

Output

PASS FAIL

Explanation: both branches return members of the Verdict union, so the function’s return type is satisfied.

8. Nullish coalescing with typed defaultsBeginner

Use ?? to provide a default for an optional timeout.

Show solution
function getTimeout(custom?: number): number {
  return custom ?? 30000;
}
console.log(getTimeout(), getTimeout(0), getTimeout(5000));

Output

30000 0 5000

Explanation: ?? falls back only for undefined or null, so a valid 0 is kept. The result type is number.

9. Optional chainingBeginner

Safely read a nested optional property.

Show solution
type User = { name: string; address?: { city?: string } };
const users: User[] = [{ name: 'Asha', address: { city: 'Delhi' } }, { name: 'Ravi' }];
for (const u of users) console.log(u.name, u.address?.city ?? 'unknown');

Output

Asha Delhi
Ravi unknown

Explanation: ?. returns undefined when a link in the chain is missing, and TypeScript types the result as string | undefined.

10. Exponent and Math operationsBeginner

Calculate compound growth with **.

Show solution
const principal: number = 1000;
const rate: number = 0.1;
const years: number = 3;
const amount: number = principal * (1 + rate) ** years;
console.log(amount.toFixed(2));

Output

1331.00

Explanation: ** raises a number to a power, and the result type is number.

TypeScript Intermediate Programs

11. typeof narrowingIntermediate

Format a value that can be a number or a string.

Show solution
function format(value: number | string): string {
  if (typeof value === 'number') return value.toFixed(2);
  return value.trim().toUpperCase();
}
console.log(format(3.14159));
console.log(format('  passed '));

Output

3.14
PASSED

Explanation: after typeof value === 'number', TypeScript knows value is a number. In the other branch it must be a string.

12. in operator narrowingIntermediate

Distinguish two object shapes with the in operator.

Show solution
type ApiError = { error: string };
type ApiData = { data: number[] };
function describe(res: ApiError | ApiData): string {
  return 'error' in res ? `Error: ${res.error}` : `Data: ${res.data.length} items`;
}
console.log(describe({ error: 'Unauthorized' }));
console.log(describe({ data: [1, 2, 3] }));

Output

Error: Unauthorized
Data: 3 items

Explanation: checking for a property with in narrows a union of object types.

13. instanceof narrowingIntermediate

Handle a Date or a timestamp number.

Show solution
function toIso(value: Date | number): string {
  return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
console.log(toIso(new Date('2026-01-01T00:00:00Z')));
console.log(toIso(0));

Output

2026-01-01T00:00:00.000Z
1970-01-01T00:00:00.000Z

Explanation: instanceof narrows to a class type, so Date methods are allowed in that branch.

14. Non-null assertionIntermediate

Use ! when you know a value is not undefined, and compare it with a safe check.

Show solution
const roles = new Map<string, string>([['asha', 'admin']]);
const role: string = roles.get('asha')!;
console.log(role);
const maybe = roles.get('ravi');
console.log(maybe !== undefined ? maybe : 'no role');

Output

admin
no role

Explanation: ! removes undefined from the type without a runtime check. Prefer a real check when the value might be missing.

15. keyof operatorIntermediate

Read a property of a config object using a key typed with keyof.

Show solution
const config = { browser: 'chromium', workers: 4, headless: true };
type ConfigKey = keyof typeof config;
function read(key: ConfigKey) {
  return config[key];
}
console.log(read('workers'), read('browser'));
// @ts-expect-error: '"timeout"' is not assignable to parameter of type 'ConfigKey'
read('timeout');

Output

4 chromium

Explanation: keyof creates a union of property names, so only existing keys can be passed.

16. typeof type operatorIntermediate

Create a type from an existing value with typeof.

Show solution
const defaultUser = { name: 'qa', role: 'viewer' as 'viewer' | 'admin' };
type User = typeof defaultUser;
const admin: User = { name: 'boss', role: 'admin' };
console.log(admin.role);

Output

admin

Explanation: typeof value in a type position reuses the value’s type, so you do not repeat it by hand.

17. Indexed access typesIntermediate

Get the type of a nested property with indexed access.

Show solution
type Order = { id: number; customer: { name: string; tier: 'gold' | 'silver' } };
type Tier = Order['customer']['tier'];
const tiers: Tier[] = ['gold', 'silver', 'gold'];
console.log(tiers.filter(t => t === 'gold').length);

Output

2

Explanation: Type['prop'] looks up a property’s type, so related types stay in sync automatically.

18. satisfies operatorIntermediate

Check a config against a type while keeping its literal values.

Show solution
type Env = 'qa' | 'prod';
const urls = {
  qa: 'https://qa.app.test',
  prod: 'https://app.test'
} satisfies Record<Env, string>;
console.log(urls.qa.startsWith('https'), Object.keys(urls).length);

Output

true 2

Explanation: satisfies validates the shape without widening the type, so the compiler still knows the exact keys.

19. as const with operatorsIntermediate

Use a literal-typed array with includes and a type guard.

Show solution
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];
const isRole = (value: string): value is Role => (ROLES as readonly string[]).includes(value);
for (const v of ['editor', 'guest']) console.log(v, isRole(v));

Output

editor true
guest false

Explanation: widening the array to readonly string[] lets includes accept any string, while the type guard narrows the result.

20. Bitwise flags with an enumIntermediate

Combine permission flags with | and test them with &amp;.

Show solution
enum Permission { Read = 1, Write = 2, Delete = 4 }
const userPerms: number = Permission.Read | Permission.Write;
const can = (p: Permission): boolean => (userPerms & p) !== 0;
console.log(can(Permission.Read), can(Permission.Write), can(Permission.Delete));

Output

true true false

Explanation: numeric enum values are numbers, so bitwise operators combine and test flags.

TypeScript Real-World QA Scenario Programs

21. Cart total with taxReal-world

Write a typed function that calculates a cart total with tax.

Show solution
type CartItem = { name: string; price: number; qty: number };
function cartTotal(items: CartItem[], taxRate: number): number {
  const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  return Math.round(subtotal * (1 + taxRate) * 100) / 100;
}
console.log(cartTotal([{ name: 'Mouse', price: 599, qty: 2 }, { name: 'Pad', price: 299, qty: 1 }], 0.18));

Output

1766.46

Explanation: typed items guarantee price and qty are numbers, so the arithmetic is safe.

22. Pass rate with typed resultReal-world

Calculate a pass rate and return a typed status.

Show solution
type Health = 'green' | 'amber' | 'red';
function health(passed: number, total: number): { rate: number; status: Health } {
  const rate = total === 0 ? 0 : (passed / total) * 100;
  const status: Health = rate >= 95 ? 'green' : rate >= 80 ? 'amber' : 'red';
  return { rate: Number(rate.toFixed(1)), status };
}
console.log(health(97, 100), health(41, 50), health(5, 10));

Output

{ rate: 97, status: 'green' } { rate: 82, status: 'amber' } { rate: 50, status: 'red' }

Explanation: the return type documents the shape, and the guard total === 0 avoids dividing by zero.

23. Response time SLA checkReal-world

Check whether response times are within an SLA range.

Show solution
const SLA = { min: 0, max: 800 } as const;
const within = (ms: number): boolean => ms >= SLA.min && ms <= SLA.max;
const timings: number[] = [120, 950, 640];
for (const t of timings) console.log(`${t} ms`, within(t) ? 'OK' : 'BREACH');

Output

120 ms OK
950 ms BREACH
640 ms OK

Explanation: &amp;&amp; combines the two boundary checks, and the typed array guarantees numeric input.

24. Optional chaining on an API responseReal-world

Safely read the first item’s name from a response that may be empty.

Show solution
type Res = { data?: { items: { name: string }[] } };
const responses: Res[] = [{ data: { items: [{ name: 'Mouse' }] } }, { data: { items: [] } }, {}];
for (const r of responses) console.log(r.data?.items[0]?.name ?? 'no items');

Output

Mouse
no items
no items

Explanation: ?. on both data and items[0] prevents errors when either is missing.

25. Merge config with spreadReal-world

Merge typed default and override configs, letting overrides win.

Show solution
type Config = { baseURL: string; retries: number; headless: boolean };
const defaults: Config = { baseURL: 'http://localhost:3000', retries: 0, headless: true };
const overrides: Partial<Config> = { retries: 2, baseURL: 'https://qa.app.test' };
const finalConfig: Config = { ...defaults, ...overrides };
console.log(finalConfig);

Output

{ baseURL: 'https://qa.app.test', retries: 2, headless: true }

Explanation: Partial&lt;Config&gt; makes every property optional, and spreading it over the defaults produces a complete Config.

26. Type guard for API statusReal-world

Narrow a status code to success or failure with a type guard.

Show solution
type SuccessCode = 200 | 201 | 204;
const isSuccess = (code: number): code is SuccessCode => code === 200 || code === 201 || code === 204;
for (const code of [200, 204, 404, 500]) {
  console.log(code, isSuccess(code) ? 'success' : code >= 500 ? 'server error' : 'client error');
}

Output

200 success
204 success
404 client error
500 server error

Explanation: a custom type guard returns a boolean at runtime and narrows the type at compile time.

27. Retry decision with grouped logicReal-world

Decide whether to retry based on typed failure details.

Show solution
type Failure = { status: number; attempt: number; idempotent: boolean };
const MAX = 3;
const shouldRetry = (f: Failure): boolean => f.attempt < MAX && (f.status >= 500 || f.status === 429) && f.idempotent;
const cases: Failure[] = [
  { status: 503, attempt: 1, idempotent: true },
  { status: 404, attempt: 1, idempotent: true },
  { status: 429, attempt: 3, idempotent: true },
  { status: 500, attempt: 1, idempotent: false }
];
for (const c of cases) console.log(c.status, c.attempt, shouldRetry(c));

Output

503 1 true
404 1 false
429 3 false
500 1 false

Explanation: parentheses group the status rule, and typed input makes the rule easy to test case by case.

28. Convert milliseconds with integer operatorsReal-world

Convert durations to m:ss using Math.floor, / and %.

Show solution
const toClock = (ms: number): string => {
  const total = Math.floor(ms / 1000);
  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
for (const ms of [5000, 65000, 754000]) console.log(ms, '->', toClock(ms));

Output

5000 -> 0:05
65000 -> 1:05
754000 -> 12:34

Explanation: division gives whole minutes and % gives the remaining seconds.

29. Version comparisonReal-world

Compare semantic versions numerically.

Show solution
function compareVersions(a: string, b: string): -1 | 0 | 1 {
  const pa = a.split('.').map(Number);
  const pb = b.split('.').map(Number);
  for (let i = 0; i < 3; i++) {
    if (pa[i] > pb[i]) return 1;
    if (pa[i] < pb[i]) return -1;
  }
  return 0;
}
console.log(compareVersions('1.48.2', '1.50.0'), compareVersions('2.0.0', '1.9.9'), compareVersions('1.2.3', '1.2.3'));

Output

-1 1 0

Explanation: the literal return type -1 | 0 | 1 documents exactly which values the function can produce.

30. Exhaustive switch on a unionReal-world

Map browser names to engines, with a compile-time check that all cases are handled.

Show solution
type BrowserName = 'chromium' | 'firefox' | 'webkit';
function engine(b: BrowserName): string {
  switch (b) {
    case 'chromium': return 'Blink';
    case 'firefox': return 'Gecko';
    case 'webkit': return 'WebKit';
    default: { const unhandled: never = b; return unhandled; }
  }
}
const all: BrowserName[] = ['chromium', 'firefox', 'webkit'];
console.log(all.map(engine).join(', '));

Output

Blink, Gecko, WebKit

Explanation: the never assignment fails to compile if a new browser is added to the union but not handled.

Keep Practising