Skip to content

revisit round-trip matching constraint for number literal inferencing #57404

Closed
@dimitropoulos

Description

@dimitropoulos

🔎 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;

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
Use Case Literal ToNumber
2
2.1
-2
-2.1
2.0 🟥 number
2.10 🟥 number

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
Use Case Literal ToNumber
0.000001
0.0000001 🟥 number
1e-6 🟥 number
1e-7

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
Use Case Literal ToNumber
100000000000000000000
1000000000000000000000 🟥 number
1e+20 🟥 number
1e20 🟥 number
1e+21
1e21 🟥 number

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
Use Case Literal ToNumber
0b10 🟥 number
0xff 🟥 number
0o12345670 🟥 number

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
Use Case Literal ToNumber
1_000.000_1 🟥 never
1_000 🟥 never
0b11_1110_1000 🟥 never
0x31_78_c6 🟥 never
1_000n 🟥 never
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 n after the matching clause:
type ToBigIntN = T extends ${infer N extends bigint}n ? N : never;
But this seems pretty inconsistent with how binary and hex numbers do actually match (although, not "all the way" to the point of being a literal).
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

Playground Link

thanks to @JoshuaKGoldberg for suggestions on how to clean up this issue's formatting

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions