Excessive instantiation depth with fluent chaining API
See original GitHub issue@amcasey reduced a perf complaint from https://github.com/glideapps/glide-code-columns/blob/master/src/columns/as-array.ts to this playground:
class Col<TParams> {
public withRequiredPrimitiveParam<TName extends string>(name: TName) {
return undefined! as Col<
TParams & { readonly [K in TName]: any }
>;
}
}
new Col()
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value")
.withRequiredPrimitiveParam("value") // Hits stack limit
Each additional chain results in a new instantiation of Col
with an additional { value: any }
intersected with the previous instantiation’s type argument. These intersections don’t seem to get simplified for some reason—hovering the third call in the chain yields the quick info
(method) Col<{ readonly value: any; } & { readonly value: any; }>.withRequiredPrimitiveParam<"value">(name: "value"): Col<{
readonly value: any;
} & {
readonly value: any;
} & {
readonly value: any;
}>
Working with a more realistic simplification of the source, @amcasey measured type instantiations as a function of calls in the chain:
It seems that the function is the same even for this dead simple reproduction where each call after the first doesn’t meaningfully change the return type since we max out at the same number of calls, and it’s not obvious to me why that pattern is exponential.
Issue Analytics
- State:
- Created 2 years ago
- Reactions:4
- Comments:9 (4 by maintainers)
Top GitHub Comments
First, the easy workaround:
Basically, replace
{ readonly [K in TName]: any }
withRecord<TName, any>
. What’s the difference? Well, in order to maximally reuse instantiations of anonymous object types we determine which in-scope type parameters are possibly referenced within the object type and then create a map keyed by the type IDs for which the type parameters are instantiated. The object type only referencesTName
, so that should be the only type parameter contributing to the key. However, it is hard for us to prove thatTParams
isn’t referenced because there is intervening non-type-declaration code between the object type and the declaration ofTParams
. For example, there could be a local type that referencesTParams
and a reference to that local type in the anonymous object type. For that reason, we viewTParams
as possibly referenced and include it in the instantiation key map. That in turn causes all the instantiations to look different to us, so we keep manufacturing new ones, making more and more types until we eventually hit our governor.If we instead write
Record<TName, any>
, we effectively removeTParams
from scope and all the instantiations collapse to a single cached type.I’ll think about ways we can be smarter about concluding that type parameters aren’t referenced, but it’s not trivial.
Yes, they are structurally identical. But they are still represented as two distinct type instances in the type checker (specifically, two distinct instances of the
ObjectType
type), and intersections only deduplicate types with the same object identity. When we useRecord<K, T>
, the instantiation cache is keyed by the type identities used forK
andT
. In the example there, that means all instantiations for the sameTName
are shared. But when we use{ [K in TName]: any }
, the instantiation cache is keyed by the type identities used forTParams
andTName
. And the fact that we can’t excludeTParams
is what causes the problem.