Principles of Object-oriented Programming in TypeScript
Camilo Reyes
Posted on April 19, 2022
Object-oriented programming (OOP) is hard to achieve in a dynamic prototypical language like JavaScript. You have to manually stick to OOP principles because of language features like duck typing. This requires discipline, as nothing in the language enforces the principles. If a diverse team of developers with different backgrounds is involved, a codebase filled with good intentions can quickly become one chaotic mess.
In this take, we will delve into proper OOP techniques in TypeScript, showing how the language automates a bunch of manual labor and encourages best practices. We will begin by talking a bit about duck typing, then go into the three pillars: encapsulation, inheritance, and polymorphism.
Ready? Let’s go!
A Bit of Duck Typing in TypeScript
You may follow along by copy-pasting code in the TypeScript Playground. The goal is for you to reproduce the following, to prove these techniques work in any codebase.
Take a look at the following:
interface Todo {
title: string;
description?: string;
}
const todo1 = {
title: "organize desk",
extra: "metadata", // duck typing is allowed!
};
const updateTodo = (
todo: Todo,
fieldsToUpdate: Partial<Todo> // allow partial updates
) => ({ ...todo, ...fieldsToUpdate });
const result1 = updateTodo(todo1, {
description: "throw out trash",
});
const todo2 = {
...todo1,
description: "clean up", // call bombs without description
};
const updateRequiredTodo = (
todo: Required<Todo>,
fieldsToUpdate: Partial<Todo>
): Required<Todo> => ({ ...todo, ...fieldsToUpdate });
const result2 = updateRequiredTodo(todo2, {
description: "throw out trash",
});
The Todo
interface is declared with an optional property description
, so this code can skip the property. The question mark ?
tells TypeScript that this is optional. One way to revert this design decision is to wrap the interface around Required<Todo>
, which makes all properties non-optional. In classic OOP, the data integrity of objects matters. Here, the compiler automates this behavior.
If the intent is to allow partial updates, Partial<Todo>
can revert to optional properties. This only affects the parameter fieldsToUpdate
. The updateRequiredTodo
function explicitly declares an output type of Required<Todo>
, guaranteeing the object's shape when it returns.
Note that the property extra
is accepted in todo1
. This is a legacy from the fact that TypeScript is a superset of JavaScript, and duck typing is allowed. Without explicit typing, TypeScript has no choice but to revert to legacy. As you write more TypeScript programs, it is a good idea to lean on the type checker as much as possible via explicit types.
Now, let's look at object-oriented programming's three pillars.
The Three Pillars of Object-Oriented Programming with TypeScript
Encapsulation
This pillar is all about restricting access and decoupling software modules. TypeScript makes this information hiding technique achievable via a class.
class Base {
private hiddenA = 0;
#hiddenB = 0;
printInternals() {
console.log(this.hiddenA); // works
console.log(this.#hiddenB); // works
}
}
const obj = new Base();
console.log(obj.hiddenA); // these two bomb
console.log(obj.#hiddenB);
Here, the hiddenA
member has restricted access within Base
enforced by the compiler. However, the compiler gives JavaScript runtime constructs like in
access to the hidden property. You can use the hard private #
to maintain private fields.
A class isn’t the only tool available in your TypeScript arsenal. Utility types like Pick<Type, Keys>
, Omit<Type, Keys>
, Readonly<Type>
, and NonNullable<Type>
can also limit access.
interface Todo {
title: string;
description?: string; // string | undefined
completed: boolean;
}
type TodoPreview1 = Pick<Todo, "title" | "completed">;
const todo1: TodoPreview1 = {
//explicit typing
title: "Clean room",
completed: false,
description: "x", // duck typing is NOT allowed
};
type TodoPreview2 = Omit<Todo, "description" | "completed">;
const todo2: TodoPreview2 = {
title: "Clean room",
};
const todo3: Readonly<Todo> = {
title: "Delete inactive users",
completed: true,
};
todo3.completed = false; // bombs
const todo4: Todo = {
...todo1,
description: "Doing shores is fun",
};
const description: NonNullable<string | undefined> =
// bombs without null coalescing
todo4.description ?? "";
The Pick
and Omit
utility types pluck a subset of properties. If an object is explicitly typed, then duck typing is not allowed. This technique slices and dices types and narrows the information available to the compiler.
Immutability is achievable via Readonly
. Any attempt to mutate the object automatically fails the build. If you need protection at runtime, Object.freeze
is also available. By limiting the objects that can mutate state, you enforce encapsulation, safeguarding interactions between types.
This whole time you may have assumed undefined
or null
is a natural consequence of working with OOP because nullable types are allowed. Well, offering nullable objects is like selling street mangos, then telling people you never intended to carry any!
A null
is the absence of an object and is therefore diametrically opposed to object orientation. NonNullable
helps you be honest about types so there are no unforeseen mishaps. Note that the compiler will bark at you if null doesn't coalesce, as description
is no longer optional.
Inheritance
Inheritance represents a hierarchy of types with an is-a relationship. This technique can mirror real-world relationships. Say there's a Pingable interface with a ping method. For a Sonar to be Pingable it must implement this behavior. This makes reasoning about a Sonar easier because it models specific functionality.
interface Pingable {
ping(): void;
}
class Sonar implements Pingable {
ping() {
console.log("sonar ping!");
}
}
TypeScript also comes with fancier ways of achieving inheritance. Given two types, say Colorful
and Circle
, you can combine set properties in interesting ways via union and intersection.
With union types, narrowing is necessary via a type predicate. The circle is Circle
predicate narrows the type so the logic can branch accordingly. The compiler needs this narrowing technique because it doesn’t know which type to pick from the disjunction.
type Colorful = {
color: string;
};
type Circle = {
radius: number;
};
type ColorfulCircle = Colorful | Circle; // union
function isCircle(circle: ColorfulCircle): circle is Circle {
return "radius" in circle;
}
function draw(circle: ColorfulCircle) {
if (isCircle(circle)) {
// branch logic
console.log(`Radius was ${circle.radius}`); // ok
} else {
console.log(`Color was ${circle.color}`);
}
}
draw({ color: "blue" });
draw({ radius: 42 });
Below, Colorful
and Circle
intersect to create a new type with properties from both. This promotes code reuse, much like classic inheritance.
type Colorful = {
color: string;
};
type Circle = {
radius: number;
};
type ColorfulCircle = Colorful & Circle; // intersection
function draw(circle: ColorfulCircle) {
console.log(`Radius was ${circle.radius}`); // ok
console.log(`Color was ${circle.color}`);
}
draw({ color: "blue", radius: 42 });
The this
object in JavaScript can be a mess to work with because it changes depending on the context. TypeScript types this
dynamically to the class and has type guards.
abstract class Box {
content: string = "";
sameAs(other: this) {
return other.content === this.content;
}
isDerivedBox(): this is DerivedBox {
return this instanceof DerivedBox;
}
}
class DerivedBox extends Box {
otherContent: string = "?";
}
const base = new Box(); // bombs
const derived = new DerivedBox();
derived.isDerivedBox(); // true
derived.sameAs(derived as Box); // bombs
Note that the this is DerivedBox
type guards the return type in the isDerivedBox
method. Mixing this
with type narrowing via instanceof
makes this
a predictable type instead of a moving target.
This technique creates concrete OOP types with reasonable behavior where the consuming code can ignore implementation details. The Box
class is also abstract, and the compiler restricts access by not allowing instances of the class. Contracts with common behavior decouple from concrete objects that may want to reuse some of the rich functionality.
Polymorphism in Object-Oriented Programming
You can achieve ad hoc polymorphism by using arguments that behave differently depending on the type. Let's look at an add
method that works generically and changes behavior accordingly.
interface GenericAdd<AddType> {
add: (x: AddType, y: AddType) => AddType;
}
class GenericNumber implements GenericAdd<number> {
add(x: number, y: number) {
return x + y;
} // number + number
}
class GenericString implements GenericAdd<string> {
add(x: string, y: string) {
return x + y;
} // string + string
}
const genericNumber = new GenericNumber();
genericNumber.add(1, 2); // 3
const genericString = new GenericString();
genericString.add("Hello", ", Mammals!"); // Hello, Mammals!
If you think of a type as a record, it is possible to have row-polymorphic records, with code that operates only on a section of a type. TypeScript makes this easy via Partial
. Note that subset
is explicitly typed, and duck typing is not allowed since beta
is not a subset of AType
.
type AType = { x: number; y: number; z: number };
const subset: Partial<AType> = {
x: 2,
y: 3,
beta: "bomb", // not allowed
};
For the pièce de résistance, apply the Liskov Substitution principle with composition and the Decorator Pattern. At runtime, the object gains functionality via polymorphic behavior.
Say there's a Barista
class that wants to make different kinds of coffee. The relationship here is that a barista has-a cup of coffee, which in OOP parlance means composition versus inheritance. Like a cup of coffee, the object being prepared can have milk, sugar, or sprinkles added. My barista is busy and doesn’t have time for convoluted wretched code, so everything must be reusable and easy to use.
interface Coffee {
getCost(): number;
getIngredients(): string;
}
class SimpleCoffee implements Coffee {
getCost() {
return 8;
}
getIngredients() {
return "Coffee";
}
}
abstract class CoffeeDecorator implements Coffee {
constructor(private readonly decoratedCoffee: Coffee) {}
getCost() {
return this.decoratedCoffee.getCost();
}
getIngredients() {
return this.decoratedCoffee.getIngredients();
}
}
class WithMilk extends CoffeeDecorator {
constructor(private readonly c: Coffee) {
super(c);
}
getCost() {
return super.getCost() + 2.5;
}
getIngredients() {
return super.getIngredients() + ", Milk";
}
}
class WithSprinkles extends CoffeeDecorator {
constructor(private readonly c: Coffee) {
super(c);
}
getCost() {
return super.getCost() + 1.7;
}
getIngredients() {
return super.getIngredients() + ", Sprinkles";
}
}
class WithSugar extends CoffeeDecorator {
constructor(private readonly c: Coffee) {
super(c);
}
getCost() {
return super.getCost() + 1;
}
getIngredients() {
return super.getIngredients() + ", Sugar";
}
}
The Coffee
interface works like a contract used consistently throughout the code and models a real-world object. For example, a cup of coffee has some ingredients and costs money. The starting price is $8, assuming US currency and adjusting for inflation, set in SimpleCoffee
.
The decorator pattern is a good example of the Liskov substitution principle because all subtypes of the CoffeeDecorator
stick to this same contract. This makes the code more predictable and intuitive. Because TypeScript does a good job of ensuring subclasses implement contracts, it is harder for developers to sneak odd behavior in weird places.
class Barista {
constructor(private readonly cupOfCoffee: Coffee) {}
orders() {
this.orderUp(this.cupOfCoffee);
let cup: Coffee = new WithMilk(this.cupOfCoffee);
this.orderUp(cup);
cup = new WithSugar(cup);
this.orderUp(cup);
cup = new WithSprinkles(cup);
this.orderUp(cup);
}
private orderUp(c: Coffee) {
console.log(
"Cost: " + c.getCost() + "; Ingredients: " + c.getIngredients()
);
}
}
const barista = new Barista(new SimpleCoffee());
barista.orders();
As shown, the consuming code remains easy to use and nukes a lot of complexity. This is the ultimate goal in OOP: to abstract away all your problems. So my barista doesn’t waste time poring through low-level code to make a simple cup of coffee.
As a bonus, this code is now testable because the Barista
class sticks to the same contract. You can inject a mock that implements Coffee
to unit test all of this code.
Wrap Up: Use Proper Object-Oriented Programming Techniques in TypeScript
In this post, I've run through the three pillars of object-oriented programming — encapsulation, inheritance, and polymorphism — and have also introduced duck typing.
You've seen how TypeScript automates best practices, thus, you no longer need to rely on sheer willpower or discipline.
This helps you stick to OOP principles, as well as get rid of code smells. Your types and the compiler should become allies to keep your code clean and free from unfortunate accidents.
Happy coding!
P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.
Posted on April 19, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024