feat(directive): validate input transforms like ngtsc - #494
Open
ashley-hunter wants to merge 6 commits into
Open
ashley-hunter wants to merge 6 commits into
ashley-hunter wants to merge 6 commits into
Conversation
Brooooooklyn
added this pull request to stack #503
September 30, 2026 04:46
Brooooooklyn
force-pushed
the
feat/validate-input-transforms
branch
2 times, most recently
from
September 30, 2026 13:11
f8b746e to
2d8c9f8
Compare
Brooooooklyn
force-pushed
the
feat/validate-input-transforms
branch
2 times, most recently
from
September 30, 2026 19:07
46ddfe6 to
78402ae
Compare
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
force-pushed
the
feat/validate-input-transforms
branch
from
September 30, 2026 19:38
78402ae to
67ce48b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack (2/5): #493 inputs/outputs → #494 transform validation → #495
queries:→ #496.d.tstransform types → #504 signal API import identity. This is #494.Checks input
transforms the way ngtsc does, for both@Input({ transform })andinputs: [{ 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.The fix
Each rule matches ngtsc, with its diagnostic word for word and in its order (
inputs:entries, then members):satisfies, uninitializedlets, static getters/setters/fields without a value, theNumber/Stringglobals and members likeMath.round, and function expressions reached through a variable, property access or parentheses (only one written directly as the property value is analyzable)assertEmittableInputType, only type references by plain name are checked, sotypeof XandX.Ypass. Type parameters (the class's, mapped,infer, generic signatures) are rejected, and so are interfaces and type aliases exported only throughexport { X }.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 : borT?.fresolve to the function they pick. A transform reached through such a reference (T.fwhereconst T = { f }) is emitted as the function's own name, as ngtsc does. The lib functionsparseInt,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.@Inputand the other member decorators are also recognised through a namespace import (@core.Input), like@core.Componentalready was, but only when the namespace isimport * as core from '@angular/core'. The check reads the import as written, so aresolvedImportsrewrite of the path doesn't hide it.Deliberate differences and known gaps:
transform: Utils.coercengtsc 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.u.g) reportscould 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 likeNumber) point at the expression, since ngtsc's target is in a file oxc doesn't read.declare globaltypes aren't checked: oxc can't tell a new global from an augmented lib type.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 importedalias/required) report the "imported from another module" error on the argument. Before, the input compiled without its alias,requiredflag or transform, and no error was shown.A global declared outside the file (the DOM's
atob, a project'sdeclare function) is assumed to be a function, like an imported transform: it's emitted as written and only thengAcceptInputType_clash is checked. ES2022 lib non-functions (includingIntlandReflect) 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,ngAcceptInputTypeclash), plus probes of each rule, of the type checks above and of how ngtsc describes values in these errors. Skipped with a reason: the threengtsc_speccases and theu.gprobe 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/coreversus another module.