You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
TypeScript ignoring checks for deeply nested computed object types #56138
inference, deeply nested, no error, type evaluation, typebox, hkt
🕗 Version & Regression Information
Hi,
I've come across some unusual behavior where TypeScript seems to be skipping checks for certain kinds of computed types. This seems to be specific to deeply nested object types that are computed via intersection. I note that the language service is reporting the correct type information in editor, however the compiler seems to be skipping checks on the reported type. This behavior can be observed using the TypeBox library, specifically for object types at least 2 levels deep.
This is the behavior in every version I tried (5.1.6 onwards)
import{Static,Type}from"@sinclair/typebox"exporttypeInput=Static<typeofInput>exportconstInput=Type.Object({level1: Type.Object({level2: Type.Object({foo: Type.String(),})})})exporttypeOutput=Static<typeofOutput>exportconstOutput=Type.Object({level1: Type.Object({level2: Type.Object({foo: Type.String(),bar: Type.String(),})})})// The following functions should all report errors for the return value, however the first does not.functionproblematicFunction1(ors: Input[]): Output[]{returnors;// <-- this does not error }functionproblematicFunction2<TextendsOutput[]>(ors: Input[]): T{returnors;// <-- ... but this does}functionproblematicFunction3(ors: (typeofInput.static)[]): Output[]{returnors;// <-- ... and so does this}
🙁 Actual behavior
First function does not report an error
🙂 Expected behavior
All functions should report errors
Additional information about the issue
I get the feeling this may have something to do with TypeBox's implementation of Static<T> and where TypeScript may be implementing some optimizations around certain forms of computed / evaluated types (but not sure). TypeBox currently implements Static<T> as follows.
{ level1: { level2: { foo: string; bar: string;};};}->{level2: {foo: string; bar: string;};}->{foo: string; bar: string;}
Since each of these types are instantiations of the same symbol Evaluate<T>, it appears that we might be stuck in an infinitely-expanding recursive type like this one, where each level of expansion creates new fresh types that seem like they might be worth looking into:
So we need some way to tell apart OP's situation - where the types are actually converging, probably - from the infinite-descent case where they'll never converge.
I'm going to put up a PR to change the object type depth limit from 3 to 5 but it's almost certainly going to break someone else's performance or cause them to hit a depth limit which they previously didn't...
The PR tanked xstate performance; we'll need something smarter.
Reduced and annotated example (apologies for renaming but these make more sense to me and avoid conflicting with certain terms we use internally):
typeInput=GetType<typeofInput,[]>constInput=MakeObject({level1: MakeObject({level2: MakeObject({foo: MakeString(),})})});typeOutput=GetType<typeofOutput,[]>;constOutput=MakeObject({level1: MakeObject({level2: MakeObject({foo: MakeString(),bar: MakeString(),})})});functionproblematicFunction1(ors: Input): Output{// Should errorreturnors;}// Representational types have a 'myType' field that represents// the TypeScript type of the object, e.g.: MakeString belowinterfaceHasType{myType: unknown}declarefunctionMakeString(): {myType: string};// Reads the 'myType' field out of an object. 'P' exists for// reverse inference purposes when dealing with object types (see MakeObject below)typeGetType<TextendsHasType,P>=(T&{objectProps: P;})['myType'];// Mapped version of abovetypeGetTypeMapped<TextendsRecordOfTypes,P>={[KinkeyofT]: GetType<T[K],P>};// Creates an object typedeclarefunctionMakeObject<TextendsRecordOfTypes>(object: T): TObject<T>;interfaceTObject<TextendsRecordOfTypes>{objectProps: unknown;myType: GetTypeMapped<T, this['objectProps']>;}// Any dictionary from string -> statically-typed typetypeRecordOfTypes=Record<string,HasType>;
This example defeats the existing "are we making progress" heuristics because of
myType: GetTypeMapped<T, this['objectProps']>;
When computing Output.level1.level2, this is computing this['objectProps']['objectProps']['objectProps'], which looks a heck of a lot like the infinite series of nexts in RabbitHole.
Reduced and annotated example (apologies for renaming but these make more sense to me and avoid conflicting with certain terms we use internally):
Hey no problem. Also, thank you for taking the time to investigate this issue (and for the test PR), that was very much appreciated. If there is anything that can be done library side to help work within TS depth limits here, please let me know.
webstrand I think the halting problem goes both ways - if you can't prove that a program halts (or, equivalently, that a type is not infinitely recursive), then you also can't prove that it doesn't. So you'd have to rely on equally inaccurate heuristics to do that marking, no?
I mean an intrinsic Converges<T> (or a keyword) which may loop infinitely if poorly written. I recognize that detecting these cases automatically may be impossible, but rather than an esoteric "you must write your recursive types in a particular pattern that typescript recognizes, but is only known to the few who frequent issues" mark it explicitly.
With #55638 we got pretty close to fixing the issue. However, use of the non-homomorphic Pick and the Evaluate conditional causes the compiler to loose track of the symbol of the original literal type. Once that happens, all of the transformed types have the same symbol and we stop relating them after three levels. The code works if I remove the use of Evaluate and change to a homomorphic definition of Pick:
Activity
RyanCavanaugh commentedon Oct 17, 2023
Please inline the relevant parts of
typeboxsinclairzx81 commentedon Oct 17, 2023
Ryan Cavanaugh (@RyanCavanaugh) See link below for the relevant parts of TypeBox inlined
TypeScript Link Here
RyanCavanaugh commentedon Oct 17, 2023
WIP reduction
RyanCavanaugh commentedon Oct 17, 2023
As expected, we're hitting a depth limit
Source 236 is
Evaluate<T>165 is
Evaluate<T>222 is
Evaluate<T>236 is
Evaluate<T>Target 229 is
Evaluate<T>198 is
Evaluate<T>215 is
Evaluate<T>229 is ...
Evaluate<T>The source stack is
and the target stack is
Since each of these types are instantiations of the same symbol
Evaluate<T>, it appears that we might be stuck in an infinitely-expanding recursive type like this one, where each level of expansion creates new fresh types that seem like they might be worth looking into:So we need some way to tell apart OP's situation - where the types are actually converging, probably - from the infinite-descent case where they'll never converge.
Go 5 levels deep when determining if an object type is deeply nested
RyanCavanaugh commentedon Oct 17, 2023
I'm going to put up a PR to change the object type depth limit from 3 to 5 but it's almost certainly going to break someone else's performance or cause them to hit a depth limit which they previously didn't...
RyanCavanaugh commentedon Oct 17, 2023
The PR tanked xstate performance; we'll need something smarter.
Reduced and annotated example (apologies for renaming but these make more sense to me and avoid conflicting with certain terms we use internally):
This example defeats the existing "are we making progress" heuristics because of
When computing
Output.level1.level2, this is computingthis['objectProps']['objectProps']['objectProps'], which looks a heck of a lot like the infinite series ofnexts inRabbitHole.sinclairzx81 commentedon Oct 17, 2023
Ryan Cavanaugh (@RyanCavanaugh)
Hey no problem. Also, thank you for taking the time to investigate this issue (and for the test PR), that was very much appreciated. If there is anything that can be done library side to help work within TS depth limits here, please let me know.
fatcerberus commentedon Oct 18, 2023
Didn't Anders Hejlsberg (@ahejlsberg) recently author a PR with smarter logic for this that went beyond the usual "stop after 3 levels of
Foo<T>"?sinclairzx81 commentedon Oct 18, 2023
Related #55638
fatcerberus commentedon Oct 18, 2023
sinclairzx81 That was the PR I was thinking of, thanks!
webstrand commentedon Oct 18, 2023
Could we perhaps explicitly mark these cases instead, if detecting them automatically proves infeasible?
fatcerberus commentedon Oct 18, 2023
webstrand I think the halting problem goes both ways - if you can't prove that a program halts (or, equivalently, that a type is not infinitely recursive), then you also can't prove that it doesn't. So you'd have to rely on equally inaccurate heuristics to do that marking, no?
webstrand commentedon Oct 19, 2023
I mean an intrinsic
Converges<T>(or a keyword) which may loop infinitely if poorly written. I recognize that detecting these cases automatically may be impossible, but rather than an esoteric "you must write your recursive types in a particular pattern that typescript recognizes, but is only known to the few who frequent issues" mark it explicitly.ahejlsberg commentedon Oct 19, 2023
With #55638 we got pretty close to fixing the issue. However, use of the non-homomorphic
Pickand theEvaluateconditional causes the compiler to loose track of the symbol of the original literal type. Once that happens, all of the transformed types have the same symbol and we stop relating them after three levels. The code works if I remove the use ofEvaluateand change to a homomorphic definition ofPick:I'll keep thinking about this, but it's a really tricky issue to solve.
ahejlsberg commentedon Oct 21, 2023
Actually, it appears possible to fix this by improving our logic for tracking deeply nested types in relationships. I will put up a PR.
sinclairzx81 commentedon Oct 23, 2023
Anders Hejlsberg (@ahejlsberg) This PR is amazing. Thank you so much!