Skip to content

feat(dts): type ngAcceptInputType_* with the transform's parameter type - #496

Merged
Brooooooklyn merged 12 commits into
mainfrom
feat/dts-input-transform-types
Oct 1, 2026
Merged

Brooooooklyn merged 12 commits into
mainfrom
feat/dts-input-transform-types

Conversation

@ashley-hunter

@ashley-hunter ashley-hunter commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stack (4/5): #493 inputs/outputs → #494 transform validation → #495 queries: → #496 .d.ts transform types → #504 signal API import identity. This is #496.

Types static ngAcceptInputType_<input> in library .d.ts output with the transform's parameter type, as ngtsc does.

The bug

Every input with a transform was declared as

static ngAcceptInputType_count: unknown;

so a consumer's template type-checking accepted any value for it. ngtsc writes the type of the transform's first parameter, so [count]="true" against (value: string | number) => number is a type error.

The fix

The type is printed the way ngtsc prints it (directive/dts_type.rs). Every part of the type is printed from the AST, never copied from the source, so type arguments, mapped types, predicates, this parameters and members all get the same rules:

  • @angular/core names become i0.Name (also through import * as ng)
  • local and global names, and typeof x, are kept as written
  • a qualified name whose head the file declares (NS.T, A.B.T) is written as its last part, T, like ngtsc
  • TypeScript's printer output: normalised spacing, double-quoted keys and strings with non-ASCII escaped (é → \u00E9), { w } patterns, normalised numbers and bigints, and the same-line comments it keeps in front of list elements
  • a transform with no parameters gets unknown

The transform's type comes from:

  • a function written in place, or a same-file function or static method; an overloaded one is typed from its first declaration, the one ngtsc reads
  • a TypeScript lib function (parseInt, isNaN, ...), typed with its first parameter from lib.es5.d.ts
  • a member @Input overriding an inputs: entry decides the type, like the compiled inputs map

