Most projects in the JavaScript/TypeScript ecosystem release new versions with numbers respecting semantic versioning, or semver for short. Semver is a specification that describes how to predictably increase a package's version numbers upon each new release. TypeScript is notable for not following a strict interpretation of semver for its releases. This article will dig into:
- What semver is and why it's useful for many packages
- Why following a strict interpretation of semver would be impractical for TypeScript
- How TypeScript's releases are versioned to an interpretation of semver that makes sense for it
While TypeScript's diverging from a common community specification can be irksome for developers, there are real reasons why it chose to diverge.
The reasoning can be summarized as:
- Nuances of TypeScript's type checking change in virtually every release
- It would be impractical to increase TypeScript's major version for every type checking change
- If we consider those type checking nuances as details rather than the public API, TypeScript actually has quite good adherance to semantic versioning
This article will also more deeply explain each of those points.
Context: Semantic Versioning
Semver dictates that versions adhere to a Major.Minor.Patch format:
- Major: increases when you make incompatible API changes
- Minor: increases when you add functionality in a backward compatible manner
- Patch: increases when you make backward compatible bug fixes
For example, suppose a package is at version 1.2.3
.
It might increase its version to:
1.2.4
: upon release of a small bugfix that doesn't change its API or typical behaviors1.3.0
: upon release of a new feature that doesn't change its API or typical behaviors2.0.0
: upon release of a breaking change to its public API or typical behaviors
Semver is a widely accepted specification across the JavaScript/TypeScript ecosystem, including in package.json
dependency listings.
Benefits of Semantic Versioning
Adopting a standard policy for version numbers has allowed the industry to share expectations around what happens when those numbers change.
Semver for Developers
Semver acts as a "marketing" number for developers considering whether to update a dependency. If you see that a package has, say, only changed its patch version number, you can expect the update will probably not take much effort on your end. If, however, the package has updated its major version number, you can expect the update will probably take some manual effort.
Regardless of semantic version number changes, it's always a good idea to read a package's release notes before trying to update it. You never know when a small change to a dependency will impact your project in meaningful ways.
Semver for Machines
In addition to suggesting change severity to developers, many common tools across the web ecosystem rely on semantic versioning.
Most notably, Node.js package.json
's package dependencies adhere to node-semver
versions, which directly refer to semver.org.
Symbols such as ^
and ~
commonly seen in package.json
files are built on semver and explained by node-semver
's documentation.
Another common example of tooling built on semver is automated release management. Tools such as Changesets, release-it, and semantic-release can automatically publish semantically versioned packages based on the kinds of changes made to the package. Commit standards such as conventional commits can inform tooling of what changes are contained by each commit.
TypeScript Versioning Today
Given all the advantages of semantic versioning, it can be surprising to learn that TypeScript intentionally does not follow strict semantic versioning. TypeScript's version numbers to date have instead incremented according to the following pattern:
- A new minor version of TypeScript (e.g. 4.9.0) is released every three months or so with new features
- Patch updates to that version (e.g. 4.9.1) which fix for small bugs are released as needed
- A new major version of TypeScript (e.g. 5.0.0) is released if the minor version would have exceeded 9 (e.g. 4.10.0)
In other words, TypeScript uses major version numbers more for marketing and public visibility than for semantic versioning. This was an intentional engineering decision: releasing a new version of TypeScript almost always requires compiler changes that "break" some existing TypeScript programs.
"Breaking" Type Checking Changes
Many users consider TypeScript's type checking as a part of its API. When a minor or patch version introduces changes to type checking, some might consider that a "breaking change".
But - the type checker is a core part of TypeScript, and one of the most frequently modified parts upon each release. Strictly following semver would mean any change to type checking would be considered "breaking".
Changes to the type system can come in many forms. Even seemingly small changes in each of those forms can cause breakages for some consumers of TypeScript.
Most changes fall into one of the following categories.
Added Type Errors
One of the main benefits of using TypeScript is that it reports a type error when it detects an issue in code. New versions of TypeScript are often made more capable in finding more classes of type errors. But what some would consider bugfixes or new features in type checking, others would consider introduced breakages.
Updating to a new version of TypeScript and seeing new red editor squigglies or build failures could be considered "breaking" - but is a natural consequence of TypeScript improving over time.
Example: TypeScript 4.8 stopped allowing unconstrained generic type parameters to be assignable to {}
.
This is technically correct, as {}
is not supposed to be assignable to null
or undefined
.
But existing code might have been written with the assumption that the type parameters would always be provided with an object type.
That code would newly receive type errors in TypeScript >=4.8.
Removed Type Errors
Conversely, TypeScript sometimes stops reporting type errors in situations that used to be considered incorrect. This is sometimes done to make TypeScript more permissive and compatible with existing JavaScript code. But for consumers who relied on TypeScript's stricter type checking to catch issues, these changes could also be considered "breaking".
Updating to a new version of TypeScript and no longer being prevented from shipping code patterns the project wanted to avoid might be a problem - even if TypeScript considers the change an improvement.
Example: TypeScript 5.1 allowed get
ter and set
ter accessor pairs to specify two different types.
This is correct, as many built-in JavaScript objects have APIs with different types for accessor pairs.
However, teams that expected TypeScript to prevent them from writing mismatched accessor pairs could be surprised to see that TypeScript >=5.1 no longer would.
Built-In Type Definition Changes
TypeScript includes a set of built-in type definitions that are included in the majority of projects.
These definitions include types for JavaScript's built-in objects, such as Array
and String
, as well as DOM types such as Document
and HTMLElement
.
Changes to these definitions can cause type errors in existing code, even if the changes are purely additive. Many projects augment built-in type definitions to fill in gaps or make them more strict per the project's needs. Modifications to augmented built-in types can introduce type errors for declaration merging that previously was permitted.
Example: TypeScript 4.9 improved the accuracy of the built-in Promise.resolve
types.
That improved type can break existing code relying on Promise.resolve(...)
calls to be typed as returning any
or unknown
(<4.9) instead of a Promise<...>
(>=4.9).
Strict Versioning Options
We've seen now how "breaking" many common type checking changes are. It follows that if TypeScript followed a strict interpretation of semver, every release would be a major. TypeScript's version strategy options therefore are:
- Allowing changes to the type system in non-major versions (the strategy in use today)
- Only releasing those changes in major versions
Unfortunately, only releasing type system changes in those major versions is impractical regardless of how often major versions are released.
Frequent Majors
In theory, TypeScript could release a new major version every three or so months instead of a new minor as it does today. This would more directly follow traditional semver by indicating type system changes as breaking.
However, doing so would negate many of the benefits of semver for consumers. With a major version released every 3 months, consumers would have to update to that major version very frequently. The distinction between major and minor versions would lose meaning.
That loss of distinction would be detrimental to consumers because TypeScript does occasionally need to release an API-level breaking change. If every release were to be branded as major, then neither developers nor tooling would be able to easily identify the "true" major breakages.
Furthermore, frequent majors can contribute to large swathes of the community being left behind. Most projects don't update major versions of dependencies very often. Enterprise software in particular is notorious for sticking with older major versions. The larger the number of major versions of a piece of software exist, the more likely it is that many users will still be on old, unsupported versions.
Bugs and Bug Fix Releases
What happens when an unintentional change, such as a type checking bug, is shipped in a TypeScript release? If TypeScript were to consider type system changes as semver-breaking, then bug fixes would also have to be released as major versions. The inevitable need to patch bug fixes after a release would necessitate a significant number of added major versions per intended release.
Infrequent Majors
On the other hand, TypeScript could release major versions infrequently - say, once every six months, or even every year. This would minimize the frequency consumers would need to update major versions and experience breakages.
But restricting all "breaking" changes to an occasional new major version would mean bottling up nearly all changes for months at a time. New infrequent major versions would contain a significant amount of type system changes at once. Onboarding to new infrequent major versions would be much more difficult than today's minor versions.
Even worse, because the new changes would have so little public user testing in stable versions before release, they'd likely contain many more bugs. TypeScript is a complex enough language with a large enough community that many type system edge cases aren't caught until a stable release. Even with long beta periods for new infrequent major versions, bugs would likely creep in - and their fixes would often be considered "breaking" changes that have to wait for another new major version.
TypeScript Does Follow (Loose) Semver
Despite the aforementioned difficulties in semantic versioning TypeScript releases, the language does in fact follow a looser version of semver. It considers API changes to be breaking:
- Backwards-incompatible modifications to its Node.js APIs
- Removals of TSConfig compiler options
- Changes to default values of TSConfig compiler options (other than the catch-all
strict
, which is explicitly noted as an exclusion)
In other words, the way you might call TypeScript, such as API inputs and the general format of its outputs, are considered its stable public API. Nuances in how TypeScript generates its outputs, such as type checking behavior, may change in non-major releases.
Working with TypeScript Releases
Given that TypeScript's minor releases generally change type checking behavior, updating to new TypeScript versions can sometimes be nontrivial, even practically painful. TypeScript's breaking changes are documented online:
- Release notes are always available on the TypeScript handbook: e.g. What's New > TypeScript 5.1
- github.com/microsoft/TypeScript/wiki/Breaking-Changes: End-user changes, most notably around emitting and type checking behavior
- github.com/microsoft/TypeScript/wiki/API-Breaking-Changes: Changes that impact running TypeScript itself as a library or Node.js API
Additionally, there are a few strategies you can take to minimize that pain.
Pinned TypeScript Versions
Most software projects in the JavaScript ecosystem today use a "lockfile" such as a package-lock.json
(npm), pnpm-lock.yaml
(pnpm), and yarn.lock
(Yarn) to keep consistent versions of packages.
A lockfile will ensure that installing dependencies in your project will always use the same versions.
Using a lockfile is generally a good practice - especially for tools such as TypeScript that can substantially change behavior across versions.
Note that when using a lockfile, you don't have to specify an exact major.minor.patch version for "typescript"
in your package.json
.
Your lockfile will specify an exact version of every dependency for you - generally defaulting to the latest stable version of any dependencies in your package.json
, including "devDependencies"
.
Intentional TypeScript Version Upgrades
When you do want to update your TypeScript version, consider the following strategy:
- Create a pull request to isolate just the changes for the new TypeScript version
- In the pull request body, link to the TypeScript release notes (e.g. TypeScript 5.1) and call out changes that are impactful to your project
- In the PR's files view, for each kind of change you had to make, add a comment to the first instance of the change explaining why it was necessary
Dealing with Breakages
If and when a new TypeScript version causes a type error in your code, consider trying each of the following strategies in order until it is fixed:
- Fix small issues with the types to work with the new TypeScript behavior
- Refactor the types to reduce complexity and work with the new TypeScript behavior
- Use
any
on types you can't figure out, with a// TODO
comment linked to a tracking issue/ticket for cleaning it up later - Use
// @ts-expect-error
on remaining issues you can't figure out, with a// TODO
comment linked to a tracking issue/ticket for cleaning it up later
Let's look at those tips in more details.
Straightforward, Clean Types
TypeScript's type system is fantastically powerful and can represent some wild and wacky type operations. However, the more complex your types are, the more difficult they are to read, write, and update over time. TypeScript types follow general software development principles around keeping code simple:
- Prefer simple, easy-to-read code (types) whenever possible
- Prefer well-named types that explain what they're doing
- When code does become complex to the point of being difficult to read, consider using comments to explain the tricky details
Simpler types are less likely to be broken by new TypeScript versions tinkering with nuances of the type system. They're also easier to debug when something becomes broken.
Therefore, if you're unable to fix small issues with newly broken types, you might benefit from trying to refactor them to be simplier and easier to work with.
Falling Back to any
Another general type system best practice is to avoid the any
type.
The any
type indicates that a value that can be anything and TypeScript should allow using it in almost any way.
Which is as dangerous as it sounds: any
stops TypeScript from reporting potentially useful type errors and stops other TypeScript developer utilities from working as well.
Still, if you're struggling to get a type to work, any
can be an effective backup band-aid to lessen TypeScript's type checking strictness.
Replacing a type with any
can save you from having to dive into complexities around that type.
Try to always add a // TODO
comment explaining why you used the any
, to help inform future efforts to remove the any
.
The @typescript-eslint/no-explicit-any
lint rule can enforce against explicitly writing any
types in code.
Falling Back to TypeScript Comment Directives
Even more dangerous than any
are the TypeScript comment directives that completely turn off the type checker for a line:
// @ts-ignore
: Silences any type checking errors for a line// @ts-expect-error
: Acts like// @ts-ignore
, but if the corresponding line doesn't cause a type error, TypeScript will report that the comment directive is unnecessary
These are absolute last ditch strategies for when all else has failed.
Silencing TypeScript type errors altogether brings the same downside as an any
, but even more so - across an entire line of code.
If you absolutely must use a comment directive to silence TypeScript, prefer using // @ts-expect-error
with a // TODO
comment explaining why you needed it.
If a future change to your codebase removes the need for a comment directive, the // @ts-expect-error
will direct TypeScript to let you know to remove the comment.
- The
@typescript-eslint/ban-ts-comment
lint rule can enforce against comment directives, or enforce that they be accompanied by an explanatory comment. - The
@typescript-eslint/prefer-ts-expect-error
lint rule can enforce using// @ts-expect-error
over// @ts-ignore
.
Closing Thoughts
Semantic versioning is a lovely specification that has helped standardize how many tools publish new versions and/or build on top of predictable versioning. The TypeScript project does its best to keep to semver with its public API.
Unfortunately, strictly adhering to semver would be practically impossible for much of TypeScript -especially the type checker- regardless of how TypeScript attempted to schedule new versions. TypeScript instead aims to be as semver-compatible as possible in its public API while still iterating on its type system at a reasonable pace through minor versions.
When upgrading a project's TypeScript dependency to a new version, there are several strategies one can take to minimize disruption. Those strategies mostly revolve around having clean, idiomatic TypeScript code, and using TypeScript's "escape hatches" only as a last resort.
For more public discourse, see TypeScript issue #14116 that discussed a request to start using semantic versioning: in particular @RyanCavanaugh's second comment.
If you find the nuances of breaking changes in a type system interesting, you might enjoy Ember's Semantic Versioning for TypeScript Types RFC and the resultant www.semver-ts.org.
This article was inspired by @MatteoColina's spicy thread on Twitter - in particular this threaded context explanation.
Many thanks to Christine Belzie, Daniel Rosenwasser, Kenny Lin, and Ryan Cavanaugh for helping proofread and suggest additions to this article. 💙
Got your own TypeScript questions? Tweet @LearningTSBook and the answer might become an article too!