Semantic Versioning for TypeScript Types

Version 1.0.0-beta.1
(Last updated on April 19, 2022)
See the source, file an issue, or suggest a change.


Summary

This document defines a specification of Semantic Versioning for managing changes to TypeScript types, including when the TypeScript compiler makes breaking changes in its type-checking and type emit across a “minor” release. (Note that this RFC addresses only type checking and type emit, not the “transpilation” mode of the TypeScript compiler.)

While this RFC was authored in the context of Ember.js’ adoption of TypeScript as a first-class supported language, its recommendations are intentionally general, in hopes that these recommendations can be widely adopted by the broader TypeScript community.

Design overview

This section is a non-normative short summary for easy digestion. See Detailed Design below for normative text.

Informally, the big idea is the no new “red squiggles” rule: If you are using a library which follows the policy outlined in this specification, you should not normally get new type errors (“red squiggles” in your editor) when upgrading the library to a new minor version. Although this an easy-enough intuition to describe, implementing it correctly is tricky: thus this specification.

For package consumers

Things a package may do in a non-breaking way:

Things which constitute breaking changes in a package:

Note that this summary elides many important details, and those details may surprise you! In particular "what it accepts" and "what it provides" have considerable depth and nuance: they include interfaces or types you can construct, function arguments, class field mutability, and more.

For package authors

Outline

Motivation

For TypeScript packages be good citizens of the broader, semantically-versioned JavaScript ecosystem, package authors need a useful definition of SemVer for TypeScript’s type system.

This is somewhat more complicated than in other languages, even those other statically-typed languages with language-level SemVer guarantees (such as Rust and Elm), because TypeScript has an unusually flexible type system. In particular, its structural type system means many more kinds of both breaking and non-breaking changes are possible than in languages with a nominal type system.1 Accordingly, this document proposes a definition of SemVer which accounts for the extra flexibility afforded by these features.

Furthermore, unlike the rest of the JavaScript ecosystem, the TypeScript compiler explicitly rejects SemVer. TypeScript's core team argues that every change to a compiler is a breaking change, and that SemVer is therefore meaningless for TypeScript. We do not agree with this characterization, but take the TypeScript team's position as a given for the purposes of this document. Accordingly, every TypeScript non-patch release may be a breaking change, and "major" numbers for releases signify nothing beyond having reached x.9 in the previous cycle.

This means that defining SemVer for TypeScript Types requires that we specify a definition of Semantic Versioning which can absorb breaking changes in the TypeScript compiler as well as intentional changes by package authors. As such, it also requires clearly defined TypeScript compiler version support policies.

Detailed design

TypeScript types should be treated with exactly the same (or even greater!) rigor as other elements of the JavaScript ecosystem, with the same level of commitment to stability, clear and accurate use of Semantic Versioning, testing, and clear policies about breaking changes.

TypeScript introduces two new concerns around breaking changes for packages which publish types.

  1. 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.

  2. 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.

Accordingly, we must define breaking changes precisely and carefully.

Background: TypeScript and Semantic Versioning

TypeScript does not adhere to Semantic Versioning, but since it participates in the npm ecosystem, it uses the same format for its version numbers: <major>.<minor>.<patch>.

In Semantic Versioning, <major> would be a breaking change release, <minor> would be a backwards-compatible feature-addition release, and <patch> would be a "bug fix" release.

In TypeScript, both <major> and <minor> releases may introduce breaking changes of the sort that Semantic Versioning reserves for the <major> slot in the version number. Not all <minor> or <major> releases do introduce breaking changes in the normal Semantic Versioning sense, but either may. Accordingly, and for simplicity, the JavaScript ecosystem should treat all TypeScript <major>.<minor> releases as a major release.

TypeScript's use of patch releases is more in line with the rest of the ecosystem's use of patch versions. The TypeScript Wiki currently summarizes patch releases as follows:

Patch releases are periodically pushed out for any of the following:

These fixes are typically weighed against the cost (how hard it would be for the team to retroactively apply/release a change), the risk (how much code is changed, how understandable is the fix), as well as how feasible it would be to wait for the next version.

These three categories of fixes are well within the normally-understood range of fixes that belong in a "bug fix" release in the npm ecosystem. In these cases, a user's code may stop type-checking, but only if they were depending on buggy behavior. This matches users' expectations around runtime code: a SemVer patch release to a package which fixes a bug may cause packages which were depending on that bug to stop working.

By example:

Defining breaking changes

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:

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 the following 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.

Definitions

Symbols

There are two kinds of symbols in TypeScript: value symbols and type symbols. (Note that these are distinct from the Symbol object in JavaScript.)

Value symbols represent values present at runtime in JavaScript:

  • let, const, and var bindings
  • function declarations
  • class declarations
  • enum and const enum declarations
  • namespace declarations (which produce or represent objects at runtime)

Type symbols represent types which are used in type checking:

  • interface declarations
  • type (type alias) declarations
  • function declarations
  • class declarations
  • enum and const enum declarations

(Note that namespace declarations can also be present in type-only declarations, as when a type is exported from a namespace and referenced like let val: SomeNamespace.ExportedInterface, but the value produced by the namespace is not itself a type.)

Functions

Unless otherwise specified, "functions" always refers interchangeably to: functions in standalone scope, whether defined with either function or an arrow; class methods; and class constructors.

User constructibility

A type is user-constructible if the consumer of a package is allowed to create their own objects which match a given type structurally, that is, without using a function or class exported from the package which provides the type.

For example, a package may choose to export an interface to allow users to name the type returned by a function, while specifying that the only legal way to construct such an interface is via that exported function, in which case the type is not user-constructible.

Alternatively, a package may export an interface or type alias explicitly for users to construct objects implementing that type, in which case the type is user-constructible.

Using the type-level typeof operator to construct a type using the type of an exported item from a library wholly defeats the ability of authors to specify a public API. Accordingly:

  1. Authors who wish to treat any given type as user-constructible should export a type definition for it under their public API contract (see the next section).
  2. A type defined in terms of typeof which “breaks” under the rules discussed below is not breaking, because it was not legally user-constructible.

One challenge for this definition is the common scenario where code is not ordinarily user-constructible but may need to be mocked for tests. Changes to these do not constitute breaking changes—but library authors can also mitigate the challenge presented by this scenario. (See discussion below under Appendix B: Tooling – Mitigate Breaking Changes – Avoiding User constructibility.)

Non-normative example: in Ember.js today, the interface for a Transition is public API and consumers can rely on its stability, but only Ember is allowed to create Transition instances.

If a user imported the Transition interface and wrote a class CustomTransition implements Transition { ... }, this would be stepping outside the SemVer contract.

Public API

Overview: Some packages may choose to specify that the public API consists of documented exports, in which case no published type may be considered public API unless it is in the documentation. Other packages may choose to say the reverse: all exports are public unless explicitly defined as private (for example with the @private JSDoc annotation, a note in the docs, etc.). In either case, no change to a type documented as private is a breaking change, whether or not the type is exported, where documented as private is defined in terms of the documentation norm of the package in question.

Documentation of user constructibility: Exported types (interfaces, type aliases, and the type side of classes) may be defined by documentation to be user-constructible or not.

Documentation of subclassibility: Exported classes may be defined by documentation to be user-subclassible or not.

Re-exports: using the export * from re-export syntax can in theory cause breakage by causing export conflicts: if the library being re-exported and the library doing the re-export both export the same name. For this reason, changes caused by the export * from ... are never breaking changes.2

Reasons for Breaking Changes

Each of the kinds of breaking changes defined below will trigger a compiler error for consumers, surfacing the error. As such, they should be easily detectable by testing infrastructure (see below under Tooling: Detect breaking changes in types).

There are several reasons why breaking changes may occur:

  1. The author of the package may choose to change the API for whatever reason. This is no different than the situation today for packages which do not support TypeScript. This would be a major version independent of types.

  2. The author of the package may need to make changes to adapt to changes in the JavaScript ecosystem, for example to support Octane idioms in Ember.js. This is likewise identical with the situation for packages which do not support TypeScript: it would require a major version regardless.

  3. Adopting a new version of TypeScript may change the meaning of existing types. For example, in TypeScript 3.5, generic types without a specified default type changed their default value from {} to unknown. This improved type safety, but broke many existing types, as described in detail by Google.

  4. Adopting a new version of TypeScript may change the type definitions emitted in .d.ts files in backwards-incompatible ways. For example, changing to use the finalized ECMAScript spec for class fields meant that types emitted by TypeScript 3.7 were incompatible with TypeScript 3.5 and earlier.

The kinds of breaking changes represented by reasons (1) and (2) are described below under Changes to Types; reasons (3) and (4) are discussed below in Compiler Considerations.

Additionally, there are some changes which we define not to be breaking changes because, while they will cause the compiler to produce a type error, they will do so in a way which simply allows the removal of now-defunct code.

Changes to types

Variance

Virtually all of the rules around what constitutes a breaking change to types come down to variance.3 In a real sense, everything in the discussion below is a way of showing the variance rules by example.4

In many cases, these are the standard variance rules applicable in any and all languages with types:

These basic intuitions underlie the guidelines below. However, several factors complicate them.

First of all, notice that the vast majority of objects in JavaScript are mutable, which means they must be invariant. When combined with type inference, this effectively means that any change to an object type can cause breakage for consumers. (The only real counter-examples are readonly types.)

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.5

For a more detailed explanation and analysis of the impact of variance on these rules, see Appendix C.

Breaking Changes

Symbols

Changing a symbol is a breaking change when:

Interfaces, Type Aliases, and Classes

Object types may be defined with interfaces, type aliases, or classes. Interfaces and type aliases define type symbols only. Classes define both type symbols and value symbols. The namespace construct defines a value symbol (as well as introducing a context in which you can name other nested type or value symbols). The additional constraints for the value symbols introduced by classes are covered above under Breaking Changes: Symbols.

A change to any object type (user constructible or not) is breaking when:

A change to a user-constructible type is breaking when:

A change to a non-user-constructible object type is breaking when:

Functions

For functions which return or accept user-constructible types, the rules specified for Breaking Changes: Interfaces, Type Aliases, and Classes hold. Otherwise, a change to the type of a function is breaking when:

Non-breaking changes

In each of these cases, some user code becomes superfluous, but it neither fails to type-check nor causes any runtime errors.

Symbols

A change to an exported symbol is not breaking when:

Interfaces, Type Aliases, and Classes

Any change to a non-user-constructible type is not breaking when:

Functions

For functions which return or accept user-constructible types, the rules specified for Non-breaking Changes: Interfaces, Type Aliases, and Classes hold. Otherwise, a change to a function declaration is not breaking when:

Bug fixes

As with runtime code, types may have bugs. We define a ‘bug' here as a mismatch between types and runtime code. That is: if the types allow code which will cause a runtime type error, or if they forbid code which is allowed at runtime, the types are buggy. Types may be buggy by being inappropriately wider or narrower than runtime.

For example (noting that this list is illustrative, not exhaustive):

As with runtime bugs, authors are free to fix type bugs in a patch release. As with runtime code, this may break consumers who were relying on the buggy behavior. However, as with runtime bugs, this is well-understood to be part of the sociotechnical contract of semantic versioning.

In practice, this suggests two key considerations around type bugs:

  1. It is essential that types be well-tested! See discussion below under Appendix B: Tooling.

  2. If a given type bug has existed for long enough, an author may choose to treat it as "intimate API" and change the runtime behavior to match the types rather than vice versa.

Compiler considerations

To reiterate, Semantic Versioning is a matter of adherence to a specified contract. This is particularly important when dealing with transitive or peer dependencies, especially at the level of ecosystem dependencies—including Node versions, browsers, compilers, and frameworks (such as Svelte, Vue, Ember, React, etc.). Accordingly, the specification of breaking changes as described below is further defined in terms of the TypeScript compiler support version adopted by any given package as well as specific settings.

Supported compiler versions

Conforming packages must adopt and clearly specify one of two support policies: simple majors or rolling support windows.

Simple majors

In “simple majors” pattern, dropping a previously-supported TypeScript version constitutes a breaking change, because it has the same kind of impact on users of the package as dropping support for a previously-supported version of Node: they must upgrade a different dependency to guarantee their code continues to work. Thus, whenever dropping a previously-supported TypeScript release, packages using “simple majors” should publish a new major version.

However, bug fix/patch releases to TypeScript (as described above under Bug fixes) qualify for bug fix releases for packages using the “simple majors” policy. For example, if a package initially publishes support for TypeScript 4.5.0, but a critical bug is discovered and fixed in 4.5.1, the package may drop support for 4.5.0 without a major release. Dropping support for a bad version does not require publishing a new release, only documenting the change.

In this case, packages should generally couple dropping support for previously-supported TypeScript versions with dropping support for other ecosystem-level dependencies, such as previously-supported Node.js LTS versions, Ember LTS releases, React major versions, etc. (This is not a requirement for conformance, but makes for a generally healthier ecosystem.)

This pattern is recommended for “normal” packages, where major versions do not themselves have ecosystem-wide implications. For example, a package like True Myth (maintained by the primary author of this RFC) is small and not presently foundational to any broader ecosystem. It is safely using the “simple majors” approach today for both Node and TypeScript versions.

Rolling support windows

The “rolling support windows” policy decouples compiler version support from major breaking changes, by specifying a rolling window of supported versions. For example, Ember and Ember CLI specify that any change landing on master must work on the Current, Active LTS, and Maintenance LTS Node versions at the time the change lands, and that when the Node Working Group drops support for an LTS, Ember and Ember CLI do so as well without a breaking change. Similarly, Redux has maintained support over a long time horizon while informally dropping support for Node versions (and TypeScript versions!) and documenting in their releases. This allows the CLI to use new Node features as part of its public API over time, rather than being fixed at the set of features available at the time of the latest release of the library.

Following this pattern, core ecosystem components (hypothetically including examples such ember-source, react, @vue/cli, etc.) could adopt a similar policy for supported TypeScript compiler versions, allowing the component to adopt new TypeScript features which impact the published types (e.g. in type emit, type system features such as conditional types, etc.) rather than being coupled to the features available at the time of release. Conforming projects which adopt this may choose any rolling support window they choose, except that if they have an LTS release schedule, upgrading to a new LTS shall not require upgrading to a new version of TypeScript.

Bug fix/patch releases to TypeScript (as described above under Bug fixes) qualify for bug fix releases for packages using the “rolling support windows” policy. For example, if a package initially publishes support for TypeScript 4.5.0, but a critical bug is discovered and fixed in 4.5.1, the package may drop support for 4.5.0 without a major release. Dropping support for a bad version does not require publishing a new release, only documenting the change.

Strictness

Type-checking in TypeScript behaves differently under different “strictness” settings, and the compiler adds more strictness settings over time. Changes to types which are not breaking under looser compiler settings may be breaking under stricter compiler settings.

For example: a package with strictNullChecks: false could make a function return type nullable without the compiler reporting it within the package or the package’s type tests. However, as described above, this is a breaking change for consumers which have strictNullChecks: true. (By contrast, a consumer may disable strictness settings safely: code which type-checks under a stricter setting also type-checks under a less strict setting.) Likewise, with noUncheckedIndexedAccess: false, an author could change a type SomeObj from { a: string } to { [key: string]: string } and accessing someObj.a.length would now error.

Accordingly, conforming packages must use strict: true in their compiler settings. Additionally, communities may define further strictness settings to which they commit to conform which include “pedantic” strictness settings like noUncheckedIndexedAccess. For example, the Ember community might commit to a set of additional strictness flags it supports for its own types for any LTS release, published in Ember’s own TypeScript documentation.

Note: While the TypeScript compiler may include new strictness flags under strict: true in any release, this is simply a special case of TypeScript’s policy on breaking changes.

Module interop

The two flags esModuleInterop and allowSyntheticDefaultImports smooth the interoperation between ES Modules and CommonJS, AMD, and UMD modules for emit from TypeScript and type resolution by TypeScript respectively. The options are viral: enabling them in a package requires all downstream consumers to enable them as well (even if this is not desirable for whatever reasons). The reasons for this are details of how CommonJS and ES Modules interoperate for bundlers (Webpack, Parcel, etc.), and are beyond the scope of this document.

Here, it is enough to note that changing from esModuleInterop: true to esModuleInterop: false on a package which emits is a breaking change:

Accordingly, library authors should set both allowSyntheticDefaultImports and esModuleInterop to false. This allows consumers to opt into these semantics, but does not require them to do so. Consumers can always safely use alternative import syntaxes (including falling back to require() and import()), or can enable these flags and opt into this behavior themselves.

(If the Node ecosystem migrates fully to ES modules over the next few years, this problem will be substantially mitigated.)

Conformance

To conform to this standard, a package must:

Drawbacks

Alternatives

No policy

Currently, no frameworks and few packages in the broader TypeScript ecosystem have any specific TypeScript support policy. Instead, they just roughly track the latest TypeScript version and expect downstream consumers of the types to absorb the changes. This strategy could work if libraries were honest about the SemVer implications of this and cut major releases any time a new TypeScript version resulted in breaking changes. Notably, the Ember TypeScript ecosystem has largely operated in this mode to date, and it has worked all right so far—though not without some challenges.

However, there are three major problems with this approach.

Decouple TypeScript support from LTS cycles

The “rolling support window” policy could be decoupled from LTS requirements. Similarly, the “simple majors” policy could drop the recommendation to combine dropping Node versions, TypeScript versions, and other LTS packages. This would simplify the rule for adopting packages. However, it comes with the previously mentioned challenges when multiple major versions of a package exist in a given ecosystem. While a strategy for resolving those challenges at the ecosystem level would be nice, it is far beyond the scope of this RFC and indeed is a general challenge for package-rich ecosystems like Node’s.

Appendices

These sections are non-normative.

Appendix A: Existing Implementations

The recommendations in this RFC have been fully implemented in ember-modifier, True Myth, and ember-async-data; and partly implemented in ember-concurrency. ember-modifier, ember-async-data, and true-myth all publish types generated from implementation code. ember-concurrency supplies a standalone, hand-written type definition file. Since adopting this policy in these implementations (beginning in early summer 2020), no known issues have emerged, and the experience of implementing earlier versions of the recommendations from this RFC were incorporated into the final form of this RFC.

There are, to the best of our knowledge, no other major adopters of these recommendations, and no similar such recommendations exist in the TypeScript ecosystem at large.

Appendix B: Tooling

To successfully adopt this RFC’s recommendations, package authors need to be able to detect breaking changes (whether from their own changes or from TypeScript itself) and to mitigate them. Package consumers need to know the support policy for the library.

Documenting supported versions and policy

In line with ecosystem norms, badges linking to CI status

Detect breaking changes in types

As with runtime code, it is essential to prevent unintentional changes to the API of types supplied by a package. We can accomplish this using type tests: tests which assert that the types exposed by the public API of the package are stable.

Package authors publishing types can use whatever tools they find easiest to use and integrate, within the constraint that the tools must be capable of catching all the kinds of breaking changes outlined above. Additionally, they must be able to run against multiple supported versions of TypeScript, so that users can detect and account for breaking changes in TypeScript.

The current options include:

expect-type seems to be the best option, and a number of libraries in the TS community are already using expect-type successfully (see Appendix A above). However, for the purposes of this RFC, we do not make a specific recommendation about which library to use. The tradeoffs above are offered to help authors make an informed choice in this space.

Users should add one of these libraries and generate a set of tests corresponding to their public API. These tests should be written in such a way as to test the imported API as consumers will consume the library. For example, type tests should not import using relative paths, but using the absolute paths at which the types should resolve, just as consumers would.

These type tests should be specific and precise. It is important, for example, to guarantee that an API element never accidentally becomes any, thereby making many things allowable which should not be in the case of function arguments, and "infecting" the caller's code by eliminating type safety on the result in the case of function return values. For example, the expect-type library's .toEqualTypeOf assertion is robust against precisely this scenario; package authors are also encouraged to use its .not modifier and .toBeAny() method where appropriate to prevent this failure mode.

To be safe, these tests should be placed in a directory which does not emit runtime code—either colocated with the library's runtime tests, or in a dedicated type-tests directory. Additionally, type tests should never export any code.

In addition to writing these tests, package authors should make sure to run the tests (as appropriate to the testing tool chosen) in their continuous integration configuration, so that any changes made to the library are validated to make sure the API has not been changed accidentally.

Further, just as packages are encouraged to test against a matrix of peer dependencies versions, they should do likewise with TypeScript. For example:

Along the same lines, TypeScript packages should follow should test the types against all versions of TypeScript supported by the package (see the suggested policy for version support below) as well as the upcoming version (the next tag for the typescript package on npm).

Type tests can run as normal ember-try variations or similar CI. Typed Ember will document a conventional setup for ember-try configurations, so that correct integration into CI setups will be straightforward for package authors.

Mitigate breaking changes

It is insufficient merely to be aware of breaking changes. It is also important to mitigate them, to minimize churn and breakage for package users.

Avoiding user constructibility

For types where it is useful to publish an interface for end users, but where users should not construct the interface themselves, authors have a number of options (noting that this list is not exhaustive):

Each of these leaves this module in control of the construction of Persons, which allows more flexibility for evolving the API, since non-user-constructible types are subject to fewer breaking change constraints than user-constructible types. Whichever is chosen for a given type, authors should document it clearly.

Updating types to maintain compatibility

Sometimes, it is possible when TypeScript makes a breaking change to update the types so they are backwards compatible, without impacting consumers at all. For example, TypeScript 3.5 changed the default resolution of an otherwise-unspecified generic type from the empty object {} to unknown. This change was an improvement in the robustness of the type system, but it meant that any code which happened to rely on the previous behavior broke.

This example from Google's writeup on the TS 3.5 changes illustrates the point. Given this function:

function dontCarePromise() {
  return new Promise((resolve) => {
    resolve();
  });
}

In TypeScript versions before 3.5, the return type of this function was inferred to be Promise<{}>. From 3.5 forward, it became Promise<unknown>. If a user ever wrote down this type somewhere, like so:

const myPromise: Promise<{}> = dontCarePromise();

…then it broke on TS 3.5, with the compiler reporting an error (playground):

Type 'Promise' is not assignable to type 'Promise<{}>'. Type 'unknown' is not assignable to type '{}'.

This change could be mitigated by supplying a default type argument equal to the original value (playground):

function dontCarePromise(): Promise<{}> {
  return new Promise((resolve) => {
    resolve();
  });
}

This is a totally-backwards compatible bugfix-style change, and should be released in a bugfix/point release. Users can then just upgrade to the bugfix release before upgrading their own TypeScript version—and will experience zero impact from the breaking TypeScript change.

Later, the default type argument Promise<{}> could be dropped and defaulted to the new value for a major release of the library when desired (per the policy outlined below, giving it the new semantics. (Also see Opt-in future types below for a means to allow users to opt in to these changes before the major version.)

"Downleveling" types

When a new version of TypeScript includes a backwards-incompatible change to emitted type definitions, as they did in 3.7, the strategy of changing the types directly may not work. However, it is still possible to provide backwards-compatible types, using the combination of downlevel-dts and typesVersions. (In some cases, this may also require some manual tweaking of types, but this should be rare for most packages.)

The recommended flow would be as follows:

  1. Add downlevel-dts, npm-run-all, and rimraf to your dev dependencies:

    npm install --save-dev downlevel-dts npm-run-all rimraf
    

    or

    yarn add --dev downlevel-dts npm-run-all rimraf
    
  2. Create a script to downlevel the types to all supported TypeScript versions:

    # scripts/downlevel.sh
    npm run downlevel-dts . --to 3.7 ts3.7
    npm run downlevel-dts . --to 3.8 ts3.8
    npm run downlevel-dts . --to 3.9 ts3.9
    npm run downlevel-dts . --to 4.0 ts4.0
    
  3. Update the scripts key in package.json to generate downleveled types generated by running downlevel-dts on the output from tsc, and to clean up the results after publication. For example, using ember-cli-typescript’s tooling:

    {
      "scripts": {
    -   "prepublishOnly": "ember ts:precompile",
    +   "prepublish:types": "ember ts:precompile",
    +   "prepublish:downlevel": "./scripts/downlevel.sh",
    +   "prepublishOnly": "run-s prepublish:types prepublish:downlevel",
    -   "postpublish": "ember ts:clean",
    +   "clean:ts": "ember ts:clean",
    +   "clean:downlevel": "rimraf ./ts3.7 ./ts3.8 ./ts3.9 ./ts4.0",
    +   "clean": "npm-run-all --aggregate-output --parallel clean:*",
    +   "postpublish": "npm run clean",
      }
    }
    
  4. Add a typesVersions key to package.json, with the following contents:

    {
      "types": "index.d.ts",
      "typesVersions": {
        "3.7": { "*": ["ts3.7/*"] },
        "3.8": { "*": ["ts3.8/*"] },
        "3.9": { "*": ["ts3.9/*"] },
        "4.0": { "*": ["ts4.0/*"] },
      }
    }
    

    This will tell TypeScript how to use the types generated by this process. Note that we explicitly include the types key so TypeScript will fall back to the defaults for 3.9 and higher.

  5. If using the files key in package.json to specify files to include (unusual but not impossible for TypeScript-authored packages), add each of the output directories (ts3.7, ts3.8, ts3.9, ts4.0) to the list of entries.

Now consumers using older versions of TypeScript will be buffered from the breaking changes in type definition emit.

If the community adopts this practice broadly we will want to invest in tooling to automate support for managing dependencies, downleveling, and type tests. However, the core constraints of this RFC do not depend on such tooling existing, and the exact requirements of those tools will emerge organically as the community begins implementing this RFC's recommendations.

Opt-in future types

In the case of significant breaking changes to only the types—whether because the package author wants to make a change, or because of TypeScript version changes—packages may supply future types, which users may opt into before the library ships a breaking change. (We expect this use case will be rare, but important.)

In this case, package authors will need to hand-author the types for the future version of the types, and supply them at a specific location which users can then import directly in their types/my-app.d.ts file—which will override the normal types location, while not requiring the user to modify the paths key in their tsconfig.json.

This approach is a variant on Updating types to maintain compatibility. Using that same example, a package author who wanted to provide opt-in future types instead (or in addition) would follow this procedure:

  1. Backwards-compatibly fix the types by explicitly setting the return type on dontCarePromise, just as discussed above:

    - function dontCarePromise() {
    + function dontCarePromise(): Promise<{}> {
    
  2. Create a new directory, named something like ts3.5.

  3. Generate the type definition files for the package by running ember ts:precompile.

  4. Manually move the generated type definition files into ts3.5.

  5. In the ts3.5 directory, either remove or change the explicit return type, so that the default from TypeScript 3.5 is restored:

    - function dontCarePromise(): Promise<{}> {
    + function dontCarePromise(): Promise<unknown> {
    
  6. Wrap each module file in the generated definition with a declare module specifying the canonical module name. For example, if our dontCarePromise definition were from a module at my-library/sub-package, we would have the following structure:

    my-library/
      ts3.5/
        index.d.ts
        sub-package.d.ts
    

    —and the contents of sub-package.d.ts would be:

    declare module 'my-library/sub-package' {
      export function dontCarePromise(): Promise<unknown>;
    }
    
  7. Explicitly include each such sub-module in the import graph available from ts3.5/index.d.ts—either via direct import in that file or via imports in the other modules. (Note that these imports can simply be of the form import 'some-module';, rather than importing specific types or values from the modules.)

  8. Commit the ts3.5 directory, since it now needs to be maintained manually until a breaking change of the library is released which opts into the new behavior.

  9. Cut a release which includes the new fixes. With that release:

    • Inform users about the incoming breaking change.

    • Tell users to add import 'fancy-package/ts3.5'; to the entry point of their package or a similar location. For example, in Ember, users would add the import to the top of their types/my-app.d.ts or types/my-package.d.ts file (which are generated by ember-cli-typescript).

  10. At a later point, cut a breaking change which opts into the TypeScript 3.5 behavior.

    • Remove the ts3.5 directory from the repository.

    • Note in the release notes that users who did not previously opt into the changes will need to do so now.

    • Note in the release notes that users who did previously opt into the changes should remove the import 'fancy-package/ts3.5'; import from types/my-app.d.ts or types/my-package.d.ts.

Matching exports to public API

Another optional tool for managing public API is API Extractor. Authors can mark their exports as @public, @protected, @private, @alpha, @beta, etc. and use the tool to generate type definitions accordingly. For example, for mitigating a future TypeScript version change, or experimenting on a new API, authors can use @alpha or @beta and use typesVersions to publish to a dedicated directory. Similarly, authors can make an export public for use through the package or even a set of related packages in a moinorepo, but mark it as @private and use API Extractor to generate types which exclude it when publishing to npm.

Appendix C: On Variance in TypeScript

As alluded to in Changes to Types: Variance, there are several complicating factors for the discussion of variance in TypeScript:

Inference and pervasive mutability

For example, by the classic rules, Array<T> should be invariant: it is a read-write (i.e. mutable) type. That means that a very simple change, otherwise apparently safe for consumers, can break it. Start with a library function which returns string | number:

declare function example(): string | number;

A consumer might use this code in the construction of an array, and then having leaned on inference, push both strings and numbers into it:

const myArray = [example()]; // Array<string | number>
myArray.push(123);           //
myArray.push("hello");       //

The author of the library might later update example to return only strings:

declare function example(): string;

This would be safe under the rule for write-only types, which is the intuition underlying many of the definitions below—but for our array example, it is not safe: .push()-ing in a number is now illegal.

const myArray = [example()]; // Array<string>
myArray.push(123);           // ❌ number not assignable to string
myArray.push("hello");       //

What's more, we don't need an object like an array to trigger this kind of behavior. Using a let binding instead of a const binding will produce exactly the same issue. Under the original definition of example, this would be perfectly legal:

let value = example(); // string | number
value = 123;           //
value = "hello";       //

But it stops being valid as soon as example is narrowed:

let value = example(); // string
value = 123;           // ❌ number not assignable to string
value = "hello";       //

While lint guidelines preferring const may help mitigate the latter, they are controversial8 and they do not and cannot help with the Array example or others like it. Nor is it feasible to require a “functional” immutable-update style, given that JavaScript lacks robust immutable data structures, which would allow for recommending that approach.

In this case, cautious users may work around this by explicitly annotating their types to match the return type of the:

const myArray: Array<string | number> = [example()];
let value: string | number = example();

We do not expect this to be common, however: the cost of this is much higher than the cost of changing one's code in the cases where it may be broken.

Structural typing

Most programming languages where programmers must deal with variance have nominal type systems, and and subtyping relations can be straightforwardly specified in terms of the relations between the types—particular via subclassing (as in Java, C++, and C#) or between interfaces (as in Rust’s trait system). In TypeScript, however, subtyping relationships include both subclassing and interface-based subtypes and also structural subtyping.

Given types A and B, B is a subtype of A for the purposes of assignability (e.g. in function calls) when it is a superset of A. Most simply:

type A = {
  a: number;
}

type B = {
  a: number;
  b: string;
}

type C = {
  a?: number;
  b: string;
}

declare function takesA(a: A): void;

declare let a: A;
declare let b: B;
declare let c: C;
takesA(a); //
takesA(b); //
takesA(c); //

Notice that this is unlike the dynamics in nominal type systems, where unless B explicitly declared a relationship to A (e.g. class B extends A { } or interface B : A { } or similar), the two are unrelated, regardless of their structural relationships. Similar dynamics play out for other kinds of types.

Higher-order type operations

The second factor which makes dealing with TypeScript types difficult is its support for type-level mutation. Consider the type of x at points 1–4 in the following simple, but relatively idiomatic, TypeScript function definition:

function describe(x: string | number | undefined) {
  switch (typeof x) {                 // 1
    case 'string':
      return `x is the string ${x}`;  // 2
    case 'number':
      return `x is the number ${x}`;  // 3
    default:
      return `x is "undefined"`;      // 4
  }
}
  1. The type is string | number | undefined.
  2. The type is string.
  3. The type is number.
  4. The type is undefined.

While this quickly becomes second-nature to TypeScript developers and we don’t give it a second thought, it’s important to take a step back and consider what is actually happening here: the type of x is a variable—a type-level variable—whose value changes over the body of the function. That is, it is a mutable type-level variable. While it is possible to construct values whose types in TypeScript are not mutable (e.g. with never or a boolean or numeric literal value), most values constructed in an ordinary TypeScript program have mutable types.

What’s more, this combines with TypeScript’s use of structural typing and inference mean that many cases which would intuitively be “safe” to make changes around can in fact create compiler errors. For example, consider a function which today returns string | number:

declare function a(): string | number;

Using this function to create a value x will give us the type x: string | number as we would expect. Then we might narrow the type later:

const x = a(); // string | number
const y = typeof x === 'string' ? x.length : x;  //

In general by the rules of variance, we would expect that narrowing the return type of a to always return number would be fine. This is in a “write-only” position, and so we would expect that we should allow contravariance: a narrower type is permissible. From a runtime perspective, that is true, because all existing code will continue to work (even if there are some unnecessary branches). However, TypeScript will produce a type error here, because the type of x no longer includes string, and so the typeof x === 'string' check can be statically known to be.

Practically speaking, this is an annoyance rather than a meaningful breaking change. It can, however, result in significant work across a code base! What is more, it is not possible to work around this merely with an explicit type definition today. Naïvely, we might expect explicit type declarations to allow us to dodge this problem in places we actually care about it:

const x: string | number = a();
const y = typeof x === 'string' ? x.length : x;  //

In practice, however, TypeScript today (up through 4.5) will first check that the type returned by a() is a subtype of the declared type of x, and then if a() returns a narrower type than that declared for x, it will actually set x's type to the narrower type returned by a() instead of the explicitly-declared type. Thus, a user who wishes to avoid this problem must everywhere annotate their code with explicit type casts:

const x = a() as string | number;
const y = typeof x === 'string' ? x.length : x;

This is very annoying; worse, it is also easy to break. TypeScript today silently allows an unsafe cast here, which can in turn produce runtime errors:

declare function a(): string | number;
const x = a() as string; // 👎🏼
const y = x.length;  // possible runtime error!

Thus, for the thoroughly pragmatic reason that no one would ever want to write these kinds of casts and the more principled reason that these kinds of casts as readily undermine as support the kinds of type safety TypeScript aims to provide and the versioning guarantees this RFC aims to provide, we simply acknowledge that from a practical standpoint, the pervasiveness of type-level mutation makes it impossible to provide a definition of breaking changes which forbids the introduction of compiler errors by even apparently-safe changes.

The problem runs the other direction, too: while this example shows now-extraneous code which can be deleted, the same underlying issue can also require adding code, e.g. when adding a field to a library type which was previously being used to discriminate two objects.

Given this starting code:

// provided by the library
type LibType = {
  a: boolean;
}

type MyType = {
  b: string;
}

function takesEither(obj: LibType | MyType) {
  if ('b' in obj) {
      // narrowed obj to `MyType`
    console.log(obj.b.substring(0));
  }
}

If the library adds a field b which is of any type but string

type LibType = {
  a: boolean;
  b: number;
}

—then we have a type error in takesEither() because the in operator no longer successfully discriminates between LibType and MyType:

function takesEither(obj: LibType | MyType) {
  if ('b' in obj) {
    // `obj` is still `LibType | MyType` so `b` is now `string | number`
    console.log(obj.b.substring(0)); //
  }
}

The compiler will dutifully report:

Property 'substring' does not exist on type 'string | number'.

In sum, just as pervasive runtime mutability and inference made it impossible to fully specify an approach which prevents users from experiencing breaking changes.


Notes

1

Many languages include structural typing in certain contexts, including Swift's protocols, Elm's record types, and row-polymorphic types in OCaml, PureScript, etc. However, of these only Elm provides language-level guidance or tooling, and at the time of authoring there is no public specification of its behavior. Its current algorithm is implementations-specified and roughly checks for addition or removal of fields.

2

In general, it is an antipattern for one package to re-export another package directly like this, and the cases where it makes sense (e.g. Ember re-exporting Glimmer APIs) are cases of collaborators which can manage this.

3

For the purposes of this discussion, I will assume knowledge of variance, rather than explaining it.

4

Thanks to Ryan Cavanaugh of the TypeScript team for pointing out the various examples which motivated this discussion.

5

Precisely because SemVer is a sociological and not only a technical contract, the problem is tractable: We define a breaking change as above, and accept the reality that some changes are not preventable (but may in many cases be mitigated or fixed automatically). This is admittedly unsatisfying, but we believe it satisfices our constraints.

6

Strictly speaking, one value may stop being comparable to another value in this scenario. However, this is both a rare edge case and fits under the standard rule where changes which simply let users delete now-defunct code are allowed.

7

Mostly, anyway—see Appendix C: On Variance in TypeScript – Higher-order type operations below for a discussion of how the in operator (or similar operations discriminating between unions) makes this cause breakage in some cases. These are rare enough, and easily-enough solved, that the rule remains workable.

8

Rightly so, in my opinion!