Skip to content

feat(directive): validate input transforms like ngtsc - #494

Open
ashley-hunter wants to merge 6 commits into
fix/decorator-metadata-inputs-outputsfrom
feat/validate-input-transforms
Open

ashley-hunter wants to merge 6 commits into
fix/decorator-metadata-inputs-outputsfrom
feat/validate-input-transforms

Conversation

@ashley-hunter

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

Copy link
Copy Markdown
Collaborator

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

Checks input transforms the way ngtsc does, for both @Input({ transform }) and inputs: [{ transform }].

The bug

ngtsc rejects a transform it can't prove is a usable function (parseDecoratorInputTransformFunction). oxc accepted anything, so code that fails to build with ngc compiled here, e.g.

const toNumber = (v: string) => Number(v);

@Directive({ selector: '[d]' })
export class D {
  @Input({ transform: toNumber }) value!: number;   // ngtsc: Input transform must be a function
}

The fix

Each rule matches ngtsc, with its diagnostic word for word and in its order (inputs: entries, then members):

  • not a function: literals, classes, enums, calls, satisfies, uninitialized lets, static getters/setters/fields without a value, the Number/String globals and members like Math.round, and function expressions reached through a variable, property access or parentheses (only one written directly as the property value is analyzable)
  • generic, or overloaded: more than one call signature. An overloaded function or static method is checked at its first declaration, not its implementation.
  • first parameter: untyped, a spread, or typed with a same-file type that isn't exported. Like ngtsc's assertEmittableInputType, only type references by plain name are checked, so typeof X and X.Y pass. Type parameters (the class's, mapped, infer, generic signatures) are rejected, and so are interfaces and type aliases exported only through export { X }.
  • name clash: a static ngAcceptInputType_<name> member already on the class. This is also checked for imported, namespaced and global (parseInt) transforms.

The evaluator learns functions to do this: same-file function declarations and static methods resolve to their definitions, and cond ? a : b or T?.f resolve to the function they pick. A transform reached through such a reference (T.f where const T = { f }) is emitted as the function's own name, as ngtsc does. The lib functions parseInt, isNaN, ... are accepted. Like ngtsc, a shorthand { transform } whose variable holds an arrow or function expression (also through a const object, a spread or destructuring) is checked and emitted as that function, while naming it (transform: t) is still rejected. A function passed to a helper that returns the transform (inputs: make('x', (v: string) => ...), @Input(opts(...))) is checked the same way, including the helper's type parameters.

@Input and the other member decorators are also recognised through a namespace import (@core.Input), like @core.Component already was, but only when the namespace is import * as core from '@angular/core'. The check reads the import as written, so a resolvedImports rewrite of the path doesn't hide it.

Deliberate differences and known gaps:

  • Static method: for transform: Utils.coerce ngtsc emits the bare method name. That name isn't in scope, so it either throws or binds an unrelated same-named function; oxc keeps the written expression instead.
  • Other files: an imported transform can't be inspected from one file and is assumed to be a function (only the name clash is checked). A namespace member (u.g) reports could not be referenced, where ngtsc reads the file and can report a more specific error (e.g. cannot be generic). Errors about a transform declared in another file (an import, or a lib global like Number) point at the expression, since ngtsc's target is in a file oxc doesn't read.
  • declare global types aren't checked: oxc can't tell a new global from an augmented lib type.
  • Aliased imports: import {Input as In} isn't recognised; member decorators are matched by name.

A function expression that reaches the transform through a spread or a rest parameter is rejected with ngtsc's "Input transform must be a function" / "could not be determined statically".

@Input(...) options that come from another module (@Input(OPTS), ns.OPTS, {...OPTS}, or an imported alias/required) report the "imported from another module" error on the argument. Before, the input compiled without its alias, required flag or transform, and no error was shown.

A global declared outside the file (the DOM's atob, a project's declare function) is assumed to be a function, like an imported transform: it's emitted as written and only the ngAcceptInputType_ clash is checked. ES2022 lib non-functions (including Intl and Reflect) keep ngtsc's errors, members of globals stay "could not be determined statically", and a shorthand { transform } naming a global is still rejected, as in ngtsc.

Tests

Added to the ngtsc snapshot: Angular's input transform specs from ngtsc_spec (non-function values, generic, overloaded, spread, untyped, non-exported type, ngAcceptInputType clash), plus probes of each rule, of the type checks above and of how ngtsc describes values in these errors. Skipped with a reason: the three ngtsc_spec cases and the u.g probe that need declarations from another file, the static method, the aliased @Input, declare global, and the cases that differ only in where the error points (their messages are checked in a separate test). Other separate tests cover the static-method case, an overloaded static method, and namespaced member decorators for @angular/core versus another module.

ashley-hunter and others added 6 commits October 1, 2026 03:28
ngtsc rejects an input `transform` it can't prove is a usable function
(`parseDecoratorInputTransformFunction`); oxc accepted anything, so code that
fails to build with ngc compiled here. Both `@Input({ transform })` and
`inputs: [{ transform }]` are now checked, with ngtsc's diagnostics word for
word and in its order (`inputs:` entries, then members):

- not a function: literals, classes, calls, and function expressions reached
  through a variable, property access or parentheses (only one written directly
  as the property value is analyzable)
- generic, or overloaded (more than one call signature)
- first parameter untyped or a spread
- first parameter typed with a same-file type that isn't exported
- a `static ngAcceptInputType_<name>` member on the class

The evaluator learns functions to do this: same-file function declarations and
static methods resolve to their definitions. A transform reached through such a
reference (`T.f` where `const T = { f }`) is emitted as the function's own name,
as ngtsc does; a static method keeps the written expression, since ngtsc's bare
method name isn't in scope and would bind an unrelated same-named function.

`@Input` members are also found through a namespace import (`@core.Input`),
like `@core.Component` already was. Imported transforms can't be inspected from
one file and are assumed to be functions.
- Only a type reference named by a plain identifier is checked for being
  exported, so `typeof X` and `X.Y` are accepted. Interfaces and type
  aliases exported only through `export { X }` and type parameters (the
  class's, mapped, `infer`, generic signatures) are rejected.
- An overloaded function or static method is checked at its first
  declaration.
- The `ngAcceptInputType_` clash is checked for imported, namespaced and
  global transforms too; a global that isn't a function, or a member of
  one, is rejected.
- A static getter, setter or uninitialized property is reported at its
  declaration.
- `@ns.Input()` and the other member decorators are only recognised when
  `ns` is a namespace import of `@angular/core`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nd `@core.Input()` by its import

Checked against @angular/compiler-cli 22.1.7 (probes are in the fixture):

- A function expression passed to a same-file function
  (`make('x', (v: string) => v.length)`, or as a parameter's default)
  and returned as `{ transform }` is analyzable, as in ngtsc, instead of
  "Input transform must be a function". Named through the parameter
  (`transform: t`) it still isn't, like ngtsc.
- `@Input(opts(...))` emits the transform the options resolve to, as
  written where the directive is compiled (`booleanAttribute`), and a
  transform there that uses the helper's parameters is reported like one
  in `inputs:`.
- A transform written in a generic helper can't use the helper's type
  parameters: "Symbol must be exported ...", as ngtsc reports (the
  `.d.ts` would otherwise name a type that isn't in scope).
- `@core.Input()` and the other namespaced member decorators are found
  from the import as written. With `resolved_imports` mapping `core` to a
  file path, the `@angular/core` check failed and the inputs and outputs
  disappeared from the definition.

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

`const transform = (v: string) => v.length;` with
`inputs: [{name: 'x', transform}]` or `@Input({transform})` was rejected
("Input transform must be a function"); ngtsc 22.1.7 accepts it and
emits the arrow. ngtsc looks a shorthand property up by its declaration
(`visitDeclaration`), which reads the variable's initializer as is,
while naming the variable (`transform: transform`, `transform: t`)
wraps it in a dynamic value for that identifier, which it rejects. oxc
now does the same:

- A shorthand for a top-level variable whose initializer is an arrow or
  function expression (`const`/`let`/`var`, exported, typed, or
  destructured from an object or array literal) is checked as that
  function, and emits it. So are the objects it's in, spread or not,
  and `@Input(OPTS)`.
- Named, it's still "must be a function", and so is an initializer in
  parentheses, behind `as`, or naming another variable; their errors
  point at the initializer, as ngtsc's do.

Probes of each form are in the fixture (`shorthand-*`).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`@Input(OPTS)`, `@Input(ns.OPTS)`, `@Input({...OPTS})` and an imported
`alias` / `required` compiled the input without its alias, `required`
flag or transform, with no error. ngtsc reads the other file (with
`export const OPTS = {alias: 'y'}` it compiles the input as `y`); oxc
can't, so it now reports that the options depend on another module, as
it already does for `inputs:` entries.

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

ngtsc resolves a global like the DOM's `atob` or a project's
`declare function f(v: string): number` to its declaration, and accepts it as
an input transform. oxc only knew the ES2022 lib, so it reported "Input
transform must be a function" for these.

A name the file doesn't declare or import, and that isn't an ES2022 lib global,
is now assumed to be a function, like an imported transform: it's emitted as
written and only the `ngAcceptInputType_` clash is checked. Everywhere else it
stays dynamic, as before, and so does a shorthand `{ transform }` naming one,
which ngtsc never accepts. `Intl` and `Reflect` join the known lib globals (so
they keep ngtsc's "reference to" error), `globalThis` stays dynamic, and a
namespace or `import x = ...` declared in the file isn't taken for a global.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Brooooooklyn
Brooooooklyn force-pushed the feat/validate-input-transforms branch from 78402ae to 67ce48b Compare September 30, 2026 19:38
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