Ricardo Borges

Personal blog

OOP Concepts in practice using TypeScript

The idea of this post is to modify a very small part of imaginary software by applying OOP concepts. For this post, I'll presume that you are familiar with OOP and the definitions of classes, objects, methods, and attributes. I'll use as an example an audio streaming software in its very early stages.

We'll start with this:

An Album and a Track class, and another file that plays a track.

1export class Album {
2  name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7}
8
9export class Track {
10  name: string;
11  length: number;
12  album: Album;
13
14  constructor(name: string, length: number, album: Album) {
15    this.name = name;
16    this.length = length;
17    this.album = album;
18  }
19}

.

1// meanwhile in another file ...
2
3function play() {
4	// Finds the track 
5	// Play it
6}

We will start to apply the OOP concepts, but we'll keep it simple, especially because the goal here is to understand those concepts and not to build a real audio streaming API.

Encapsulation

All those properties in the Track class are accessible by anyone, let's change that by encapsulating them. Encapsulation is a mechanism to hide information, those information are the classes members, like properties and methods, this is achieved by using the modifiers public, protected, and private.

  • public members can be accessed anywhere, this is the default modifier.
  • protected members are visible inside of the class they’re declared in and its subclasses.
  • private members are only visible inside of the class they’re declared in.

To encapsulate all those properties we follow these steps:

  1. Add modifier private
  2. Add _ before the property name, for example, _name
  3. Implement the accessors get and set. These methods will be invoked whenever you try to get o set the property value.

Making all properties private the Track class will look like this:

1export class Track {
2  private _name: string;
3  private _length: number;
4  private _album: Album;
5
6  constructor(name: string, length: number, album: Album) {
7    this._name = name;
8    this._length = length;
9    this._album = album;
10  }
11
12  get name(): string {
13    return this._name;
14  }
15
16  set name(name: string) {
17    this._name = name;
18  }
19
20  get length(): number {
21    return this._length;
22  }
23
24  set length(length: number) {
25    this._length = length;
26  }
27
28  get album(): Album {
29    return this._album;
30  }
31
32  set album(album: Album) {
33    this._album = album;
34  }
35}

Abstraction

Abstraction is about hiding complexity details from the user, for example, to drive a car you don't need to know how its engine works, so in a car it's abstracted and an interface (steering wheel, pedals, gear shift, etc.) is provided to you, and you only need to know how to operate that interface.

Take a look at how a track is being played, it first finds the track, and then plays it. Seems simple, now suppose we have to do the same all over places whenever we need to play a track, that would be redundant, but there are other problems, everyone who is writing a code to play a track need to know how to do it, and what if something in playing track changes? We would have to change it in many places.

This is an opportunity to abstract that logic, we do that by moving it to inside the track class, and now whenever a track needs to be played, anyone only has to call that play method without knowing its details:

1export class Track {
2  private _name: string;
3  private _length: number;
4  private _album: Album;
5
6  constructor(name: string, length: number, album: Album) {
7    this._name = name;
8    this._length = length;
9    this._album = album;
10  }
11
12  get name(): string {
13    return this._name;
14  }
15
16  set name(name: string) {
17    this._name = name;
18  }
19
20  get length(): number {
21    return this._length;
22  }
23
24  set length(length: number) {
25    this._length = length;
26  }
27
28  get album(): Album {
29    return this._album;
30  }
31
32  set album(album: Album) {
33    this._album = album;
34  }
35
36	function play() {
37		// Play it
38	}
39}
40
41// To instantiate and play a track
42const album = new Album("Rocks");
43const track = new Track("Combination", 264, album);
44
45track.play();

Inheritance

Suppose that now our audio streaming software needs to add support to podcasts, and these podcasts have episodes, how would we do that? We could simply add a Podcast class, and a type property in track class:

1export class Podcast {}
2
3export class Track {
4  private _name: string;
5  private _length: number;
6	private _type: string; // track or episode
7  private _album?: Album;
8  private _podcast?: Podcast;
9	
10
11	...
12}

That works but it is more difficult to maintain, we have to consider the type in this class behaviors, like in the play method, and if we have to support another type of media, we would have to modify everything again, besides that approach goes against two SOLID's principle at least, but this is a topic for another post.

So, how can we solve this problem? Enters the inheritance: A mechanism that allows us to create class hierarchies, where a subclass can inherit properties and behaviors from its superclass. This is great for reusability.

So, the solution I suggest is the following:

First, create an Audio class, with the properties and behaviors that are common for tracks and episodes, this will be our superclass:

1export class Audio {
2  private _name: string;
3  private _length: number;
4
5  constructor(name: string, length: number) {
6    this._name = name;
7    this._length = length;
8  }
9
10	/** getters and setters */
11
12	function play() {
13		// Play it
14	}
15}

Then, the Track class will derive from the Audio class, meaning it will inherit the Audio class properties and behaviors:

1export class Track extends Audio {
2  private _album: Album;
3
4  constructor(name: string, length: number, album: Album) {
5    super(name, length);
6    this._album = album;
7  }
8
9  get album(): Album {
10    return this._album;
11  }
12
13  set album(album: Album) {
14    this._album = album;
15  }
16}

The extends keyword means that the Track class is inheriting from the Audio class, and since Track is extending another class, we have to call super in the constructor, even if there are not any properties to pass to the superclass. Also, super can be used to call the parent class methods.

It's worthy to note that a superclass can be called a parent class, and a subclass can also be called a child class.

Finally, we create the Podcast and Episode classes, Episode will extend Audio class too:

1export class Podcast {
2  name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7}
8
9export class Episode extends Audio {
10  private _podcast: Podcast;
11
12  constructor(name: string, length: number, podcast: Podcast) {
13    super(name, length);
14    this._podcast = podcast;
15  }
16
17  get podcast(): Podcast {
18    return this._podcast;
19  }
20
21  set podcast(podcast: Podcast) {
22    this._podcast = podcast;
23  }
24}

Polymorphism

Now suppose that for some reason, when users close the streamer and open it again, only podcasts episodes need to play where they left off, and tracks have to start from the beginning. As you know the play method is in the Audio class, so it is the same for both episodes and tracks, how do we implement this feature? Polymorphism comes to the rescue!

Polymorphism is the idea of an object can take multiple forms as the word suggests, we'll use it to make the play method perform differently for tracks and episodes. In practice, we do that by overriding it in the Episode class, which means we implement the play method in the Episode class differently from how it is implemented in the superclass:

1export class Episode extends Audio {
2
3    /** everything else remains the same */
4
5    function play() {
6      // Check where it left off
7      // Play it
8    }
9}

Another way to achieve polymorphism is to overload methods, those are methods with the same name but different signatures, like different data types or number of arguments, however, this is different in TypeScript, to overload a method we have to add optional arguments or overload function declarations in an interface and implement the interface.

This is it, the idea was to briefly explain what each one is and provide an example. I wanted to focused on these four concepts and left other concepts out, therefore there is still room to improve this implementation.