An opinionated versioning strategy for TypeScript ambient types
Picking version numbers for TypeScript ambient types is quite tricky. Versioning philosophies like SemVer don't quite get the job done when it comes to types. Let's look at a simple example
Let's say we have a vanilla JavaScript library called table-component
whose version is currently 4.5.6
. If they follow SemVer, we know some things about this version
- It may not work at all with code designed to consume
table-component@3.0.0
- It may have some additional features that were not present in
table-component@4.4.2
- It may have some fixes that were not present in
table-component@4.5.3
In essence, for this code, consumers know whether it includes
- 🤕 Big changes that may require modification of their app's code
- 🎁 New features that shouldn't interfere with what already works
- 🛠 Small improvements and fixes that don't introduce any API changes of significance
However, let's say we have a library like @types/table-component
that provides some type information to describe table-component
's code. There's some more information we need to capture
This is a piece of information our consumers will certainly want to know, and there's no easy way to adhere to SemVer while also providing an answer to this question.
In addition to reflecting the breaking changes in table-component
, @types/table-component
may have its own breaking changes that are strictly related to the types themselves.
For example:
- Dropping support for old TypeScript versions
- Starting to require a generic parameter that was previously optional
- Making types more specific
Think about the previous point about "which version does this describe?". Maybe now you can see why the SemVer, and "match the table-component
version number" (sometimes referred to as "Lockstep") strategies both break down for types.
If we currently have a package.json
like this
"table-component": "4.5.6",
"@types/table-component": "4.5.6"
You might guess that these types describe exactly the library version we're working with. However, what happens when drop support for TypeScript 2.4 (a breaking change). Semver would tell us to do this:
"table-component": "4.5.6",
"@types/table-component": "5.0.0" # SemVer?
and now it looks like our library and types don't match. If table-component@5.0.0
is released we'd have to release a @types/table-component@6.0.0
. How can our consumers tell the difference between a breaking change in the types, and type-alignment with breaking changes in the code our types describe?
If we try the "Lockstep" strategy, and match the library version number, we'd have to use something like pre-release versions
"table-component": "4.5.6",
"@types/table-component": "4.5.7-beta.1" # Lockstep?
but now we've deprived our users of the ability to protect themselves against breaking changes within the types
The job of ambient types is simply to describe some other piece of code, the concept of a "non-breaking feature" within the types themselves is extremely rare. If a change is made to improve the way the types describe their corresponding table-component
code, that's a bug fix. All that's left to think about is whether it's breaking or non-breaking
If the library our types describe follows SemVer, the types that work for table-component@4.5.x
should be a superset of the types that describe table-component@4.1.0
. Because of this, consumers should be able to use the latest @types/table-component@4.*
with any table-component@4.*
and not encounter any type errors. We're relying on this aspect of SemVer
Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced to the public API.
which guarantees the backwards compatibility we need to mix newer types with older libraries (within the same major release)
Before getting into a specific solution, let's lay out some goals
- Make it clear to consumers, which major version of a library the types are designed to work with
- Use
npm
andyarn
commands the standard way to take in safe and non-breaking changes - Allow consumers to protect themselves from breaking changes
- Compatibility with tools like dependabot and greenkeeper
- Some flexibility within type versions to allow for breaking changes even between patch releases of the library they describe (i.e., if a breaking TypeScript change forces dropping old TS versions)
If we treat versions as X.Y.Z
X
- Indicates the major release of a library that the types describe. As long asX
follows the SemVer convention, this is all we need to track in order to maintain compatability.Y
- Indicates a breaking change in types, within the sameX
Z
- Indicates a non-breaking change in types, within the sameY
// @types/ember v2.1.2
2 // Tracking the ember@2 release series
.
1 // Breaking change since @types/ember@2.0.x
.
2 // Non-breaking change since @types/ember@2.1.0
This library provides a CLI tool and a JavaScript API for managing versioned types that follow this style of versioning
Use your choice of package manager to add this package as a devDependency
of your project
yarn add -D typever # yarn
npm install --save-dev typever # npm
Run this commmand, providing the package name, and optionally a types library name (defaults to "@types/<package-name>"
) to use.
typever check <package-name> [types-library-name]
You should get some feedback regarding the current state of your versions
import { readFileSync } from 'fs';
import { join } from 'path';
import { versionCheck } from 'typever';
const pkgPath = join(__dirName, '..', 'package.json');
const pkg = JSON.parse(
readFileSync(pkgPath).toString();
);
const allDependencies = Object.assign({}, pkg.devDependencies, pkg.dependencies);
versionCheck(allDependencies, 'react', '@types/react').then(checkResult => {
console.log(checkResult);
});
a sample of what checkResult
looks lke:
{
lib: { name: 'commander', target: '^2.17.1', version: '2.17.1' },
types: {
recommendedTarget: '~2.12.2',
name: '@types/commander',
target: '^2.12.2',
version: '2.12.2'
},
result: {
compatibility: 'warn',
reason:
'Type library target of "^2.12.2" will allow your app to take in breaking changes.\nThis is the SemVer equivalent of { "@types/commander": "*" }',
suggestion:
'Update package.json with dependency { "@types/commander": "~2.12.2" }'
}
}
© 2018 Mike North