Semantic Versioning Pitfalls in Real Projects
Cover photo by Carlos Gonzalez on Unsplash
Semantic Versioning is often treated like a law of nature, but it’s really just a suggestion that people frequently ignore. It’s supposed to be simple. You have Major.Minor.Patch. If you break something, bump the Major version. If you add a feature, bump the Minor. If you fix a bug, bump the Patch. Simple, right?
The naive approach
I used to think this was enough. I’d set up a small library and follow the rules strictly. Here is how I used to handle my package.json logic.
export const add = (a, b) => a + b;
// v1.0.1 (Patch)export const add = (a, b) => Number(a) + Number(b);This works until you realize that your definition of a break doesn’t match your user’s definition. I remember shipping a “non-breaking” change where I renamed an internal type. I thought it was fine because the runtime code didn’t change. My users using TypeScript disagreed because their builds started failing.
Where it breaks
“But wait, if you’re using TypeScript, isn’t that just a type issue?” That’s a fair point. If you aren’t strict about what constitutes a public API, your versioning strategy is going to crumble. A breaking change isn’t just a runtime error. It is anything that forces a consumer to change their code to upgrade. If I delete a CSS class or change the structure of a JSON response, that’s a breaking change (even if the old code still “runs”).
I once worked on a project where we used a tool to auto-generate changelogs based on commit messages. We thought we were being clever. It turns out, developers are lazy. Someone pushed a breaking change with a “feat” commit message. The tool bumped the minor version. Half the people using our SDK woke up to broken builds. Humans are bad at tagging things correctly. Maybe we should just accept that?
How to actually survive
If you want to stop the pain, stop relying on humans. I now use automated checks in my CI pipeline. If I change a type or remove an export, my build fails before I even get the chance to merge it as a minor version.
# A simple way to check for binary breaking changesnpx dts-buddy check --public-api ./index.d.tsI’ll admit, this isn’t perfect. It can’t catch logical changes that break consumer assumptions. If I change an API to return a string instead of a number, that’s technically a breaking change that static analysis might miss depending on your setup. It’s a limitation of the tools, but it’s better than nothing.
Can we just stop pretending?
We love to act like we have perfect control over our dependencies. We define ranges like ^1.2.0 and hope for the best. Sometimes, a maintainer makes a mistake and pushes a breaking change as a patch. It happens. Are we really going to stop using libraries because of human error? Probably not.
I’ve started pinning dependencies more aggressively lately (using exact versions), especially in production apps. It makes updates more tedious, but it means I control when my build breaks. Is that overkill? Maybe. I’d rather deal with the friction of updating manually than the panic of a broken production site at midnight.
What’s your threshold for pinning versions? Do you trust the semver promises, or do you lock everything down and hope for the best?