Every change to a public interface is a breaking change

Suppose we have a Typescript library called shapes exporting an interface called Point. What change can we make to this interface so we can release a new version of shapes as a minor change, not breaking anyone’s code?

This is not an easy question as it sounds like. I’ve witnessed many people releasing a new version of a library as a minor change without realizing that this change is going to break someone else’s code.

Let’s look at our interface Point in our shapes library that currently has version 1.0.0:

// shapes@1.0.0
interface Point {
	x: number;
	y: number;
}

It just represents a point in the 2D plane.

Removing a property from the interface

Let start with a simple case. What happens when we remove a property from the interface? Suppose we no longer want to work with the 2D plane, one dimension is good enough for us thus we want to remove the y: number property and we release it in shapes@1.1.0:

// shapes@1.1.0
interface Point {
	x: number;
}

Did we break something? Well, yes. Imagine another application, let’s call it HappyPainting, that uses our library and contains this function:

function distanceFromOrigin(point: Point): number {
	return Math.sqrt((point.x * point.x) + (point.y * point.y));
}

Suddenly the interface Point in shapes@1.1.0 does not contain point.y property and we get a compilation error.

Property 'y' does not exist on type 'Point'

So remember: removing a property from an interface is a breaking change.

Adding a new property to the interface

Let’s add a new field to the interface: the z coordinate as we’re slowly moving to three dimensions:

// shapes@1.2.0
interface Point {
	x: number;
	y: number;
	z: number;
}

Will it break anything? It definitely does not break the distanceFromOrigin function as the function needs just x and y properties in the Point and they are still there. This function will compile without any troubles even with shapes@1.2.0. The problem will be if we try to implement the interface in our HappyPainting application:

class PointIn2D implements Point {
	public constructor(public x: number, public y: number) {}
}

This class works with shapes@1.0.0 but it does not work with shapes@1.2.0 because the class does not define the z coordinate. We get this error:

Property 'z' is missing in type 'PointIn2D' but required in type 'Point'.

We broke someone’s code by adding a new property to the interface.

Adding a new optional property

If we cannot add a new required property maybe we can add a new optional property, right? Let’s stick with a 2D point and add a new method called distance. It supposes to compute the distance from the point to the given point:

// shapes@1.3.0
interface Point {
	x: number;
	y: number;
	distance?(point: Point): number;
}

We made the method distance optional so there should be no problem with it, right? The function distanceFromOrigin has no problem with this change because it doesn’t use the method distance. And the class PointIn2D has no problem with it because the method distance is optional.

But what if we had this class in the HappyPainting app?

class Another2DPoint implements Point {
	public constructor(public x: number, public y: number) {}

	public distance(x: number, y: number): number;
}

Maybe someone had a similar idea – implementing a distance method but with a different definition. Instead of a Point, it takes two numbers as arguments. The class Another2DPoint works with shapes@1.0.0 (and with 1.1.0 and 1.2.0) but it does not work with shapes@1.3.0 because of the incompatible definition of the distance method.

Property 'distance' in type 'Another2DPoint' is not assignable to the same property in base type 'Point'.
  Type '(x: number, y: number) => number' is not assignable to type '(point: Point) => number'.

If we upgrade from 1.0.0 to 1.3.0 the app won’t work.

Removing an optional property

Ok, what about removing an optional property? Let’s remove our optional distance method. Can we hurt someone?

// shapes@1.4.0
interface Point {
	x: number;
	y: number;
}

So in shapes@1.3.0 there was optional distance method and in shapes@1.4.0 we removed this method. Now imagine this code in our HappyPainting app:

function distanceFromOriginSimplified(point: Point): number {
	if (point.distance) {
		return point.distance(new PointIn2D(0, 0,));
	} else {
		return distanceFromOrigin(point);
	}
}

So if there is point.distance we use it, otherwise, we use the original implementation. This works with shapes@1.3.0. But using shapes@1.4.0 we get error

Property 'distance' does not exist on type 'Point'

So again, we cannot remove an optional property without causing potential problems.

Solutions

  1. The first obvious solution is to release the library as a new major version. This is the least we can do. We should mention it in the readme or changelog and we are out of the woods. The downside of this solution is that we moved the burden to the client. The new version won’t be installed on the applications depending on "shapes": "^1.0.0". Someone has to probably manually update package.json, read the readme, and apply the necessary changes.
  2. Use a different interface. Keep the old Point interface and create a new ThreeDimPoint interface with x, y, and z properties. Everyone using the old interface is going to be fine and if someone wants to use the third dimension, they have to update the code anyway.
  3. Don’t export the interfaces at all. Let the client decide what it wants and provide just implementations. It’s more work for the client in the beginning but you can freely develop the library without the instant worries about breaking the code because some interface was changed.
  4. Have small interfaces. This is a good idea in general. Usually, it’s better to have more smaller interfaces than a few big interfaces.

Summary

Changing a public interface is a tricky thing. You never know how is the interface used in the outside world and in general, better be safe than sorry. Of course, if you develop just internal libraries and you can verify every module using your library, the situation is slightly better for you.

Some of the errors may depend on how strict you set your typescript compiler. But you should always expect the worst.

Comments

Do you have any thoughts? Write them down! You can use Markdown.