The member name is quoted only when it has a - or . (Angular's isUnsafeObjectKey) and escaped like a TypeScript string ("ngAcceptInputType_a-\"b"). Any other name is written as is, like ngtsc (ngAcceptInputType_a"b).

Deliberate differences from ngtsc:

  • Another module's type (including NS.T through a named import) is unknown. ngtsc adds an import * as iN for it. These aliases are numbered per source file, which can't be merged safely into bundled declaration files, since the Vite .d.ts injector combines every module's declarations.
  • An imported transform is unknown: its signature can't be read from one file.
  • Comments: /** */ comments after a type or on their own line, and comments spanning lines, are dropped. ngtsc keeps them.
  • Forms ngtsc can't emit: a qualified name with a global head (Intl.NumberFormat) is kept as written, where ngtsc writes NumberFormat, which doesn't resolve. An enum member type (E.A) is kept as written, where ngtsc throws. import('...') types are unknown, where ngtsc aborts the build.

Known risk: local type names are written as is, like ngtsc. But ngtsc adds these members before bundling, and the Vite plugin injects them after. So in a bundled .d.ts where the bundler renamed or dropped that local type, the name can fail to resolve or bind to a different type. Per-file .d.ts output is not affected.

This PR also emits "ng-component" as the .d.ts selector of a component without one, as ngtsc does (it was never).

An enum member reached through a namespace (NS.E.A, C.E.A) is kept as written, like E.A (ngtsc throws on these). A computed property name follows the type-name rules: another module's name makes the type unknown, and @angular/core names become i0.X.

Import-equals aliases (import A = NS, import C = NS.T, export import) are typed like their target, as ngtsc does. Aliases of other modules give unknown, aliases of @angular/core give i0.X, and aliases of globals give their target (ngtsc writes a bare name that doesn't resolve). typeof this is kept, a name in a namespace merged with an enum is shortened unless it is a real enum member, and lone surrogates in string types and keys print as "\uD800".

Import-equals aliases declared inside namespaces (NS.Alias, NS.Inner.Alias2, aliases of aliases) are typed as the declaration they resolve to, like ngtsc, with the same core, other-module and global rules as top-level aliases. A transform that's a global declared outside the file is typed unknown.

import Core = require('@angular/core') and aliases of it (top level or in a namespace) type Core.X as i0.X. ngtsc writes the bare X, which doesn't resolve; require aliases of other modules stay unknown.

A computed property name through an import-equals alias resolves like a type name: @angular/core gives i0.X, another module gives unknown, and a global gives its target (ngtsc copies these as written, and its .d.ts can't resolve them). An alias of a name the file declares stays as written, like ngtsc.

Enum members named by a string or template literal ([`A`]) are recognised, so E.A / NS.E.A stay as written instead of becoming an unresolved A. Transforms declared in namespaces are typed from their declaration, like ngtsc.

Import-equals alias chains are followed to their end (a cycle gives unknown), and a U+2028 / U+2029 line break before a union no longer crashes the .d.ts printer.

Tests

  • dts_input_transform_type_test.rs (new): compares the printed type of 239 type forms with what ngtsc 22.1.7 wrote into the .d.ts for the same source. It also covers the documented differences (other-module types, comments, import() types, enum members), an overloaded static method, and member-name quoting and escaping.
  • Snapshot: Angular's input transforms specs from ngtsc_spec, two directive_spec acceptance cases, and probes of the type forms, qualified names and lib functions are added to the ngtsc snapshot. The test maps ngtsc's other-module types to unknown (the documented difference) and doesn't expect a static ngAcceptInputType_* the class declares itself (TypeScript writes that, not ngtsc). The cases that need declarations from another file, and the qualified-name forms above, are skipped with that reason.
  • Checked by hand (on the first commit): oxc's generated library .d.ts, injected by the Vite plugin's helper, type-checks with skipLibCheck: false. ngtsc with strictTemplates accepts a consumer with valid bindings against it, and rejects wrong ones (boolean for a string | number transform, a value outside a literal union, a missing required input).

@Brooooooklyn
Brooooooklyn added this pull request to stack #503 September 30, 2026 04:46
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 1359b77 to 8567a50 Compare September 30, 2026 07:08
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from add60c9 to b8fd06a Compare September 30, 2026 07:08
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 8567a50 to 3f789e6 Compare September 30, 2026 13:11
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from b8fd06a to 3d138f9 Compare September 30, 2026 13:11
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 3d138f9 to bf5288b Compare September 30, 2026 15:58
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 3f789e6 to 3d2b3b3 Compare September 30, 2026 15:58
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from bf5288b to 1d53f16 Compare September 30, 2026 17:31
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 3d2b3b3 to ec3376c Compare September 30, 2026 17:31
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 1d53f16 to 56e4adb Compare September 30, 2026 18:32
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from ec3376c to 3be6c6c Compare September 30, 2026 18:32
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 56e4adb to 7ae9dd3 Compare September 30, 2026 19:07
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 3be6c6c to 13db4af Compare September 30, 2026 19:07
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 7ae9dd3 to df67666 Compare September 30, 2026 19:38
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch 2 times, most recently from 3d03819 to 0dac7a7 Compare October 1, 2026 02:43
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from df67666 to 023efb2 Compare October 1, 2026 02:43
Brooooooklyn
Brooooooklyn previously approved these changes Oct 1, 2026
@Brooooooklyn
Brooooooklyn removed this pull request from stack #503 October 1, 2026 08:45
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 023efb2 to 2264f94 Compare October 1, 2026 08:46
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch 2 times, most recently from 0869aa8 to 0d8231d Compare October 1, 2026 08:47
@Brooooooklyn
Brooooooklyn force-pushed the feat/decorator-metadata-queries branch from 2264f94 to 167ea92 Compare October 1, 2026 08:50
Base automatically changed from feat/decorator-metadata-queries to main October 1, 2026 08:50
@Brooooooklyn
Brooooooklyn dismissed their stale review October 1, 2026 08:50

The base branch was changed.

ashley-hunter and others added 12 commits October 1, 2026 16:50
…type

Library `.d.ts` declarations wrote `static ngAcceptInputType_<input>: unknown`
for every input with a transform, so consumers' template type-checking accepted
any value. ngtsc writes the type of the transform's first parameter, so e.g.
`[count]="true"` against a `(value: string | number) => number` transform is a
type error. That type is now printed the way ngtsc prints it: `@angular/core`
names become `i0.Name`, string literals are re-quoted, spacing is normalised,
local and global names are kept, `unknown` for a transform with no parameters.

Two deliberate differences from ngtsc, both falling back to the previous
`unknown`:

- a type referencing another module: ngtsc adds `import * as iN` for it, but
  aliases numbered per source file can't be merged safely into bundled
  declaration files
- an imported transform, whose signature can't be read from one file

A member `@Input` overriding an `inputs:` entry decides the type, like the
compiled inputs map. Also emits `"ng-component"` as the `.d.ts` selector of a
component without one, as ngtsc does (it was `never`).
The `ngAcceptInputType_*` printer copied type parameters, mapped types,
predicates, `this` parameters and non-property members verbatim, so
`@angular/core` names there missed the `i0.` rewrite and other-module names
escaped the `unknown` fallback. Print every node from the AST instead, and
fall back to `unknown` for forms that can't be printed.

Also match TypeScript's printer: property keys and destructuring keys
double-quoted, strings escaped to ASCII (`\0`, `😀`), `{ w }`
patterns, bigint/number normalisation, and the same-line comments it keeps in
front of list elements. `/** */` comments after a type or on their own line are
still dropped.

The new test compares 239 forms against ngtsc 22.1.7's `.d.ts` output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ed local names like ngtsc

Checked against @angular/compiler-cli 22.1.7:

- An overloaded function or static method transform is typed from its first
  declaration (the one ngtsc reads), not its implementation.
- A qualified type name whose head the file declares (`NS.T`, `C.T` for a
  class merged with a namespace, `A.B.T`, `declare namespace`) is written as
  its last part, `T`, as ngtsc writes it. An enum member (`E.A`), which
  ngtsc can't emit, stays as written; `ng.Signal` through an
  `@angular/core` namespace is still `i0.Signal`, and names from other
  modules are still `unknown`.
- A TypeScript library function used as a transform (`parseInt`, `isNaN`,
  ...) is typed with its first parameter's type from `lib.es5.d.ts`.
- The ngtsc snapshot gets back the typed `ngAcceptInputType_*` of the 26
  cases added while every one was `unknown`, regenerated from ngtsc, plus
  probes of qualified names and library functions. A static
  `ngAcceptInputType_*` the class declares itself is TypeScript's own
  declaration output, so the test doesn't expect oxc to write it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An input whose class property is a string key was written quoted but not
escaped whenever it wasn't an identifier, so `'a-"b'` gave
`static "ngAcceptInputType_a-"b": ...`, which doesn't parse, and `'c.\\d'`
named a different property.

ngtsc (22.1.7) quotes the name only when Angular's `isUnsafeObjectKey`
(`/[-.]/`) matches, and prints it as a TypeScript string literal
(`"ngAcceptInputType_a-\"b"`, `"ngAcceptInputType_k-é"`). Every
other name is written as is, like `ngAcceptInputType_a"b`. oxc now does
the same, reusing the string printer of the transform types.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…per functions

The snapshot's `scope-*` probes (added earlier in the stack, before
`.d.ts` transform types) left out ngtsc's `ngAcceptInputType_*` types.
With them back, a function expression passed to a helper, as a default
value, through `@Input(opts(...))` or written in a helper is typed from
its own parameter, like ngtsc 22.1.7 (`string`).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…type names

- An enum member reached through a namespace (`NS.E.A`, `C.E.A` for a
  class merged with a namespace, `NS.M.E.A`, `namespace A.B`) is kept as
  written, like `E.A`. It was shortened to `A`, which doesn't resolve;
  ngtsc 22.1.7 throws on these. `NS.E` and `A.B.T` are still shortened
  to `E` / `T`, as ngtsc writes them.
- A computed property name in a transform type (`{ [token]: string }`,
  `{ [ns.token]: string }`) follows the type-name rules: an
  `@angular/core` value becomes `i0.token`, another module's makes the
  type `unknown`. ngtsc copies the expression, which names an import the
  `.d.ts` doesn't have. Local and global names are unchanged.

Also compares the `ngAcceptInputType_*` types of the `scope-*` probes added
under #493, and adds a `dts-qualifiedNamespaceEnum` probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…spaces and lone surrogates like ngtsc

- `import A = NS` / `import C = NS.T` (and `export import`) stand for their
  target: `A.T` and `C` are written `T`, as ngtsc writes them. An alias of
  another module (`import R = require('./other')`, or of an import) makes
  the type `unknown`, like any other module's type (ngtsc writes the bare
  name, which doesn't resolve). An alias of `@angular/core` becomes `i0.X`,
  and one of a global is written as its target (`Intl.NumberFormat`).
- `typeof this` is kept, like ngtsc (it was `unknown`).
- A qualified name through an enum merged with a namespace (`E.T`) is
  shortened to `T` like ngtsc; only an actual enum member (`E.A`, from any
  declaration of the enum) is kept as written.
- Lone surrogates in string literal types and quoted keys (`'\uD800'`) are
  printed as `"\uD800"`, like ngtsc, instead of OXC's internal encoding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r target

`namespace NS { export import Alias = Local; }` with a parameter typed
`NS.Alias` printed `Alias`, where ngtsc resolves the alias and writes `Local`.
The same went for nested namespaces (`NS.Inner.Alias2`), aliases of aliases,
aliases reached through a top-level alias (`import X = NS.Alias`), and targets
named from the namespace's own scope (`export import Rel = In2`).

Namespace member aliases now resolve to their target, whose head is looked up
in the alias's namespace first, then the enclosing ones, then the file. The
result follows the existing rules: local names are shortened, `@angular/core`
names become `i0.X`, other modules make the type `unknown`, and globals keep
their target (`Intl.NumberFormat`).

Also tests that a transform that's a global declared outside the file (`atob`,
now assumed to be a function) is typed `unknown`, like an imported one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…0.X`

Every `require` alias was treated as another module's, so `Core.Signal<string>`
(also through `import S = Core.Signal`, `import C = Core` or a namespace's
alias of it) typed the input as `unknown`. An alias of `@angular/core` now
gives `i0.Signal<string>`, like a namespace import of it. ngtsc 22.1.7 writes
the bare `Signal<string>`, which doesn't resolve in its `.d.ts`; other modules
stay `unknown`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A computed name in a transform's parameter type (`{ [Core.ɵSIGNAL]: string }`)
skipped the alias resolution type names get, so an alias of
`@angular/core` (`import Core = require('@angular/core')`, `import C = ng`,
`import S = Core.ɵSIGNAL`, or one declared in a namespace) was written as
is, another module's alias didn't make the type `unknown`, and a global's
alias wasn't written as its target. Computed names now resolve aliases
the way type names do: `i0.ɵSIGNAL`, `unknown`, and `G.k`. ngtsc 22.1.7
copies all of these as written, which doesn't resolve in its `.d.ts`. An
alias of a name the file declares stays as written, like ngtsc.

The snapshot's `scope-typeOnly-*` probes (added earlier in the stack,
before `.d.ts` transform types) now also compare ngtsc's
`ngAcceptInputType_*` types, including `typeof name` for a helper's
parameter used as a type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…written

`enum E { [`A`] = 'a' }` names its member with a template literal, which the
enum member check ignored: `E.A` was taken for a qualified local name and
shortened to `A`, which doesn't resolve (`static ngAcceptInputType_x: A;`),
also for an enum in a namespace. Members named `'A'`, `['A']` or `` [`A`] ``
(escapes included) are now recognised like `A`, so the type is kept as
written. ngtsc 22.1.7 throws on all of these.

Transforms declared in a namespace (through `typeof NS.fn`, a static method
of a class there, or `import H = NS.fn`) are typed from their declaration,
like ngtsc; add them to the tests and snapshot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…line breaks whole

An import-equals alias chain is followed until it ends, stopping only on a
cycle, instead of giving up after 16 aliases (`A17.T` through 18 aliases is
`T`, like ngtsc). Looking back for a `//` before a union now steps over a
U+2028 / U+2029 line break by its full UTF-8 length, where it used to slice
inside the character and panic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the feat/dts-input-transform-types branch from 0d8231d to 2be0457 Compare October 1, 2026 08:50
@Brooooooklyn
Brooooooklyn merged commit 861e1b0 into main Oct 1, 2026
8 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/dts-input-transform-types branch October 1, 2026 08:51
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.

2 participants