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
easyWrite 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. Declare the fields with their types at the top of the class, then assign them in the constructor.
- 2. TypeScript has a shortcut: put
publicin front of each constructor parameter and it declares and assigns the field for you. - 3. Under
strict, a declared field that the constructor never assigns is an error (TS2564).
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
easyTake 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. A method goes in the class body with no
functionkeyword:start(): string { ... }. - 2. Inside the method,
thisis the instance the method was called on. - 3. Methods live on the prototype, so
a.start === b.startis true.
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
easyGiven 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.
class SUV extends Car, and the subclass constructor must callsuper(...)first. - 2. Call
super()before you touchthis, or you get aReferenceError. - 3. Returning
thisfrom the toggle would let calls chain; returning a message is what the exercise asks for.
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
fairWrite 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. This is composition: the car has an engine. Nothing extends anything here.
- 2. Let the engine describe itself, and have the car ask it. Do not reach into the engine’s fields from the car.
- 3. Taking the engine as a constructor parameter makes it swappable and testable.
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
easyWrite 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.
classis a reserved word, so a property calledclassneeds care;classGroupreads better. - 2. A
toString()method makes the object print nicely in template literals and logs. - 3. Keep the fields
readonlyif nothing should change them after construction.
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
fairWrite 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. TypeScript allows overload signatures on a constructor, but only one implementation body.
- 2. An optional parameter,
rollNumber?: number, is simpler and usually enough. - 3. With
exactOptionalPropertyTypeson, an optional property will not accept an explicitundefined; usenumber | nullif "unknown" is a real state.
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
easyWrite 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. The subclass constructor takes both values and hands one of them to
super(). - 2. The parent field is available on the child through
thisoncesuper()has run. - 3. A getter for the area reads better than a method, since it takes no arguments.
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
fairWrite 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. The type
numberpermits-5,0,2.5andNaN. Only a runtime check rules those out. - 2.
Number.isFinite(age) && age > 0covers the realistic cases. - 3. Consider whether falling back silently is right, or whether throwing would serve the caller better. Say which you chose and why.
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
easyWrite 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. The subclass can set the species itself, so its constructor need only take a name.
- 2. That is a common pattern: the child knows something fixed about itself and passes it up.
- 3. Nothing stops you overriding a method too, but here you are only adding one.
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
easyWrite 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. The child constructor takes all three values and passes two of them to
super(). - 2. Declare the extra field as a parameter property to keep it short.
- 3. Override
describe()and callsuper.describe()so the parent’s part is not rewritten.
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
easyWrite 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. The child’s method wins because lookup stops at the first match it finds.
- 2. Mark the override with the
overridekeyword so a rename upstream cannot silently orphan it. - 3. Put both in an array typed as the parent and loop; each answers in its own way. That is polymorphism.
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
fairWrite 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. Each constructor calls
super()with what the level above needs. - 2. Walk the prototype chain at the end to see all three links.
- 3. Three levels works. Ask yourself whether it reads better than two plus a property.
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
fairWrite 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.
abstract class Shapecannot be instantiated;new Shape(...)is a compile error. - 2. An
abstractmethod has no body. A subclass that forgets it will not compile. - 3. The parent can still hold shared code, which is why abstract classes beat plain interfaces here.
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
easyWrite 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. Same shape as the shape exercise: abstract parent, concrete children.
- 2. A subclass that leaves out
makeSoundis a compile error, not a runtime surprise. - 3. Loop over an array typed as the parent to show the polymorphism.
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
fairWrite 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. Full-time pay might be an annual figure; part-time is usually a rate times hours.
- 2. Keep the pay numbers whole to avoid floating-point noise in the assertions.
- 3. A payroll total over a mixed array is the clearest proof the polymorphism 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
fairWrite 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. For the triangle, use the three sides plus Heron’s formula so area needs no height.
- 2. Heron: with
s = (a + b + c) / 2, the area isMath.sqrt(s * (s - a) * (s - b) * (s - c)). - 3. Round in the assertions, not in the methods, so the maths stays exact.
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
fairWrite 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.
privatemeans this class only.protectedmeans this class and its subclasses. - 2. Both are compile-time only. Neither survives into the JavaScript output.
- 3. Write a subclass that reads the protected field to prove the difference.
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
fairWrite 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. Refuse a withdrawal that would overdraw, and refuse a deposit that is not positive.
- 2. Return
thisfrom both so calls can chain. - 3. A savings subclass that adds interest is the natural way to prove
protectedworks.
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
easyWrite 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. Parameter properties accept modifiers:
constructor(protected make: string). - 2. The subclass can read the fields; code outside the family cannot.
- 3. Returning a formatted string from a public method is how the outside world gets the data.
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
easyWrite 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. The public method is the only route to the age from anywhere else.
- 2. A subclass may use the name but not the age. Write one and show the difference.
- 3. This is the same lesson as exercise 17 from the other direction, so keep it short.
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
easyWrite 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.
staticmembers belong to the class, so you call them asCounter.increment(). - 2. Instances cannot see statics:
new Counter().countisundefined. - 3. Making the count private and exposing it through a static getter stops outside code setting it directly.
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
easyWrite 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. None of these needs an instance, so every method is
static. - 2.
1 / 0isInfinityin JavaScript, not an error. Decide whether to allow that. - 3. A class of only static methods is really a namespace. A plain object of functions would do the same job.
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
fairWrite 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. A
private constructoris what stopsnewfrom outside. - 2.
getInstance()creates the object the first time and returns the stored one afterwards. - 3. Compare with
===to prove it is literally the same object, not just an equal one.
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
fairWrite 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. Keep the date formatting deterministic so it can be asserted; take the date as an argument.
- 2. For the random string, assert its length and character set rather than its value.
- 3. Capitalising an empty string must not crash. Check that edge first.
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.