Backward compatibility for a published package
Strict semver: breaking change → major. Use Changesets for changelog discipline. Mark APIs experimental with `@experimental` JSDoc. Deprecate with console.warn (or peer-dep typing) for one minor cycle before removing. Ship codemods for non-trivial migrations. Maintain release branches for the last 1–2 majors. Type-test with `tsd` / `expect-type` to catch unintended type changes.
Public packages have an implicit contract with consumers: upgrades shouldn't break them in a minor or patch. Honoring that contract is a discipline, not magic.
Semver, strictly
| Change | Bump |
|---|---|
| Bug fix, no API change | Patch (1.2.3 → 1.2.4) |
| New API, existing intact | Minor (1.2.3 → 1.3.0) |
| Breaking change | Major (1.2.3 → 2.0.0) |
"Breaking" includes types. Adding a required parameter, removing an export, narrowing a return type, renaming a prop — all major.
Changelog discipline
Use Changesets (@changesets/cli):
- Every PR with a user-visible change adds a changeset file.
- Bumps + changelog auto-generated.
- Forces contributors to articulate the change at PR time.
Deprecation flow
Never delete an API in the same release you stop loving it:
- Deprecate.
@deprecatedJSDoc, console.warn first call ("X will be removed in v3, use Y"). - Coexist for at least one minor cycle (3+ months for active projects).
- Remove in the next major. Document migration in changelog + migration guide.
Codemods
For non-trivial migrations:
npx @org/package-codemods rename-component-propjscodeshift or ts-morph for the heavy lifting. Time invested here pays back enormously — consumers upgrade willingly when the migration is one command.
Type stability
Public types are part of the API. Use tsd or expect-type tests:
expectType<string>(myFn("a"));
expectAssignable<Props>({ x: 1 });Catches accidental type-narrowings that break consumers.
Experimental APIs
Mark with @experimental JSDoc or prefix unstable_. Document they may break in minor versions. Once stable, promote without underscore.
Multiple release lines
For widely-used libraries (React, Vue), maintain branches:
main— next major.v17.x— security patches for the last major.
npm publish --tag latest for current, --tag v17 for legacy.
Migration guides
Every major: MIGRATING.md per release. List every breaking change, the rationale, and the replacement. Link the codemod.
Dependencies
- Peer deps for plugin-style packages so consumers pin React/Vue version.
- Exact dep ranges vs caret — match your own update appetite.
Anti-patterns
- "Soft breaking" changes in minors ("it's only the unusual case").
- No deprecation period — surprise removals.
- Renaming the package on a major (breaks even strict semver consumers).
- Different changelog per maintainer (no consistency).
Interview framing
"Strict semver: any breaking change — runtime, types, or behavior — is a major. Use Changesets for changelog discipline so every PR articulates its impact. Deprecate APIs with @deprecated + console.warn for one minor cycle before removing them in the next major. Ship codemods for non-trivial migrations so upgrades are a single command. Pin types with tsd so type signatures don't drift silently. Maintain prior major release branches with security patches. Write a migration guide per major."
Follow-up questions
- •When is a type change technically not breaking?
- •How do you handle a dependency's breaking change?
- •What's a good deprecation window?
Common mistakes
- •Breaking change in a minor 'because it's a small case'.
- •No codemods → consumers stuck on old majors.
- •Removing APIs in same release as deprecating them.
- •Types drift in patch versions.
Performance considerations
- •Releases themselves cheap. Cost of breaking minor: many consumers pinned at old versions, defeats the upgrade cycle.
Edge cases
- •ESM/CJS dual publish — careful with subpath exports.
- •Native dependencies that need rebuilds.
- •Breaking behavior under a flag for opt-in.
Real-world examples
- •React's deprecation cycles, Next.js codemods, TypeScript's release rhythm, Vue 2→3 migration tooling.