Description
🔎 Search Terms
infer number, extends number, extends bigint, binary number, number representation, number notation, exponential notation, binary notation, hex numbers, hexadecimal numbers, hexadecimal notation, literal numbers, number literals
✅ Viability Checklist
- This wouldn't be a breaking change in existing TypeScript/JavaScript code
- strictly speaking might depend on the implementation, but it seems like some exist
- This wouldn't change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- This isn't a request to add a new utility type: https://github.com/microsoft/TypeScript/wiki/No-New-Utility-Types
- it seems like it can be implemented without a utility type, but that will depend on the implementation
- This feature would agree with the rest of our Design Goals: https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals
- I reread them twice just now and I can strawman reasons against checking this box, but it seems fairly reasonable, really
⭐ Suggestion
Back in #48094, constrained "infer" types in template literals were set to be limited by a "round trip" constraint. Meaning: numeric inference of string literals would only be allowed for literals that remain the same going from string
to number
and then back to string
again. E.g.:
- ✅
"123"
satisfies the constraint because"123"
->123
->"123"
- 🟥
"0x10"
isn't allowed because"0x10"
->16
->"16"
That makes sense. I can certainly understand the tradeoff of preferring type system performance and simplicity over the nuance edge case of number-to-string conversion. These type-system arithmetics weren't a common use case for end users at the time.
However, since #48094, there've been quite a few use cases that have popped up.
Some of them even impact real-world libraries* that have been inconvenienced by not being able to infer number literals that don't satisfy the round-trip constraint.
Let's consider a common ToNumber
utility type and less-common variant ToBigInt
, defined roughly as:
type ToNumber<T extends string> = T extends `${infer N extends number}`
? N
: never;
- real world libraries with this code hotscript, type-fest, hkt-toolbelt.
The following table has:
- ✅ Allowed: what can be inferred as a number literal today
- 🟥 Blocked: what would be possible if the round-trip constraint were removed
Use Case | Post TS 4.8 Behavior | Notes | |||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Fractional Numbers |
|
Fractional number representations ending in zero are probably the one that people hit the most. This is one that people try to use recursion to fix. For example, see @anuraghazra's attempt at fixing this problem (link). Since you pay one recursion tax per digit of the number this is probably ok since the recursion max is 100. |
|||||||||||||||||||||
The 1e-6 Boundary
|
|
For small numbers, TypeScript switches its underlying notation somewhat arbitrarily at the 1e-6 boundary. This causes a "flip" where you can infer numbers between the 0 and 1e-6 boundary, but as soon as your number gets smaller than that, the inferencing breaks if you're not using the same notation. |
|||||||||||||||||||||
The 1e20 Boundary
|
|
This one is sort of an inverse of the above (just for large numbers) but with an added footgun: if you don't have the + sign once you get into the e-notation range, then it will also not work because TypeScript always includes the + in this range. |
|||||||||||||||||||||
Base Notations |
|
Binary, Hexadecimal, and Octal number notations don't work. Common approaches for binary require lots of recursion if you have a scenario where you need to convert a binary number to decimal. Hex numbers perhaps with even more use cases. A lot of the use-cases that are listed in #54925 also apply here (e.g. RGB values, reading bytes, etc.). |
|||||||||||||||||||||
Numeric Separators |
|
Separators are allowed in number literals but never in strings because they're always dropped in TypeScript's representation and therefore any string input with a separator can never match. | |||||||||||||||||||||
BigInts |
BigInts as inputs are not allowed*. The typeical code you see for this looks like type ToBigInt = T extends `${infer N extends bigint}` ? N : never; 2n as a literal works but ToBigInt<"2n"> results in never and ToBigInt<"2"> is the way to get 2n .
This is different from the above because in this situation the string representation and the number representation do match but it only works if the input is not a bigint.
This seems to break the round-trip rule, because in this case if the input is 2n and the output is 2n then you'd think they'd match.
* You can sorta flip the result of the last two cases by adding an |
also: a quirky consequence regarding `-0n` (don't laugh)
I also noticed that there's a (presumably unintended) behavioral mismatch regarding -0n
. As far as I can tell, this is the only situation where you can get a bigint
out "the other side".
There is no negative-zero BigInt as there are no negative zeros in integers. -0.0 is an IEEE floating-point concept that only appears in the JavaScript Number type (source). Yet, TypeScript allows it. I think that's sorta fine because, actually the BigInt constructor also allows it, and that's presumably what this code courses through anyway.
type PeopleOnTwitterAreGonnaMakeFunOfAnyoneWhoComplainsAboutThis = [
/*✅*/ -0n, // 0n
/*🟥*/ ToBigInt<"-0n">, // never
/*✅*/ ToBigInt<"1">, // 1n
/*✅*/ ToBigInt<"0">, // 0n
/*🟥*/ ToBigInt<"-0">, // bigint
/*✅*/ ToBigInt<"-1">, // -1n
]
⏯ Playground Link
thanks to @JoshuaKGoldberg for suggestions on how to clean up this issue's formatting