Skip to content

fix(di): give no token to a ctor param typed by a same-file interface or type alias - #666

Merged
Brooooooklyn merged 4 commits into
voidzero-dev:mainfrom
GabeSilvaDev:fix/type-only-ctor-param
Nov 2, 2026
Merged

Brooooooklyn merged 4 commits into
voidzero-dev:mainfrom
GabeSilvaDev:fix/type-only-ctor-param

Conversation

@GabeSilvaDev

@GabeSilvaDev GabeSilvaDev commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #653

A constructor parameter typed by an interface or type alias declared in the same file was compiled as an injection token, i0.ɵɵinject(Foo). The interface is erased, so creating the class threw ReferenceError: Foo is not defined.

ngtsc 22.2.3 has no value for that type: the full output is ɵɵinvalidFactory() and the partial output is deps: "invalid". The class metadata here already emitted { type: undefined } for these parameters, because LocalDecls::is_type_only in class_metadata/builders.rs covers the local typeToValue cases.

Changes

  • resolve_factory_dep_namespaces (directives, pipes, injectables, NgModules) and the component's extract_param_dependency now ask LocalDecls::is_type_only about a token that is not imported. When the token is an interface or type alias declared in the file, the dep becomes type_only_invalid, the same as a type-only import (DI: import type tokens promoted to runtime value imports #288). This includes one inside a namespace (ns.Foo).
  • LocalDecls is pub(crate) so both places use the same lookup as the metadata. It is built once per class, and only when a token is not imported.
  • An @Inject(Foo) token is left as written, as before.

A name that is also a value keeps its token, as in ngtsc: an interface merged with a class, an enum.

An interface imported through a value import from another file still can't be detected one file at a time, as the issue says.

Checks

  • ngtsc_emit_parity_test.rs: new INVALID_DEP_CASES rows (interface, type alias, exported interface, interface in a namespace, and an interface on a directive, pipe, NgModule and component) check ɵɵinvalidFactory() and deps:"invalid". For an @Injectable, the interface, type alias, exported interface, merged class and enum outputs match ngtsc 22.2.3. On a directive, pipe, NgModule or component, ngtsc reports NG2003 instead, the same as for the existing string param rows. local_value_ctor_dep_stays_the_token checks the merged class and the enum.
  • Reverting the transform.rs change fails the injectable rows. Reverting the decorator.rs change fails the component row.
  • cargo test --all-features, cargo fmt --all -- --check and conformance (1264/1264, snapshot unchanged) pass.

Review follow-up (bbb6dba, 9ddad74, e7e1cb7)

A sweep of ~250 cases compared with ngtsc 22.2.3 (each decorator kind, full and partial) found one regression from the first commit. It is fixed here:

declare global { interface Window { a: number } }
constructor(w: Window)

ngtsc            ɵɵinject(Window)          (merges with lib.dom's `declare var Window`)
first commit     ɵɵinvalidFactory()        ← wrong
now              ɵɵinject(Window)
  • A name declared in declare global is not counted as type-only for a DI token (LocalDecls::is_type_only_token). One file can't see the global values it may merge with. A made-up global interface keeps its token, as before this PR (ngtsc: invalid).
  • From the Codex review (9ddad74), two more cases that keep their token, as in ngtsc:
    • a class nested in a function or block, named like a top-level interface, whose parameter is typed by its own name;
    • NS.Foo where NS is a class merged with a namespace (class NS { static Foo = .. } + namespace NS { export interface Foo {} }).
    • an interface merged with a var declared in a block or loop ({ var Foo = class {} }, e7e1cb7). This also fixes ctorParameters there.
  • LocalDecls is built once per file (StringConsts::local_decls), not once or twice per class. A file with 2000 classes: 2.5 s → 0.3 s.
  • Tests: the value cases also on a component, an interface merged with a namespace that holds a value, @Inject(FOO) f: Foo, and the global interface.

Still not covered (same output as before this PR, rare):

  • an interface merged with an empty or type-only namespace (namespace Foo {})
  • import Foo = ns.Bar where Bar is an interface
  • a module-level interface Foo next to declare global { var Foo }
  • JIT ctorParameters (@Component({ jit: true })) still writes type: Foo

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 31, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T05:09:06.005370Z e7e1cb7 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…arations once per file

- A `declare global { interface Window { .. } }` merges with lib.dom's
  `declare var Window`, so ngtsc keeps `ɵɵinject(Window)`. The new check
  made it `ɵɵinvalidFactory()`. Names declared in `declare global` are now
  not counted as type-only for a DI token (`LocalDecls::is_type_only_token`).
- `LocalDecls` is built once per file (`StringConsts::local_decls`) instead
  of once or twice per class. 2000 classes: 2.5 s -> 0.3 s.
- Move the `member_chain_root` doc back, and build a token's path only when
  it is not imported.
- Tests: the value cases on a component too, an interface merged with a
  namespace that holds a value, `@Inject(FOO) f: Foo`, the global interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bbb6dba9aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…tatic

Two cases where the type-only check made a working factory invalid
(ngtsc 22.2.3 keeps the token):

- `interface Foo {}` at the top, and in a function or block
  `@Injectable() class Foo { constructor(@optional() @SkipSelf() p: Foo) {} }`:
  the parameter type is the class itself.
- `class NS { static Foo = class {} }` + `namespace NS { export interface Foo {} }`,
  `constructor(f: NS.Foo)`: `NS.Foo` is the class's static.

`LocalDecls::is_type_only_token` now takes the class and leaves out its own
name and the members of a namespace that is also a value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ddad7405d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

`interface Foo {}` + `{ var Foo = class {} }`: the `var` binds in the
module scope and merges with the interface, so ngtsc 22.2.3 keeps
`ɵɵinject(Foo)` and `type: Foo`. `LocalDecls` now records the `var`s of
blocks, `if`, loops, `try` and `switch` (not of function or class bodies).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: e7e1cb73c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Brooooooklyn
Brooooooklyn merged commit b3bc757 into voidzero-dev:main Nov 2, 2026
9 checks passed
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.

Constructor param typed by a same-file interface is injected as a token (ReferenceError at runtime)

3 participants