when a compile error counts just as much as a runtime break
A type change that turns consumer code into a compile error is a breaking change, even without any change to runtime behavior at all. Backwards compatibility for type changes means deliberately using overloads, deprecation comments, API reports and Semantic Versioning so a TypeScript library can grow without surprising existing consumers on every release.
Table of Contents
- 1. Why type changes are breaking changes, even without a runtime change
- 2. Categories of type changes: extending vs. narrowing
- 3. Deprecation strategy with @deprecated instead of instant removal
- 4. Using overloads to support old and new signatures
- 5. API reports for automated detection of breaking changes
- 6. SemVer for types: when major, minor, patch
- 7. Migration guides and codemods for consumers
- 8. Testing: type-level tests against consumer codebases
- 9. Strategies for type changes compared
- 10. Summary
- 11. FAQ
1. Why type changes are breaking changes, even without a runtime change
In classic software development without static types, a breaking change is defined as a change that produces a different result at runtime or throws an error. In a TypeScript library, that definition is not enough, because a type change can turn existing, working consumer code into a compile error even though the actual program behavior does not change at all. For example, extending a parameter from string to string | number leaves existing code compiling, but narrowing the parameter from string | number down to only string breaks every call that previously passed a number.
Backwards compatibility for a TypeScript library therefore needs to account for two separate layers: the runtime layer, where classic tests apply, and the type layer, where only the compiler itself can check whether existing code keeps compiling. A team that relies exclusively on runtime tests as a safety net regularly overlooks type changes that still constitute a major-release-worthy break for consumers.
2. Categories of type changes: extending vs. narrowing
For classifying a type change, the distinction between extending and narrowing changes helps. An extending change makes a type more permissive, for example by adding an optional field to an interface or adding an extra case to a union. Such changes are generally backwards compatible, because existing code that worked with the narrower type also works with the broader type.
A narrowing change, on the other hand, makes a type more restrictive, for example by removing a union case, adding a required field, or tightening a function signature. Such changes almost always break existing code, even when the restriction was substantively sensible and long overdue. The catch: for function parameters, this rule sometimes reverses due to contravariant parameter type checking, which is why every change to public function signatures of a TypeScript library should be checked individually instead of relying on a blanket rule.
// Extending: adding an optional field — backwards compatible
interface QueryOptions {
limit?: number;
offset?: number;
// NEW in v1.4.0 — optional, existing callers are unaffected
timeoutMs?: number;
}
// Narrowing: removing a union case — breaking change
// BEFORE (v1.x): type SortDirection = "asc" | "desc" | "none";
type SortDirection = "asc" | "desc"; // "none" removed — v2.0 only
3. Deprecation strategy with @deprecated instead of instant removal
Instead of removing an outdated function or type immediately, a well maintained TypeScript library first marks it with the JSDoc tag @deprecated. IDEs such as VS Code and WebStorm then display a struck through name at every usage site without breaking the code from running. Consumers get a visible but non enforcing warning that a migration is coming, with enough lead time before the actual removal in the next major release.
It is important to describe concretely in the @deprecated comment which alternative should be used instead, not just that something is deprecated. A comment such as @deprecated Use createQueryBuilder() instead, will be removed in v3.0 gives consumers a clear course of action and a rough timeframe for when the old API will actually disappear.
// query-builder.ts — deprecated but still functional
export class QueryBuilder {
/**
* @deprecated Use `where(conditions: WhereClause[])` instead.
* Will be removed in v3.0. This overload silently ignored
* operator precedence for more than two conditions.
*/
where(field: string, operator: string, value: unknown): this;
where(conditions: WhereClause[]): this;
where(fieldOrConditions: string | WhereClause[], operator?: string, value?: unknown): this {
// Implementation dispatches to the new logic either way
const conditions = Array.isArray(fieldOrConditions)
? fieldOrConditions
: [{ field: fieldOrConditions, operator: operator!, value }];
this.applyConditions(conditions);
return this;
}
private applyConditions(conditions: WhereClause[]): void {
// ...
}
}
4. Using overloads to support old and new signatures
Function overloads are the most important technical tool for offering old and new call signatures of a TypeScript library at the same time, without immediately removing either variant. Multiple overload signatures define which combinations of parameter types are valid, while a single implementation signature internally handles both cases. To consumers, it looks like there are two independent functions, while in reality they share the same implementation.
The advantage over a single, wide union signature like where(a: string | WhereClause[], b?: string, c?: unknown) is type safety at the call site: TypeScript allows only the exactly declared combinations with overloads, whereas a single union signature does not automatically rule out unwanted mixed forms like where(clauseArray, "eq"). Overloads are therefore the more precise, but also more effortful, solution for backwards compatibility on signature changes.
5. API reports for automated detection of breaking changes
Manual review of every type change does not scale once a TypeScript library spans several hundred exported symbols. Tools such as API Extractor generate a so called API report, a compact text file with the complete public signature of the library. This file is checked into the repository as a snapshot and automatically regenerated on every pull request, then compared against the checked in version.
If the newly generated report diverges from the checked in one, the CI check fails, and the pull request must deliberately update the report before it can be merged. This forced intermediate step makes every type change to the public API visible, even if it arose incidentally as a side effect of an internal refactor. Without an API report, such unintended changes to the public signature often stay undiscovered until the first consumer bug report.
# CI step: fails the build if the public API drifted
# without a deliberate, reviewed report update
npx api-extractor run --local --verbose
# Diff shown in CI when a signature changed unexpectedly
# - export declare function where(field: string, operator: string, value: unknown): QueryBuilder;
# + export declare function where(field: string, operator: string, value: string): QueryBuilder;
6. SemVer for types: when major, minor, patch
Semantic Versioning applies to a TypeScript library not only for runtime behavior, but equally for the public type surface. A patch release changes neither types nor visible behavior, a pure bugfix without a signature change. A minor release extends the API additively: new exported functions, new optional parameters, new union cases that do not break existing code. A major release removes or narrows something on the public type surface, regardless of whether runtime behavior changes at all.
Practice shows that teams frequently interpret this rule too loosely and incorrectly publish a type fix that was previously too permissive as a patch release. A proven test: trial compile the change against a collection of representative consumer code snippets. If even one of those snippets breaks, it counts as a major release under SemVer, regardless of the original intent.
# .changeset/curious-lions-argue.md — describes the release type
# generated by "npx changeset" and reviewed before merge
---
"@mironsoft/query-builder": major
---
BREAKING: `where(field, operator, value)` overload removed in favor
of `where(conditions: WhereClause[])`. See MIGRATION.md for the codemod.
7. Migration guides and codemods for consumers
A major release that actually carries out a type change should always be accompanied by a migration guide showing concrete before and after examples, not just abstractly describing what changed. Consumers want to see in seconds whether and how their code needs adjusting, without reading the entire changelog. A good migration guide lists every breaking change individually with a short before and after code example.
For larger TypeScript libraries with many consumers, it is additionally worth building a codemod, an automated script based on ts-morph or jscodeshift that automatically rewrites consumer code from the old to the new API. A codemod substantially reduces manual migration effort and lowers the barrier for consumers to actually adopt a major release instead of staying permanently on an old version.
// codemod.ts — ts-morph script rewriting the old call signature
import { Project, SyntaxKind } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("src/**/*.ts");
for (const file of project.getSourceFiles()) {
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression().getText();
if (expr.endsWith(".where") && call.getArguments().length === 3) {
// Rewrite where(field, operator, value) to where([{ field, operator, value }])
const [field, operator, value] = call.getArguments().map((a) => a.getText());
call.replaceWithText(
`${expr}([{ field: ${field}, operator: ${operator}, value: ${value} }])`
);
}
}
}
project.saveSync();
8. Testing: type-level tests against consumer codebases
Alongside classic runtime tests, a TypeScript library needs type-level tests that check exclusively whether certain type expressions should or should not compile. Tools such as tsd or expect-type let you capture exactly this behavior in test files: an expectError() block marks code that is intentionally meant to trigger a compile error, an expectType() block checks that an expression has exactly the expected type.
For especially critical consumers, usually the largest or most important ones, it is additionally worth setting up a so called consumer test: a minimal, representative code snippet from a real consumer codebase gets added to the library's CI pipeline as a standalone test. If that snippet breaks at compile time, it blocks the merge of the change before it is ever published, instead of only being able to react after a support request.
9. Strategies for type changes compared
Depending on the kind of planned type change, different strategies are suitable to preserve backwards compatibility or to communicate the break in a controlled way.
| Change type | Strategy | Release type |
|---|---|---|
| New optional property | Add directly | Minor |
| Retiring an old function | @deprecated, then remove | Minor, then major |
| Changing a signature | Overloads as transition | Minor, then major |
| Introducing a required field | Immediate break, no detour | Major with migration guide |
The common denominator across all four rows: the bigger the potential impact on consumers, the more lead time and communication the change needs before it actually ships as a breaking change.
Mironsoft
TypeScript libraries, API design and release processes
Want to avoid breaking changes in your TypeScript library?
We set up API reports, deprecation processes and SemVer compliant release pipelines so type changes reach consumers in a controlled way, without surprises.
API report setup
Automated detection of unintended type changes in the CI pipeline
Migration tooling
Codemods and migration guides for smooth major releases
SemVer consulting
Defining clean release classification rules for type changes
10. Summary
Backwards compatibility for type changes means taking the public type surface of a TypeScript library just as seriously as its runtime behavior. Overloads allow old and new call signatures to be supported in parallel, @deprecated comments give consumers lead time instead of instant breaks, and API reports make every change to the public API visible before it unintentionally ends up in a release.
Semantic Versioning must be applied to types just as consistently as to runtime behavior, because a compile error for the consumer's team is just as much an outage as a runtime error. Migration guides and codemods lower the barrier to actually adopting a necessary major release instead of staying permanently on an outdated version.
Backwards compatibility for type changes — the essentials at a glance
Recognizing breaking changes
Even pure type changes without a runtime effect can turn existing code into a compile error.
Transition strategies
Overloads and @deprecated instead of instantly removing old signatures.
Automated control
API reports and type-level tests uncover unintended type changes before release.
SemVer & migration
Consistent SemVer for types, accompanied by migration guides and codemods for consumers.