Skip to content

Modifying operators must only be allowed for type number (and += for string) #48857

Closed
@fwienber

Description

@fwienber

Bug Report

In this report, I'd like to motivate that the type checker should not allow applying modifying operators to custom sub-types at all.
For reference and all kinds of unexpected behavior because this is currently allowed, see #14745, #47027, #30783 and an attempt to fix (part of) this behavior, #28344.
In #14745 (comment), @ahejlsberg was asking for further practical examples that would motivate stricter typing of modifying operators, which this report provides imho.
@fatcerberus #47027 (comment) agrees that any usage of := on something that is not of type string is "code smell".

🔎 Search Terms

  • increment operator
  • decrement operator
  • assignment operator
  • modifying assignment

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about the search terms

⏯ Playground Link

Playground link 1: Integer Range Example

Playground link 2: Unsigned Integer Example

💻 Code

Example 1: Integer Range

type ZeroToThree = 0 | 1 | 2 | 3;
// a function that coerces any given number to the integer range 0..3:
const ZeroToThree = (value: number): ZeroToThree => (value >>> 0) % 4 as ZeroToThree;

let a: ZeroToThree = 3;
const b: ZeroToThree = a + 1; // error, good, because + always results in a 'number', not necessarily in range of ZeroToThree
const c: ZeroToThree = ZeroToThree(a + 1); // type-safe _and_ run-time-safe!
const d: ZeroToThree = a - 1 as ZeroToThree; // if you know what you're doing and want to avoid run-time overhead, use 'as'
a += 1; // no error, bad, because a is now out of range (4)
++a; // no error, too, also bad
console.log(a); // a is now 5 :-(

Example 2: Unsigned Integer

type uint = number & {__uint__: never};
const uint = (value: number): uint => (value >>> 0) as uint;

let a = uint(0);
const b: uint = a - 1; // type error; you are forced to use the coercion function!
const c: uint = uint(a - 1); // no error when using coercion function (leads to MAX_UINT = 4294967295)
let d = uint(1); // type inference works: hover over 'd' shows 'let d: uint'
d -= 1; // here, TypeScript correctly complains "Type 'number' is not assignable to type 'uint'." 
--d; // no type error, although it behaves the same as the line above
d--; // no type error, too: 'd' is an uint, but becomes -2 :-(
const e: uint = --d; // again, a correct type error, so the _result_ of --d is correctly typed as 'number'

🙁 Actual behavior

Modifying operators like ++, --, +=, -=, *=, /= etc. are allowed for types that specialize number (and for +=, string), although the result does not necessarily adhere to the constraints given by the sub-type.

🙂 Expected behavior

Modifying operators are only allowed for the type number (plus string for +=) itself.
When using a sub-type of number, like a literal type, a union of literal types (like in the "Integer Range" example), or an intersection of number with any other type (like in the "Unsigned Integer" example), an explicit type conversion or type assertion is needed.

Like for other changes that lead to stricter typing, this behavior could be activated through a compiler option.

Rationale:

The built-in numeric operators in ECMAScript, +, -, *, /, % are all defined for number arguments (except for overloaded '+') and always result in a number (maybe NaN, which is also of type number). At compile-time, it is in most cases impossible to check that a sub-type of number is maintained by any of these operators. Thus, the result is only safely assignable to number (or a more generic type, essentially only any), not to a more specific type.

Any modifying operator used with a sub-type of number (and += used with a sub-type of string) would then be a type error. To resolve this error is not an unacceptable burden for the developer, because modifying operators are just a shorthand for an assignment. When expanding to an assignment, the right-hand-side can easily be complemented by a type assertion (if you are sure the computation result stays within the constraints of the sub-type for semantic reasons the type checker cannot deduce) or a conversion function that ensures the constraints at run-time (see both examples). It could be discussed whether there should be special syntax to add a type assertion to modifying operators that refer to the right-hand side only (note that ++foo as uint is only an assertion for the expression result, not for the right-hand side foo + 1 of the implicit assignment), but I don't think this is necessary.

The examples show typical use cases, where you want to enforce a restricted set of number values, which is implemented through a coercion function at run-time. You want the compiler to check that you never forget to apply the coercion function to a number before assuming the result is one of the restricted values. This works very well in TypeScript, but applying modifying operators currently disables the check in a way only a type assertion should.

The same argument can be made for string and its specializations, like unions of string literals or string template types. For string, the only meaningful modifying operator is +=. I tried to add this special case in parenthesis throughout this report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions