4

In Javascript, should appending to the signature of a callback be considered a breaking change?

I.e. given an operation op(target, callback) should changing it from callback(item, index, array) to callback(item, index, array, root) result in a new major release according to semver?

The spec specifies:

increment the MAJOR version when you make incompatible API changes

and the two signatures are not incompatible, as the new one encompasses the old, seeming to suggest a new major version is not required.

On the other hand, consider this (admittedly slightly contrived) example:

function doTheThing(item, index, array, doItDifferently) {
    if (doItDifferently) 
        // Do something unusual and amazing with the arguments
    else
        // Do the same old boring thing with the arguments
}
// Use doTheThing as a callback to the operation:
op(target, doTheThing)
// doTheThing is also used directly:
doTheThing(arr[0],0,arr,true)

doTheThing is a valid callback, but might be used elsewhere in the code in its more elaborate form. This would break if the callback signature were changed.

Update

Wow, lots of really good food for thought. I see no consensus, but there seems to be a majority in favor of considering it a breaking change, and, hence, a major release. The more I have been thinking about it, the more that seems right to me, as well, and I think that is what I will do. It's definitely the safer option.

I had posted this as more or less an abstract question, not expecting it to spark so much debate. To facilitate further discussion, the library in question is object-selectors, a selector language for complex and deeply nested Javascript objects, and specifically, the perform operation.

5 Answers5

9

As long as clients don't need to update their code to achieve the same behavior as the old version, and this change is backwards-compatible, it is not a breaking change for JavaScript. Callers are not obligated to specify every parameter passed to a function. This assumes callback(item, index, array) in the new library version is semantically equivalent to the same call in the old version. If the behavior of callback(item, index, array) is different in the new version, then it would be considered a breaking change.

The addition of a callback parameter in JavaScript is kind of like supporting a new overload. You could justify increasing the minor version number, making this a backwards-compatible feature release instead. However, if the addition of the new callback parameter corrects a defect, then increase the patch version. This would be applicable if you had distributed the previous version of the library and included documentation that clients could use callback(item, index, array, root), but your library mistakenly did not pass root.

The intention of the change matters as well.

2

It depends on how the call of the callback changes!

Let's say in V1.0.0 of a library or component where op resides, the function looks like this:

 function op(target, callback)
 {
     // ...
     callback(item, index, array);
 }

and the next version of that component, it is

 function op(target, callback)
 {
     // ... everything else remains ...
     callback(item, index, array, true);
 }

and you ask whether the next version's version number should be V1.1.0 or V2.0.0, right?

In a strongly typed and compiled language, it would be clear that clients need to be changed and recompiled after such a change, thus it would clearly be a breaking change. In JavaScript, however, this will not force any recompilation, and client code which passed in just 3-parameter callbacks would not behave differently in conjunction with this change, since the value of the fourth parameter will simply be ignored. This gives the impression this kind of change might be backwards compatible.

Unfortunately, as long as you did not clearly state in the V1.0.0 API description that valid callback must ignore anything beyond the 3rd parameter, you don't know if some of your clients had passed a 4-parameter version of callback formerly into the "V1.0.0", since JavaScript does not forbid this. For a callback which accepts 3 or 4 parameters, the change in the component looks effectively like a change from

 callback(item, index, array, undefined);

to

 callback(item, index, array, true);

and that is clearly a different behaviour of the component.

Hence, in the next API version, there is some risk the change might cause different behaviour for a client at run time - a "breaking change". That means, SEMVER will require to use "V2.0.0", and not "V1.1.0".

Now imagine op being part of some component C, and the "next version" of C gets a new boolean flag variable newFeatureActivated which defaults to false. Then it will be possible to implement the behaviour in a truly backwards compatible way, like this:

 function op(target, callback)
 {
     // ... 
     if(!newFeatureActivated)
         callback(item, index, array);
     else
         callback(item, index, array, true);
 }

Now, any unchanged client which uses C the old way will have the new feature deactivated by default. Hence passing in 4-parameter callback will behave exactly like before. So when one is careful, there is no breaking change, and the version number V1.1.0 will be appropriate.

In short, the fact alone the callback's signature is changed in a backwards compatible manner is not enough to guarantee backwards compatibility - one has to also be very careful to keep the calls to any callback backwards compatible as well.

Pang
  • 335
Doc Brown
  • 218,378
1

I'm sidestepping the specific example because I feel it's inefficient to try and define breaking changes on a case by case basis. The definition of a breaking change, at its very core, is this:

If every possible usage (i.e. consuming code) of your previous version will keep working in the newer version, then it's not a breaking change. Otherwise, it's a breaking change.

Some notes on that:

  • Note that I said every possible usage, not just every actual usage. It's not productive to label changes as breaking/not breaking based on you knowing that your consumers are doing things one way and not another way. You shouldn't be aware of your consumers' specific implementation, you only need to focus on what you've made possible for your consumers to do with your code.
  • "Keep working" implies not just that it compiles, but also that the behavior is unchanged. This includes the returned data and any side effects. To the consumer, the upgrade from the previous to the new version needs to be invisible, nothing should change about any existing consumption of your code.

So, back to your question:

I.e. given an operation op(target, callback) should changing it from callback(item, index, array) to callback(item, index, array, root) result in a new major release according to semver?

If the language you're working in allows consumers to omit trailing parameters (in C# they're called "optional" parameters, I don't know other names), then the introduction of such an optional parameter does not break existing consumers' code.

If this requires the consumers to update their code, even if it's just passing in a null, that's a breaking change. Any update which forces a consumer to update the code is a breaking change, by its very definition.

Flater
  • 58,824
1

Question 1: Does my code compile and run after I switch to the changed library?

Question 2: Does my unmodified code run without any change in its behaviour?

If the answer is “Yes” twice then it is a non-breaking change. What kind of change doesnt matter. If I passed a callback, and you changed the signature, then I would expect that my code doesnt compile. In that case it is a breaking change.

gnasher729
  • 49,096
0

Is changing the signature of a callback a breaking change?

It is just when it is implemented to be. In weakly typed dynamic languages the way JavaScript is the signature is loose the change that could be backward incompatible is a change of the order in its parameters list that is also the case with strict typed languages that support multiple callback signatures simultaneously by using overloading. For callbacks without parameters just intentfully with quite some development backward incompatibility could be achieved while for callbacks with parameters it can be achieved by changing the parameter(s)'(s) precedence.