Breaking Changes

Symbols

Changing a symbol is a breaking change when:

  • changing the name of an exported symbol (type or value), since users' existing imports will need to be updated. This is breaking for value exports (let, const, class, function) independent of types, but renaming exported interface, type alias, or namespace declarations is breaking as well.

  • removing an exported symbol, since users' existing imports will stop working. This is a breaking change for value exports (let, const, class, function) independent of types, but removing exported interface, type alias, or namespace declarations is breaking as well.

    This includes changing a previously type-and-value export such as export class to either—

    • a type-only export, since the exported value symbol has been removed:

      -export class Foo {
      +class Foo {
        neato: string;
      }
      +
      +export type { Foo };
      
    • a value-only export, since the exported type symbol has been removed:

      -export class Foo {
      +class _Foo {
        neato: string;
      }
      +
      +export let Foo: typeof _Foo;
      
  • changing the kind (value vs. type) of an exported symbol in any way, since users' imports and own definitions may both be broken, since imports resolve all symbols imported together if they share a name:

    • Given a value-only exported symbol, including namespace declarations, adding a type export with the same name as the value may break users' code: they may have imported the value and safely created a type of the same name. Their existing import will now cause a re-declaration conflict. Note that this is distinct from adding an entirely new type export where there was no type or value export previously, since the user could never accidentally introduce the conflict, and could work around the conflict using the as import specifier when introducing the import.

    • Given a type-only exported symbol, including type, interface, or export type for a type or value, adding a value export with the same name may break users' code: they may have imported the type and safely created a value of the same name. Their existing import will now cause a re-declaration conflict. Note that this is distinct from adding an entirely new value export where there was no type or value export previously, since the user could never accidentally introduce the conflict, and could work around the conflict using the as import specifier when introducing the import. (Type-only imports via import type do not change this because they still import the symbol into value space to use with typeof, e.g. to get a class' constructor.)

    • Given a namespace export, changing it to a value-only export (that is, to an exported object) will break all nested type access, since types cannot be exported as nested members of non-namespace values. (namespace exports cannot be directly converted to type-only exports.)

  • changing an interface to a type alias will break any user code which used interface merging

  • changing a namespace export to any other type will break any code which used namespace merging

  • changing a class export to a type-only export will break any code which extended the class or constructed an instance of the class (playground), and changing a class export to a value-only export will break any code which referred to the class as a type (playground)

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 non-readonly object property's type changes in any way:

    • if it was previously string but now is string | number, some of the user's existing reads of the property will now be wrong (playground). Note that this includes making a property optional.

    • if it was previously string | number but now is string, some of the user's existing writes to the property will now be wrong (playground). Note that this includes making a previously-optional property required.

      Note that at present, TypeScript cannot actually catch all variants of this error. This playground demonstrates that there is a runtime error but no type error in one scenario. TypeScript's type system understands these types in terms of assignability, rather than local mutability. However, package authors should test for the catchable variant of this condition.

  • a property is removed from the type entirely, since some of the user's existing uses of the type will break, even if the property was optional (optional, required)

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

  • a required property is added to the type, since all of the user's existing constructions of the type will be incorrect (new-required-property)

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

  • a readonly object property type becomes a less specific ("wider") type, for example if it was previously string but now is string | string[]—since the user's existing handling of the property will be wrong in some cases (playground—the playground uses a class but an interface or type alias would have the same behavior).

    Note that this includes making a property optional, since these are equivalent for the purposes of type-checking:

    interface A {
    - a: string;
    + a?: string;
    }
    
    interface A {
    - a: string;
    + a: string | undefined;
    }
    

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:

  • an argument or return type changes entirely, for example if a function previously accepted number and now accepts { count: number }, or previously returned string and now returns boolean—since the user will have to change all call sites for the function (playground)

  • a function (including a class constructor or methods) argument requires a more specific ("narrower") type, for example if it previously accepted string | number but now requires string—since the user will have to change some calls to the function (playground)

  • a function (including a class constructor or method) returns a less specific ("wider") type, for example if it previously returned string but now returns string | null—since the user's existing handling of the return value will be wrong in some cases (playground).

    This includes widening from a type guard to a more general check. For example:

    -function isString(x: string | number): x is string {
    +function isString(x: string): boolean {
      return typeof x === 'string';
    }
    

    This change would cause user-land code that expects narrowing to break:

    if (isString(value)) {
      return value.length;
    } else {
      return value;
    }
    
  • a function (including a class constructor or method) adds any new required arguments—since all user invocations of the function will now be broken (playground)

  • a function (including a class constructor or method) removes an existing argument entirely—since user invocations of the function may now fail to type-check

    • if the argument was required, all invocations will fail to type-check (playground)

    • if the argument was optional, any invocations which used it will fail to type-check (playground)

  • changing a function from a function declaration to an arrow function declaration, since it changes the type of this, the effect of calling bind or call on the function, and requires parameters to be contravariant instead of allowing bivariance


Notes

1

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

2

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.

3

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

4

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.

5

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.