Skip to content

Fix false-positive TS2354 for native private class field access with importHelpers at dated targets - #4841

Open
Andrew Stegmaier (astegmaier) wants to merge 2 commits into
microsoft:mainfrom
astegmaier:fix-63728-private-field-tslib-false-positive
Open

Fix false-positive TS2354 for native private class field access with importHelpers at dated targets#4841
Andrew Stegmaier (astegmaier) wants to merge 2 commits into
microsoft:mainfrom
astegmaier:fix-63728-private-field-tslib-false-positive

Conversation

@astegmaier

@astegmaier Andrew Stegmaier (astegmaier) commented Aug 6, 2026

Copy link
Copy Markdown

Related to microsoft/TypeScript#63728, and a Go port of the companion fix microsoft/TypeScript#63729.

Disclosure (per CONTRIBUTING.md): This PR was authored with AI assistance (GitHub Copilot CLI). It was directed by me (a specific human operator) investigating and fixing this one specific, previously-filed issue — it is not part of a bulk or queue-driven workflow across issues, and I will personally shepherd it through review and respond to feedback myself.

The bug

With importHelpers: true and a dated target (e.g. ES2022ES2025), tsgo incorrectly reports TS2354: This syntax requires an imported helper but module 'tslib' cannot be found for plain, fully-native private field/method/accessor access (this.#x, #x in obj, static private fields/methods, private accessors, static blocks, private fields in generics/closures/class expressions/inheritance) — even though the emitted JS for all of these is 100% native ES2022+ syntax that never references tslib.

Root cause

setNodeLinksForPrivateIdentifierScope, checkPropertyAccessExpressionOrQualifiedName, and checkInExpression in internal/checker/checker.go gate the private-identifier helper-requirement check on:

c.languageVersion < LanguageFeatureMinimumTarget.PrivateNamesAndClassStaticBlocks ||
    c.languageVersion < LanguageFeatureMinimumTarget.ClassAndClassElementDecorators ||
    !c.compilerOptions.GetUseDefineForClassFields()
  • ClassAndClassElementDecorators is pinned to the ESNext sentinel because TC39 decorators have never been assigned to a dated ECMAScript edition. That makes languageVersion < ClassAndClassElementDecorators true for every dated target, forever — regardless of whether the file uses decorators at all. The actual emitter (estransforms/classfields.go's shouldTransformPrivateElementsOrClassStaticBlocks) only checks languageVersion < ES2022, with no decorator-related gating, so this term in the checker never corresponded to real emit behavior.
  • !c.compilerOptions.GetUseDefineForClassFields() has the same problem: private fields are always emitted using "define" semantics regardless of useDefineForClassFields (that option only affects public fields), so it also doesn't correspond to any real difference in whether tslib is needed for private-field access. Verified by emitting with --useDefineForClassFields false --target es2022: output is fully native with no tslib references, yet the checker still errored before this fix.

The fix

Removes both spurious clauses from the 3 call sites above, leaving just c.languageVersion < LanguageFeatureMinimumTarget.PrivateNamesAndClassStaticBlocks.

A 4th call site with the same ClassAndClassElementDecorators check, inside getFirstTransformableStaticClassElement, is intentionally left unchanged. That one only matters when a class is decorated, to decide if the decorator transform's hoisting of static private/static-block elements into an IIFE needs the __setFunctionName helper. I verified (by emitting @dec class C { static #foo() {} } at ES2022) that this really is required in the actual output, so this clause is a genuine, necessary coupling and was left in place.

Testing

  • Added testdata/tests/cases/compiler/importHelpersNoHelpersForPrivateFieldsAtES2022.ts — target ES2022, importHelpers: true, no tslib present, covering instance/static private fields, private methods, static private methods, private accessors, private auto-accessors (instance + static), static blocks, and #x in obj. Asserts zero errors; baseline .js confirms the emit never references tslib.
  • Added testdata/tests/cases/compiler/importHelpersNoHelpersForPrivateFieldsAtES2022UseDefineForClassFieldsFalse.ts — same coverage with useDefineForClassFields: false, confirming that option no longer triggers the false positive either.
  • Ran the full Go test suite (go test ./...); all pass except one unrelated, pre-existing flaky test in internal/fswatch (macOS fsevents timing test, confirmed to fail/pass independently of this change by running it in isolation both with and without the fix).
  • Ran the submodule-based conformance suite (TestSubmodule) filtered to privateName|esDecorators|importHelpers|classField|classStaticBlock — all pass, including the decorator+static-private-method case that verifies __setFunctionName is still correctly required.
  • hereby lint and hereby format both clean.
  • Manually confirmed via the built tsgo binary that decorators and using declarations still correctly require tslib at dated targets (these are real transforms, not part of this bug), and that all "confirmed bug" scenarios from the original repro (https://github.com/astegmaier/typescript-private-field-tslib-repro) now compile cleanly with zero references to tslib in the emitted output.

Update: follow-up fix for a regression found by automated review

The automated Copilot PR reviewer correctly caught a real gap in the fix above: removing the ClassAndClassElementDecorators clause from checkPropertyAccessExpressionOrQualifiedName and checkInExpression went slightly too far. When a class has a native (non-legacy) class decorator and at least one static private/auto-accessor element, decorator lowering (estransforms/esdecorator.go's hasStaticPrivateClassElements/shouldTransformPrivateStaticElementsInClass) hoists all of that class's static private/auto-accessor elements out of the class body into a wrapping closure — which forces accesses to them to use the __classPrivateFieldGet/Set/In helpers from tslib, even at ES2022+. The simplified checks stopped validating that tslib actually exports those helpers in this narrow scenario, which could let an incomplete tslib silently pass type-checking and then fail at runtime. Instance private fields in the same decorated class are unaffected and stay fully native (confirmed by direct JS emission).

I verified this regression directly: reverted to the pre-fix checker, confirmed it correctly errored (TS2343: ... '__classPrivateFieldGet' ... does not exist in 'tslib') for this scenario, then confirmed the (until-now) fixed checker silently accepted it — a real regression, not a false alarm.

The follow-up fix adds isStaticPrivateElementOfDecoratedClass, which mirrors newESDecoratorTransformer's exact skip condition (!legacyDecorators && (target < ESNext || !useDefineForClassFields)), and ORs it back into the two checks (checkPropertyAccessExpressionOrQualifiedName, checkInExpression) that validate the classPrivateField* helpers specifically. setNodeLinksForPrivateIdentifierScope is intentionally left alone — its only consumer (checkWeakMapSetCollision) independently gates on languageVersion <= ES2021, making any change there dead code for this scenario.

Additional verification for the follow-up

  • Confirmed via actual JS emission that decorated classes' static private field/method/get-set-accessor/auto-accessor access, and #x in obj checks, really do call tslib_1.__classPrivateFieldGet/Set/In, while instance private field access in the same decorated class stays fully native — in every target/option combination that matters (es2022; esnext with useDefineForClassFields true and false; legacy experimentalDecorators, where this combination is grammatically forbidden anyway).
  • Dispatched two independent review passes (one focused on tracing the fix against the emitter source and edge cases; one focused on writing fresh, independent test matrices and directly diffing checker behavior against real JS emission across targets/decorators/element-kinds/access-kinds). Investigated and ruled out two additional discrepancies they surfaced (a missing TS2354 for the decorator's own helpers, and an emit panic for static auto-accessors — both specific to target: esnext with useDefineForClassFields: false): both are confirmed pre-existing in main, unrelated to any change in this branch, and out of scope for this fix.
  • Ran the full Go test suite (go test ./...) and the submodule conformance suite filtered to privateName|esDecorators|importHelpers|classField|classStaticBlock — all passing.
  • hereby lint and hereby format clean.

New tests

  • importHelpersRequiredForDecoratedStaticPrivateElements.ts — a decorated class with an instance field plus static field/method/get-set-accessor/auto-accessor and a static #x in obj check, with a tslib stub that provides the decorator helpers but omits the classPrivateField* ones. Confirms exactly the 3 expected errors (one per helper), anchored at the static accesses, with zero errors for the instance field.
  • importHelpersRequiredForDecoratedStaticPrivateElementsUseDefineForClassFieldsFalse.ts — same shape at target: esnext with useDefineForClassFields: false, confirming decorator lowering (and the helper requirement) still applies in that specific combination.
  • importHelpersNotRequiredForDecoratedStaticPrivateElementsAtESNext.ts — the same shape at target: esnext with the default useDefineForClassFields: true, confirming zero errors and fully native emission when decorator lowering is skipped entirely.

…ted targets

Port of the fix for microsoft/TypeScript#63728 to this codebase, since
the checks in question (setNodeLinksForPrivateIdentifierScope,
checkPropertyAccessExpressionOrQualifiedName, checkInExpression in
internal/checker/checker.go) were carried over unchanged from the
classic compiler's checker.ts by the private-field-helpers porting
work, and exhibit the identical bug.

With importHelpers: true and a dated target (ES2022 through ES2025),
the checks required tslib for private-identifier access via:

  c.languageVersion < LanguageFeatureMinimumTarget.PrivateNamesAndClassStaticBlocks ||
      c.languageVersion < LanguageFeatureMinimumTarget.ClassAndClassElementDecorators ||
      !c.compilerOptions.GetUseDefineForClassFields()

Since decorators have never been assigned a dated ECMAScript edition,
ClassAndClassElementDecorators is pinned to the ESNext sentinel, so the
middle clause is true for every dated target unconditionally,
regardless of whether the file uses decorators at all. The actual
emitter (estransforms/classfields.go's
shouldTransformPrivateElementsOrClassStaticBlocks) only checks
languageVersion < ES2022, with no decorator-related gating, so this
required tslib to be resolvable even though the emitted JS for plain
private field/method/accessor access never references it.

The !GetUseDefineForClassFields() clause has the same problem: private
fields are always emitted using 'define' semantics regardless of that
option (it only affects public fields), so it also doesn't correspond
to any real difference in whether tslib is needed - verified by
emitting with --useDefineForClassFields false --target es2022 and
confirming the output is fully native with zero tslib references.

A fourth call site with the same ClassAndClassElementDecorators check,
inside getFirstTransformableStaticClassElement, is left unchanged: it's
used only to decide whether a *decorated* class needs the
__setFunctionName helper for hoisting its static private/static-block
elements, which is a genuine, verified coupling between decorators and
private statics (confirmed by emitting a decorated class with a static
private method and observing __setFunctionName really is called).

Note on scope: per CONTRIBUTING.md, this repo is currently only
accepting 6.0/7.0-difference or crash fixes. This bug is not a
6.0/7.0 difference - it reproduces identically in the classic compiler
(see microsoft/TypeScript#63728, and companion fix
microsoft/TypeScript#63729) and this port, since the checker logic was
ported over unchanged. Submitting here anyway since this is shared
logic both compilers carry, and the fix keeps their behavior in sync;
happy to close this if it's considered out-of-scope for the 7.0 bridge
period.

Related to microsoft/TypeScript#63728.

---
Disclosure: This PR was authored with AI assistance (GitHub Copilot
CLI), directed by a specific human operator (astegmaier) investigating
and fixing this one specific, previously-filed issue. It is not part of
a bulk or queue-driven workflow, and I will personally shepherd it
through review and respond to feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@astegmaier

Andrew Stegmaier (astegmaier) commented Aug 6, 2026

Copy link
Copy Markdown
Author

Ryan Cavanaugh (@RyanCavanaugh) - this was the bug fix that we just discussed.

Disclosure: I put a lot of human effort into making the simplified repro for the original bug (https://github.com/astegmaier/typescript-private-field-tslib-repro). But this PR was agent-made -- by pointing it at the reproduction -- but it seems simple and sensible enough (really just three small lines) that it was worth submitting.

@astegmaier
Andrew Stegmaier (astegmaier) marked this pull request as ready for review August 6, 2026 21:09
Copilot AI balanced review requested due to automatic review settings August 6, 2026 21:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/checker/checker.go:11272

  • Decorator lowering is another path that emits these helpers even at ES2022+. internal/transformers/estransforms/esdecorator.go:632-635 marks class-decorated classes with static private elements for forced transformation, and classfields.go:1064-1071 / 1495-1518 then rewrites their reads and writes to __classPrivateFieldGet/__classPrivateFieldSet. With this target-only guard, the checker no longer validates those imported helpers, so a partial or incompatible tslib can type-check but fail in emitted code. Please retain helper checking when decorator lowering forces the referenced static private element to transform, and add a decorated-static-private regression case.
    internal/checker/checker.go:13083
  • This also drops required helper validation for #x in value when #x is static and its class is class-decorated. The decorator transform sets EFTransformPrivateStaticElements regardless of target (esdecorator.go:632-635), after which classfields.go:1606-1608 emits __classPrivateFieldIn. At ES2022+ this guard now skips checking that tslib exports the emitted helper. Please include the decorator-forced static-private path in this condition as well.

…elements

Follow-up to the earlier fix in this branch for microsoft/TypeScript#63728: that
fix simplified checkPropertyAccessExpressionOrQualifiedName, checkInExpression, and
setNodeLinksForPrivateIdentifierScope to only require tslib's classPrivateField*
helpers below ES2022, since the actual emitter doesn't need them for plain private
field/method/accessor access at ES2022+.

However, that simplification missed one real exception, caught by an automated PR
review: when a class has a native (non-legacy) class decorator AND at least one
static private/auto-accessor element, decorator lowering
(estransforms/esdecorator.go's hasStaticPrivateClassElements /
shouldTransformPrivateStaticElementsInClass) hoists ALL of that class's static
private/auto-accessor elements out of the class body into a wrapping closure. That
hoisting forces accesses to those specific elements to use the
classPrivateFieldGet/Set/In helpers from tslib, even at ES2022+ - so the checker
needs to keep validating tslib exports them in this narrow case. Instance private
fields in the same decorated class are unaffected and stay fully native.

Adds isStaticPrivateElementOfDecoratedClass, mirroring
newESDecoratorTransformer's exact skip condition (decorator lowering only doesn't
happen for !legacyDecorators when target >= ESNext with useDefineForClassFields
true), and ORs it into the two checks that validate these specific helpers
(checkPropertyAccessExpressionOrQualifiedName, checkInExpression).
setNodeLinksForPrivateIdentifierScope is intentionally left alone: its
NodeCheckFlagsContainsClassWithPrivateIdentifiers flag has exactly one consumer
(checkWeakMapSetCollision), which independently gates on languageVersion <=
ES2021, making any change there dead code for the ES2022+ scenario this follow-up
addresses.

Verified with two independent review passes plus manual testing:
- Confirmed via actual JS emission that decorated classes' static private
  field/method/accessor access and '#x in obj' checks call
  tslib_1.__classPrivateFieldGet/Set/In, while instance private field access in the
  same decorated class stays fully native.
- Confirmed the fix correctly requires the helpers for static private fields,
  methods, get/set accessors, and auto-accessors in decorated classes; correctly
  leaves instance fields, non-decorated classes, and legacy
  (experimentalDecorators) classes unaffected; and correctly follows the
  useDefineForClassFields/ESNext edge case (decorator lowering is skipped, and no
  helpers are needed, only at target >= ESNext with useDefineForClassFields true).
- Ran the full Go test suite (go test ./...) and the submodule conformance suite
  filtered to privateName|esDecorators|importHelpers|classField|classStaticBlock -
  all passing.
- hereby lint and hereby format are clean.
- Investigated two additional discrepancies surfaced during review (a missing
  TS2354 for decorator-only helpers, and an emit panic for static auto-accessors,
  both specific to target: esnext with useDefineForClassFields: false) and
  confirmed both are pre-existing in main, unrelated to any change in this
  branch, and out of scope for this fix.

Adds three new compiler tests:
- importHelpersRequiredForDecoratedStaticPrivateElements.ts: a decorated class
  with an instance field plus static field/method/get-set-accessor/auto-accessor
  and a static '#x in obj' check, with a tslib stub missing the
  classPrivateField* helpers - confirms exactly the expected 3 errors (one per
  helper) anchored at the static accesses, with zero errors for the instance
  field.
- importHelpersRequiredForDecoratedStaticPrivateElementsUseDefineForClassFieldsFalse.ts:
  same shape at target: esnext with useDefineForClassFields: false, confirming
  decorator lowering (and thus the helper requirement) still applies in that
  specific target/option combination.
- importHelpersNotRequiredForDecoratedStaticPrivateElementsAtESNext.ts: the same
  shape at target: esnext with the default useDefineForClassFields: true,
  confirming zero errors and fully native emission when decorator lowering is
  skipped entirely.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@astegmaier

Andrew Stegmaier (astegmaier) commented Aug 7, 2026

Copy link
Copy Markdown
Author

In response to copilot feedback (agent generated):

I verified this was a real regression (confirmed via actual JS emission that decorated classes' static private elements do call tslib_1.__classPrivateFieldGet/Set/In, and that the pre-follow-up checker silently stopped validating for it) and pushed a fix that restores the check specifically for that scenario (native class decorator + static private/auto-accessor element), while keeping the original fix's improvement for the common case (plain classes, instance fields). Added 3 new tests covering it. See the updated PR description for details.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants