Emmanuel Akinfulubi

TypeScript: Classes and OOP

Class definitions, constructors, inheritance, abstract classes, access modifiers and statics.

24 exercises

1 A class with three properties and a constructor

easy

Write a class for a bus with a make, a model and a year. Give it a constructor that fills all three in when the object is created.

Stuck? Three hints, smallest first
  1. 1. Declare the fields with their types at the top of the class, then assign them in the constructor.
  2. 2. TypeScript has a shortcut: put public in front of each constructor parameter and it declares and assigns the field for you.
  3. 3. Under strict, a declared field that the constructor never assigns is an error (TS2564).

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The long form: declare, then assign.
class Bus {
  make: string;
  model: string;
  year: number;

  constructor(make: string, model: string, year: number) {
    this.make = make;
    this.model = model;
    this.year = year;
  }
}

// The short form. Parameter properties declare and assign in one go.
class BusShort {
  constructor(
    public make: string,
    public model: string,
    public year: number,
  ) {}
}

const a = new Bus('Toyota', 'Coaster', 2019);
const b = new BusShort('Toyota', 'Coaster', 2019);

console.log(a.make, a.model, a.year);
console.log(b.make, b.model, b.year);

test('long form make', a.make, 'Toyota');
test('long form year', a.year, 2019);
test('short form is identical', b.model, 'Coaster');
test('both produce the same data', JSON.stringify(a), JSON.stringify(b));

Output


      

The two classes produce identical objects. Parameter properties save six lines and are the normal choice for a plain data class. Worth knowing that they are a TypeScript feature, not JavaScript: they compile down to the assignments in the first version.

Answers TS OOP 1 in the original set.

2 Adding a method that reports on the object

easy

Take the bus class and add a start() method that returns a message saying it is starting. Show that the method is shared between instances rather than copied into each one.

Stuck? Three hints, smallest first
  1. 1. A method goes in the class body with no function keyword: start(): string { ... }.
  2. 2. Inside the method, this is the instance the method was called on.
  3. 3. Methods live on the prototype, so a.start === b.start is true.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Bus {
  constructor(
    public make: string,
    public model: string,
    public year: number,
  ) {}

  start(): string {
    return `${this.make} ${this.model} (${this.year}) is starting`;
  }

  describe(): string {
    return `${this.year} ${this.make} ${this.model}`;
  }
}

const a = new Bus('Toyota', 'Coaster', 2019);
const b = new Bus('Mercedes', 'Sprinter', 2021);

console.log(a.start());
console.log(b.start());

// One copy of the method, shared by every instance.
const shared = a.start === b.start;
const onPrototype = Object.hasOwn(Bus.prototype, 'start');
const ownKeys = Object.keys(a);

console.log('same function object?', shared);
console.log('the instance owns only data:', ownKeys);

test('start message', a.start(), 'Toyota Coaster (2019) is starting');
test('the other instance', b.start(), 'Mercedes Sprinter (2021) is starting');
test('describe', a.describe(), '2019 Toyota Coaster');
test('methods are shared', shared, true);
test('start lives on the prototype', onPrototype, true);
test('instances hold data only', ownKeys, ['make', 'model', 'year']);

Output


      

The last two assertions are the point. Data lands on the instance, behaviour lands on the prototype, and every instance borrows the one copy. That is why Object.keys(a) lists only the three fields.

Answers TS OOP 2 in the original set.

3 A subclass that adds a capability

easy

Given a car class, write an SUV class that extends it and adds a flag for whether it can go off-road. Include a method that toggles the flag and reports the new state.

Stuck? Three hints, smallest first
  1. 1. class SUV extends Car, and the subclass constructor must call super(...) first.
  2. 2. Call super() before you touch this, or you get a ReferenceError.
  3. 3. Returning this from the toggle would let calls chain; returning a message is what the exercise asks for.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Car {
  constructor(
    public make: string,
    public model: string,
  ) {}

  describe(): string {
    return `${this.make} ${this.model}`;
  }
}

class SUV extends Car {
  offRoad: boolean;

  constructor(make: string, model: string, offRoad = false) {
    super(make, model);      // must run before any use of `this`
    this.offRoad = offRoad;
  }

  toggleOffRoad(): string {
    this.offRoad = !this.offRoad;
    return this.offRoad
      ? `${this.describe()}: off-road mode on`
      : `${this.describe()}: back to road mode`;
  }
}

const suv = new SUV('Toyota', 'Prado');

console.log(suv.describe());
const first = suv.toggleOffRoad();
const second = suv.toggleOffRoad();
console.log(first);
console.log(second);

test('inherited describe works', suv.describe(), 'Toyota Prado');
test('starts on the road', new SUV('Kia', 'Sportage').offRoad, false);
test('first toggle turns it on', first, 'Toyota Prado: off-road mode on');
test('second toggle turns it off', second, 'Toyota Prado: back to road mode');
test('an SUV is also a Car', suv instanceof Car, true);

Output


      

describe() was never written in SUV, and it works, because lookup walks up to Car.prototype. The default value on offRoad = false means callers can leave it out, which is usually kinder than making every argument required.

Answers TS OOP 3 in the original set.

4 A car that has an engine, rather than being one

fair

Write an engine class with horsepower and fuel type. Then give a car class an engine as one of its properties, and a method that prints both the car's and the engine's details.

Stuck? Three hints, smallest first
  1. 1. This is composition: the car has an engine. Nothing extends anything here.
  2. 2. Let the engine describe itself, and have the car ask it. Do not reach into the engine’s fields from the car.
  3. 3. Taking the engine as a constructor parameter makes it swappable and testable.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Engine {
  constructor(
    public horsepower: number,
    public fuelType: 'diesel' | 'petrol',
  ) {}

  describe(): string {
    return `${this.horsepower}hp ${this.fuelType}`;
  }
}

class Car {
  constructor(
    public make: string,
    public model: string,
    public year: number,
    private engine: Engine,      // has-a, not is-a
  ) {}

  start(): string {
    return `${this.make} ${this.model} is starting`;
  }

  // The car asks the engine to describe itself rather than reading its fields.
  printCarDetails(): string {
    return `${this.year} ${this.make} ${this.model} - ${this.engine.describe()}`;
  }
}

const diesel = new Engine(120, 'diesel');
const car = new Car('Toyota', 'Coaster', 2019, diesel);

console.log(car.start());
console.log(car.printCarDetails());

// Swapping the engine is easy, because it was passed in.
const stronger = new Car('Toyota', 'Coaster', 2019, new Engine(150, 'diesel'));
console.log(stronger.printCarDetails());

test('engine describes itself', diesel.describe(), '120hp diesel');
test('car details include the engine', car.printCarDetails(), '2019 Toyota Coaster - 120hp diesel');
test('start still works', car.start(), 'Toyota Coaster is starting');
test('a different engine changes the output', stronger.printCarDetails(), '2019 Toyota Coaster - 150hp diesel');

Output


      

Two habits worth copying. The engine is private, so no caller can reach car.engine.horsepower and tie itself to the engine’s shape. And the car calls engine.describe() instead of formatting the engine’s fields itself, so a change to the engine touches one file.

Answers TS OOP 4 in the original set.

5 A student with a name and a class

easy

Write a student class with a name and a class group. Fill both in through the constructor and add a way to print the student's details.

Stuck? Three hints, smallest first
  1. 1. class is a reserved word, so a property called class needs care; classGroup reads better.
  2. 2. A toString() method makes the object print nicely in template literals and logs.
  3. 3. Keep the fields readonly if nothing should change them after construction.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Student {
  constructor(
    public readonly name: string,
    public readonly classGroup: string,
  ) {}

  // Defining toString means the object reads well anywhere text is expected.
  toString(): string {
    return `${this.name} (${this.classGroup})`;
  }

  details(): string {
    return `Name: ${this.name}, Class: ${this.classGroup}`;
  }
}

const one = new Student('Amaka Obi', 'JSS 3');
const two = new Student('Chidi Eze', 'SSS 1');

console.log(one.details());
console.log(`${two}`);          // toString is used here
console.log(String(one));

// readonly is refused at compile time:
// one.name = 'Somebody else';   // Cannot assign to 'name'

test('details', one.details(), 'Name: Amaka Obi, Class: JSS 3');
test('toString via a template literal', `${two}`, 'Chidi Eze (SSS 1)');
test('toString via String()', String(one), 'Amaka Obi (JSS 3)');
test('the field is set', two.classGroup, 'SSS 1');

Output


      

I renamed the second property to classGroup because class is a reserved word in JavaScript; as a property name it is legal but it reads badly and trips people up. The toString() is one line and pays for itself the first time you log a list of these.

Answers TS OOP 5 in the original set.

6 Two ways to build a student

fair

Write a student class that can be created either with a name and a roll number, or with a name alone when the roll number is not yet known. Show both.

Stuck? Three hints, smallest first
  1. 1. TypeScript allows overload signatures on a constructor, but only one implementation body.
  2. 2. An optional parameter, rollNumber?: number, is simpler and usually enough.
  3. 3. With exactOptionalPropertyTypes on, an optional property will not accept an explicit undefined; use number | null if "unknown" is a real state.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Student {
  name: string;
  rollNumber: number | null;

  // Two ways in, described for the caller...
  constructor(name: string, rollNumber: number);
  constructor(name: string);
  // ...and one body that handles both.
  constructor(name: string, rollNumber?: number) {
    this.name = name;
    this.rollNumber = rollNumber ?? null;
  }

  describe(): string {
    return this.rollNumber === null
      ? `${this.name}, roll number not yet assigned`
      : `${this.name}, roll number ${this.rollNumber}`;
  }

  get hasRoll(): boolean {
    return this.rollNumber !== null;
  }
}

const known = new Student('Amaka Obi', 17);
const unknown = new Student('Chidi Eze');

console.log(known.describe());
console.log(unknown.describe());

// A third call shape is refused, because no overload matches:
// const bad = new Student(17);

test('with a roll number', known.describe(), 'Amaka Obi, roll number 17');
test('without one', unknown.describe(), 'Chidi Eze, roll number not yet assigned');
test('the flag reflects it', known.hasRoll, true);
test('and the other way', unknown.hasRoll, false);
test('missing becomes null, not undefined', unknown.rollNumber, null);

Output


      

I used number | null rather than an optional property on purpose. "Not yet assigned" is a real state worth naming, and with exactOptionalPropertyTypes switched on an optional field cannot be set to undefined explicitly, so null avoids a fight. The two overload signatures document the call shapes; the single body does the work.

Answers TS OOP 6 in the original set.

7 A shape and a circle that extends it

easy

Write a shape class holding a colour. Then write a circle class that extends it, taking a colour and a radius and passing the colour up to the parent.

Stuck? Three hints, smallest first
  1. 1. The subclass constructor takes both values and hands one of them to super().
  2. 2. The parent field is available on the child through this once super() has run.
  3. 3. A getter for the area reads better than a method, since it takes no arguments.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Shape {
  constructor(public colour: string) {}

  describe(): string {
    return `a ${this.colour} shape`;
  }
}

class Circle extends Shape {
  constructor(colour: string, public radius: number) {
    super(colour);      // the colour goes up to Shape
  }

  get area(): number {
    return Math.PI * this.radius ** 2;
  }

  // Extending rather than replacing: reuse the parent's sentence.
  override describe(): string {
    return `${super.describe()}, a circle of radius ${this.radius}`;
  }
}

const c = new Circle('red', 3);

console.log(c.describe());
console.log('colour from the parent:', c.colour);
console.log('area:', c.area.toFixed(2));

test('parent field was set through super', c.colour, 'red');
test('own field was set', c.radius, 3);
test('area', Number(c.area.toFixed(2)), 28.27);
test('describe extends the parent', c.describe(), 'a red shape, a circle of radius 3');
test('a Circle is a Shape', c instanceof Shape, true);

Output


      

Note super.describe() inside the override. The child does not repeat the parent’s wording; it calls it and adds to it. When the parent’s sentence changes, the child follows for free.

Answers TS OOP 7 in the original set.

8 A constructor that refuses a bad age

fair

Write a student class with a name and an age. The constructor must check the age: if it is not a positive number, fall back to zero rather than storing nonsense.

Stuck? Three hints, smallest first
  1. 1. The type number permits -5, 0, 2.5 and NaN. Only a runtime check rules those out.
  2. 2. Number.isFinite(age) && age > 0 covers the realistic cases.
  3. 3. Consider whether falling back silently is right, or whether throwing would serve the caller better. Say which you chose and why.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Student {
  readonly name: string;
  readonly age: number;

  constructor(name: string, age: number) {
    this.name = name;
    // A positive, finite number, or fall back to 0 as the exercise asks.
    this.age = Number.isFinite(age) && age > 0 ? age : 0;
  }

  describe(): string {
    return this.age === 0
      ? `${this.name}, age not on record`
      : `${this.name}, age ${this.age}`;
  }
}

// The stricter alternative, for comparison.
class StrictStudent {
  constructor(public readonly name: string, public readonly age: number) {
    if (!Number.isFinite(age) || age <= 0) {
      throw new RangeError(`${name}: age must be a positive number, got ${age}`);
    }
  }
}

const good = new Student('Amaka Obi', 14);
const negative = new Student('Chidi Eze', -3);
const notANumber = new Student('Bola Ade', NaN);

console.log(good.describe());
console.log(negative.describe());
console.log(notANumber.describe());

let refused = '';
try { new StrictStudent('Bad', -1); } catch (err) { refused = (err as RangeError).message; }
console.log('strict version says:', refused);

test('a good age is kept', good.age, 14);
test('a negative age becomes 0', negative.age, 0);
test('NaN becomes 0', notANumber.age, 0);
test('the message reflects it', negative.describe(), 'Chidi Eze, age not on record');
test('the strict version refuses instead', refused.includes('positive number'), true);

Output


      

Both versions are shown because the choice matters more than the code. Falling back to 0 means bad data enters the system quietly and someone debugs it next month. Throwing means the caller finds out immediately. The exercise asked for the fallback; in real code I would usually throw.

Answers TS OOP 8 in the original set.

9 An animal, and a tiger that roars

easy

Write an animal class with a name and a species. Then write a tiger class that extends it and adds a roar() method.

Stuck? Three hints, smallest first
  1. 1. The subclass can set the species itself, so its constructor need only take a name.
  2. 2. That is a common pattern: the child knows something fixed about itself and passes it up.
  3. 3. Nothing stops you overriding a method too, but here you are only adding one.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Animal {
  constructor(
    public name: string,
    public species: string,
  ) {}

  describe(): string {
    return `${this.name} is a ${this.species}`;
  }
}

class Tiger extends Animal {
  // The species is fixed for a tiger, so the caller does not supply it.
  constructor(name: string) {
    super(name, 'tiger');
  }

  roar(): string {
    return `${this.name} roars!`;
  }
}

const t = new Tiger('Shere Khan');
const generic = new Animal('Rex', 'dog');

console.log(t.describe());
console.log(t.roar());
console.log(generic.describe());

// roar exists only on Tiger:
// generic.roar();   // Property 'roar' does not exist on type 'Animal'.

test('species was filled in by the subclass', t.species, 'tiger');
test('inherited describe', t.describe(), 'Shere Khan is a tiger');
test('the new method', t.roar(), 'Shere Khan roars!');
test('the parent still works alone', generic.describe(), 'Rex is a dog');
test('a Tiger is an Animal', t instanceof Animal, true);

Output


      

Tiger’s constructor takes one argument and passes two up. That is worth noticing: a subclass often exists precisely to fix one of the parent’s values, which makes the class easier to call than the parent it came from.

Answers TS OOP 9 in the original set.

10 An employee that extends a person

easy

Write a person class with a name and an age. Then write an employee class extending it, whose constructor also takes an employee id.

Stuck? Three hints, smallest first
  1. 1. The child constructor takes all three values and passes two of them to super().
  2. 2. Declare the extra field as a parameter property to keep it short.
  3. 3. Override describe() and call super.describe() so the parent’s part is not rewritten.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Person {
  constructor(
    public name: string,
    public age: number,
  ) {}

  describe(): string {
    return `${this.name}, ${this.age}`;
  }
}

class Employee extends Person {
  constructor(name: string, age: number, public employeeId: string) {
    super(name, age);
  }

  override describe(): string {
    return `${super.describe()}, staff id ${this.employeeId}`;
  }
}

const p = new Person('Amaka Obi', 31);
const e = new Employee('Chidi Eze', 28, 'EMP-0042');

console.log(p.describe());
console.log(e.describe());

test('the parent alone', p.describe(), 'Amaka Obi, 31');
test('the child adds to it', e.describe(), 'Chidi Eze, 28, staff id EMP-0042');
test('inherited fields are there', e.age, 28);
test('and the new one', e.employeeId, 'EMP-0042');
test('an Employee is a Person', e instanceof Person, true);

Output


      

The override keyword on describe is not decoration. With noImplicitOverride switched on, a method that replaces a parent method must say so, which means renaming the parent’s method turns every stale child into a compile error instead of a silent behaviour change.

Answers TS OOP 10 in the original set.

11 Overriding a draw method

easy

Write a shape class with a colour and a draw() method that says a shape is being drawn. Then write a circle class that overrides draw() with something specific to circles.

Stuck? Three hints, smallest first
  1. 1. The child’s method wins because lookup stops at the first match it finds.
  2. 2. Mark the override with the override keyword so a rename upstream cannot silently orphan it.
  3. 3. Put both in an array typed as the parent and loop; each answers in its own way. That is polymorphism.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Shape {
  constructor(public colour: string) {}

  draw(): string {
    return `drawing a ${this.colour} shape`;
  }
}

class Circle extends Shape {
  constructor(colour: string, public radius: number) {
    super(colour);
  }

  override draw(): string {
    return `drawing a ${this.colour} circle, radius ${this.radius}`;
  }
}

class Square extends Shape {
  constructor(colour: string, public side: number) {
    super(colour);
  }

  override draw(): string {
    return `drawing a ${this.colour} square, side ${this.side}`;
  }
}

// One loop, three types, no type names mentioned inside it.
const shapes: Shape[] = [
  new Shape('grey'),
  new Circle('red', 3),
  new Square('blue', 4),
];

const drawn = shapes.map((s) => s.draw());
for (const line of drawn) console.log(line);

test('the base version', drawn[0], 'drawing a grey shape');
test('the circle override', drawn[1], 'drawing a red circle, radius 3');
test('the square override', drawn[2], 'drawing a blue square, side 4');
test('three shapes drawn', drawn.length, 3);

Output


      

The loop is the payoff. It is typed Shape[] and never asks what it is holding, yet each object answers correctly. Adding a triangle means writing one class and changing nothing here.

Answers TS OOP 11 in the original set.

12 Three levels of inheritance

fair

Write a vehicle class with a make and model, a car class extending it, and a sports car extending that. Give each its own constructor and check the fields are inherited correctly.

Stuck? Three hints, smallest first
  1. 1. Each constructor calls super() with what the level above needs.
  2. 2. Walk the prototype chain at the end to see all three links.
  3. 3. Three levels works. Ask yourself whether it reads better than two plus a property.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Vehicle {
  constructor(public make: string, public model: string) {}

  describe(): string {
    return `${this.make} ${this.model}`;
  }
}

class Car extends Vehicle {
  constructor(make: string, model: string, public doors: number) {
    super(make, model);
  }

  override describe(): string {
    return `${super.describe()}, ${this.doors} doors`;
  }
}

class SportsCar extends Car {
  constructor(make: string, model: string, doors: number, public topSpeed: number) {
    super(make, model, doors);
  }

  override describe(): string {
    return `${super.describe()}, top speed ${this.topSpeed}km/h`;
  }
}

const s = new SportsCar('Toyota', 'Supra', 2, 250);
console.log(s.describe());

// Every level's fields arrived.
console.log(s.make, s.model, s.doors, s.topSpeed);

// The chain, walked.
const chain: string[] = [];
let link: object | null = Object.getPrototypeOf(s);
while (link !== null && link !== Object.prototype) {
  chain.push(link.constructor.name);
  link = Object.getPrototypeOf(link);
}
console.log('chain:', chain.join(' -> '));

test('all three levels described', s.describe(), 'Toyota Supra, 2 doors, top speed 250km/h');
test('grandparent field', s.make, 'Toyota');
test('parent field', s.doors, 2);
test('own field', s.topSpeed, 250);
test('chain is three deep', chain, ['SportsCar', 'Car', 'Vehicle']);
test('it is all three types', s instanceof Car && s instanceof Vehicle, true);

Output


      

This works, and it is close to the limit of what stays readable. To know what describe() returns you now read three classes. Beyond two levels, ask whether a property such as topSpeed on Car would have done the job with less machinery.

Answers TS OOP 12 in the original set.

13 An abstract shape with a required perimeter

fair

Write an abstract shape class with a colour and an abstract method for the perimeter. Write a circle and a rectangle that supply real implementations.

Stuck? Three hints, smallest first
  1. 1. abstract class Shape cannot be instantiated; new Shape(...) is a compile error.
  2. 2. An abstract method has no body. A subclass that forgets it will not compile.
  3. 3. The parent can still hold shared code, which is why abstract classes beat plain interfaces here.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

abstract class Shape {
  constructor(public colour: string) {}

  // No body. Every concrete subclass must supply one.
  abstract getPerimeter(): number;

  // Shared code, written once, using the abstract member.
  describe(): string {
    return `${this.colour} ${this.constructor.name}, perimeter ${this.getPerimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(colour: string, public radius: number) {
    super(colour);
  }
  override getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(colour: string, public width: number, public height: number) {
    super(colour);
  }
  override getPerimeter(): number {
    return 2 * (this.width + this.height);
  }
}

// new Shape('red');   // error: Cannot create an instance of an abstract class.

const shapes: Shape[] = [new Circle('red', 3), new Rectangle('blue', 4, 5)];
for (const s of shapes) console.log(s.describe());

const total = shapes.reduce((n, s) => n + s.getPerimeter(), 0);
console.log('total perimeter:', total.toFixed(2));

test('circle perimeter', Number(shapes[0]?.getPerimeter().toFixed(2)), 18.85);
test('rectangle perimeter', shapes[1]?.getPerimeter(), 18);
test('shared describe used the subclass answer', shapes[1]?.describe(), 'blue Rectangle, perimeter 18.00');
test('total across both', Number(total.toFixed(2)), 36.85);

Output


      

describe() is the reason to use an abstract class rather than an interface. It is written once in the parent and calls getPerimeter(), which does not exist yet at that point. The parent depends on a promise the children are forced to keep.

Answers TS OOP 13 in the original set.

14 An abstract animal that must make a sound

easy

Write an abstract animal class with a name and an abstract makeSound(). Write a tiger and a lion that each return their own sound.

Stuck? Three hints, smallest first
  1. 1. Same shape as the shape exercise: abstract parent, concrete children.
  2. 2. A subclass that leaves out makeSound is a compile error, not a runtime surprise.
  3. 3. Loop over an array typed as the parent to show the polymorphism.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

abstract class Animal {
  constructor(public name: string) {}

  abstract makeSound(): string;

  // Shared, and it leans on the abstract method.
  introduce(): string {
    return `${this.name} says ${this.makeSound()}`;
  }
}

class Tiger extends Animal {
  override makeSound(): string { return 'roar'; }
}

class Lion extends Animal {
  override makeSound(): string { return 'growl'; }
}

class Elephant extends Animal {
  override makeSound(): string { return 'trumpet'; }
}

// A subclass that forgets makeSound will not compile:
// class Snake extends Animal {}
//       ^ Non-abstract class 'Snake' does not implement inherited abstract member.

const zoo: Animal[] = [new Tiger('Shere Khan'), new Lion('Elsa'), new Elephant('Dumbo')];

for (const a of zoo) console.log(a.introduce());

const sounds = zoo.map((a) => a.makeSound());

test('tiger', zoo[0]?.makeSound(), 'roar');
test('lion', zoo[1]?.makeSound(), 'growl');
test('elephant', zoo[2]?.makeSound(), 'trumpet');
test('shared introduce uses each own sound', zoo[1]?.introduce(), 'Elsa says growl');
test('all three sounds', sounds, ['roar', 'growl', 'trumpet']);

Output


      

The commented-out Snake is the part worth trying. Uncomment it and the build stops with a clear message. That is the difference between an abstract method and a note in a README asking people to remember.

Answers TS OOP 14 in the original set.

15 Abstract employees with different pay rules

fair

Write an abstract employee class with a name, an id and an abstract salary calculation. Write a full-time and a part-time employee whose pay is worked out differently.

Stuck? Three hints, smallest first
  1. 1. Full-time pay might be an annual figure; part-time is usually a rate times hours.
  2. 2. Keep the pay numbers whole to avoid floating-point noise in the assertions.
  3. 3. A payroll total over a mixed array is the clearest proof the polymorphism works.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

abstract class Employee {
  constructor(
    public name: string,
    public employeeId: string,
  ) {}

  abstract calculateSalary(): number;

  payslip(): string {
    return `${this.employeeId} ${this.name}: ₦${this.calculateSalary().toLocaleString('en-NG')}`;
  }
}

class FullTimeEmployee extends Employee {
  constructor(name: string, id: string, private annualSalary: number) {
    super(name, id);
  }
  // Monthly pay from an annual figure.
  override calculateSalary(): number {
    return Math.round(this.annualSalary / 12);
  }
}

class PartTimeEmployee extends Employee {
  constructor(name: string, id: string, private hourlyRate: number, private hours: number) {
    super(name, id);
  }
  override calculateSalary(): number {
    return this.hourlyRate * this.hours;
  }
}

const staff: Employee[] = [
  new FullTimeEmployee('Amaka Obi', 'EMP-001', 4_800_000),
  new PartTimeEmployee('Chidi Eze', 'EMP-002', 2_500, 80),
];

for (const s of staff) console.log(s.payslip());

const payroll = staff.reduce((n, s) => n + s.calculateSalary(), 0);
console.log('monthly payroll:', payroll);

test('full-time monthly', staff[0]?.calculateSalary(), 400000);
test('part-time monthly', staff[1]?.calculateSalary(), 200000);
test('payroll total', payroll, 600000);
test('payslip formats the name and id', staff[1]?.payslip().startsWith('EMP-002 Chidi Eze'), true);

Output


      

The payroll line never asks who is full-time. Adding a contractor with a completely different formula means one new class and no change to the total. Note the numeric separators, 4_800_000, which are just for readability and vanish at runtime.

Answers TS OOP 15 in the original set.

16 Geometric shapes with area and perimeter

fair

Write an abstract geometric shape with a name and abstract methods for both area and perimeter. Implement a circle, a rectangle and a triangle.

Stuck? Three hints, smallest first
  1. 1. For the triangle, use the three sides plus Heron’s formula so area needs no height.
  2. 2. Heron: with s = (a + b + c) / 2, the area is Math.sqrt(s * (s - a) * (s - b) * (s - c)).
  3. 3. Round in the assertions, not in the methods, so the maths stays exact.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

abstract class GeometricShape {
  constructor(public name: string) {}

  abstract getArea(): number;
  abstract getPerimeter(): number;

  summary(): string {
    return `${this.name}: area ${this.getArea().toFixed(2)}, perimeter ${this.getPerimeter().toFixed(2)}`;
  }
}

class Circle extends GeometricShape {
  constructor(private radius: number) { super('circle'); }
  override getArea(): number { return Math.PI * this.radius ** 2; }
  override getPerimeter(): number { return 2 * Math.PI * this.radius; }
}

class Rectangle extends GeometricShape {
  constructor(private width: number, private height: number) { super('rectangle'); }
  override getArea(): number { return this.width * this.height; }
  override getPerimeter(): number { return 2 * (this.width + this.height); }
}

class Triangle extends GeometricShape {
  constructor(private a: number, private b: number, private c: number) {
    super('triangle');
    const valid = a + b > c && b + c > a && a + c > b;
    if (!valid) throw new RangeError(`no triangle has sides ${a}, ${b}, ${c}`);
  }
  override getPerimeter(): number { return this.a + this.b + this.c; }
  override getArea(): number {
    // Heron's formula, so no height is needed.
    const s = this.getPerimeter() / 2;
    return Math.sqrt(s * (s - this.a) * (s - this.b) * (s - this.c));
  }
}

const shapes: GeometricShape[] = [new Circle(2), new Rectangle(3, 4), new Triangle(3, 4, 5)];
for (const s of shapes) console.log(s.summary());

let refused = '';
try { new Triangle(1, 2, 10); } catch (err) { refused = (err as RangeError).message; }
console.log('refused:', refused);

test('circle area', Number(shapes[0]?.getArea().toFixed(2)), 12.57);
test('rectangle area', shapes[1]?.getArea(), 12);
test('right triangle area via Heron', shapes[2]?.getArea(), 6);
test('triangle perimeter', shapes[2]?.getPerimeter(), 12);
test('an impossible triangle is refused', refused.includes('no triangle'), true);

Output


      

Two things beyond the inheritance. Heron’s formula gets the area from the three sides alone, which is why the constructor does not need a height. And the constructor validates the triangle inequality, so an impossible shape cannot exist in the first place rather than silently returning NaN.

Answers TS OOP 16 in the original set.

17 private and protected on a student

fair

Write a student class with a private name and a protected id. Add a public method that prints both. Show that a subclass can reach the protected field but nothing outside can reach either.

Stuck? Three hints, smallest first
  1. 1. private means this class only. protected means this class and its subclasses.
  2. 2. Both are compile-time only. Neither survives into the JavaScript output.
  3. 3. Write a subclass that reads the protected field to prove the difference.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Student {
  constructor(
    private name: string,
    protected studentId: number,
  ) {}

  public displayInfo(): string {
    return `${this.name} (#${this.studentId})`;
  }
}

class Undergraduate extends Student {
  constructor(name: string, id: number, private year: number) {
    super(name, id);
  }

  // Allowed: studentId is protected, and this is a subclass.
  idCard(): string {
    return `year ${this.year}, id ${this.studentId}`;
  }

  // Not allowed, because name is private to Student:
  // nameOnly(): string { return this.name; }
}

const s = new Student('Amaka Obi', 17);
const u = new Undergraduate('Chidi Eze', 42, 2);

console.log(s.displayInfo());
console.log(u.displayInfo());
console.log(u.idCard());

// From outside, both are refused at compile time:
// s.name;        // Property 'name' is private
// s.studentId;   // Property 'studentId' is protected

// But they are only compile-time. The data is right there at runtime:
const leaked = Object.keys(s);
console.log('own keys at runtime:', leaked);

test('public method sees both', s.displayInfo(), 'Amaka Obi (#17)');
test('subclass reached the protected field', u.idCard(), 'year 2, id 42');
test('inherited method still works', u.displayInfo(), 'Chidi Eze (#42)');
test('neither modifier hides the data at runtime', leaked, ['name', 'studentId']);

Output


      

The last assertion is the honest part. private and protected are checked by the compiler and then erased, so Object.keys and JSON.stringify still show everything. If you need real privacy that survives to runtime, use a # field, which the next exercise does.

Answers TS OOP 17 in the original set.

18 A bank account with a protected balance

fair

Write a bank account with a private account number and a protected balance starting at zero. Add public deposit and withdraw methods. Only the class and its subclasses may touch the balance.

Stuck? Three hints, smallest first
  1. 1. Refuse a withdrawal that would overdraw, and refuse a deposit that is not positive.
  2. 2. Return this from both so calls can chain.
  3. 3. A savings subclass that adds interest is the natural way to prove protected works.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class BankAccount {
  protected balance = 0;

  constructor(private accountNumber: string) {}

  public deposit(amount: number): this {
    if (!Number.isFinite(amount) || amount <= 0) {
      throw new RangeError(`deposit must be positive, got ${amount}`);
    }
    this.balance += amount;
    return this;
  }

  public withdraw(amount: number): this {
    if (amount > this.balance) {
      throw new RangeError(`cannot withdraw ${amount}; balance is ${this.balance}`);
    }
    this.balance -= amount;
    return this;
  }

  public statement(): string {
    return `${this.accountNumber}: ₦${this.balance}`;
  }
}

class SavingsAccount extends BankAccount {
  constructor(accountNumber: string, private rate: number) {
    super(accountNumber);
  }

