From 90801ac27beff32b6b66394e9ae6d58a4b97f2e9 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:08:19 -0700 Subject: [PATCH 1/5] Make resolveStructuredTypeMembers inlinable resolveStructuredTypeMembers is called on nearly every property, signature and index lookup, and almost always finds the members already resolved. Because the resolution switch lived in the same function, the whole thing was too large to inline, so every lookup paid for a call just to test one flag. Move the resolution switch into a separate worker so the remaining resolved-bit check and cast fit in the inlining budget. To get under the budget, AsStructuredType now only checks the lower bound of the kind range, which is sufficient because intersection is the last type kind. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 40 ++++++++++++++++++--------------- tsc/internal/checker/types.go | 4 ++-- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index d9cb44e00a903..79b5e54ed8374 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -19548,29 +19548,33 @@ func (c *Checker) isApplicableIndexType(source *Type, target *Type) bool { func (c *Checker) resolveStructuredTypeMembers(t *Type) *StructuredType { if t.objectFlags&ObjectFlagsMembersResolved == 0 { + c.resolveStructuredTypeMembersWorker(t) + } + return t.AsStructuredType() +} + +func (c *Checker) resolveStructuredTypeMembersWorker(t *Type) { + switch { + case t.flags&TypeFlagsObject != 0: switch { - case t.flags&TypeFlagsObject != 0: - switch { - case t.objectFlags&(ObjectFlagsClassOrInterface|ObjectFlagsReference) != 0: - c.resolveObjectTypeMembers(t) - case t.objectFlags&ObjectFlagsReverseMapped != 0: - c.resolveReverseMappedTypeMembers(t) - case t.objectFlags&ObjectFlagsAnonymous != 0: - c.resolveAnonymousTypeMembers(t) - case t.objectFlags&ObjectFlagsMapped != 0: - c.resolveMappedTypeMembers(t) - default: - panic("Unhandled case in resolveStructuredTypeMembers") - } - case t.flags&TypeFlagsUnion != 0: - c.resolveUnionTypeMembers(t) - case t.flags&TypeFlagsIntersection != 0: - c.resolveIntersectionTypeMembers(t) + case t.objectFlags&(ObjectFlagsClassOrInterface|ObjectFlagsReference) != 0: + c.resolveObjectTypeMembers(t) + case t.objectFlags&ObjectFlagsReverseMapped != 0: + c.resolveReverseMappedTypeMembers(t) + case t.objectFlags&ObjectFlagsAnonymous != 0: + c.resolveAnonymousTypeMembers(t) + case t.objectFlags&ObjectFlagsMapped != 0: + c.resolveMappedTypeMembers(t) default: panic("Unhandled case in resolveStructuredTypeMembers") } + case t.flags&TypeFlagsUnion != 0: + c.resolveUnionTypeMembers(t) + case t.flags&TypeFlagsIntersection != 0: + c.resolveIntersectionTypeMembers(t) + default: + panic("Unhandled case in resolveStructuredTypeMembers") } - return t.AsStructuredType() } func (c *Checker) resolveObjectTypeMembers(t *Type) { diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 2af3c46a0d198..b1a3955a913da 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -780,7 +780,7 @@ const ( typeKindReverseMapped typeKindEvolvingArray typeKindUnion - typeKindIntersection + typeKindIntersection // Must be last; AsStructuredType relies on this ) type Type struct { @@ -894,7 +894,7 @@ func (t *Type) AsConstrainedType() *ConstrainedType { } func (t *Type) AsStructuredType() *StructuredType { - if t.kind >= typeKindObject && t.kind <= typeKindIntersection { + if t.kind >= typeKindObject { return (*StructuredType)(unsafe.Pointer(t)) } return nil From fec4233f5fcc3f9f0fb25b3978e9d87e3a501836 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:10:38 -0700 Subject: [PATCH 2/5] Cache intersection normalization and non-nullable unions on types Type has a byte of padding after its kind. Use it for a small set of internal flags that record answers which never change for a type. shouldNormalizeIntersection is asked repeatedly for the same intersections during relation checking and rescans every constituent each time. Its answer depends only on constituent flags and on whether anonymous constituents are empty, so remember it once those answers are final. Some member resolvers publish empty members before resolution finishes, so a bit now marks types whose members are still being resolved, and the result is not cached while any anonymous constituent is unresolved or mid-resolution. Unions whose constituents are all definitely non-nullable and not instantiable can never produce undefined/null type facts. Record this when the union is created, so that getTypeFacts can skip walking the constituents when the caller only asks about nullability, as it already does for object types. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 43 +++++++++++++++++++++++++++++---- tsc/internal/checker/types.go | 12 +++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 79b5e54ed8374..7403448235e38 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -19554,6 +19554,8 @@ func (c *Checker) resolveStructuredTypeMembers(t *Type) *StructuredType { } func (c *Checker) resolveStructuredTypeMembersWorker(t *Type) { + resolving := t.cacheFlags & typeCacheResolvingMembers + t.cacheFlags |= typeCacheResolvingMembers switch { case t.flags&TypeFlagsObject != 0: switch { @@ -19575,6 +19577,13 @@ func (c *Checker) resolveStructuredTypeMembersWorker(t *Type) { default: panic("Unhandled case in resolveStructuredTypeMembers") } + t.cacheFlags = t.cacheFlags&^typeCacheResolvingMembers | resolving +} + +// Reports whether the members of a structured type are resolved and will no longer change. Some +// resolvers publish partial (e.g. empty) members while resolution is still in progress. +func hasFinalMembers(t *Type) bool { + return t.objectFlags&ObjectFlagsMembersResolved != 0 && t.cacheFlags&typeCacheResolvingMembers == 0 } func (c *Checker) resolveObjectTypeMembers(t *Type) { @@ -26501,6 +26510,9 @@ func (c *Checker) getUnionTypeFromSortedList(types []*Type, precomputedObjectFla t := c.unionTypes[key] if t == nil { t = c.newUnionType(precomputedObjectFlags|c.getPropagatingFlagsOfTypes(types, TypeFlagsNullable), types) + if core.Every(types, func(t *Type) bool { return t.flags&(TypeFlagsDefinitelyNonNullable&^TypeFlagsInstantiable) != 0 }) { + t.cacheFlags |= typeCacheNonNullableUnion + } t.AsUnionType().origin = origin t.alias = alias if len(types) == 2 && types[0].flags&TypeFlagsBooleanLiteral != 0 && types[1].flags&TypeFlagsBooleanLiteral != 0 { @@ -28839,16 +28851,30 @@ func (c *Checker) getNormalizedUnionOrIntersectionType(t *Type, writing bool) *T } func (c *Checker) shouldNormalizeIntersection(t *Type) bool { + if t.cacheFlags&typeCacheShouldNormalizeComputed != 0 { + return t.cacheFlags&typeCacheShouldNormalize != 0 + } + result := false + // Whether an anonymous object type is empty can change until its members are fully resolved, + // so the result is only cached if every answer it depends on is final. + cacheable := true hasInstantiable := false hasNullableOrEmpty := false - for _, t := range t.Types() { - hasInstantiable = hasInstantiable || t.flags&TypeFlagsInstantiable != 0 - hasNullableOrEmpty = hasNullableOrEmpty || t.flags&TypeFlagsNullable != 0 || c.IsEmptyAnonymousObjectType(t) + for _, u := range t.Types() { + hasInstantiable = hasInstantiable || u.flags&TypeFlagsInstantiable != 0 + if !hasNullableOrEmpty && u.flags&TypeFlagsNullable == 0 && u.objectFlags&ObjectFlagsAnonymous != 0 && !hasFinalMembers(u) { + cacheable = false + } + hasNullableOrEmpty = hasNullableOrEmpty || u.flags&TypeFlagsNullable != 0 || c.IsEmptyAnonymousObjectType(u) if hasInstantiable && hasNullableOrEmpty { - return true + result = true + break } } - return false + if cacheable { + t.cacheFlags |= typeCacheShouldNormalizeComputed | core.IfElse(result, typeCacheShouldNormalize, 0) + } + return result } func (c *Checker) getNormalizedTupleType(t *Type, writing bool) *Type { @@ -31904,6 +31930,13 @@ func (c *Checker) getTypeFactsWorker(t *Type, callerOnlyNeeds TypeFacts) TypeFac case flags&TypeFlagsNever != 0: return TypeFactsNone case flags&TypeFlagsUnion != 0: + if t.cacheFlags&typeCacheNonNullableUnion != 0 { + // Like object types below, none of the constituents can produce facts outside of these. + possibleFacts := core.IfElse(c.strictNullChecks, TypeFactsEmptyObjectStrictFacts, TypeFactsEmptyObjectFacts) + if callerOnlyNeeds&possibleFacts == 0 { + return TypeFactsNone + } + } var facts TypeFacts for _, t := range t.Types() { facts |= c.getTypeFactsWorker(t, callerOnlyNeeds) diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index b1a3955a913da..93736cbe34310 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -783,11 +783,23 @@ const ( typeKindIntersection // Must be last; AsStructuredType relies on this ) +// typeCacheFlags record internal state and memoized answers for a type. They fit in what would otherwise +// be padding in Type. +type typeCacheFlags uint8 + +const ( + typeCacheResolvingMembers typeCacheFlags = 1 << iota // Structured type members are being resolved + typeCacheShouldNormalizeComputed // shouldNormalizeIntersection result has been computed + typeCacheShouldNormalize // shouldNormalizeIntersection result + typeCacheNonNullableUnion // Union of only definitely non-nullable, non-instantiable types +) + type Type struct { flags TypeFlags objectFlags ObjectFlags id TypeId kind typeKind `customlint:"protected"` + cacheFlags typeCacheFlags symbol *ast.Symbol alias *TypeAlias checker *Checker From 116b559bc4867329a2e11871787b63f9f972ce56 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:13:12 -0700 Subject: [PATCH 3/5] Cache weak type classification on object types isWeakType runs for the target of nearly every assignability check against an object type, and each time it re-examines the signatures, index infos and every property of the target, which for large interfaces is a full walk of the member list. The classification is a fixed property of the type once its members are resolved, so remember it in the type's cache flags. The answer is cached only when the members are fully resolved (not published early by an in-progress resolver) and the properties came from the type itself rather than a different apparent type. When getBaseTypes resets partially resolved members of a class or interface, the cached answer is reset along with them. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 1 + tsc/internal/checker/relater.go | 21 ++++++++++++++++++--- tsc/internal/checker/types.go | 2 ++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 7403448235e38..77b9529ae689a 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -19789,6 +19789,7 @@ func (c *Checker) getBaseTypes(t *Type) []*Type { // members partially resolved. Here we ensure any such partial resolution is reset. // See https://github.com/microsoft/TypeScript/issues/16861 for an example. t.objectFlags &^= ObjectFlagsMembersResolved + t.cacheFlags &^= typeCacheIsWeakComputed | typeCacheIsWeak data.baseTypesResolved = true } return data.resolvedBaseTypes diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index 562ae0451cb43..47166ad440d33 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -679,10 +679,25 @@ func (c *Checker) elaborateArrowFunction(node *ast.Node, source *Type, target *T // and no required properties, call/construct signatures or index signatures func (c *Checker) isWeakType(t *Type) bool { if t.flags&TypeFlagsObject != 0 { + if t.cacheFlags&typeCacheIsWeakComputed != 0 { + return t.cacheFlags&typeCacheIsWeak != 0 + } resolved := c.resolveStructuredTypeMembers(t) - return len(resolved.getSignatures()) == 0 && len(resolved.getIndexInfos()) == 0 && len(c.getLayoutPropertiesOfType(t)) > 0 && core.Every(c.getLayoutPropertiesOfType(t), func(p *ast.Symbol) bool { - return p.Flags()&ast.SymbolFlagsOptional != 0 - }) + // The answer can only be cached once the members are fully resolved. + cacheable := hasFinalMembers(t) + result := false + if len(resolved.getSignatures()) == 0 && len(resolved.getIndexInfos()) == 0 { + props := c.getLayoutPropertiesOfType(t) + result = len(props) > 0 && core.Every(props, func(p *ast.Symbol) bool { + return p.Flags()&ast.SymbolFlagsOptional != 0 + }) + // The properties come from the apparent type, so it must be the type itself. + cacheable = cacheable && c.getReducedApparentType(t) == t + } + if cacheable { + t.cacheFlags |= typeCacheIsWeakComputed | core.IfElse(result, typeCacheIsWeak, 0) + } + return result } if t.flags&TypeFlagsSubstitution != 0 { return c.isWeakType(t.AsSubstitutionType().baseType) diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index 93736cbe34310..fc27f06f27440 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -792,6 +792,8 @@ const ( typeCacheShouldNormalizeComputed // shouldNormalizeIntersection result has been computed typeCacheShouldNormalize // shouldNormalizeIntersection result typeCacheNonNullableUnion // Union of only definitely non-nullable, non-instantiable types + typeCacheIsWeakComputed // isWeakType result has been computed + typeCacheIsWeak // isWeakType result ) type Type struct { From da136a0572bef72a221e860053bf19e930acb494 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:14:35 -0700 Subject: [PATCH 4/5] Memoize isTypeReferenceWithGenericArguments Every relation cache key computation calls isTypeReferenceWithGenericArguments on both the source and the target, and the check recursively walks all type arguments of nested type references. For deeply nested generic references this repeats the same walk on every relation check involving them. The type arguments of a non-deferred type reference are fixed when it is created, so the answer never changes. Remember it in the type's cache flags so later calls are a flag test. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 16 +++++++++++++--- tsc/internal/checker/types.go | 14 ++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 77b9529ae689a..811342232699c 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -18091,9 +18091,19 @@ func getNodeListKey(nodes []*ast.Node) CacheHashKey { } func isTypeReferenceWithGenericArguments(t *Type) bool { - return isNonDeferredTypeReference(t) && core.Some(t.checker.getTypeArguments(t), func(t *Type) bool { - return t.flags&TypeFlagsTypeParameter != 0 || isTypeReferenceWithGenericArguments(t) - }) + if !isNonDeferredTypeReference(t) { + return false + } + // The type arguments of a non-deferred type reference are fixed at creation, so the answer never changes. + if t.cacheFlags&typeCacheHasGenericArgumentsComputed == 0 { + if core.Some(t.checker.getTypeArguments(t), func(t *Type) bool { + return t.flags&TypeFlagsTypeParameter != 0 || isTypeReferenceWithGenericArguments(t) + }) { + t.cacheFlags |= typeCacheHasGenericArguments + } + t.cacheFlags |= typeCacheHasGenericArgumentsComputed + } + return t.cacheFlags&typeCacheHasGenericArguments != 0 } func isNonDeferredTypeReference(t *Type) bool { diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index fc27f06f27440..233cc6a1af824 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -788,12 +788,14 @@ const ( type typeCacheFlags uint8 const ( - typeCacheResolvingMembers typeCacheFlags = 1 << iota // Structured type members are being resolved - typeCacheShouldNormalizeComputed // shouldNormalizeIntersection result has been computed - typeCacheShouldNormalize // shouldNormalizeIntersection result - typeCacheNonNullableUnion // Union of only definitely non-nullable, non-instantiable types - typeCacheIsWeakComputed // isWeakType result has been computed - typeCacheIsWeak // isWeakType result + typeCacheResolvingMembers typeCacheFlags = 1 << iota // Structured type members are being resolved + typeCacheShouldNormalizeComputed // shouldNormalizeIntersection result has been computed + typeCacheShouldNormalize // shouldNormalizeIntersection result + typeCacheNonNullableUnion // Union of only definitely non-nullable, non-instantiable types + typeCacheIsWeakComputed // isWeakType result has been computed + typeCacheIsWeak // isWeakType result + typeCacheHasGenericArgumentsComputed // isTypeReferenceWithGenericArguments result has been computed + typeCacheHasGenericArguments // isTypeReferenceWithGenericArguments result ) type Type struct { From f51d05ecdee55683587e0c97c6202b1260cd1f81 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:15:45 -0700 Subject: [PATCH 5/5] Skip the cache lookup for references without an equivalent base getSingleBaseForNonAugmentingSubtype is called for every pair of type references during relation checks. Once computed, the common answer is that there is no single equivalent base type, yet each call still did a map lookup to find that out. Set IdenticalBaseTypeExists when a base is actually cached, as Strada does, so a computed reference without one returns immediately and only the positive case consults the map. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 811342232699c..df50526cd02c5 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -28908,6 +28908,9 @@ func (c *Checker) getSingleBaseForNonAugmentingSubtype(t *Type) *Type { } key := CachedTypeKey{kind: CachedTypeKindEquivalentBaseType, typeId: t.id} if t.objectFlags&ObjectFlagsIdenticalBaseTypeCalculated != 0 { + if t.objectFlags&ObjectFlagsIdenticalBaseTypeExists == 0 { + return nil + } return c.cachedTypes[key] } t.objectFlags |= ObjectFlagsIdenticalBaseTypeCalculated @@ -28938,6 +28941,7 @@ func (c *Checker) getSingleBaseForNonAugmentingSubtype(t *Type) *Type { if len(c.getTypeArguments(t)) > len(typeParameters) { instantiatedBase = c.getTypeWithThisArgument(instantiatedBase, core.LastOrNil(c.getTypeArguments(t)), false) } + t.objectFlags |= ObjectFlagsIdenticalBaseTypeExists c.cachedTypes[key] = instantiatedBase return instantiatedBase }