From 921b4eae40b77210d47195a100ca8ff77363e926 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:35:54 -0700 Subject: [PATCH 1/5] Exit instantiation early for variable-free types Many types passed to instantiation cannot contain type variables, and whether they can is already cached in their object flags. Check that cached bit inline at the top of instantiateTypeWithAlias so the common "nothing to do" case doesn't go through an indirect call. getPermissiveInstantiation and getRestrictiveInstantiation previously did a map lookup and one or two map inserts for every non-primitive type, even though instantiating a variable-free type always yields the type itself. Return such types directly instead. The restrictive self-entry is only consulted for conditional types, which are never variable-free, so skipping it is unobservable. instantiateSymbol unconditionally fetched the symbol's value links, allocating them for symbols whose types were never resolved just to find them empty. Only look the links up, and check the cheap this-only mapper case first. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 62 ++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 086779ceb115a..dfa5e8e3ebcb2 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -21338,30 +21338,32 @@ func (c *Checker) instantiateSymbolTable(symbols ast.SymbolTable, m *TypeMapper) } func (c *Checker) instantiateSymbol(symbol *ast.Symbol, m *TypeMapper) *ast.Symbol { - if symbol == nil || m == nil { + if symbol == nil || m == nil || m.MapsThisOnly() && isThisless(symbol) { return symbol } - links := c.valueSymbolLinks.Get(symbol) - if m.MapsThisOnly() && isThisless(symbol) { - return symbol - } - // If the type of the symbol is already resolved, and if that type could not possibly - // be affected by instantiation, simply return the symbol itself. - if links.resolvedType != nil && !c.couldContainTypeVariables(links.resolvedType) { - if symbol.Flags()&ast.SymbolFlagsSetAccessor == 0 { - return symbol + // Use TryGet to avoid allocating links for a symbol whose type has never been resolved. + // Instantiated symbols always have links. + var nameType *Type + if links := c.valueSymbolLinks.TryGet(symbol); links != nil { + // If the type of the symbol is already resolved, and if that type could not possibly + // be affected by instantiation, simply return the symbol itself. + if links.resolvedType != nil && !c.couldContainTypeVariables(links.resolvedType) { + if symbol.Flags()&ast.SymbolFlagsSetAccessor == 0 { + return symbol + } + // If we're a setter, check writeType. + if links.getWriteType() != nil && !c.couldContainTypeVariables(links.getWriteType()) { + return symbol + } } - // If we're a setter, check writeType. - if links.getWriteType() != nil && !c.couldContainTypeVariables(links.getWriteType()) { - return symbol + if symbol.CheckFlags()&ast.CheckFlagsInstantiated != 0 { + // If symbol being instantiated is itself a instantiation, fetch the original target and combine the + // type mappers. This ensures that original type identities are properly preserved and that aliases + // always reference a non-aliases. + symbol = links.target + m = c.combineTypeMappers(links.mapper, m) } - } - if symbol.CheckFlags()&ast.CheckFlagsInstantiated != 0 { - // If symbol being instantiated is itself a instantiation, fetch the original target and combine the - // type mappers. This ensures that original type identities are properly preserved and that aliases - // always reference a non-aliases. - symbol = links.target - m = c.combineTypeMappers(links.mapper, m) + nameType = links.getNameType() } // Create a new transient symbol that shares the underlying data with the original symbol. result := c.newSharedDataSymbol(symbol) @@ -21369,7 +21371,7 @@ func (c *Checker) instantiateSymbol(symbol *ast.Symbol, m *TypeMapper) *ast.Symb resultLinks := c.valueSymbolLinks.Get(result) resultLinks.target = symbol resultLinks.mapper = m - resultLinks.setNameType(c, links.getNameType()) + resultLinks.setNameType(c, nameType) return result } @@ -22754,7 +22756,7 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl // Check for type variables in the alias, so things like `type Brand = number & {}` can potentially be copied with new alias type args, despite them being unreferenced. // This is the behavior most people using aliases expect, and prevents the cache from leaking type parameters outside their scope of validity. // tests/cases/compiler/declarationEmitArrowFunctionNoRenaming.ts contains an example of this, which previously only worked in strada via some input node reuse logic instead. - if t == nil || m == nil || !(c.couldContainTypeVariables(t) || (t.alias != nil && len(t.alias.typeArguments) > 0 && core.Some(t.alias.typeArguments, c.couldContainTypeVariables))) { + if t == nil || m == nil || c.isTypeVariableFree(t) { return t } if len(c.instantiationStack) == 100 || c.instantiationCount >= 5_000_000 { @@ -22801,6 +22803,18 @@ func (c *Checker) instantiateTypeWithAlias(t *Type, m *TypeMapper, alias *TypeAl return result } +// Returns true if no instantiation of t can differ from t itself, because neither t nor its alias +// type arguments could contain type variables. The result is cached on the types involved. +func (c *Checker) isTypeVariableFree(t *Type) bool { + // Check the cached result inline before falling back to couldContainTypeVariables, as this is on a hot path. + knownFree := t.flags&TypeFlagsStructuredOrInstantiable == 0 || + t.objectFlags&(ObjectFlagsCouldContainTypeVariablesComputed|ObjectFlagsCouldContainTypeVariables) == ObjectFlagsCouldContainTypeVariablesComputed + if !knownFree && c.couldContainTypeVariables(t) { + return false + } + return t.alias == nil || !core.Some(t.alias.typeArguments, c.couldContainTypeVariables) +} + func (c *Checker) getCircularTypeNames() []string { typeCounts := make(map[*Type]int) var circularTypeNames []string @@ -25201,7 +25215,7 @@ func (c *Checker) isDeferredType(t *Type, checkTuples bool) bool { } func (c *Checker) getPermissiveInstantiation(t *Type) *Type { - if t.flags&(TypeFlagsPrimitive|TypeFlagsAnyOrUnknown|TypeFlagsNever) != 0 { + if t.flags&(TypeFlagsPrimitive|TypeFlagsAnyOrUnknown|TypeFlagsNever) != 0 || c.isTypeVariableFree(t) { return t } key := CachedTypeKey{kind: CachedTypeKindPermissiveInstantiation, typeId: t.id} @@ -25214,7 +25228,7 @@ func (c *Checker) getPermissiveInstantiation(t *Type) *Type { } func (c *Checker) getRestrictiveInstantiation(t *Type) *Type { - if t.flags&(TypeFlagsPrimitive|TypeFlagsAnyOrUnknown|TypeFlagsNever) != 0 { + if t.flags&(TypeFlagsPrimitive|TypeFlagsAnyOrUnknown|TypeFlagsNever) != 0 || c.isTypeVariableFree(t) { return t } key := CachedTypeKey{kind: CachedTypeKindRestrictiveInstantiation, typeId: t.id} From 1317b0ed6368bb0b42c73610bb09cfc2386708ff Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:38:51 -0700 Subject: [PATCH 2/5] Reuse type alias when instantiation leaves it unchanged instantiateTypeAlias allocated a new TypeAlias on every call, even though instantiateTypes already returns the original argument slice when no argument changes. This happens frequently, e.g. for aliases without type arguments or whose arguments are unaffected by the mapper, and the result is passed along on most object, union and indexed access instantiations. Return the existing alias in that case, extending the copy-on-change behavior of list instantiation to the alias itself. Aliases are never mutated after being attached to a type, and are compared by content rather than identity, so sharing them is safe. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index dfa5e8e3ebcb2..38fac14a88009 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -23453,7 +23453,11 @@ func (c *Checker) instantiateTypeAlias(alias *TypeAlias, m *TypeMapper) *TypeAli if alias == nil { return nil } - return &TypeAlias{symbol: alias.symbol, typeArguments: c.instantiateTypes(alias.typeArguments, m)} + typeArguments := c.instantiateTypes(alias.typeArguments, m) + if core.Same(typeArguments, alias.typeArguments) { + return alias + } + return &TypeAlias{symbol: alias.symbol, typeArguments: typeArguments} } func (c *Checker) instantiateTypes(types []*Type, m *TypeMapper) []*Type { From 31ec24464db322305284744234564ce27a5b5d88 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:05:35 -0700 Subject: [PATCH 3/5] Check instantiation caches before copying type arguments When instantiating an anonymous, mapped or deferred object type, and when instantiating a type reference, we computed the new type arguments into a freshly allocated slice, then used them to look up an existing instantiation. Most of these lookups hit, at which point the slice was simply discarded. Compute the type arguments into a stack buffer instead, look up the cache with it, and only copy the arguments to the heap when a new instantiation is actually created. For type references that need no tuple normalization, the cache key computed for the lookup is reused when creating the reference. Co-authored-by: Steven Chong <25894545+teamchong@users.noreply.github.com> --- tsc/internal/checker/checker.go | 62 ++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 38fac14a88009..76c6e20aee213 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -22918,12 +22918,7 @@ func (c *Checker) instantiateTypeWorker(t *Type, m *TypeMapper, alias *TypeAlias objectFlags := t.objectFlags if objectFlags&(ObjectFlagsReference|ObjectFlagsAnonymous|ObjectFlagsMapped) != 0 { if objectFlags&ObjectFlagsReference != 0 && t.AsTypeReference().node == nil { - resolvedTypeArguments := t.AsTypeReference().resolvedTypeArguments - newTypeArguments := c.instantiateTypes(resolvedTypeArguments, m) - if core.Same(newTypeArguments, resolvedTypeArguments) { - return t - } - return c.createNormalizedTypeReference(t.Target(), newTypeArguments) + return c.instantiateTypeReference(t, m) } if objectFlags&ObjectFlagsReverseMapped != 0 { return c.instantiateReverseMappedType(t, m) @@ -22988,6 +22983,27 @@ func (c *Checker) instantiateTypeWorker(t *Type, m *TypeMapper, alias *TypeAlias return t } +func (c *Checker) instantiateTypeReference(t *Type, m *TypeMapper) *Type { + resolvedTypeArguments := t.AsTypeReference().resolvedTypeArguments + // Instantiate the type arguments into a temporary buffer, and only copy them once we know + // they're needed for a new type reference. + var buf [8]*Type + newTypeArguments := c.instantiateListInto(resolvedTypeArguments, m, (*Checker).instantiateType, buf[:]) + if core.Same(newTypeArguments, resolvedTypeArguments) { + return t + } + target := t.Target() + if target.objectFlags&ObjectFlagsTuple == 0 || target.AsTupleType().combinedFlags&ElementFlagsNonRequired == 0 { + // No normalization is needed, so check for an existing instantiation first. + id := getTypeListKey(newTypeArguments) + if existing, ok := target.AsInterfaceType().instantiations[id]; ok { + return existing + } + return c.newTypeReferenceInstantiation(target, id, slices.Clone(newTypeArguments), ObjectFlagsNone) + } + return c.createNormalizedTypeReference(target, slices.Clone(newTypeArguments)) +} + // Handles instantiation of the following object types: // AnonymousType (ObjectFlagsAnonymous|ObjectFlagsSingleSignatureType) // TypeReference with node != nil (ObjectFlagsReference) @@ -23044,8 +23060,15 @@ func (c *Checker) getObjectTypeInstantiation(t *Type, m *TypeMapper, alias *Type } // We are instantiating an anonymous type that has one or more type parameters in scope. Apply the // mapper to the type parameters to produce the effective list of type arguments, and compute the - // instantiation cache key from the type IDs of the type arguments. - typeArguments := make([]*Type, len(typeParameters)) + // instantiation cache key from the type IDs of the type arguments. The type arguments are computed + // into a temporary buffer and only copied when creating a new instantiation. + var buf [8]*Type + var typeArguments []*Type + if len(typeParameters) <= len(buf) { + typeArguments = buf[:len(typeParameters)] + } else { + typeArguments = make([]*Type, len(typeParameters)) + } for i, tp := range typeParameters { typeArguments[i] = c.mapTypeWithCompositeMapper(tp, t.Mapper(), m) } @@ -23061,7 +23084,7 @@ func (c *Checker) getObjectTypeInstantiation(t *Type, m *TypeMapper, alias *Type } result := data.instantiations[key] if result == nil { - newMapper := newTypeMapper(typeParameters, typeArguments) + newMapper := newTypeMapper(typeParameters, slices.Clone(typeArguments)) if target.objectFlags&ObjectFlagsSingleSignatureType != 0 && m != nil { newMapper = c.combineTypeMappers(newMapper, m) } @@ -23477,10 +23500,21 @@ func (c *Checker) instantiateIndexInfos(indexInfos []*IndexInfo, m *TypeMapper) } func (c *Checker) instantiateList[T comparable](values []T, m *TypeMapper, instantiator func(c *Checker, value T, m *TypeMapper) T) []T { + return c.instantiateListInto(values, m, instantiator, nil) +} + +// Like instantiateList, but if any value changes, the result is written into buf when it has +// sufficient capacity. Callers must copy such a result before retaining it. +func (c *Checker) instantiateListInto[T comparable](values []T, m *TypeMapper, instantiator func(c *Checker, value T, m *TypeMapper) T, buf []T) []T { for i, value := range values { mapped := instantiator(c, value, m) if mapped != value { - result := make([]T, len(values)) + var result []T + if cap(buf) >= len(values) { + result = buf[:len(values)] + } else { + result = make([]T, len(values)) + } copy(result, values[:i]) result[i] = mapped for j := i + 1; j < len(values); j++ { @@ -25856,10 +25890,14 @@ func (c *Checker) createTypeReference(target *Type, typeArguments []*Type) *Type func (c *Checker) createTypeReferenceEx(target *Type, typeArguments []*Type, objectFlags ObjectFlags) *Type { id := getTypeListKey(typeArguments) - intf := target.AsInterfaceType() - if t, ok := intf.instantiations[id]; ok { + if t, ok := target.AsInterfaceType().instantiations[id]; ok { return t } + return c.newTypeReferenceInstantiation(target, id, typeArguments, objectFlags) +} + +func (c *Checker) newTypeReferenceInstantiation(target *Type, id CacheHashKey, typeArguments []*Type, objectFlags ObjectFlags) *Type { + intf := target.AsInterfaceType() t := c.newObjectType(ObjectFlagsReference|objectFlags|c.getPropagatingFlagsOfTypes(typeArguments, TypeFlagsNone), target.symbol) d := t.AsTypeReference() d.target = target From bb49b5fe37228fe78396336033831306e0f1be88 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:46:52 -0700 Subject: [PATCH 4/5] Copy type arguments only when they live in the stack buffer When more type arguments are instantiated than fit in the stack buffer, the list is already freshly allocated and owned by the caller. Cloning it again before creating a new instantiation added a second allocation and copy on every cache miss, making these cases more expensive than before the buffer was introduced. Only clone arguments that were written into the buffer, and pass heap-allocated lists through as-is. --- tsc/internal/checker/checker.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 76c6e20aee213..ddda22ac57190 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -22993,15 +22993,23 @@ func (c *Checker) instantiateTypeReference(t *Type, m *TypeMapper) *Type { return t } target := t.Target() - if target.objectFlags&ObjectFlagsTuple == 0 || target.AsTupleType().combinedFlags&ElementFlagsNonRequired == 0 { + normalize := target.objectFlags&ObjectFlagsTuple != 0 && target.AsTupleType().combinedFlags&ElementFlagsNonRequired != 0 + var id CacheHashKey + if !normalize { // No normalization is needed, so check for an existing instantiation first. - id := getTypeListKey(newTypeArguments) + id = getTypeListKey(newTypeArguments) if existing, ok := target.AsInterfaceType().instantiations[id]; ok { return existing } - return c.newTypeReferenceInstantiation(target, id, slices.Clone(newTypeArguments), ObjectFlagsNone) } - return c.createNormalizedTypeReference(target, slices.Clone(newTypeArguments)) + if len(newTypeArguments) <= len(buf) { + // Arguments written into buf must be copied before they are retained. + newTypeArguments = slices.Clone(newTypeArguments) + } + if normalize { + return c.createNormalizedTypeReference(target, newTypeArguments) + } + return c.newTypeReferenceInstantiation(target, id, newTypeArguments, ObjectFlagsNone) } // Handles instantiation of the following object types: @@ -23084,7 +23092,11 @@ func (c *Checker) getObjectTypeInstantiation(t *Type, m *TypeMapper, alias *Type } result := data.instantiations[key] if result == nil { - newMapper := newTypeMapper(typeParameters, slices.Clone(typeArguments)) + if len(typeParameters) <= len(buf) { + // Arguments written into buf must be copied before the mapper retains them. + typeArguments = slices.Clone(typeArguments) + } + newMapper := newTypeMapper(typeParameters, typeArguments) if target.objectFlags&ObjectFlagsSingleSignatureType != 0 && m != nil { newMapper = c.combineTypeMappers(newMapper, m) } From 1f7c036f216501026e0d65a665f68a2944d06bf0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:10:20 -0700 Subject: [PATCH 5/5] Keep instantiation argument buffers from escaping Escape analysis does not consider control flow. Because the variable that could point into the stack buffer was also handed to code that retains it, the buffer was moved to the heap on every call, including the unchanged and cache-hit paths that were meant to be allocation free. Keep retained arguments in a separate variable that is only assigned from a fresh allocation or a copy, so the buffer never flows into retained storage and can stay on the stack. --- tsc/internal/checker/checker.go | 37 ++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index ddda22ac57190..fe9b9eac6ccab 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -22985,10 +22985,18 @@ func (c *Checker) instantiateTypeWorker(t *Type, m *TypeMapper, alias *TypeAlias func (c *Checker) instantiateTypeReference(t *Type, m *TypeMapper) *Type { resolvedTypeArguments := t.AsTypeReference().resolvedTypeArguments - // Instantiate the type arguments into a temporary buffer, and only copy them once we know - // they're needed for a new type reference. + // Instantiate small argument lists into a temporary buffer, and only copy them once we know + // they're needed for a new type reference. Retained arguments are kept in a separate variable + // that never aliases buf, so that buf does not escape to the heap. var buf [8]*Type - newTypeArguments := c.instantiateListInto(resolvedTypeArguments, m, (*Checker).instantiateType, buf[:]) + var ownedTypeArguments []*Type + var newTypeArguments []*Type + if len(resolvedTypeArguments) <= len(buf) { + newTypeArguments = c.instantiateListInto(resolvedTypeArguments, m, (*Checker).instantiateType, buf[:]) + } else { + ownedTypeArguments = c.instantiateList(resolvedTypeArguments, m, (*Checker).instantiateType) + newTypeArguments = ownedTypeArguments + } if core.Same(newTypeArguments, resolvedTypeArguments) { return t } @@ -23002,14 +23010,13 @@ func (c *Checker) instantiateTypeReference(t *Type, m *TypeMapper) *Type { return existing } } - if len(newTypeArguments) <= len(buf) { - // Arguments written into buf must be copied before they are retained. - newTypeArguments = slices.Clone(newTypeArguments) + if ownedTypeArguments == nil { + ownedTypeArguments = slices.Clone(newTypeArguments) } if normalize { - return c.createNormalizedTypeReference(target, newTypeArguments) + return c.createNormalizedTypeReference(target, ownedTypeArguments) } - return c.newTypeReferenceInstantiation(target, id, newTypeArguments, ObjectFlagsNone) + return c.newTypeReferenceInstantiation(target, id, ownedTypeArguments, ObjectFlagsNone) } // Handles instantiation of the following object types: @@ -23069,13 +23076,16 @@ func (c *Checker) getObjectTypeInstantiation(t *Type, m *TypeMapper, alias *Type // We are instantiating an anonymous type that has one or more type parameters in scope. Apply the // mapper to the type parameters to produce the effective list of type arguments, and compute the // instantiation cache key from the type IDs of the type arguments. The type arguments are computed - // into a temporary buffer and only copied when creating a new instantiation. + // into a temporary buffer and only copied when creating a new instantiation. Retained arguments + // are kept in a separate variable that never aliases buf, so that buf does not escape to the heap. var buf [8]*Type + var ownedTypeArguments []*Type var typeArguments []*Type if len(typeParameters) <= len(buf) { typeArguments = buf[:len(typeParameters)] } else { - typeArguments = make([]*Type, len(typeParameters)) + ownedTypeArguments = make([]*Type, len(typeParameters)) + typeArguments = ownedTypeArguments } for i, tp := range typeParameters { typeArguments[i] = c.mapTypeWithCompositeMapper(tp, t.Mapper(), m) @@ -23092,11 +23102,10 @@ func (c *Checker) getObjectTypeInstantiation(t *Type, m *TypeMapper, alias *Type } result := data.instantiations[key] if result == nil { - if len(typeParameters) <= len(buf) { - // Arguments written into buf must be copied before the mapper retains them. - typeArguments = slices.Clone(typeArguments) + if ownedTypeArguments == nil { + ownedTypeArguments = slices.Clone(typeArguments) } - newMapper := newTypeMapper(typeParameters, typeArguments) + newMapper := newTypeMapper(typeParameters, ownedTypeArguments) if target.objectFlags&ObjectFlagsSingleSignatureType != 0 && m != nil { newMapper = c.combineTypeMappers(newMapper, m) }