Repository navigation
Regression with branded types in TypeScript 4.2 #42939
Copy link
Copy link
Closed
Labels
Breaking ChangeWould introduce errors in existing codeWould introduce errors in existing codeFix AvailableA PR has been opened for this issueA PR has been opened for this issueWorking as IntendedThe behavior described is the intended behavior; this is not a bugThe behavior described is the intended behavior; this is not a bug
Milestone
Description
Bug Report
When attempting to put together some conditional types to extract information about branded types, TS 4.2 is no longer able to correctly infer your intent.
type Branded<T, A> = A & { __brand: T }
type BrandedValue<A> = A extends Branded<unknown, infer R> ? R : never
// Expected to be 'string' but is 'string & { __brand: T }' in 4.2+
type T1 = BrandedValue< Branded< 'GUID', string > >🔎 Search Terms
- Branded Types
- Conditional Types
- TypeScript 4.2
🕗 Version & Regression Information
- This changed between versions 4.1.5 and 4.2 beta
⏯ Playground Link
Working example in 4.1.5
🙁 Actual behavior
The brand is not removed from the return type
🙂 Expected behavior
The brand should be removed from the return type
Reactions are currently unavailable
Metadata
Metadata
Labels
Breaking ChangeWould introduce errors in existing codeWould introduce errors in existing codeFix AvailableA PR has been opened for this issueA PR has been opened for this issueWorking as IntendedThe behavior described is the intended behavior; this is not a bugThe behavior described is the intended behavior; this is not a bug
Activity
RyanCavanaugh commentedon Feb 24, 2021
Wesley Wigham (@weswigham) this one might require a hotfix if we see multiple reports
ahejlsberg commentedon Feb 24, 2021
Hang on, I'm about to post an explanation of what's going on...
ahejlsberg commentedon Feb 24, 2021
TL;DR answer is to change the
BrandedValuetype to useBrandOf<A>instead ofunknown:This helps type inference better recognize that we're inferring between two types with identical
{ __brand: xxx }brands, allowing the remainder of the type to be extracted. The change fixes the issue in 4.2 and generally cause theBrandedValuetype to work with types that are structurally compatible withBranded<T, A>.Longer explanation...
First, the example in the original post above actually works in 4.2. However, the examples in the playground link are broken as indicated, which is an effect of our improved type alias preservation.
The type alias
used to simply reference the intersection type
string & { __brand: "Guid" }with the associated aliasBranded<"Guid", string>. Internally in the compiler, the type referencesGuidandBranded<"Guid", string>would reference the exact same intersection type object and the type would always display asBranded<"Guid", string>.In 4.2,
Guidactually becomes a distinct intersection typestring & { __brand: "Guid" }with the associated aliasGuid. This type is structurally compatible withBranded<"Guid", string>, but it is not the exact same type object (because it has a different alias associated with it). The new representation allows us to preserve and display theGuidalias (which is good), but it also means that type inference can't rely on inferring between instantiations of the same type alias--and this is where things go wrong in your example.Some examples to illustrate the issue:
With the change to
BrandedValueI suggested above, all of these examples work in both 4.1 and 4.2.TylorS commentedon Feb 24, 2021
Hey Anders Hejlsberg (@ahejlsberg) I really appreciate the workaround and the detailed explanation, it makes perfect sense. 🙏
Editor-focused improvements, despite being very welcomed, requiring code changes does feel a little like this could be a leak of implementation details. Was this really the intended behavior when trying to preserve aliases? I couldn't find mention of this behavior in #35654 or #42149
RyanCavanaugh commentedon Feb 24, 2021
When you have an inference of the form
There is not one exact-right answer for
W. In fact, any subtype ofVis a valid answer forinfer W, and our choice amongst those is sometimes reliant on heuristics around "what you intended". You should think of an inference position here as possibly returning any of those types.For example:
Here,
TisPoint3d, not{ z: number }. However, if you wrote this asThen you'd see T =
{ z: number }Or in this form, we go back to inferring the whole type:
Importantly, none of these answers are wrong.
The "peeling off" heuristic behavior of intersections in inference positions is delicate and it's best to try to avoid relying on it too heavily. It's expected that indirections (including things that weren't previously considered indirections) can thwart that process. It's a nice-to-have when it works but is not a correctness deficit when it doesn't.
TylorS commentedon Feb 24, 2021
The subtyping issue makes sense from a theoretical standpoint, thanks for that explanation Ryan Cavanaugh (@RyanCavanaugh).
I still feel a portion of my point was missed. It's fine that there are multiple answers, but this broke code by changing which answer TypeScript picks. Since this is a hidden choice from programmers, that would be my definition of an implementation detail. I'll concede here though...I'm happy enough there's a workaround for this particular issue and to understand why it's happening.
Less related to the original question, and more specific to the last paragraph regarding the "peeling off" behavior of intersections. I've seen and written several open-source libraries making pretty heavy use of this type-inference w/ intersections as a way to track requirements (as a record) that can be added/subtracted from. They all pretty much run into these issues you describe but are generally workable. Are there any proposals/features on the horizon to actually improve this ability?
weswigham commentedon Feb 24, 2021
#33290 or #33038 probably.
TylorS commentedon Feb 24, 2021
Ooh thanks for those links, I wasn't aware of either of those 🙏
But I meant more generally regarding records and not just brands, sorry I wasn't more clear. Variadic kinds from way back is the one thing that comes to mind which could have maybe solved this example like
typescript-bot commentedon Feb 28, 2021
This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.