Skip to content

Warn when a @let declaration shadows an existing component class member #69619

Description

@skrtheboss

Which @angular/* package(s) are relevant/related to the feature request?

compiler

Description

@let correctly follows normal lexical/block scoping: inside its own block it shadows any outer identifier of the same name (e.g. a class member), and outside that block the outer identifier applies again — this is consistent, just like JavaScript's let.

The problem is that this shadowing is easy to miss when reading a template, especially when the @let is declared deep inside a nested @if/@for block while the same-named class member is used elsewhere in the same template.
Nothing in the compiler flags the collision, so it's easy to accidentally rely on the wrong one depending on which part of the template you're looking at, without any warning.

Proposed solution

A compiler/language-service diagnostic (opt-in or warning by default) when a @let name shadows an existing
component class member (property, getter/setter, or plain field), similar to a no-shadow lint rule. This would
make the (correct, but easy-to-miss) scoping explicit instead of silent.

Alternatives considered

  • Renaming the @let to avoid any collision — works, but relies on developers noticing the collision themselves.
  • A custom ESLint rule for Angular templates — could work, but a compiler-level diagnostic would catch it for
    everyone by default without extra tooling setup.

Anything else?

Example illustrating the shadowing that prompted this request, with multiple usage contexts of the same name:

@if (thing$ | async; as thing) {
  @let flag = flag$ | async;

  <!-- (A) Inside the block where @let is declared: "flag" resolves to the -->
  <!-- @let value (the unwrapped Observable value). -->
  @if (flag) {
    <inner-block />
  }
}

<!-- (B) Outside/after the block where @let was declared: "flag" is no -->
<!-- longer in scope here, so this resolves to the class member (the -->
<!-- getter below) instead — a different value/source than (A). -->
@if (flag) {
  <toggle-button />
}
export class ExampleComponent {
  readonly flag$ = new BehaviorSubject<boolean>(false);

  // Class member sharing the same name as the @let above — setter-only,
  // no getter.
  set flag(flag: boolean) {
    this.flag$.next(flag);
  }
}
  • At (A), flag refers to the @let binding — this is correct and intentional, standard block scoping.
  • At (B), flag refers to the class member — but since it's a setter without a getter, there is no readable value
    behind it at all. Reading flag here doesn't just resolve to "the wrong value"; it silently resolves to a
    member that was never meant to be read from a template expression in the first place.

Both resolutions are individually "correct" per normal scoping rules, but nothing in the template highlights that
these two flag references — just a few lines apart, with the exact same name — are bound to two completely
different things, one of which isn't even readable. This is exactly the kind of easy-to-miss shadowing this
request is about: a warning at the point the @let is declared (flagging that it shadows an existing class
member) would make the split obvious immediately, instead of only being discovered by debugging why (B) doesn't
behave like (A).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: compilerIssues related to `ngc`, Angular's template compiler

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions