Principles of Object-oriented Programming in TypeScript
Tags: typescript, javascript
Published: 2022-04-19 12:56:51

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](https://www.typescriptlang.org/play). The goal is for you to reproduce the following, to prove these techniques work in any codebase.

Take a look at the following:

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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`.

```typescript
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.

```typescript
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.

```typescript
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](https://blog.appsignal.com/javascript-sorcery) 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](https://www.appsignal.com/nodejs).**