TypeScript with OOP

Aiden Kim
10 min readAug 6, 2021

--

I would like to explain how to create an object-oriented program using TypeScript in this blog. Let’s look at object-oriented programming principles while creating a simple coffee machine program and simple class.

Class

Do not use keywords such as let or const when declaring variables inside a class written in TypeScript. It also does not need to use the keyword function when creating a function within a class.

type CoffeeCup = {
shots: number;
hasMilk: boolean;
};
class CoffeeMaker { static BEANS_GRAMM_PER_SHOT: number = 7;
coffeeBeans: number = 0;

constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
static makeMachine(coffeeBeans: number): CoffeeMaker {
return new CoffeeMaker(coffeeBeans);
}

makeCoffee(shots: number): CoffeeCup {
if (this.coffeeBeans < shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT{
throw new Error('Not enough coffee beans!');
}

this.coffeeBeans -= shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT;

return {
shots,
hasMilk: false,
};
}
}
const maker = new CoffeeMaker(32);
console.log(maker);
const maker2 = new CoffeeMaker(14);
console.log(maker2);
const maker3 = CoffeeMaker.makeMachine(3);

A constant or function with a static keyword prevents memory waste because no variables are created in the memory each time an object is created.

Getter and Setter

class User {
private internalAge = 4;

get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}

get age(): number {
return this.internalAge;
}
set age(num: number) {
if (num < 0) {
throw new Error('number of age is invalid');
}
this.internalAge = num;
}
constructor(private firstName: string, public lastName: string) {}}const user = new User('Aiden', 'Kim');// use setter in the User class
user.age = 15;
// call getter in the User class
console.log(user.fullName);
console.log(user.age);

Using an access modifier to a parameter when declaring a constructor eliminates the need to declare a member variable in the field.

Encapsulation

Encapsulation combines attributes and functions of objects and conceals some of the actual implementations outside. It usually uses access modifiers to hide information.

type CoffeeCup = {
shots: number;
hasMilk: boolean;
};
// public
// private
// protected
class CoffeeMaker { private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
private constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}

static makeMachine(coffeeBeans: number): CoffeeMaker {
return new CoffeeMaker(coffeeBeans);
}

fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0')
}
this.coffeeBeans += beans;
}

makeCoffee(shots: number): CoffeeCup {
if (this.coffeeBeans < shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT{
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMaker.BEANS_GRAMM_PER_SHOT;
return {
shots,
hasMilk: false,
};
}
}
const maker = CoffeeMaker.makeMachine(32);
maker.fillCoffeeBeans(32);

Abstraction

Abstraction refers to external exposure in the form of user-friendly and intended functions by separating the functions required within a class from those required externally, while considering what to use or how to use in the class externally. Although the interface is used to make abstraction, enough abstraction can be made through the access modifier.

Interface

Interface acts as if it were a contract specifying what can be guaranteed and the protocol.

type CoffeeCup = {
shots: number;
hasMilk: boolean;
};
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
interface CommercialCoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
fillCoffeeBeans(beans: number): void;
clean(): void;
}
class CoffeeMachine implements CoffeeMaker, CommercialCoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
private constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up... 🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots... ☕️`);
return {
shots,
hasMilk: false,
};
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}

The CoffeeMachine class implements both of interfaces called CoffeeMaker and CommercialCoffeeMaker.

class AmateurUser {
constructor(private machine: CoffeeMaker) {}

makeCoffee() {
const coffee = this.machine.makeCoffee(2);
console.log(coffee);
}
}
class ProBarista {
constructor(private machine: CommercialCoffeeMaker) {}

makeCoffee() {
const coffee = this.machine.makeCoffee(2);
console.log(coffee);
this.machine.fillCoffeeBeans(45);
this.machine.clean();
}
}
const maker: CoffeeMachine = CoffeeMachine.makeMachine(32);
const amateur = new AmateurUser(maker);
const pro = new ProBarista(maker);
pro.makeCoffee();

The AmateurUser class and The Provarista class use CoffeeMachine class as parameters when creating instances, but specify parameter type as interface to communicate with CoffeeMachine object created using only functions that are stated to the interface. This is why users of this need to know how to use the interface without having to know the other complex features of the CoffeeMachine class.

Inheritance

Inheritance is a way of defining relationships between objects, allowing a child class to inherit attributes and functions of the parent class.

type CoffeeCup = {
shots: number;
hasMilk: boolean;
};
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker, CommercialCoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up... 🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots... ☕️`);
return {
shots,
hasMilk: false,
};
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}

private steamMilk(): void {
console.log('Steaming some milk... 🥛');
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
console.log(coffee, 'super');
this.steamMilk();
return {
...coffee,
hasMilk: false,
};
}
}
const machine = new CoffeeMachine(23);
const latteMachine = new CaffeLatteMachine(23, 'SSSS');
const coffee = latteMachine.makeCoffee(1);
console.log(coffee);
console.log(latteMachine.serialNumber);

The readonly keyword makes serialNumber which is a class member variable become a constant.

Polymorphism

Polymorphism makes a function different way (overriding) in the child class from function in the parent class or interface. This makes it simple for users to take advantage of a variety of features by calling one promised function name without having to worry about the internal implementation of the function in the child class.

type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
};
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;

constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}

private preheat(): void {
console.log('heating up... 🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots... ☕️`);
return {
shots,
hasMilk: false,
};
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk... 🥛');
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
this.steamMilk();
return {
...coffee,
hasMilk: true,
};
}
}
class SweetCoffeeMaker extends CoffeeMachine {
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return {
...coffee,
hasSugar: true,
};
}
}
const machines: CoffeeMaker[] = [
new CoffeeMachine(16),
new CaffeLatteMachine(16, '1'),
new SweetCoffeeMaker(16),
new CoffeeMachine(16),
new CaffeLatteMachine(16, '2'),
new SweetCoffeeMaker(16),
];
machines.forEach(machine => {
console.log('-------------------------');
machine.makeCoffee(1);
console.log(machine.makeCoffee(1));
});

Composition

Inheritance is recommended only in the is-a relationship. The has-a relationship uses composition. Composition is implemented in a way that refers to other objects in the field.

type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
};
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}

static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}

fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}

clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up... 🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots... ☕️`);
return {
shots,
hasMilk: false,
};
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CheapMilkSteamer {
private steamMilk(): void {
console.log('Steaming some milk.... 🥛');
}

makeMilk(cup: CoffeeCup): CoffeeCup {
this.steamMilk();
return {
...cup,
hasMilk: true,
};
}
}
class AutomaticSugarMixer {
private getSuger() {
console.log('Getting some sugar from candy 🍭');
return true;
}
addSugar(cup: CoffeeCup): CoffeeCup {
const sugar = this.getSuger();
return {
...cup,
hasSugar: sugar,
};
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(
beans: number,
public readonly serialNumber: string,
private milkFrother: CheapMilkSteamer
) {
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return this.milkFrother.makeMilk(coffee);
}
}
class SweetCoffeeMaker extends CoffeeMachine {
constructor(
private beans: number,
private sugar: AutomaticSugarMixer) {
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return this.sugar.addSugar(coffee);
}
}
class SweetCaffeLatteMachine extends CoffeeMachine {
constructor(
private beans: number,
private milk: CheapMilkSteamer,
private sugar: AutomaticSugarMixer) {
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
const sugarAdded = this.sugar.addSugar(coffee);
return this.milk.makeMilk(sugarAdded);
}
}
const machines: CoffeeMaker[] = [
new CoffeeMachine(16),
new CaffeLatteMachine(16, '1', new CheapMilkSteamer()),
new SweetCoffeeMaker(16, new AutomaticSugarMixer()),
new CoffeeMachine(16),
new CaffeLatteMachine(16, '1', new CheapMilkSteamer()),
new SweetCoffeeMaker(16, new AutomaticSugarMixer()),
];
machines.forEach(machine => {
console.log('-------------------------');
machine.makeCoffee(1);
console.log(machine.makeCoffee(1));
});

Composition with Interface

When Composition is used, it can be coupled between classes, but when Composition is created using the interface, it becomes decoupling.

type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
};
interface MilkFrother {
makeMilk(cup: CoffeeCup): CoffeeCup;
}
interface SugarSource {
addSugar(cup: CoffeeCup): CoffeeCup;
}
class CheapMilkSteamer implements MilkFrother {
makeMilk(cup: CoffeeCup): CoffeeCup {
console.log(`Steaming some milk🥛...`);
return {
...cup,
hasMilk: true,
};
}
}
class FancyMilkSteamer implements MilkFrother {
makeMilk(cup: CoffeeCup): CoffeeCup {
console.log(`Fancy!!!! Steaming some milk🥛...`);
return {
...cup,
hasMilk: true,
};
}
}
class AutomaticSugarMixer implements SugarSource {
addSugar(cuppa: CoffeeCup): CoffeeCup {
console.log(`Adding sugar...`);
return {
...cuppa,
hasSugar: true,
};
}
}
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;
constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
static makeMachine(coffeeBeans: number): CoffeeMachine {
return new CoffeeMachine(coffeeBeans);
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up... 🔥');
}
private extract(shots: number): CoffeeCup {
console.log(`Pulling ${shots} shots... ☕️`);
return {
shots,
hasMilk: false,
};
}
makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk... 🥛');
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
this.steamMilk();
return {
...coffee,
hasMilk: true,
};
}
}
class SweetCoffeeMaker extends CoffeeMachine {
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
return {
...coffee,
hasSugar: true,
};
}
}
class SweetCaffeLatteMachine extends CoffeeMachine {
constructor(
beans: number,
private sugar: SugarSource,
private milk: MilkFrother) {
super(beans);
}
makeCoffee(shots: number): CoffeeCup {
const coffee = super.makeCoffee(shots);
const milkCoffee = this.milk.makeMilk(coffee);
return this.sugar.addSugar(milkCoffee);
}
}
const machine = new SweetCaffeLatteMachine(
32,
new AutomaticSugarMixer(),
new FancyMilkSteamer()
);
machine.makeCoffee(2);

Abstract class VS Interface

The difference between an abstract class and an interface is that an abstract class cannot create an instance by itself. In other words, based on abstract class, a child class needs to be materialized and an instance can be created through the child class. In addition, abstract class may or may not have abstract functions. In other words, an abstract function may be materialized within an abstract class. However, all the methods an interface has are abstract methods. Therefore, an interface cannot have a constructor as well.

type CoffeeCup = {
shots: number;
hasMilk?: boolean;
hasSugar?: boolean;
};
interface CoffeeMaker {
makeCoffee(shots: number): CoffeeCup;
}
abstract class CoffeeMachine implements CoffeeMaker {
private static BEANS_GRAMM_PER_SHOT: number = 7;
private coffeeBeans: number = 0;

constructor(coffeeBeans: number) {
this.coffeeBeans = coffeeBeans;
}
fillCoffeeBeans(beans: number) {
if (beans < 0) {
throw new Error('value for beans should be greater than 0');
}
this.coffeeBeans += beans;
}
clean() {
console.log('cleaning the machine...🧼');
}
private grindBeans(shots: number) {
console.log(`grinding beans for ${shots}`);
if (this.coffeeBeans < shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT) {
throw new Error('Not enough coffee beans!');
}
this.coffeeBeans -= shots * CoffeeMachine.BEANS_GRAMM_PER_SHOT;
}
private preheat(): void {
console.log('heating up... 🔥');
}
protected abstract extract(shots: number): CoffeeCup;

makeCoffee(shots: number): CoffeeCup {
this.grindBeans(shots);
this.preheat();
return this.extract(shots);
}
}
class CaffeLatteMachine extends CoffeeMachine {
constructor(beans: number, public readonly serialNumber: string) {
super(beans);
}
private steamMilk(): void {
console.log('Steaming some milk... 🥛');
}
protected extract(shots: number): CoffeeCup {
this.steamMilk();
return {
shots,
hasMilk: true,
};
}
}
class SweetCoffeeMaker extends CoffeeMachine {
protected extract(shots: number): CoffeeCup {
return {
shots,
hasSugar: true,
};
}
}
const machines: CoffeeMaker[] = [
new CaffeLatteMachine(16, '1'),
new SweetCoffeeMaker(16),
new CaffeLatteMachine(16, '1'),
new SweetCoffeeMaker(16),
];
machines.forEach(machine => {
console.log('-------------------------');
machine.makeCoffee(1);
});

Generic

Generic is that must include or reference another type in order to be complete. It enforces meaningful constraints between various variables.

interface Either<L, R> {
left: () => L;
right: () => R;
}
class SimpleEither<L, R> implements Either<L, R> {
constructor(private leftValue: L, private rightValue: R) {}
left(): L {
return this.leftValue;
}

right(): R {
return this.rightValue;
}
}
const either: Either<number, number> = new SimpleEither(4, 5); either.left(); // 4
either.right(); //5
const best: Either<Object, string> = new SimpleEither(
{ name: 'aiden' },
'hello'
);
// This syntax is correct (Type Inference)
const best2 = new SimpleEither({ name: 'aiden' }, 'hello');
console.log(best.left());
console.log(best.right());
console.log(best2.left());
console.log(best2.right());

Already generic declared at the Either class name so all types allow put it in the Either class when it creates an instance.

The code below is generic constraint.

interface Employee {
pay(): void;
}
class FullTimeEmployee implements Employee {
pay() {
console.log(`full time!!`);
}

workFullTime() {}
}class PartTimeEmployee implements Employee {
pay() {
console.log(`part time!!`);
}

workPartTime() {}
}
// Bad example
function payBad(employee: Employee): Employee {
employee.pay();
return employee;
}
// Good example
function pay<T extends Employee>(employee: T): T {
employee.pay();
return employee;
}
const aiden= new FullTimeEmployee();
const bob = new PartTimeEmployee();
aiden.workFullTime();
bob.workPartTime();
const aidenAfterPayBad = payBad(aiden);
const bobAfterPayBad = payBad(bob);
// class information lose because return type is interface
//aidenAfterPayBad.workFullTime();
const aidenAfterPay = pay(aiden);
const bobAfterPay = pay(bob);
aidenAfterPay.workFullTime();const obj = {
name: 'aiden',
age: 20,
};
const obj2 = {
animal: '🐕',
};
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
console.log(getValue(obj, 'name')); // aiden
console.log(getValue(obj, 'age')); // 20
console.log(getValue(obj2, 'animal')); // 🐕

“keyof T” meaning is the set of keys for a given type.

Tutorial

This video includes how to implement a Stack based on what we learned above.

--

--

No responses yet