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
  • Are you asking about your library changing how op calls it's parameter, or what your library passes to op? Commented Aug 14, 2024 at 16:01
  • I'm asking about how op, which is exported by my library, calls its callback parameter. Commented Aug 14, 2024 at 16:19
  • 6
    If some clients may need to change their code due to the change, it's a breaking change. Commented Aug 14, 2024 at 16:20
  • "a majority in favor considering it a breaking change" - well, thanks for accepting my answer. Note, however, though I first thought it is a breaking change, my final answer says, it depends on how it is implemented. It will not necessarily become one when one is careful. Commented Aug 16, 2024 at 14:43
  • @DocBrown Yes, I saw that, and I saw the change, too. You're essentially saying, make the new feature opt-in, and it's guaranteed to not be breaking change. I've mentally added it to my list of options, but still more leaning toward just accepting it is a breaking change. I might offer an opt-out, though, to ease migration. Commented Aug 18, 2024 at 14:42

5 Answers 5

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.

3
  • 5
    Hard disagree. Breaking stuff for users relying on undocumented behavior is not a backwards incompatible change. If a user expects an unspecified fourth parameter to always be undefined (because JS), that’s on them. Otherwise, one could claim every change is a breaking change. For example, if I (undocumentedly) use /tmp, but switch to in-memory cache, that’s not a breaking change, even if some user decided they’d access the files while I am. Alternatively, is adding a new property to a JS object I return a breaking change because some user named their own prop the same way on that object? Commented Aug 16, 2024 at 19:25
  • 2
    @ColeTobin: even if you don't like it, such things can break client's code, and when you write libraries for a huge number of clients, you will be better off to either increase the major version number, or to be careful to make a more backwards compatible implementation. Those two options are rarely both that terrible that it pays off to refuse them both, Unfortunately, Hyrum's law is based on factual observations, not some pathological cases. Commented Aug 16, 2024 at 19:57
  • .. see also Flater's IMHO correct answer, who states that a any change which breaks a possible usage (even if that usage is based on side effects) counts as a breaking change. Of course, there are edge cases where the literal interpreation of "observable behaviour" might go too far, Commented Aug 16, 2024 at 20:07
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.

12
  • "As long as clients don't need to update their code, and this change is backwards-compatible" - hm, didn't the OP show as an example where doTheThing needs to be updated, because the change is not that backwards compatible in JavaScript as it looks at a first glance? Commented Aug 15, 2024 at 14:05
  • @DocBrown: the second sentence addresses doTheThing - or at least I thought people would make that connection. Perhaps I need to clarity my answer? Commented Aug 15, 2024 at 14:11
  • What the second paragraph adresses is clear, still I think the statement that this would be a "backwards-compatible feature release" is probably not correct - there is a possible case where this code does not behave backwards-compatible. Commented Aug 15, 2024 at 14:16
  • 3
    @DocBrown: I see what you mean. In my opinion, if the client chooses to pass too many parameters, which breaks a future update, I wouldn't consider that a breaking change from the perspective of the library. The client has a bug that needs to be fixed. We get lots of these gray areas with JavaScript, and I think at some point you just need to draw a line, otherwise the both of us could think of a thousand ways client code could break. It's a balancing act. You don't want every change to be "breaking." Client code has some responsibility here to use the library as it was intended. Commented Aug 15, 2024 at 15:22
  • 1
    "Client code has some responsibility here to use the library as it was intended." I agree with that, on the other hand client code may have taken a pre-existing (and elsewhere-used) function of higher arity and used it as the 3-parameter-callback to op. This is fairly common is JS, I would consider this "responsible" usage. Like imagine if Array#reduce's callback did not have the index and array parameter, doing array.reduce(Math.sum) would be perfectly fine, but adding index and array to the callback signature would break that. I think this is what @DocBrown is saying, right? Commented Aug 16, 2024 at 14:19
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.

6
  • Note that changes to optional parameters might be source-compatible (not requiring a change in client code) but binary-incompatible (requiring client code to be recompiled). This is the case with .NET. Commented Aug 15, 2024 at 14:39
  • 1
    @Craig: Good callout. Depends on the tech stack and how the consumer loads the library. Commented Aug 15, 2024 at 23:35
  • @Craig In C++ if there is an optional parameter, say “int x = 0”, then calling without the parameter is compiled exactly like passing 0. But not sure if I would count a required recompile as a breaking change, probably not. Obviously you have to re-link, so why not re-compile? And your build system might do that automatically anyway. Commented Aug 18, 2024 at 11:15
  • It does not (generally) require clients to update their code, provided they are not using a higher-arity function as the callback. Which (because JS), they might, the question is: Is that on them, or is it my responsibility? In other words: The three documented parameters are clearly part of the contract, but is the lack of further parameters also part of the contract? Commented Aug 18, 2024 at 14:28
  • 1
    @gnasher729 Consider the alternative of a dynamically-loaded library where the content (but not the signature) of the function changed. That would be a drop-in replacement without needing to recompile the client (both source-compatible and binary-compatible). When the default parameter changes, now you need to recompile and/or relink, so it's source-compatible (you don't need to retype it) but not binary-compatible. Commented Aug 19, 2024 at 13:18
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.

2
  • Javascript is an interpreted language, and its way of handling function arguments is sufficiently lenient that changing the callback signature does not prevent legacy code from running unchanged. Commented Aug 18, 2024 at 14:03
  • 2
    In that case Question 1 is answered, and Question 2 needs answering: Does the unmodified code run without any change in its behaviour? Commented Aug 18, 2024 at 16:35
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.

2
  • I'm sorry, but could you clarify your answer a bit? I'm not sure what you mean by what you mean by changing the parameters' precedence, nor do I know of such a concept as parameter precedence in Javascript, let alone how to change it. Are you advocating for or against it being a breaking change? Commented Aug 16, 2024 at 14:11
  • My read is that they are thinking of object parameters like callback({item: someItem, index: someIndex, array: someArray});, though I may be reading it incorrectly. Commented Nov 29, 2024 at 0:06

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.