Repository navigation
Modifying operators must only be allowed for type number (and += for string) #48857
Description
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), Anders Hejlsberg (@ahejlsberg) was asking for further practical examples that would motivate stricter typing of modifying operators, which this report provides imho.
Bruce Pascoe (@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.
Activity
fwienber commentedon Apr 27, 2022
Martin Johns (@MartinJohns), what is the confusion here? Can you clarify, so I could try to respond?
Already saw your posts in the other issues, so I am aware that you are interested in this topic, too.
fwienber commentedon Apr 27, 2022
Martin Johns (@MartinJohns) Your issue #47027 focuses on
+=onstring, while mine focuses on all modifying operators onnumberand mentions thestring/+=case only as a side-note.If this did not become apparent, I totally agree with you that for example
should result in a type error.
The
+=operator should only be allowed on variables or properties of the exact typenumberorstring, not on any more specific type (sub-type).fwienber commentedon Apr 27, 2022
Maybe another example, where one could be surprised by the type error:
Although
"aa"would still meet the constraints ofSomeAs, the compiler cannot check this for the general case (it would have to do all kinds of computations at compile-time, and once you introduce loops or conditions, this becomes impossible), so it should better reject it and enforce an explicit type assertion rather than be "optimistic" about such operations.As mentioned before, this would be consistent with the type error you get when you try
andrewbranch commentedon Apr 27, 2022
Thanks for the clear and thorough report; however, the original issues are not locked so if you want to continue the discussion, you can do so in those issues. Opening new issues to discuss old ones just creates more triage work and makes it harder for future readers to follow the conversation.
fwienber commentedon Apr 27, 2022
Some of these issues are already closed as won't fix, and the argument I am trying to make is a bit different, so none of them fit 100%.
I think the root issue is not about literal types or only strings, as all the former issues I found (#14745, #28344, #30783, #47027) seemed to assume. I hope my "Unsigned Integers" example shows this aspect (no literal type, string or union type anywhere). I would like to propose the new viewpoint that all modifying operators may only be used on variables and properties of exactly the type
number(and+=additionally on exactly the typestring). Which existing issue do you think would fit that approach?In other words, if you label it "Duplicate", of which issue do you think it is a duplicate?
andrewbranch commentedon Apr 27, 2022
It’s a duplicate of #14745 and #47027. The
uintexample is indeed a new and interesting point but it doesn’t rise to the level of needing a whole new issue.MartinJohns commentedon Apr 27, 2022
I'm confused to why you open a new issue when you already found an existing open (#47027) which has the label "In Discussion".
The focus on
stringis irrelevant IMO. It's fairly clear that this applies to any literal type and any assignment operator. Ryan even linked another issue where the focus is numbers (#47027 (comment)).fwienber commentedon Apr 27, 2022
Okay, I found this discussion rather fragmented and after realizing that #14745 had been closed as "won't fix", I tried to consolidate it in a new issue with a concrete suggestion that is supposed to tackle all special cases.
But if you prefer, I would be happy to close this issue and add the "uint" example to #47027. Would it be an option to clarify the more general claim of that issue by adapting the subject? As explained above, I think "literal types" are not the only source of the problem.
fwienber commentedon Apr 27, 2022
Well, maybe I tried to "solve fragmentation by adding another fragment", like in https://xkcd.com/927/
MartinJohns commentedon Apr 27, 2022
I've changed the title and mentioned that it applies to
numberas well, but I didn't add the mention ofuint.fwienber commentedon Apr 27, 2022
Thank you for already changing the title!
I still think that, as explained above, "literal types" are not the only source of the problem, so I'd suggest "constrained types" or even "subtypes of
number/string" or something like that.fwienber commentedon Apr 27, 2022
And I'll add the "uint" example there, but maybe not today (as it is already 8 pm in my time-zone).
MartinJohns commentedon Apr 27, 2022
Literal types can only be
string,numberandbigint(and well,trueandfalse). No other. It's literally the name for these "subtypes ofnumber/string".fatcerberus commentedon Apr 27, 2022
Martin Johns (@MartinJohns) He also intends for the rule to apply to e.g.
number & { __brand: never }style types too.fwienber commentedon Apr 28, 2022
Please continue here: #47027 (comment)
fwienber commentedon May 5, 2022
Andrew Branch (@andrewbranch) After following your advice to close this issue and add my examples and suggestions to #47027, I would be delighted to get some reaction there!