Union Types vs Interface Inheritance — Writing Extensible Software
Tags: typescript, programming, softwareengineering, software
Published: 2024-04-03 15:14:48

Recently there's been an increasing number of discussion on how _union types_ (or _type-unions_) are awesome and much simpler to use than classical design patterns:

```ts
type Circle = {
  radius: number;
};

type Rectangle = {
  width: number;
  height: number;
};

// Union type! Shape is either a Circle OR a Rectangle
type Shape = Circle | Rectangle;

function getArea(shape: Shape): number {
  if (isCircle(shape)) {  // Checks if shape has a 'radius' field
    return Math.PI * shape.radius ** 2;
  } else if (isRectangle(shape)) {
    return shape.width * shape.height;
  }
}
```

The general consensus seems to be that this code is easy to comprehend and you can trust a junior developer to manage it, therefore it must be good and all the complex paradigms must be bad, right?

![Modern cruise ship control room with lots of controls vs old wooden ship with a simple wheel.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i4zku9zqpl5lfie3s84h.png)<center><small>Simple is better than complex. Now which one would the customer rather travel in?<small></center><br/>

What most developers forget is that **the complex patterns exist for a reason**. It's not like the industry was born with these patterns, no, it all started out simple but as the projects grew so did the need for more advanced patterns. But like with everything in life, our collective memory is short and the elderly are dumb, so surely these simplifications must be good. And honestly, they _can_ be, as long as we understand the trade-offs of our choices. But do we?

---

## Polymowhat?

_Polymorphism_, _poly_ meaning many and _morphism_ meaning forms, is when you access objects of different type ("many forms") through one uniform interface:

```ts
function printAreaOf(shape: Shape): void {
  // Is the shape a triangle? A rectangle? A circle?
  // Polymorphism doesn't care, all shapes have an area!
  console.log('Shape has area of', shape.area);
}

printAreaOf(new Circle({radius: 4})); // 50
printAreaOf(new Square({width: 3, height: 2})); // 6
```

While union types themselves can be thought of as a form of polymorphism, the difference here is that _we_ don’t need to check the object’s type, instead _the object itself_ knows how to calculate the area for us. This can be referred to as _interface inheritance_, a type of polymorphism that’s open for extension.

Let's now take the earlier shape example and rewrite it using TypeScript's polymorphic interfaces:

```ts
interface Shape {
  readonly area: number;
}

// I like classes, but anything that fulfills the interface works
class Circle implements Shape {
  radius: number;

  get area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Rectangle implements Shape {
  width: number;
  height: number;

  get area(): number {
    return this.width * this.height;
  }
}
```

Now we don't need the `getArea()` function at all anymore, as all the shapes inherently know their area already.

---

## Polymowhy?

The major advantage of interface inheritance is that our `Shape` type is no longer aware of its subtypes, instead all the subtypes are aware of the base `Shape` type!

![Type-union `Shape` pointing at `Rectangle` and `Circle` vs polymorphism's `Rectangle` and `Circle` pointing at `Shape`.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lgcd0bpnnguxsw7syrrv.png)<center><small>Union types and interface inheritance have reversed dependency order.</small></center><br/>

This makes it so that we can add, replace, and remove types without having to touch the base `Shape` type or any existing code that's using it. This ends up **minimizing the need for future code changes** and **maximizing modularity and extensibility**. If you don't need any of these things, then you don't *need* interface inheritance (or any other paradigm for that matter), you can just write code. And that's completely fine if all you want is to build simple websites, not everyone has to be the next Martin Fowler!

But if you're a curious learner who wants to design the next million dollar software some day, you need to be prepared to make it customizable for different customers and their ever-changing requirements without having to rebuild everything from scratch every two years. The initial design process will take more time, effort, and iterations, but by the time you deliver your fifth application you've already saved countless of hours of work.

![Polymorphism's time consumption growing linearly, union types exponentially.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/133xwtox58ikgvsjcsjv.png)<center><small>Interface inheritance saves invested time in the long run, in theory.</small></center><br/>

In my experience this is the case with most software design patterns, where they might initially feel complex and overkill, but the early struggles are an investment to save time in the future.

---

## More shapes!

To demonstrate the upside of interface inheritance, let's try adding a new `Triangle` shape to our earlier implementations. In the type-union variation, not only do we need a new `Triangle` type, we also need to modify the `Shape` union, the `getArea()` function, and *all other functions that operate on shapes* as well!

```ts
type Triangle = {
  width: number;
  height: number;
  // No longer distinguishable from Rectangle, needs a type field...
  type: 'triangle';
}

// Must edit the union type!
type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
  // ...
  } else if (isTriangle(shape)) {  // Now checks the 'type' field
    return shape.width * shape.height / 2; 
  }
}
```

In smaller software this might not be an issue, but it quickly becomes cumbersome to add or remove types as the number of functions operating on shapes increases. Worse yet, if you've shipped your awesome `shapelib` to your customers who have already written their own functions using your shapes (think of Photoshop or Minecraft plugins), they may not be too happy to find out that they need to write support for new shapes that *some other client* of yours needed!

Meanwhile, adding a new shape via interface inheritance is as simple as it gets:

```ts
class Triangle extends Shape {
  width: number;
  height: number;

  get area(): number {
    return this.width * this.height / 2;
  }
}
```

That's it, no other changes are needed. No modifications to existing code, no humble emails to your clients. Any existing code accessing `shape.area` works just fine, as interface inheritance hides the concrete type behind a common interface. This shifts the responsibility from the library users to the `Triangle` class's developer.

---

## What even is a plugin?

Imagine if a web-browser's code needed to know every single plugin that an user might want to install, we'd need to rebuild our browser every time a new plugin is released.

> *type Plugin = DarkMode | Bitwarden | HttpsEverywhere | [...](https://www.debugbear.com/blog/counting-chrome-extensions)*

This is easily solved with interface inheritance, where the main application (e.g. browser) defines a `Plugin` interface and loads the plugins dynamically on start-up, without the application's base code having to refer to any concrete plugins.
 
But wait! Why stick to just plugins in the conventional sense, why not implement our whole software as if it was a collection of plugins? Interface inheritance allows building different versions of your software simply by including/excluding certain classes:

```ts
// mobile-app.ts
interface MobileApp {
  launch(): void;
}

// android-app.ts
class AndroidApp implements MobileApp {
  launch(): void {
    androidLibrary.doAndroidStuff();
  }
}

// ios-app.ts
class IosApp implements MobileApp {
  launch(): void {
    iosLibrary.doIosStuff();
  }
}

// main.ts
const app = platform === 'iOS' ? new IosApp() : new AndroidApp();

// Everything from here on only refers to `MobileApp`
app.launch();
...
```

This can be done at runtime (as seen above), or you can even do this at build time to exclude the "incorrect" modules completely. This can be useful to reduce the final application's size when working on distinct large domains (e.g. iOS and Android), or if the same software is being deployed to different competitors who must not know of each other (sometimes legally due to NDAs).

![One code base being built into two different Apps with different subtypes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9gtr146zhobwau7xfflu.png)<center><small>Different build targets will never know of each other.</small></center><br/>

Now let's stretch our imagination a little and say that Nokia's Symbian OS _did_ break through after all, we would be forced to change... _absolutely nothing_ in our existing code! Instead we just add new modules for Symbian and change the `main.ts` or our build scripts a little. Modularity!

Now to be fair, with interface inheritance you may still need to introduce breaking API changes if you add or modify _functions_ — unless they can be default implemented, which is why sometimes an `abstract class` is preferable over an `interface` in TypeScript — but not only have I found these functional API changes to be much more rare, it's also much more acceptable to inform your customers that they need to start supporting a new "lane assist" functionality of your `carlib` package, than it is to call them every three weeks of a new car model having come out.

---

## R.I.P. Union Types?

Union types still have their use cases, but only when there is **a fixed and a small number of possible types**. Is there a fixed number of shapes? Absolutely not, just because you don't need a [triacontadigon](https://simple.wikipedia.org/wiki/Triacontadigon) *right now* doesn't mean you won't need one in the future! Well surely there is a fixed and a relatively small number of chess pieces, right? You'd be correct, until you become one of the largest chess sites and players start demanding a mode with [the knook](https://en.wikipedia.org/wiki/Empress_(chess)) or any of the other [100+ fairy chess pieces](https://en.wikipedia.org/wiki/List_of_fairy_chess_pieces). As you can see, more often than not our current requirements are not our final requirements, so it's better to come prepared.

What is considered fixed and small may vary based on who you ask, and everyone just has to find what works for them. Some examples that I've not yet had any issues with are:

```ts
// Distinct types that might not have any common fields,
// but are often handled as one type.
// Imo. this is the best use case for union types.
type HttpResponse = HttpSuccessResponse | HttpErrorResponse;

// This is fine, but enums do the same thing. Personal preference.
type ReviewStatus = 'InReview' | 'Accepted' | 'Declined';

// An optional helper type is an obvious use case.
type Alias = string | null;

// This is often better done with interface inheritance, but can be valid.
type Node = File | Folder;
```

Even here some of these are *opinionated choices* between union types, polymorphic interfaces, or simply enums.

The only thing I'm sure of is that nowadays a large number of people are too quick to choose one side of the "argument" and stick to it no matter what, instead of carefully learning the ups and downs of each approach. Remember to choose the right tool for the job, not the right tool to prove a point.