Skip to content

fix(spectator): resolve input aliases in props and setInput - #16

Open
nicobytes wants to merge 7 commits into
openng-org:mainfrom
nicobytes:fix/props-input-aliases
Open

fix(spectator): resolve input aliases in props and setInput#16
nicobytes wants to merge 7 commits into
openng-org:mainfrom
nicobytes:fix/props-input-aliases

Conversation

@nicobytes

@nicobytes nicobytes commented Aug 26, 2026

Copy link
Copy Markdown

Fixes #15.

Problem

props and setInput() both funnel into setProps(), which calls ComponentRef.setInput(key, value). Angular resolves inputs by their public name (the alias). But both public APIs are typed by the class property name:

  • props?: InferInputSignals<C> — keys are keyof C
  • setInput<K extends keyof C>(input: K, ...)

So the keys TypeScript advertises are exactly the keys that fail at runtime. This is wider than the issue title: neither API accepts the class property name.

Call TypeScript Runtime
setInput('userName', v) — alias OK works
setInput('name', v) — property name OK (keyof C overload) NG0303, silently ignored
props: { name: v } — property name OK NG0303, silently ignored
props: { userName: v } — alias TS2353 works
props: { name: v } + input.required({ alias }) OK throws NG0950

The silent rows are the dangerous ones — the test just renders empty, with no obvious cause.

Fix

1. Runtime — resolve the key before handing it to Angular

A new internals/resolve-input-name.ts builds a resolver from reflectComponentType(type).inputs ({ propName, templateName, isSignal }, which covers decorator inputs, input(), input.required() and model()), and setProps() maps every key through it:

  • a public name match wins, so existing behaviour is preserved exactly;
  • otherwise fall back to propName → templateName;
  • an unknown key is passed through untouched, so Angular still reports it (NG0303).

Public-name precedence is required, not cosmetic. A key can be the templateName of one input and the propName of another:

class C {
  @Input({ alias: 'age' }) numOfYears = 0;  // templateName 'age'
  @Input({ alias: 'collided' }) age = '';   // propName  'age'
}

props: { age: 1 } sets numOfYears today, and still does. There is a test pinning it.

setHostProps() is deliberately not touched — it assigns onto the host component instance, where the class property name is already correct. So createHostFactory / createDirectiveFactory / createPipeFactory are unaffected.

2. Types — let alias keys through without loosening what matters

Aliases cannot be derived from the type system: neither @Input('userName') nor input.required({ alias: 'userName' }) encodes the literal in the type. An index signature is the only way in. I type-checked the candidates with tsc --strict rather than guessing:

Candidate alias keys value types on known inputs pre-declared props object
InferInputSignals<C> (status quo) TS2353 ✅ enforced ✅ assignable
… | Record<string, unknown> lost
… & Record<string, unknown> breaks interface/class-typed
T | (T & Record<string, unknown>)

The plain union lets props: { name: 123 } compile clean on a string input. The plain intersection is a breaking change for a common pattern, since interfaces get no implicit index signature:

interface MyProps { name: string }
const p: MyProps = { name: 'John' };
createComponent({ props: p });
// TS2322: Index signature for type 'string' is missing in type 'MyProps'

So this PR ships the last row:

export type InferInputProps<C> = InferInputSignals<C> | (InferInputSignals<C> & Record<string, unknown>);

Verified against the real Angular types: alias keys accepted; interface, type-alias and inferred-const sources still assignable; and { name: 999 }, { sigReq: 'nope' }, { sigOpt: input(5) }, { mdl: 42 } all still rejected.

Accepted trade-off, stated plainly: an unknown key in a props object literal is no longer a compile error. That is unavoidable — TypeScript cannot tell an alias from a typo. Runtime NG0303 remains the guard, which is exactly why the resolver passes unknown keys through instead of swallowing them.

The runtime fix and the type change are separate commits so the type trade-off can be reviewed, or rejected, without losing the runtime fix. The runtime fix alone already closes the issue's core complaint, since it makes the property name — what the type already advertises — actually work.

Result

@Component({ /* … */ })
class UserComponent {
  @Input('userName') name = '';
  age = input.required<number>({ alias: 'userAge' });
}

// before: NG0303 (silent) / NG0950 (throw) -> had to createComponent() then setInput()
// after: both spellings work
createComponent({ props: { name: 'John', age: 30 } });        // class property names
createComponent({ props: { userName: 'John', userAge: 30 } }); // public aliases

Known limitation

When a key is the public name of one input and a property of another, the type
describes the property while the resolver resolves the public name. So a collision
is only expressible in props when the two inputs agree on their value type:

class C {
  @Input({ alias: 'age' }) numOfYears = 0;   // public name 'age', number
  @Input({ alias: 'collided' }) age = '';    // property   'age', string
}

createComponent({ props: { age: 1 } });
// TS2322: type 'number' is not assignable to type 'string'
// — the type sees the `age` property; the runtime sets `numOfYears`

The runtime side is unchanged from today and there is a test pinning it. Nothing to
do about the type: it cannot describe two inputs sharing one name. Worth knowing,
not worth blocking on — Karma surfaced it while type-checking the specs.

Verification

yarn test (Karma) 341/341 — this one type-checks the specs, so it also validates the type change under real compilation
yarn test:jest 47 suites, 257 tests
yarn test:vitest 47 files, 227 tests
yarn build && yarn test:types clean for all three entry points
yarn lint 0 errors; the 3 warnings are pre-existing on main (verified by comparison)

Tests

New props-alias-names.spec.ts, triplicated across the three suites (identical but for the import specifier), mirroring the layout of the existing set-input-alias-names.spec.ts. 15 cases:

  • props + property name for @Input('x'), @Input({alias}), input.required({alias}), input(d, {alias}), model({alias})
  • props + alias names (regression guard)
  • props mixing both spellings in one object
  • setInput() + property name on an aliased input
  • createRoutingFactory({ props }), both spellings
  • inputs with no alias, unaffected
  • an unknown key is not redirected onto another input
  • the public-name/property-name collision above

At the tests-first commit 8 fail with NG0303 / NG0950 and 7 pass as regression guards, so the RED phase is visible in the history.

🤖 Generated with Claude Code

nicobytes and others added 6 commits August 26, 2026 10:27
Adds the failing cases for openng-org#15 across the Karma, Jest and Vitest suites,
before any source change.

Neither `props` nor `setInput()` accepts the class property name of an input
that declares an alias, even though that is exactly what both types advertise
(`props?: InferInputSignals<C>` and `setInput<K extends keyof C>`). Angular
resolves inputs by their public name, so the property name reaches
`ComponentRef.setInput()` unmapped and is dropped with NG0303 -- silently for
optional inputs, and as a thrown NG0950 for `input.required({ alias })`.

8 of the 15 new tests fail:

  - props + property name, @input('userName') / @input({ alias })
  - props + property name, input.required({ alias }) / input(d, { alias }) / model({ alias })
  - props mixing property names and alias names
  - setInput() + property name on an aliased input
  - createRoutingFactory({ props }) + property name

The other 7 pass and stand as regression guards for behaviour that must not
change: alias keys keep working, non-aliased inputs are unaffected, an unknown
key is still reported rather than redirected, and a key that is both the public
name of one input and the property name of another still resolves as the public
name.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ComponentRef.setInput()` addresses inputs by their public name, so an input
declaring an alias was only reachable through that alias. Both `props` and
`setInput()` are typed against the class property names, so the keys the types
advertise were exactly the keys that failed at runtime -- dropped with NG0303,
silently for optional inputs and as a thrown NG0950 for `input.required({ alias })`.

`setProps()` now maps every key through a resolver built from
`reflectComponentType(type).inputs`, which covers decorator inputs, `input()`,
`input.required()` and `model()`:

  - a public-name match wins, so existing behaviour is preserved exactly;
  - otherwise the property name is mapped to the input's public name;
  - an unknown key is passed through untouched, so Angular still reports it
    rather than it being silently redirected onto another input.

Public-name precedence is load-bearing: a key can be the public name of one
input and the property name of another, and `props: { age: 1 }` must keep
resolving the way `setInput()` has always resolved it.

`setHostProps()` is deliberately left alone -- it assigns onto the host
component instance, where the class property name is already correct, so
`createHostFactory`, `createDirectiveFactory` and `createPipeFactory` are
unaffected.

`createRoutingFactory` shares `setProps()` and is fixed by the same change.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`props` was typed `InferInputSignals<C>`, keyed by the class property names, so
addressing an input by its public name was a TS2353 even though it was the only
spelling that worked at runtime before the previous commit.

Adds `InferInputProps<C>` and applies it to `SpectatorOverrides.props`, which
`createRoutingFactory` shares. Aliases cannot be derived from the type system --
neither `@Input('userName')` nor `input({ alias: 'userName' })` carries the
literal into the type -- so an index signature is the only way in.

The form matters. Both simpler candidates were type-checked and rejected:

  - `InferInputSignals<C> | Record<string, unknown>` silently drops value-type
    checking, letting `props: { name: 123 }` compile on a string input.
  - `InferInputSignals<C> & Record<string, unknown>` breaks any `props` object
    typed through an `interface` or a class, since those get no implicit index
    signature.

The union of the mapped type with its own intersection keeps both: value types
are still checked for every known input, and `interface`, type-alias,
inferred-const and `Record<string, unknown>` sources all stay assignable.

The one thing it can no longer reject is an unknown key in a `props` object
literal, since TypeScript cannot tell an alias from a typo. Angular still
reports that at runtime, which is why the resolver added in the previous commit
passes unknown keys through untouched instead of swallowing them.

`Spectator.setInput()` is left alone: its object overload already accepted
arbitrary public names, so only its runtime behaviour needed fixing.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The type tests compile against the built `d.ts` bundles, so these also prove
`InferInputProps` actually ships for every entry point -- the failure mode of openng-org#8
and openng-org#11.

Covers both spellings, and a `props` object typed through an interface, which is
the guard against typing `props` as a plain intersection with `Record`. The
negative control is the important one: it pins that value types are still
checked for known inputs, which is what separates the shipped type from a plain
union with `Record<string, unknown>`.

Signal inputs rather than decorators, since these tsconfigs are standalone and
do not enable `experimentalDecorators`.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Karma type-checks the specs, and the collision fixture did not compile: `age` is
the public name of the `numOfYears` input and also a property of its own, so the
type describes the property while the resolver resolves the public name.

That divergence is inherent -- the type cannot express a collided name -- so the
fixture now gives both inputs the same value type. The test still pins that the
key resolves as a public name, which is the behaviour that has to stay stable.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes
nicobytes marked this pull request as ready for review August 26, 2026 14:36
Copilot AI lite review requested due to automatic review settings August 26, 2026 14:36

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.

Pull request overview

This PR fixes Spectator’s props and setInput() behavior for Angular inputs that use public-name aliases (including decorator inputs, signal input()/input.required(), and model()), by resolving provided keys to the public input name that ComponentRef.setInput() expects. It also widens the props typing to accept alias keys while preserving strong typing for known inputs, and adds tests + docs to lock in the behavior.

Changes:

  • Add a runtime resolver that maps class property names to Angular’s public input names before calling ComponentRef.setInput().
  • Update props typing to allow alias keys without losing value-type checking for known inputs.
  • Add cross-runner tests (jasmine/jest/vitest), type tests, and documentation updates covering the new behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
type-tests/vitest/consumer.ts Adds type-level coverage demonstrating props accepts both property names and alias keys while keeping known input value types checked.
type-tests/jest/consumer.ts Same type-level coverage for the Jest entry point.
type-tests/jasmine/consumer.ts Same type-level coverage for the Jasmine/default entry point.
projects/spectator/vitest/test/props-alias-names.spec.ts Adds Vitest runtime tests for props/setInput() with aliased inputs, collisions, and unknown keys.
projects/spectator/jest/test/props-alias-names.spec.ts Adds Jest runtime tests mirroring the Vitest suite.
projects/spectator/test/props-alias-names.spec.ts Adds Jasmine/Karma runtime tests mirroring the Vitest suite.
projects/spectator/src/lib/types.ts Introduces InferInputProps<C> to widen accepted props keys (including aliases).
projects/spectator/src/lib/spectator/create-factory.ts Switches props typing in createComponentFactory pipeline to InferInputProps.
projects/spectator/src/lib/spectator-routing/create-factory.ts Switches routing factory props typing to InferInputProps.
projects/spectator/src/lib/internals/resolve-input-name.ts New internal helper building an input-name resolver via reflectComponentType(...).inputs.
projects/spectator/src/lib/internals/query.ts Uses the resolver inside setProps() to normalize keys before delegating to Angular.
docs/src/content/docs/testing-components.md Documents that props and setInput() accept both property names and public input names (aliases), and clarifies compile-time vs runtime validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread projects/spectator/src/lib/internals/query.ts
Review raised the concern that `setProps()` would throw on `props: undefined`,
since the non-string branch hands `keyOrKeyValues` straight to `for..in`.

It does not -- `for..in` over `null` or `undefined` is a spec-mandated no-op, not
a throw -- so no guard is added. These tests pin it instead, across the three
reachable shapes: explicit `props: undefined`, no overrides at all, and
`setInput()` given nothing.

Refs openng-org#15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicobytes

Copy link
Copy Markdown
Author

@dominicbachmann 🙏

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.

[Bug]: createComponent({ props }) does not support input aliases

3 participants