  // Allowed, because balance is protected.
  addInterest(): this {
    this.balance += Math.round(this.balance * this.rate);
    return this;
  }
}

const current = new BankAccount('0123456789');
current.deposit(50_000).withdraw(20_000);
console.log(current.statement());

const savings = new SavingsAccount('9876543210', 0.1);
savings.deposit(100_000).addInterest();
console.log(savings.statement());

const refusals: string[] = [];
for (const attempt of [() => current.withdraw(999_999), () => current.deposit(-5)]) {
  try { attempt(); } catch (err) { refusals.push((err as RangeError).message); }
}

test('deposit then withdraw', current.statement(), '0123456789: ₦30000');
test('interest was added by the subclass', savings.statement(), '9876543210: ₦110000');
test('overdraw refused', refusals[0]?.includes('cannot withdraw'), true);
test('negative deposit refused', refusals[1]?.includes('must be positive'), true);
test('chaining worked', current.deposit(1).withdraw(1).statement(), '0123456789: ₦30000');

Output


      

Returning this is what makes deposit(50_000).withdraw(20_000) read as one sentence. The return type is written this rather than BankAccount on purpose: that way savings.deposit(...) still gives you a SavingsAccount, so you can carry on and call addInterest().

Answers TS OOP 18 in the original set.

19 A car with protected make and model

easy

Write a car class whose make and model are protected. Add a public method returning them as one string. Show a subclass using them and an outside caller being refused.

Stuck? Three hints, smallest first
  1. 1. Parameter properties accept modifiers: constructor(protected make: string).
  2. 2. The subclass can read the fields; code outside the family cannot.
  3. 3. Returning a formatted string from a public method is how the outside world gets the data.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Car {
  constructor(
    protected make: string,
    protected model: string,
  ) {}

  public getCarInfo(): string {
    return `${this.make} ${this.model}`;
  }
}

class ElectricCar extends Car {
  constructor(make: string, model: string, private rangeKm: number) {
    super(make, model);
  }

  // Allowed: both fields are protected, and this is a subclass.
  public getFullInfo(): string {
    return `${this.make} ${this.model}, ${this.rangeKm}km range`;
  }
}

const c = new Car('Toyota', 'Corolla');
const e = new ElectricCar('BYD', 'Dolphin', 420);

console.log(c.getCarInfo());
console.log(e.getCarInfo());
console.log(e.getFullInfo());

// From outside the family, both are refused:
// c.make;    // Property 'make' is protected and only accessible within class 'Car' and its subclasses.

test('public accessor works', c.getCarInfo(), 'Toyota Corolla');
test('inherited accessor works', e.getCarInfo(), 'BYD Dolphin');
test('subclass read both protected fields', e.getFullInfo(), 'BYD Dolphin, 420km range');
test('an ElectricCar is a Car', e instanceof Car, true);

Output


      

Compare this with exercise 17. There the id was protected and the name private, so the subclass could reach one and not the other. Here both are protected, which is the right default when you expect the class to be extended.

Answers TS OOP 19 in the original set.

20 An animal with a protected name and a private age

easy

Write an animal class with a protected name and a private age. Add a public method that introduces the animal using both. Only the class itself may read the age.

Stuck? Three hints, smallest first
  1. 1. The public method is the only route to the age from anywhere else.
  2. 2. A subclass may use the name but not the age. Write one and show the difference.
  3. 3. This is the same lesson as exercise 17 from the other direction, so keep it short.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Animal {
  constructor(
    protected name: string,
    private age: number,
  ) {}

  public introduce(): string {
    return `${this.name} is ${this.age} year(s) old`;
  }

  // Only this class can read age, so any derived value goes through here.
  public isAdult(): boolean {
    return this.age >= 2;
  }
}

class Dog extends Animal {
  constructor(name: string, age: number, private breed: string) {
    super(name, age);
  }

  // Allowed: name is protected.
  public tag(): string {
    return `${this.name} the ${this.breed}`;
  }

  // Not allowed: age is private to Animal.
  // public ageOnly(): number { return this.age; }
}

const a = new Animal('Rex', 4);
const d = new Dog('Bingo', 1, 'terrier');

console.log(a.introduce(), '| adult?', a.isAdult());
console.log(d.introduce(), '| adult?', d.isAdult());
console.log(d.tag());

test('introduce uses both fields', a.introduce(), 'Rex is 4 year(s) old');
test('derived value from the private field', a.isAdult(), true);
test('the young one is not an adult', d.isAdult(), false);
test('subclass used the protected name', d.tag(), 'Bingo the terrier');

Output


      

isAdult() is the pattern to take from this. When a field is private, everything the outside world needs from it has to be exposed as a method or getter. That is a feature: you can change how age is stored without touching a single caller.

Answers TS OOP 20 in the original set.

21 A static counter

easy

Write a counter class with a static count starting at zero, a static method that adds one, and a static method that returns the current value. Call both and check the result.

Stuck? Three hints, smallest first
  1. 1. static members belong to the class, so you call them as Counter.increment().
  2. 2. Instances cannot see statics: new Counter().count is undefined.
  3. 3. Making the count private and exposing it through a static getter stops outside code setting it directly.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Counter {
  // Private, so nothing outside can set it to 900 directly.
  private static count = 0;

  static increment(): void {
    Counter.count += 1;
  }

  static getCount(): number {
    return Counter.count;
  }

  static reset(): void {
    Counter.count = 0;
  }
}

Counter.increment();
Counter.increment();
Counter.increment();

console.log('after three increments:', Counter.getCount());

Counter.reset();
console.log('after reset:', Counter.getCount());

Counter.increment();

// Counter.count = 900;   // error: Property 'count' is private

test('three increments', (() => { Counter.reset(); Counter.increment(); Counter.increment(); Counter.increment(); return Counter.getCount(); })(), 3);
test('reset works', (() => { Counter.reset(); return Counter.getCount(); })(), 0);
test('increment after reset', (() => { Counter.reset(); Counter.increment(); return Counter.getCount(); })(), 1);

Output


      

Making the field private static is the detail worth copying. A plain static count = 0 can be set to anything by any caller, which makes the counter a suggestion. Private plus a getter means the only way the number changes is through increment().

Answers TS OOP 21 in the original set.

22 A static maths helper

easy

Write a class with static methods for add, subtract, multiply and divide. Each takes two numbers and returns the result. Handle division by zero.

Stuck? Three hints, smallest first
  1. 1. None of these needs an instance, so every method is static.
  2. 2. 1 / 0 is Infinity in JavaScript, not an error. Decide whether to allow that.
  3. 3. A class of only static methods is really a namespace. A plain object of functions would do the same job.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class MathOps {
  static add(a: number, b: number): number { return a + b; }
  static subtract(a: number, b: number): number { return a - b; }
  static multiply(a: number, b: number): number { return a * b; }

  static divide(a: number, b: number): number {
    // JavaScript would hand back Infinity rather than complaining.
    if (b === 0) throw new RangeError('cannot divide by zero');
    return a / b;
  }
}

console.log(MathOps.add(2, 3));
console.log(MathOps.subtract(10, 4));
console.log(MathOps.multiply(6, 7));
console.log(MathOps.divide(20, 4));

let refused = '';
try { MathOps.divide(1, 0); } catch (err) { refused = (err as RangeError).message; }
console.log('divide by zero:', refused);

// What JavaScript does if you do not guard it:
const unguarded = 1 / 0;
console.log('unguarded 1 / 0 is', unguarded);

test('add', MathOps.add(2, 3), 5);
test('subtract', MathOps.subtract(10, 4), 6);
test('multiply', MathOps.multiply(6, 7), 42);
test('divide', MathOps.divide(20, 4), 5);
test('divide by zero refused', refused, 'cannot divide by zero');
test('and this is why', unguarded, Infinity);

Output


      

The last assertion is the reason for the guard. 1 / 0 is Infinity, a perfectly valid number, so nothing in the type system will ever catch it; it just spreads through your arithmetic until something looks wrong three screens later.

Answers TS OOP 22 in the original set.

23 A singleton with exactly one instance

fair

Write a class following the singleton pattern: a private static instance of itself and a static method returning it. Prove that two calls give you the same object.

Stuck? Three hints, smallest first
  1. 1. A private constructor is what stops new from outside.
  2. 2. getInstance() creates the object the first time and returns the stored one afterwards.
  3. 3. Compare with === to prove it is literally the same object, not just an equal one.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Settings {
  private static instance: Settings | null = null;

  private values = new Map<string, string>();

  // Private, so `new Settings()` from outside is a compile error.
  private constructor() {
    this.values.set('theme', 'light');
  }

  static getInstance(): Settings {
    Settings.instance ??= new Settings();
    return Settings.instance;
  }

  set(key: string, value: string): this {
    this.values.set(key, value);
    return this;
  }

  get(key: string): string | undefined {
    return this.values.get(key);
  }
}

const a = Settings.getInstance();
const b = Settings.getInstance();

a.set('theme', 'dark');

console.log('a theme:', a.get('theme'));
console.log('b theme:', b.get('theme'));   // the same object, so the same value
console.log('same object?', a === b);

// new Settings();   // error: Constructor of class 'Settings' is private.

test('two calls give one object', a === b, true);
test('a change through a is visible through b', b.get('theme'), 'dark');
test('the default was set in the constructor', Settings.getInstance().get('theme'), 'dark');
test('an unknown key is undefined', a.get('nothing'), undefined);

Output


      

Settings.instance ??= new Settings() is the whole pattern in one line: assign only if it is currently null or undefined. Worth saying that singletons are easy to overuse; because every caller shares one object, they also share every bug, and they are awkward to reset between tests.

Answers TS OOP 23 in the original set.

24 A static utility class

fair

Write a utility class with static methods for formatting a date, generating a short random string and capitalising text. Each must work without creating an instance.

Stuck? Three hints, smallest first
  1. 1. Keep the date formatting deterministic so it can be asserted; take the date as an argument.
  2. 2. For the random string, assert its length and character set rather than its value.
  3. 3. Capitalising an empty string must not crash. Check that edge first.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class Utils {
  // A fixed format, and the date comes in as an argument so it is testable.
  static formatDate(date: Date): string {
    const y = date.getUTCFullYear();
    const m = String(date.getUTCMonth() + 1).padStart(2, '0');
    const d = String(date.getUTCDate()).padStart(2, '0');
    return `${y}-${m}-${d}`;
  }

  static generateRandomString(length = 8): string {
    const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
    let out = '';
    for (let i = 0; i < length; i++) {
      const pick = Math.floor(Math.random() * alphabet.length);
      out += alphabet[pick] ?? 'a';
    }
    return out;
  }

  static capitalizeString(text: string): string {
    if (text.length === 0) return text;         // guard the empty case first
    return text[0]!.toUpperCase() + text.slice(1);
  }

  static titleCase(text: string): string {
    return text.split(' ').map((w) => Utils.capitalizeString(w)).join(' ');
  }
}

const date = new Date('2026-09-02T00:00:00Z');
console.log(Utils.formatDate(date));
console.log(Utils.capitalizeString('lagos'));
console.log(Utils.titleCase('jibowu motor park'));

const random = Utils.generateRandomString(10);
console.log('random:', random);

test('date formatted', Utils.formatDate(date), '2026-09-02');
test('single-digit month and day are padded', Utils.formatDate(new Date('2026-01-05T00:00:00Z')), '2026-01-05');
test('capitalised', Utils.capitalizeString('lagos'), 'Lagos');
test('empty text does not crash', Utils.capitalizeString(''), '');
test('title case', Utils.titleCase('jibowu motor park'), 'Jibowu Motor Park');
test('random string has the asked-for length', random.length, 10);
test('random string uses only the alphabet', /^[a-z0-9]+$/.test(random), true);

Output


      

Two testing habits here. formatDate takes the date rather than calling new Date() inside, which is the only way to assert its output. And the random string is checked by length and shape, not by value, because you cannot assert a random result. Both are how you keep unpredictable code testable.

Answers TS OOP 24 in the original set.

The exercise list follows w3resource's TypeScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.