DEV Community

Cover image for Union Types vs Interface Inheritance — Writing Extensible Software
Mahi
Mahi

Posted on • Updated on

Union Types vs Interface Inheritance — Writing Extensible Software

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:

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

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.

Simple is better than complex. Now which one would the customer rather travel in?

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:

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

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:

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

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  raw `Shape` endraw  pointing at  raw `Rectangle` endraw  and  raw `Circle` endraw  vs polymorphism's  raw `Rectangle` endraw  and  raw `Circle` endraw  pointing at  raw `Shape` endraw .

Union types and interface inheritance have reversed dependency order.

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.

Interface inheritance saves invested time in the long run, in theory.

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!

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

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:

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

  get area(): number {
    return this.width * this.height / 2;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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:

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

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

Different build targets will never know of each other.

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 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 or any of the other 100+ 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:

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

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.

Top comments (1)

Collapse
 
rohiitbagal profile image
Rohit

Nice one oops are very useful in all the languages..