From 4008ed1034aa8f1555f1569bd181c25da7ec9b5c Mon Sep 17 00:00:00 2001 From: timurzvural <162848950+timurzvural@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:22:55 +0000 Subject: [PATCH 1/2] Fix new-expression emit parentheses after type erasure (#64981) Parenthesize new-expression operands whose leftmost expression is a call, including after as/satisfies/non-null erasure and through parentheses, so `new (f() as T).C()` emits as `new (f().C)()`. AI assistance disclosure: this change was authored with Cursor Agent. --- tsc/internal/ast/precedence.go | 1 + tsc/internal/printer/printer.go | 9 +++-- tsc/internal/printer/printer_test.go | 34 +++++++++++++++++++ .../tstransforms/typeeraser_test.go | 9 +++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/tsc/internal/ast/precedence.go b/tsc/internal/ast/precedence.go index bfaaa679b63af..c159cbe365d77 100644 --- a/tsc/internal/ast/precedence.go +++ b/tsc/internal/ast/precedence.go @@ -392,6 +392,7 @@ func GetLeftmostExpression(node *Expression, stopAtCallExpressions bool) *Expres KindPropertyAccessExpression, KindNonNullExpression, KindPartiallyEmittedExpression, + KindParenthesizedExpression, KindSatisfiesExpression: node = node.Expression() continue diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index 46bb47c25ebad..2b1c153a4353a 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -2593,8 +2593,13 @@ func (p *Printer) emitNewExpression(node *ast.NewExpression) { state := p.enterNode(node.AsNode()) p.emitToken(ast.KindNewKeyword, node.Pos(), WriteKindKeyword, node.AsNode()) p.writeSpace() - if ast.SkipPartiallyEmittedExpressions(node.Expression).Kind == ast.KindCallExpression { - // Parenthesize `C()` inside of a NewExpression so it is treated as `new (C())` and not `new C()` + // Parenthesize when the expression contains a CallExpression that would otherwise + // bind to `new`. `new C()` is fine, but `new C().D()` parses as `(new C()).D()`, so + // `new (C().D)()` needs parentheses around the whole expression — including after + // type assertions / satisfies / non-null are erased (e.g. `new (f() as T).C()`). + // See https://github.com/microsoft/TypeScript/issues/64981. + expr := ast.SkipPartiallyEmittedExpressions(node.Expression) + if ast.GetLeftmostExpression(expr, true /*stopAtCallExpressions*/).Kind == ast.KindCallExpression { p.emitExpression(node.Expression, ast.OperatorPrecedenceParentheses) } else { p.emitExpression(node.Expression, ast.OperatorPrecedenceMember) diff --git a/tsc/internal/printer/printer_test.go b/tsc/internal/printer/printer_test.go index 867ef150d491c..5cc660b315103 100644 --- a/tsc/internal/printer/printer_test.go +++ b/tsc/internal/printer/printer_test.go @@ -1047,6 +1047,40 @@ func TestParenthesizeNew3(t *testing.T) { emittestutil.CheckEmit(t, nil, file.AsSourceFile(), "new C((a, b));") } +func TestParenthesizeNew4(t *testing.T) { + t.Parallel() + + // new (C().D)() — CallExpression is not the direct expression of `new`, but + // appears on the left of a property access. Without parentheses this would + // parse as (new C()).D(). + var factory ast.NodeFactory + file := factory.NewSourceFile(ast.SourceFileParseOptions{FileName: "/file.ts", PathKey: "/file.ts"}, "", factory.NewNodeList( + []*ast.Node{ + factory.NewExpressionStatement( + factory.NewNewExpression( + factory.NewPropertyAccessExpression( + factory.NewCallExpression( + factory.NewIdentifier("C"), + nil, /*questionDotToken*/ + nil, /*typeArguments*/ + factory.NewNodeList([]*ast.Node{}), + ast.NodeFlagsNone, + ), + nil, /*questionDotToken*/ + factory.NewIdentifier("D"), + ast.NodeFlagsNone, + ), + nil, /*typeArguments*/ + factory.NewNodeList([]*ast.Node{}), + ), + ), + }, + ), factory.NewToken(ast.KindEndOfFile)) + + parsetestutil.MarkSyntheticRecursive(file) + emittestutil.CheckEmit(t, nil, file.AsSourceFile(), "new (C().D)();") +} + func TestParenthesizeTaggedTemplate1(t *testing.T) { t.Parallel() diff --git a/tsc/internal/transformers/tstransforms/typeeraser_test.go b/tsc/internal/transformers/tstransforms/typeeraser_test.go index 56d8fce25a804..7e9b97f6e4bea 100644 --- a/tsc/internal/transformers/tstransforms/typeeraser_test.go +++ b/tsc/internal/transformers/tstransforms/typeeraser_test.go @@ -58,6 +58,15 @@ func TestTypeEraser(t *testing.T) { {title: "CallExpression", input: "f()", output: "f();"}, {title: "NewExpression1", input: "new f()", output: "new f();"}, {title: "NewExpression2", input: "new f", output: "new f;"}, + // After erasing `as T`, parentheses around the call must be restored so + // `new (f().C)()` is not emitted as `new f().C()` (== `(new f()).C()`). + // See https://github.com/microsoft/TypeScript/issues/64981. + {title: "NewExpression3", input: "new (f() as T).C()", output: "new (f().C)();"}, + {title: "NewExpression4", input: "new (f() as T).C", output: "new (f().C);"}, + {title: "NewExpression5", input: "new (f() satisfies T).C()", output: "new (f().C)();"}, + // Non-null doesn't elide the original parentheses the way `as`/`satisfies` do, + // so the call keeps its own grouping: `new ((f()).C)()`. + {title: "NewExpression6", input: "new (f()!).C()", output: "new ((f()).C)();"}, {title: "TaggedTemplateExpression", input: "f``", output: "f ``;"}, {title: "NonNullExpression", input: "x!", output: "x;"}, {title: "TypeAssertionExpression#1", input: "x", output: "x;"}, From 9391b36b47105a289af5da79c62a3e1b487c3a14 Mon Sep 17 00:00:00 2001 From: timurzvural <162848950+timurzvural@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:47:49 +0000 Subject: [PATCH 2/2] fix(#65015): parenthesize new operands without walking parens in GetLeftmostExpression Address Copilot review on #65015: do not teach GetLeftmostExpression to traverse ParenthesizedExpression (that double-wrapped arrow concise bodies like `() => ({...})`). Detect leftmost calls for `new` in a printer-local helper that may walk parentheses, preserving emitConciseBody behavior while still emitting `new (f().C)()` after type erasure. AI assistance disclosure: authored with Cursor Agent. --- tsc/internal/ast/precedence.go | 1 - tsc/internal/printer/printer.go | 34 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/tsc/internal/ast/precedence.go b/tsc/internal/ast/precedence.go index c159cbe365d77..bfaaa679b63af 100644 --- a/tsc/internal/ast/precedence.go +++ b/tsc/internal/ast/precedence.go @@ -392,7 +392,6 @@ func GetLeftmostExpression(node *Expression, stopAtCallExpressions bool) *Expres KindPropertyAccessExpression, KindNonNullExpression, KindPartiallyEmittedExpression, - KindParenthesizedExpression, KindSatisfiesExpression: node = node.Expression() continue diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index 2b1c153a4353a..d2a212e7fd698 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -2597,9 +2597,9 @@ func (p *Printer) emitNewExpression(node *ast.NewExpression) { // bind to `new`. `new C()` is fine, but `new C().D()` parses as `(new C()).D()`, so // `new (C().D)()` needs parentheses around the whole expression — including after // type assertions / satisfies / non-null are erased (e.g. `new (f() as T).C()`). - // See https://github.com/microsoft/TypeScript/issues/64981. - expr := ast.SkipPartiallyEmittedExpressions(node.Expression) - if ast.GetLeftmostExpression(expr, true /*stopAtCallExpressions*/).Kind == ast.KindCallExpression { + // See https://github.com/microsoft/TypeScript/issues/64981 and + // https://github.com/microsoft/TypeScript/pull/65015. + if newExpressionOperandNeedsParens(node.Expression) { p.emitExpression(node.Expression, ast.OperatorPrecedenceParentheses) } else { p.emitExpression(node.Expression, ast.OperatorPrecedenceMember) @@ -2609,6 +2609,34 @@ func (p *Printer) emitNewExpression(node *ast.NewExpression) { p.exitNode(node.AsNode(), state) } +// newExpressionOperandNeedsParens reports whether the operand of `new` must be +// parenthesized so a CallExpression is not parsed as the NewExpression callee. +// +// Parentheses are walked here only — not in ast.GetLeftmostExpression — so that +// emitConciseBody can still treat an already-parenthesized object literal +// `({ ... })` as parenthesized and avoid emitting a second pair. +func newExpressionOperandNeedsParens(expression *ast.Expression) bool { + node := ast.SkipPartiallyEmittedExpressions(expression) + for { + node = ast.SkipPartiallyEmittedExpressions(node) + switch node.Kind { + case ast.KindCallExpression: + return true + case ast.KindParenthesizedExpression, + ast.KindPropertyAccessExpression, + ast.KindElementAccessExpression, + ast.KindNonNullExpression, + ast.KindAsExpression, + ast.KindSatisfiesExpression, + ast.KindPartiallyEmittedExpression: + node = node.Expression() + continue + default: + return false + } + } +} + func (p *Printer) emitTemplateLiteral(node *ast.TemplateLiteral) { switch node.Kind { case ast.KindNoSubstitutionTemplateLiteral: