Changing APIs is a common problem for library authors. There’s some class or function that you need to change, but you don’t want to break your library’s client code when they upgrade the library. In other words, the change needs to be backward compatible. Sure, there are cases when you’ll have to make breaking changes, but it’s usually better to avoid that. In the case of company-internal packages, when you can modify both the library and the app that consumes it, there are a few nifty patterns to deal with API changes. That is, turning changes that would usually have to be “breaking changes” to not break anything. Let’s talk about these patterns.

In TypeScript and JavaScript, it’s all about functions, so we’re talking about changing a function’s signature. We can categorize API functions into two categories:

  1. Functions with a single object options parameter. Something like function foo(options: FooOptions).
  2. Functions with multiple parameters. For example: function foo(a: string, b: number, c: MyObject).

Changing functions with an options parameter

An options object makes changing an API much easier.

Let’s start with an example of a simple options parameter:

export interface IFooOptions {
	schoolName: string;
	schoolId: number;
	teachers: string[];
}

There are 3 ways in which you can change your API: adding parameters, removing parameters, and changing parameter types.

Adding parameters

Adding parameters is as easy as adding an optional property to your interface. For example, if you want to add numberOfStudents, you can change the code to:

export interface IFooOptions {
	schoolName: string;
	schoolId: number;
	teachers: string[];
	numberOfStudents?: number;
}

There you have it, old clients will be able to call foo without the new property and new clients will be able to add it.

Inside the function foo, dealing with it is as easy as checking whether numberOfStudents is undefined:

function foo(options: IFooOptions) {
	if (options.numberOfStudents !== undefined) {
		// do something
	}
}

Another way to go is making the new parameter required. This means that once your library clients upgrade to the newest version, they’ll get compiler errors in all places calling foo. But those errors will be easy to fix, so in a way it’s the safest option. If you aren’t the one developing the client, it’s probably not an option to break everyone’s code with a new version, unless you’re developing a new major version that allows for breaking changes.

Removing parameters

Removing parameters is similarly easy. First, mark the parameter that you’re removing as optional and publish a new library version. You can mark the property as deprecated to signal your clients that it’s no longer necessary. For example, if you were to remove schoolId, our interface would change to:

export interface IFooOptions {
	schoolName: string;
	
	/**
	 * @deprecated This property will be removed in future versions.
	 */
	schoolId?: number;
	
	teachers: string[];
}

Library clients can update to the new version and remove this property in all callers. Once done, you can publish another version with the property omitted, and library clients will update without any compilation errors. These kinds of client/library manipulations aren’t very realistic in a public-facing package, but they are useful for internal packages where you can control both the library and the consumer clients.

The other choice is to remove the property all at once. Assuming your TypeScript settings are strict, library clients will get a bunch of errors when they upgrade. But as already mentioned, those errors will be easy to find and fix.

Changing parameter type

There are 2 ways to go about changing parameter type. The first way is to create a new property with a different name and the new object type. Make this property optional and make the property you’re about to remove optional as well. Publish a new library version to give your consumers the chance to change to the new property without compiler errors. Then you can remove the old parameter or leave it as optional and make the new parameter required.

The second way is to use a union type. For example, if I wanted to change schoolId to a string, I could do this:

export interface IFooOptions {
	schoolName: string;
	schoolId: number | string;
	teachers: string[];
}

Publish a new library version. Now, your callers get to pass schoolId in the old way or the new way, while inside foo you can easily distinguish the two cases with the typeof keyword, like this:

function foo(options: IFooOptions) {
	if (typeof options.schoolId === 'number') {
    	// do something
	} else if (typeof options.schoolId === 'string') {
    	// do something else
    }
}

Distinguishing by type is easy with primitive types like string or number, but it’s harder with object types. One way to make object types identifiable is to add a constant type Id property. For example:

export class MyType implements IFooOptions {
	_type = 'MyType';
}

export class MyOtherType implements IFooOptions {
	_type = 'MyOtherType';
}

By adding _type, you can distinguish between the different types like this:

function Foo(options: IFooOptions) {
	switch (options._type) {
        case 'MyType':
	    	// do something
            break;
        case 'MyOtherType':
	    	// do something else
            break;
    }
}

Changing functions with multiple parameters

While a single options-type parameter is relatively easy to change, functions with multiple parameters are harder. The root of the issue is that TypeScript doesn’t have named parameters like C# or Python . Nor does TypeScript have real functions overloading with different implementations per overload. This means the only ways you can distinguish between callers of old and new APIs is by the parameter types or by the the amount of parameters. With those constraints in mind, let’s see how we can deal with the different use cases.

Adding parameters

Let’s start with a simple function foo(schoolName: string, schoolId: number, teachers: string[]).

You can easily add a new parameter without adding breaking changes by making it optional, like this:

function foo(schoolName: string, schoolId: number, teachers: string[], numberOfStudents?: number)

When making it optional, library clients that upgrade to a new version won’t get compiler errors. Another option is to make it required, which will cause compiler errors in all callers. But those errors will be easy to find and easy to fix. If you do make the parameter optional, I suggest making it required in the next version because otherwise it will become more difficult to change your API later on. Several consecutive optional parameters don’t allow to easily remove parameters or add new ones.

Removing parameters

Removing parameters is more tricky. If the parameter you want to remove is last, you can mark it as optional and deprecated. This allows library clients to remove the parameters in all callers so that you can remove it entirely in a future library version. If the parameter you want to remove is not last, this is more of a problem. TypeScript only allows placing optional parameters last in the parameter list.

The most straightforward way to deal (easiest not to mess up) with removing non-last parameters is to create a new function. For example, if you wanted to remove schoolId, you could create fooV2 without it:

function foo2(schoolName: string, teachers: string[]) { ... }

Now you have a few options: either you want to leave both functions forever or you want to eventually return to a single function. If you choose to stick with 2 functions, if you ever want to change anything else, you’ll have 3 functions, and so on. If you want a fantastic example of old & new & new-new functions, look at older Visual Studio extensibility APIs.

If you’re a bit more flexible and have control over client library version, then you can allow your clients to upgrade to the new version, and then delete the old function. Or remove the parameter in the old function and then ask your clients to “change back” in order to keep the original name. Pretty messy though.

Another option is to mark all parameters starting from the one you want to remove as optional . So the function will look like this:

function foo(schoolName: string, schoolId?: number, teachers?: string[]) { ... }

Your clients will upgrade to the new version and remove the deprecated parameter in all callers.

foo(mySchool, undefined, teachers);

Then, you can remove the deprecated parameter and publish a new version with all parameters required.

Note that this kind of pattern will work only if there were no optional parameters to start with; that’s why it’s important to have as few optional parameters in any function that has a chance of changing. Or use an options object single parameter.

The final option is to change the inline parameters with an options parameter object. You can do it with a new function or with a union type. Here’s an example using union types:

export interface IFooOptions {
	schoolName: string;
	teachers: string[];
}

function foo(schoolNameOrOptions: string | IFooOptions, schoolId?: number, teachers?: string[]) {
    if (typeof schoolNameOrOptions === 'string') {
        //...
    } else {
        //...
    }
}

Note that I also marked the other parameters as optional so that you could remove them later and stick to a single object parameters.

Changing parameter types

To change parameter types, you can either change the type to a union type or add an additional optional parameter with a different name. For example, when using union types, the original function foo will change to:

function foo(schoolName: string, schoolId: number | string, teachers: string[]) {
    // ...
}

Instead of union types, you can make use of TypeScript’s Function Overloading feature. Overloading might be more readable for some. It also allows different documentations for each function signature, which is a nice benefit.

Conclusion

We went over a bunch of ways to change function signature and keep it backwards compatible. Or at least minimize the damage. If there’s any conclusion to be made then it’s that dealing with a single options parameter is simpler than dealing with inline parameters. There’s whole set of problems originating in that TypeScript doesn’t support named parameters or “real overloading” (i.e overloaded functions with different implementations). Having said that, inline parameters though can be a much nicer API.