DEV Community

Cover image for Principles of Object-oriented Programming in TypeScript
Camilo Reyes for AppSignal

Posted on • Originally published at blog.appsignal.com

Principles of Object-oriented Programming in TypeScript

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",
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 ?? "";
Enter fullscreen mode Exit fullscreen mode

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!");
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)