From f37a553e8068292cb07ebff92a30988043f342c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 23 Oct 2026 12:21:31 +0200 Subject: [PATCH 1/2] Skip discriminant lookups for unions that cannot be narrowed --- tsc/internal/checker/checker.go | 3 +++ tsc/internal/checker/jsx.go | 3 +++ tsc/internal/checker/relater.go | 20 +++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index 10ddb3c0c88b4..a1938d23397d6 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -31397,6 +31397,9 @@ func (d *ObjectLiteralDiscriminator) matches(index int, t *Type) bool { } func (c *Checker) discriminateContextualTypeByObjectMembers(node *ast.Node, contextualType *Type) *Type { + if !isDiscriminableUnion(contextualType) { + return contextualType + } key := DiscriminatedContextualTypeKey{nodeId: ast.GetNodeId(node), typeId: contextualType.id} if discriminated := c.discriminatedContextualTypes[key]; discriminated != nil { return discriminated diff --git a/tsc/internal/checker/jsx.go b/tsc/internal/checker/jsx.go index 7f35f796957cf..cb56335d67a5e 100644 --- a/tsc/internal/checker/jsx.go +++ b/tsc/internal/checker/jsx.go @@ -264,6 +264,9 @@ func (c *Checker) getContextualTypeForChildJsxExpression(node *ast.Node, child * } func (c *Checker) discriminateContextualTypeByJSXAttributes(node *ast.Node, contextualType *Type) *Type { + if !isDiscriminableUnion(contextualType) { + return contextualType + } key := DiscriminatedContextualTypeKey{nodeId: ast.GetNodeId(node), typeId: contextualType.id} if discriminated := c.discriminatedContextualTypes[key]; discriminated != nil { return discriminated diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index d3b6c86014498..20201a3e60432 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1051,9 +1051,27 @@ func (d *TypeDiscriminator) matches(index int, t *Type) bool { return false } +// A union can only be narrowed by discriminants when it has at least two non-primitive constituents to +// tell apart. This rules out the very common `T | undefined` shaped unions before any property work. +func isDiscriminableUnion(t *Type) bool { + if t.objectFlags&ObjectFlagsPrimitiveUnion != 0 { + return false + } + count := 0 + for _, s := range t.Types() { + if s.flags&TypeFlagsPrimitive == 0 { + count++ + if count == 2 { + return true + } + } + } + return false +} + // Keep this up-to-date with the same logic within `getApparentTypeOfContextualType`, since they should behave similarly func (c *Checker) findMatchingDiscriminantType(source *Type, target *Type, isRelatedTo func(source *Type, target *Type) Ternary) *Type { - if target.flags&TypeFlagsUnion != 0 && source.flags&(TypeFlagsIntersection|TypeFlagsObject) != 0 { + if target.flags&TypeFlagsUnion != 0 && source.flags&(TypeFlagsIntersection|TypeFlagsObject) != 0 && isDiscriminableUnion(target) { if match := c.getMatchingUnionConstituentForType(target, source); match != nil { return match } From b665e48de2185c762a27a68daff3a1049bbd1948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 23 Oct 2026 13:13:46 +0200 Subject: [PATCH 2/2] Keep dropping primitive constituents when contextual discrimination is skipped --- tsc/internal/checker/checker.go | 2 +- tsc/internal/checker/jsx.go | 2 +- tsc/internal/checker/relater.go | 17 ++++++ ...ropertyInNullableContextualType.errors.txt | 39 +++++++++++++ ...edPropertyInNullableContextualType.symbols | 58 +++++++++++++++++++ ...atedPropertyInNullableContextualType.types | 53 +++++++++++++++++ ...recatedPropertyInNullableContextualType.ts | 25 ++++++++ 7 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.errors.txt create mode 100644 tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.symbols create mode 100644 tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.types create mode 100644 tsc/testdata/tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts diff --git a/tsc/internal/checker/checker.go b/tsc/internal/checker/checker.go index a1938d23397d6..81d398f0552c1 100644 --- a/tsc/internal/checker/checker.go +++ b/tsc/internal/checker/checker.go @@ -31398,7 +31398,7 @@ func (d *ObjectLiteralDiscriminator) matches(index int, t *Type) bool { func (c *Checker) discriminateContextualTypeByObjectMembers(node *ast.Node, contextualType *Type) *Type { if !isDiscriminableUnion(contextualType) { - return contextualType + return c.removeNonDiscriminableConstituents(contextualType) } key := DiscriminatedContextualTypeKey{nodeId: ast.GetNodeId(node), typeId: contextualType.id} if discriminated := c.discriminatedContextualTypes[key]; discriminated != nil { diff --git a/tsc/internal/checker/jsx.go b/tsc/internal/checker/jsx.go index cb56335d67a5e..639a6f95bfb04 100644 --- a/tsc/internal/checker/jsx.go +++ b/tsc/internal/checker/jsx.go @@ -265,7 +265,7 @@ func (c *Checker) getContextualTypeForChildJsxExpression(node *ast.Node, child * func (c *Checker) discriminateContextualTypeByJSXAttributes(node *ast.Node, contextualType *Type) *Type { if !isDiscriminableUnion(contextualType) { - return contextualType + return c.removeNonDiscriminableConstituents(contextualType) } key := DiscriminatedContextualTypeKey{nodeId: ast.GetNodeId(node), typeId: contextualType.id} if discriminated := c.discriminatedContextualTypes[key]; discriminated != nil { diff --git a/tsc/internal/checker/relater.go b/tsc/internal/checker/relater.go index 20201a3e60432..b6ae4a921cce3 100644 --- a/tsc/internal/checker/relater.go +++ b/tsc/internal/checker/relater.go @@ -1069,6 +1069,23 @@ func isDiscriminableUnion(t *Type) bool { return false } +// Discriminating a union always drops its primitive and never constituents, even when there are no +// discriminants to go by. Callers that skip the discrimination for unions that cannot be narrowed still +// rely on that, for instance when looking up a contextual property, so this reproduces just that part. +func (c *Checker) removeNonDiscriminableConstituents(t *Type) *Type { + types := t.Types() + filtered := make([]*Type, 0, len(types)) + for _, s := range types { + if s.flags&TypeFlagsPrimitive == 0 && c.getReducedType(s).flags&TypeFlagsNever == 0 { + filtered = append(filtered, s) + } + } + if len(filtered) == 0 || len(filtered) == len(types) { + return t + } + return c.getUnionTypeEx(filtered, UnionReductionNone, nil, nil) +} + // Keep this up-to-date with the same logic within `getApparentTypeOfContextualType`, since they should behave similarly func (c *Checker) findMatchingDiscriminantType(source *Type, target *Type, isRelatedTo func(source *Type, target *Type) Ternary) *Type { if target.flags&TypeFlagsUnion != 0 && source.flags&(TypeFlagsIntersection|TypeFlagsObject) != 0 && isDiscriminableUnion(target) { diff --git a/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.errors.txt b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.errors.txt new file mode 100644 index 0000000000000..981a5245f6ccd --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.errors.txt @@ -0,0 +1,39 @@ +jsxAttributes.tsx(10,35): suggestion TS6385: 'value' is deprecated. +jsxAttributes.tsx(11,29): suggestion TS6385: 'value' is deprecated. +objectLiteral.ts(5,41): suggestion TS6385: 'value' is deprecated. +objectLiteral.ts(6,26): suggestion TS6385: 'value' is deprecated. + + +==== objectLiteral.ts (2 errors) ==== + interface Options { + /** @deprecated use other */ + value: number; + } + const nullable: Options | undefined = { value: 1 }; + ~~~~~ +!!! suggestion TS6385: 'value' is deprecated. +!!! related TS2798 objectLiteral.ts:2:9: The declaration was marked as deprecated here. + const plain: Options = { value: 1 }; + ~~~~~ +!!! suggestion TS6385: 'value' is deprecated. +!!! related TS2798 objectLiteral.ts:2:9: The declaration was marked as deprecated here. + +==== jsxAttributes.tsx (2 errors) ==== + declare namespace JSX { + interface Element {} + } + interface Props { + /** @deprecated use other */ + value: number; + } + declare function Nullable(props: Props | undefined): JSX.Element; + declare function Plain(props: Props): JSX.Element; + const nullableElement = ; + ~~~~~ +!!! suggestion TS6385: 'value' is deprecated. +!!! related TS2798 jsxAttributes.tsx:5:9: The declaration was marked as deprecated here. + const plainElement = ; + ~~~~~ +!!! suggestion TS6385: 'value' is deprecated. +!!! related TS2798 jsxAttributes.tsx:5:9: The declaration was marked as deprecated here. + \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.symbols b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.symbols new file mode 100644 index 0000000000000..8b49d638e2213 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.symbols @@ -0,0 +1,58 @@ +//// [tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts] //// + +=== objectLiteral.ts === +interface Options { +>Options : Symbol(Options, Decl(objectLiteral.ts, 0, 0)) + + /** @deprecated use other */ + value: number; +>value : Symbol(Options.value, Decl(objectLiteral.ts, 0, 19)) +} +const nullable: Options | undefined = { value: 1 }; +>nullable : Symbol(nullable, Decl(objectLiteral.ts, 4, 5)) +>Options : Symbol(Options, Decl(objectLiteral.ts, 0, 0)) +>value : Symbol(value, Decl(objectLiteral.ts, 4, 39)) + +const plain: Options = { value: 1 }; +>plain : Symbol(plain, Decl(objectLiteral.ts, 5, 5)) +>Options : Symbol(Options, Decl(objectLiteral.ts, 0, 0)) +>value : Symbol(value, Decl(objectLiteral.ts, 5, 24)) + +=== jsxAttributes.tsx === +declare namespace JSX { +>JSX : Symbol(JSX, Decl(jsxAttributes.tsx, 0, 0)) + + interface Element {} +>Element : Symbol(Element, Decl(jsxAttributes.tsx, 0, 23)) +} +interface Props { +>Props : Symbol(Props, Decl(jsxAttributes.tsx, 2, 1)) + + /** @deprecated use other */ + value: number; +>value : Symbol(Props.value, Decl(jsxAttributes.tsx, 3, 17)) +} +declare function Nullable(props: Props | undefined): JSX.Element; +>Nullable : Symbol(Nullable, Decl(jsxAttributes.tsx, 6, 1)) +>props : Symbol(props, Decl(jsxAttributes.tsx, 7, 26)) +>Props : Symbol(Props, Decl(jsxAttributes.tsx, 2, 1)) +>JSX : Symbol(JSX, Decl(jsxAttributes.tsx, 0, 0)) +>Element : Symbol(JSX.Element, Decl(jsxAttributes.tsx, 0, 23)) + +declare function Plain(props: Props): JSX.Element; +>Plain : Symbol(Plain, Decl(jsxAttributes.tsx, 7, 65)) +>props : Symbol(props, Decl(jsxAttributes.tsx, 8, 23)) +>Props : Symbol(Props, Decl(jsxAttributes.tsx, 2, 1)) +>JSX : Symbol(JSX, Decl(jsxAttributes.tsx, 0, 0)) +>Element : Symbol(JSX.Element, Decl(jsxAttributes.tsx, 0, 23)) + +const nullableElement = ; +>nullableElement : Symbol(nullableElement, Decl(jsxAttributes.tsx, 9, 5)) +>Nullable : Symbol(Nullable, Decl(jsxAttributes.tsx, 6, 1)) +>value : Symbol(value, Decl(jsxAttributes.tsx, 9, 33)) + +const plainElement = ; +>plainElement : Symbol(plainElement, Decl(jsxAttributes.tsx, 10, 5)) +>Plain : Symbol(Plain, Decl(jsxAttributes.tsx, 7, 65)) +>value : Symbol(value, Decl(jsxAttributes.tsx, 10, 27)) + diff --git a/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.types b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.types new file mode 100644 index 0000000000000..c43488ce07940 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/deprecatedPropertyInNullableContextualType.types @@ -0,0 +1,53 @@ +//// [tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts] //// + +=== objectLiteral.ts === +interface Options { + /** @deprecated use other */ + value: number; +>value : number +} +const nullable: Options | undefined = { value: 1 }; +>nullable : Options | undefined +>{ value: 1 } : { value: number; } +>value : number +>1 : 1 + +const plain: Options = { value: 1 }; +>plain : Options +>{ value: 1 } : { value: number; } +>value : number +>1 : 1 + +=== jsxAttributes.tsx === +declare namespace JSX { + interface Element {} +} +interface Props { + /** @deprecated use other */ + value: number; +>value : number +} +declare function Nullable(props: Props | undefined): JSX.Element; +>Nullable : (props: Props | undefined) => JSX.Element +>props : Props | undefined +>JSX : any + +declare function Plain(props: Props): JSX.Element; +>Plain : (props: Props) => JSX.Element +>props : Props +>JSX : any + +const nullableElement = ; +>nullableElement : JSX.Element +> : JSX.Element +>Nullable : (props: Props | undefined) => JSX.Element +>value : number +>1 : 1 + +const plainElement = ; +>plainElement : JSX.Element +> : JSX.Element +>Plain : (props: Props) => JSX.Element +>value : number +>1 : 1 + diff --git a/tsc/testdata/tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts b/tsc/testdata/tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts new file mode 100644 index 0000000000000..f4a21011b0c7b --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/deprecatedPropertyInNullableContextualType.ts @@ -0,0 +1,25 @@ +// @strict: true +// @jsx: preserve +// @captureSuggestions: true +// @noEmit: true + +// @filename: objectLiteral.ts +interface Options { + /** @deprecated use other */ + value: number; +} +const nullable: Options | undefined = { value: 1 }; +const plain: Options = { value: 1 }; + +// @filename: jsxAttributes.tsx +declare namespace JSX { + interface Element {} +} +interface Props { + /** @deprecated use other */ + value: number; +} +declare function Nullable(props: Props | undefined): JSX.Element; +declare function Plain(props: Props): JSX.Element; +const nullableElement = ; +const plainElement = ;