Repository navigation
revisit round-trip matching constraint for number literal inferencing #57404
Description
Activity
I don’t think any of these are bugs because
`${number}`very intentionally only matches strings that roundtrip through a string-number-string coercion. In other words it doesn’t mean “any numeric string” but rather “any string which can be produced by`${num}`at runtime”. The behaviors you’ve observed are all a natural consequence of that, AFAICT.As for your addendum, that’s a different thing entirely (but is also intentional): #46124 (comment)
When placeholders are immediately next to each other, the first placeholder just infers a single character from the source. The kind of placeholder being inferred to doesn't matter for determining the inferred text, it only matters for validating the text.
Reacted by vincent-guyon_Atosdimitropoulos commented
on Feb 14, 2024 ContributorAuthorMore actionsThanks! I'm on the same page with you there about this being a consequence of the round-tripping. That's why I quoted Ron at the top the
Contextsection describing how the round-tripping was originally considered at design time. Actually, the test cases in that PR were a large motivator for finally opening this issue (as a bug, not a feature request) because those test cases happen to just fly by some of the nuance I mention here that feels buggy from a "normal person using TypeScript" standpoint.If it's more appropriate to convert this to a feature request, that's fine by me: I just want to know if there's any way to go about improving this (again, I have ideas, but I wanna make sure the problem domain is agreed on first). Seems like there should be some way to protect against most of the cases I brought up (hopefully 😄).
Thanks also for the note re: the addendum!
Reacted by Bruce PascoeI don’t think
`${number}`actually has such a round-trip requirement, even though the inference does:It’s inconsistent but I’m not sure they have an appetite for doing anything to change it.
Reacted by Dimitri Mitropoulosdimitropoulos commented
on Feb 14, 2024 ContributorAuthorMore actionshaha, I totally know what you mean Joe Calzaretta (@jcalz) re: appetite to improve type numbers. Actually, as a personal rule I try very hard to never submit "make numbers better" feature requests (as a matter of respect). In this case, I realized when you add it all up it's quite a mountain of strange behavior (until you realize what's really going on re: round-tripping).
I think most people hit the "trailing zero" problem first, and it's the only one with a workaround (albeit a tad expensive, recursion-wise). My hope with this issue was to show all the places with the same root cause. When you look at it all from a distance.. it's a lot! And besides, I have the appetite to fix it now that it's thoroughly blocked me (hundreds of thousands of binary numbers, making recursive techniques a non-option).
Nobody here is asking for my opinion but here it comes anyway:
I think template literal types should only refer to what happens when you use a template literal string. So
`${number}`andT extends `${infer N extends number}` ? N : nevershould only refer to the set of values you can get when serializing a numeric value via template literal string, which would essentially require round-tripping.It is a very natural and reasonable thing to want to have some way to represent a string which could successfully be parsed as a number, but that's not what template literal strings do, at all; and pushing such semantics into template literal types feels like a category error to me.
In my own personal version of TypeScript that lives only in my dreams, I would have a completely different set of tools for what you're trying to do that don't (ab)use template literals. Imagine an intrinsic type like
Numberablecorresponding to any type which would successfully be parsed as a number (I suppose this would excludeNaNas being considered a "success") and then an intrinsictype ToNumber<T extends Numberable>which represents the type of the output ofNumber(t)wheretis of typeT. Or some other syntax, but it should stay well away from template literals.But in the issue I referenced it was made clear that nobody but me wants it that way. Now this issue is requesting that such behavior be expanded to include inference. I think that's probably ultimately fine; I'd happily use such a feature if it existed (and it is a feature request, not a bug, this is working as intended). But it's hard to explain how or why this would have anything at all to do with template literals, leading to a weird mental model that does special crazy magic for numbers but not for, say, booleans.
Reacted by Dimitri Mitropoulos, Bruce Pascoe, Dan Rose, Hans Brende, Rovshan Badirkhanov and Matt KantorReacted by Dimitri Mitropoulos and Josh Ghoulberg 👻Reacted by Ryan Cavanaughdimitropoulos commented
on Feb 14, 2024 ContributorAuthorMore actionsImagine an intrinsic type like
NumberableThat would be fantastic! Last this came up the blocker was something to do with
intrinsicbeing strongly tied to string literal types, but perhaps in the two years since then (especially with NoInfer having landed), maybe that requirement is loosened now? I'm probably just missing it but it's not immediately clear to me how the issue you referenced means that nobody wants it that way.re: bug vs feature request. sure: that's fine by me :) -> I guess all I wanted to know first is something like:
yes, it's intended behavior that
ToNumber<"0.000001">returns a literal butToNumber<"0.0000001">doesn't.After all, not every observable behavior of the number inferring has been considered by-design, for example whitespace infers to number and is accepted as a bug. To me, some of the things in this PR are not far afield from that one.
Joe Calzaretta (@jcalz) FWIW, I agree with you - especially given Anders Hejlsberg (@ahejlsberg)’s stated insistence that template type inference not “turn into another regex engine”. It would make perfect sense to me if
`${number}`-the-type consistently represented only the set of things producible by`${numberVar}`-the-string-literal.Of course, I’m also a pragmatist who recognizes that what I want doesn’t really square with the way template type inference is used by TS coders today; as such, I consider this genie to be already out of the bottle, and have to reluctantly agree with the implied feature request: if it’s indeed intentional that
"2.0"is assignable to`${number}`, then that should work in the opposite direction too.Reacted by Dimitri Mitropoulos- addedNeeds InvestigationThis issue needs a team member to investigate its status.This issue needs a team member to investigate its status.
on Feb 14, 2024 RyanCavanaugh commented
on Feb 14, 2024 MemberMore actionsI don't know how we'd ever square this circle in a way that people found acceptable. Probably 99.9% of embedded
${number}literals are consumed in aparseInt/parseFloat/+ncontext and it seems beyond comprehensibility to reject"2.0"in that context.This is a crash because,
A crash is when tsc exits abnormally due to an exception. Untasteful behavior is not a crash 😉
Reacted by Dimitri MitropoulosReacted by Josh Ghoulberg 👻it seems beyond comprehensibility to reject
"2.0"in that context....well yes, that's the point of the issue -
"2.0"is rejected when inferring from a template literal. 😉dimitropoulos commented
on Feb 14, 2024 ContributorAuthorMore actionsRyan Cavanaugh (@RyanCavanaugh) ohhhhh as in a runtime crash! sorry about that! I stared at the options on the form for a long time
- This is a crash - This changed between versions ______ and _______ - This changed in commit or PR _______ - This is the behavior in every version I tried, and I reviewed the FAQ for entries about _________ - I was unable to test this on prior versions because _______and I was thinking about it in terms of "a type that parses can suddenly stop parsing and return never" but now I see that was a pretty near-sighted of me, haha. I almost forgot that <sarcasm>some people use TypeScript for more than just the type system</sarcasm>! heh. sorry!
It seems like that's a clear indication it should be a feature request so I updated it as such (as best I could, hopefully I didn't screw something up).
re:
in a way that people found acceptable
I realized that I didn't say it above anywhere but I just wanted to clarify that I find the currently implementation completely acceptable. Anyone who says that TypeScript hasn't "gone far enough" with this stuff is being silly. ✨ TypeScript is wonderful ✨ even if you can find ways (like
2.0) to trip it up. Everything's a trade-off, haha. I totally understand.So on that note Ron Buckton (@rbuckton) since you're assigned to this I wanted to say the intentions for making this issue were:
- (most importantly) To confirm that the behaviors I described above all known and acceptable. I know some of it may be, but I didn't see discussion or tests that covered all of them.
- There didn't seem to be a place that sortof compiled the current "here's all the edge cases" and gave them the context of what they have in common. even if this issue is immediately closed, it can be a reference for that stuff whenever it comes up.
- To indicate that I am highly motivated to help in any way I can to get this work over the line. I have the time and I'm willing to sink it into this problem.
Reacted by Dimitri Mitropoulos and Josh Ghoulberg 👻Reacted by Caleb Jasik- changed the title
[-]number literal inferencing is dependent on TS's internal representation due to a round-trip matching constraint[/-][+]revisit round-trip matching constraint for number literal inferencing[/+]on Feb 14, 2024 Hi!
I've got the same problems than some of the listed ones above, and I'm disappointed my types don't work as logically expected. :'(
I think that ToNumber type should return the same result as the following type for the same argument in number:type ResolvedNumber<N extends number> = NExample:
ResolvedNumber<2e1> returns 20
The same way, it would be logical that ToNumber<"2e1"> returns 20.Dimitri Mitropoulos (@dimitropoulos) You forgot the ".<digits>" format in your table, that doesn't work either.
Ex: ToNumber<.1> returns number instead of 0.1While there may be some odd quirks to review for some cases, the general principle is that you can only use
inferto pull out of a string what would have been put into the string. If you write`${2.0}`in a JS engine, you will always get"2"and never"2.0". Regardless has to how you write your number, what actually gets put into the string is the canonical numeric string representation of that number, so that is all we should ever try to extract.inferis not a general-purposeToNumbermechanism.Reacted by vincent-guyon_Atos and Caleb JasikAs to the specific use cases mentioned:
Fractional Numbers
The canonical string representations of
2.0and2.10are"2"and"2.1", respectively.The
1e-6Boundary
The1e20BoundaryThis is specific to how JavaScript formats IEEE floats for a radix of 10. Per Step 6 of Number::toString. Once you have passed a specific order of magnitude in either direction, the canonical string representation uses the scientific notation form (Steps 7+ of the algorithm). This range is from -5 to 21 -
k(wherekis always >=1, making the range -6 to 20), and is thus why it switches at these boundaries.Base Notations
The canonical string representation of a given
Numberis always presented either as a base 10 fixed decimal or via scientific notation, via the rules mentioned above.Numeric Separators
Numeric separators are not preserved in an actual
Numbervalue and thus are not included in the canonical string representation.BigInts
The canonical string representation for a given BigInt never includes the
nsuffix.
I don't believe any of these should be supported by
inferas they are not actually produced by.toString()for any runtime values ofNumberorBigInt. If you wanted something like atype ToNumber<S, Radix> = intrinsic, that's a different feature entirely.Reacted by Dimitri Mitropoulosdimitropoulos commented
on Jun 5, 2024 ContributorAuthorMore actionsI don't believe any of these should be supported by infer
Thanks Ron Buckton (@rbuckton)! That's the answer I was looking for. I really appreciate you taking a closer look. I just wanted clarification if all of these are intended, and it seems like they are so.. that's that! I continue to be eternally grateful for the powerhouse of engineering known as TypeScript. I'll be fine without this little wrinkle (of JavaScript itself, ultimately) being ironed out.
If you wanted something like a type
ToNumber<S, Radix> = intrinsic, that's a different feature entirely.Totally agree. I'm sure anyone that tries to make the case for this in the future will refence this issue or the cases I mentioned, but I won't be the one to file it! haha.
For the 100th time. Hats off to the TypeScript team for what's already possible. Anyone who reads this issue and somehow draws the conclusion that TypeScript isn't good enough.... I'd suggest you reconsider.
Reacted by vincent-guyon_AtosReacted by Josh Ghoulberg 👻 and Caleb JasikThe round-trip constraint for numeric literals ensures consistency in TypeScript, as noted in the discussion. Using TypeScript types to parse number formats can address many edge cases. Here’s an example approach: https://tsplay.dev/weAbgw
Reacted by Dimitri Mitropoulos- removedNeeds InvestigationThis issue needs a team member to investigate its status.This issue needs a team member to investigate its status.
on Oct 22, 2024 - locked as resolved and limited conversation to collaborators
on Oct 22, 2025
🔎 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
⭐ 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
stringtonumberand then back tostringagain. 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
ToNumberutility type and less-common variantToBigInt, defined roughly as:The following table has:
numbernumberFractional 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.
1e-6 BoundarynumbernumberFor 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.
1e20 BoundarynumbernumbernumbernumberThis 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.
numbernumbernumberBinary, 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.).
nevernevernevernevernevertype ToBigInt = T extends `${infer N extends bigint}` ? N : never;2nas a literal works butToBigInt<"2n">results inneverandToBigInt<"2">is the way to get2n. 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 is2nand the output is2nthen you'd think they'd match.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 abigintout "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.
⏯ Playground Link
Playground Link
thanks to Josh Ghoulberg 👻 (@JoshuaKGoldberg) for suggestions on how to clean up this issue's formatting