TypeScript introduces two new concerns around breaking changes for packages which publish types.
TypeScript does not adhere to the same norms around Semantic Versioning as the rest of the npm ecosystem, so it is important for package authors to understand when TypeScript versions may introduce breaking changes without any other change made to the package.
The runtime behavior of the package is no longer the only source of potentially-breaking changes: types may be as well. In a well-typed package, runtime behavior and types should be well-aligned, but it is possible to introduce breaking changes to types without changing runtime behavior and vice versa.
Changes to the types of the public interface are subject to the same constraints as runtime code: breaking the public published types entails a breaking change. Not all changes to published types are breaking, however:
- Some changes will continue to allow user code to continue working without any issue.
- Some published types represent private API.
It is impossible to define the difference between breaking and non-breaking changes purely in the abstract. Instead, we must define exactly what changes are "backwards-compatible" and which are "breaking," and we must further define what constitutes a legitimate "bug fix" for type definitions designed to represent runtime behavior. Note that this is a socio-technical contract, not a purely-technical contract, and therefore (contra Hyrum’s Law) a breaking change is not simply any observable change to a system but rather a change to the system which violates the contract.
Accordingly, we propose careful and specific definitions of breaking, non-breaking, and bug-fix changes for TypeScript types. Because types are designed to represent runtime behavior, we assume throughout that these changes do in fact correctly represent changes to runtime behavior, and that changes which incorrectly represent runtime behavior are bugs.
Note: The examples supplied throughout via links to the TypeScript are illustrative rather than normative. However, the distinction between "observed" and "promised" behavior in TypeScript is quite loose: there is no independent specification, so the formal behavior of the type system is implementation-specified.
Virtually all of the rules around what constitutes a breaking change to types come down to variance.1 In a real sense, everything in the discussion below is a way of showing the variance rules by example.2
In many cases, these are the standard variance rules applicable in any and all languages with types:
- read-only types (sources) may be covariant
- write-only types (sinks) may be contravariant
- read-write (mutable) types must be invariant
These basic intuitions underlie the guidelines below. However, several factors complicate them.
Additionally, TypeScript has two other features many other languages do not which complicate reasoning about variance: structural typing, higher-order type operations. The result of these additional features is a further impossibility of safely writing types which can be guaranteed never to stop compiling for runtime-safe changes.
Accordingly, we propose the rules below, with the caveat that (as noted in several places throughout) they will not prevent all possible breakage—only the majority of it, and substantially the worst of it. Most of all, they give us a workable approach which can be well-tested and well-understood, and the edge cases identified here do not prevent the rules from being generally useful or applicable.3
For a more detailed explanation and analysis of the impact of variance on these rules, see Appendix C: Variance in TypeScript.