Skip to content

Native printer: new (C?.().D) emits as new (C?.()).D (optional call trips hasUnparenthesizedCall) #65148

Open

Description

🔎 Search Terms

hasUnparenthesizedCallAtStartOfMemberChain, optional chain, new expression, parenthesize, OperatorPrecedenceOptionalChain, printer

🕗 Version & Regression Information

  • Present on current main native printer (7.1.0-dev)
  • Related to #65133 (NonNull in the same helper), but a different gap: optional calls are skipped because OperatorPrecedenceOptionalChain < OperatorPrecedenceMember

💻 Code

Synthetic AST (factory / transform output without an outer ParenthesizedExpression), equivalent to intending:

new (C?.().D)();

Repro via printer unit test:

call := factory.NewCallExpression(
    factory.NewIdentifier("C"),
    factory.NewToken(ast.KindQuestionDotToken),
    nil,
    factory.NewNodeList([]*ast.Node{}),
    ast.NodeFlagsOptionalChain,
)
expr := factory.NewPropertyAccessExpression(call, nil, factory.NewIdentifier("D"), ast.NodeFlagsNone)
// NewExpression(expr, args=[])

🙁 Actual behavior

new (C?.()).D();

hasUnparenthesizedCallAtStartOfMemberChain returns false for C?.().D, so emitNewExpression does not force parentheses around the whole member chain. The optional call still gets local parens from ordinary precedence (OptionalChain < Member), producing new (C?.()).D().

🙂 Expected behavior

new (C?.().D)();

Root cause sketch

In hasUnparenthesizedCallAtStartOfMemberChain (printer/utilities.go):

if ast.GetExpressionPrecedence(node) < ast.OperatorPrecedenceMember {
    return false
}

Optional-chain calls report OperatorPrecedenceOptionalChain, which is less than OperatorPrecedenceMember, so the walk bails out when it reaches C?.() even though that call is exactly the “unparenthesized call at the start of the member chain” the helper exists to detect.

Control that still works: new (C.D?.())() (optional call is the entire callee) — local precedence parenthesization is enough.

Notes

Same helper already fails to walk NonNullExpression (#65133). Fixing the optional-chain precedence bail should be considered alongside that.

Activity

  1. timurzvural commented on Nov 3, 2026

    @timurzvural
    Author

    Same precedence bail also affects as / satisfies in the member chain (Relational < Member), e.g. synthetic

    new ((C() as D).E)();

    currently prints as

    new (C() as D).E();

    (still a NewExpression of the property access, so runtime-equivalent without further wrapping, but the helper fails to treat the leading call as “unparenthesized at the start of the chain” for the same reason as optional calls).

  2. JemmyIndex commented on Nov 5, 2026

    @JemmyIndex

    I investigated this issue and opened #65329 to fix it, but have since closed that PR after deeper analysis. Summary of what I found:

    1. On current main, the synthetic AST from this issue already emits a syntactically valid result. The unflagged outer access .D takes the Member-precedence branch of emitExpression, and hasUnparenthesizedCallAtStartOfMemberChain supplies the outer parens, so the output is new (C?.()).D() — no SyntaxError.

    2. The chain-interrupted form appears to be intentional, TS5-compatible behavior. TestParenthesizePropertyAccess1/2 in internal/printer/printer_test.go assert that a non-chain access wrapping an optional chain root prints as (a?.b).c.

    3. Semantically, printing (C?.()).D for an AST where .D has no optional-chain flag is faithful: short-circuiting ends at the parenthesized call. The desired output in this issue, new (C?.().D)(), would fold .D into the optional chain and change runtime behavior — for a nullish C, C?.().D evaluates to undefined, while (C?.()).D throws a TypeError. So making the printer produce the minimal form would be a semantic regression unless the AST itself is changed.

    The underlying gap seems to be that transforms can construct member chains without propagating the optional-chain flags to the outer accesses, which makes the printed output differ from what a parsed new (C?.().D)() would round-trip to (the parser sets flags on every link, and those round-trip cleanly). If the intent is for the printer to reconstruct the chain in that case, it would require deciding that unflagged accesses over an optional chain root should be treated as part of the chain — a semantic change beyond a printer tweak.

    Closing my PR for now; happy to revisit if maintainers decide the printer should treat such synthetic chains as one optional chain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugA bug in TypeScript

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions