Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ export function generateTypeCtorDeclarationFn(
* ngForTemplate: null as any,
* }); // Infers a type of NgForOf<string>.
*
* Any inputs declared on the type for which no property binding is present are assigned a value of
* type `any`, to avoid producing any type errors for unset inputs.
* Unbound non-signal inputs are assigned `0 as any` so generic inference falls back to the type
* parameter default. Unbound signal inputs are omitted (init is `Partial` over signal keys) so
* they do not poison inference and defeat TypeScript's `NoInfer` on other inputs.
*
* Inline type constructors are used when the type being created has bounded generic types which
* make writing a declared type constructor (via `generateTypeCtorDeclarationFn`) difficult or
Expand Down Expand Up @@ -118,7 +119,11 @@ function constructTypeCtorParameter(
// Pick<rawType, 'inputA'|'inputB'>
//
// Pick here is used to select only those fields from which the generic type parameters of the
// directive will be inferred.
// directive will be inferred. Unbound non-signal inputs are filled with `any` in the call.
//
// Unbound signal inputs are omitted from the call; the init type includes
// `Partial<UnwrapDirectiveSignalInputs<...>>` so they do not poison inference and defeat
// TypeScript's `NoInfer` on other inputs.
//
// In the special case there are no inputs, initType is set to {}.
let initType: string | null = null;
Expand Down Expand Up @@ -160,12 +165,13 @@ function constructTypeCtorParameter(
if (signalInputKeys.length > 0) {
const keyTypeUnion = signalInputKeys.join(' | ');

// Construct the UnwrapDirectiveSignalInputs<rawType, keyTypeUnion>.
// Construct Partial<UnwrapDirectiveSignalInputs<rawType, keyTypeUnion>> so unbound signal
// inputs are omitted rather than filled with `any` (which defeats `NoInfer`).
const unwrapRef = env.referenceExternalSymbol(
R3Identifiers.UnwrapDirectiveSignalInputs.moduleName,
R3Identifiers.UnwrapDirectiveSignalInputs.name,
);
const unwrapExpr = `${unwrapRef.print()}<${typeRefWithGenerics}, ${keyTypeUnion}>`;
const unwrapExpr = `Partial<${unwrapRef.print()}<${typeRefWithGenerics}, ${keyTypeUnion}>>`;
initType = initType !== null ? `${initType} & ${unwrapExpr}` : unwrapExpr;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,89 @@ runInEachFileSystem(() => {
),
],
},
// NoInfer on model write types (#69373): unbound inputs must not poison inference with `any`.
{
id: 'NoInfer blocks inference from model value, two-way, mode omitted',
inputs: {
value: {type: 'ModelSignal<NoInfer<Value<T>> | null>', isSignal: true},
mode: {type: 'InputSignal<T>', isSignal: true},
},
outputs: {valueChange: {type: 'ModelSignal<NoInfer<Value<T>> | null>'}},
extraFileContent: `
type Mode = 'single' | 'range';
type ValueByMode = {single: string; range: readonly [string, string]};
type Value<T extends Mode = 'single'> = ValueByMode[T];
`,
directiveGenerics: `<T extends Mode = 'single'>`,
component: `
single: string | null = null;
range: readonly [string, string] | null = null;
`,
template: `<div dir [(value)]="range">`,
expected: [
jasmine.stringContaining(
`Type 'readonly [string, string]' is not assignable to type 'string'`,
),
],
},
{
id: 'NoInfer allows single value when mode omitted, two-way',
inputs: {
value: {type: 'ModelSignal<NoInfer<Value<T>> | null>', isSignal: true},
mode: {type: 'InputSignal<T>', isSignal: true},
},
outputs: {valueChange: {type: 'ModelSignal<NoInfer<Value<T>> | null>'}},
extraFileContent: `
type Mode = 'single' | 'range';
type ValueByMode = {single: string; range: readonly [string, string]};
type Value<T extends Mode = 'single'> = ValueByMode[T];
`,
directiveGenerics: `<T extends Mode = 'single'>`,
component: `single: string | null = null;`,
template: `<div dir [(value)]="single">`,
expected: [],
},
{
id: 'NoInfer still allows inference from mode input, two-way',
inputs: {
value: {type: 'ModelSignal<NoInfer<Value<T>> | null>', isSignal: true},
mode: {type: 'InputSignal<T>', isSignal: true},
},
outputs: {valueChange: {type: 'ModelSignal<NoInfer<Value<T>> | null>'}},
extraFileContent: `
type Mode = 'single' | 'range';
type ValueByMode = {single: string; range: readonly [string, string]};
type Value<T extends Mode = 'single'> = ValueByMode[T];
`,
directiveGenerics: `<T extends Mode = 'single'>`,
component: `
range: readonly [string, string] | null = null;
rangeMode = 'range' as const;
`,
template: `<div dir [mode]="rangeMode" [(value)]="range">`,
expected: [],
},
{
id: 'NoInfer blocks inference from model value, one-way, mode omitted',
inputs: {
value: {type: 'ModelSignal<NoInfer<Value<T>> | null>', isSignal: true},
mode: {type: 'InputSignal<T>', isSignal: true},
},
outputs: {valueChange: {type: 'ModelSignal<NoInfer<Value<T>> | null>'}},
extraFileContent: `
type Mode = 'single' | 'range';
type ValueByMode = {single: string; range: readonly [string, string]};
type Value<T extends Mode = 'single'> = ValueByMode[T];
`,
directiveGenerics: `<T extends Mode = 'single'>`,
component: `range: readonly [string, string] | null = null;`,
template: `<div dir [value]="range">`,
expected: [
jasmine.stringContaining(
`Type 'readonly [string, string]' is not assignable to type 'string'`,
),
],
},
];

generateDiagnoseJasmineSpecs(bindingCases);
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export function typescriptLibDts(): TestFile {
type Partial<T> = { [P in keyof T]?: T[P]; };
type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
type NonNullable<T> = T extends null | undefined ? never : T;
// Approximation of TypeScript's intrinsic NoInfer for the minimal test lib.
type NoInfer<T> = [T][T extends any ? 0 : never];

// The following native type declarations are required for proper type inference
declare interface Function {
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/src/typecheck/ops/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export interface TcbDirectiveUnsetInput {
* The name of a field on the directive for which no input binding is present.
*/
field: string;

/**
* Whether the unset field is a signal input. Unbound signal inputs are omitted from the type
* constructor call so they do not poison generic inference with `any` and defeat `NoInfer`.
*/
isSignal: boolean;
}

export type TcbDirectiveInput = TcbDirectiveBoundInput | TcbDirectiveUnsetInput;
Expand Down
31 changes: 18 additions & 13 deletions packages/compiler/src/typecheck/ops/directive_constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ export class TcbDirectiveCtorOp extends TcbOp {
}

// Add unset directive inputs for each of the remaining unset fields.
for (const {classPropertyName} of this.dir.inputs) {
for (const {classPropertyName, isSignal} of this.dir.inputs) {
if (!genericInputs.has(classPropertyName)) {
genericInputs.set(classPropertyName, {type: 'unset', field: classPropertyName});
genericInputs.set(classPropertyName, {type: 'unset', field: classPropertyName, isSignal});
}
}

// Call the type constructor of the directive to infer a type, and assign the directive
// instance.
// instance. Unbound signal inputs are omitted so they do not poison inference with `any`
// fillers and so `NoInfer` on bound inputs is respected.
const typeCtor = tcbCallTypeCtor(this.dir, this.tcb, Array.from(genericInputs.values()));
typeCtor.markIgnoreDiagnostics();
this.scope.addStatement(new TcbExpr(`var ${id.print()} = ${typeCtor.print()}`));
Expand Down Expand Up @@ -173,16 +174,18 @@ function tcbCallTypeCtor(
inputs: TcbDirectiveInput[],
): TcbExpr {
const typeCtor = tcb.env.typeCtorFor(dir);

let literal = '{ ';

// Construct an object literal containing each directive input.
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
// Construct an object literal containing each directive input. Unbound signal inputs are omitted
// so they do not poison generic inference with `any` fillers (and so `NoInfer` works). Unbound
// non-signal inputs are assigned `0 as any` so generic inference falls back to the type
// parameter default.
const parts: string[] = [];
for (const input of inputs) {
const propertyName = TcbExpr.quoteAndEscape(input.field);
const isLast = i === inputs.length - 1;

if (input.type === 'binding') {
// For bound inputs, the property is assigned the binding expression.
let expr = widenBinding(input.expression, tcb, input.originalExpression);

if (input.isTwoWayBinding && tcb.env.config.allowSignalsInTwoWayBindings) {
Expand All @@ -191,13 +194,15 @@ function tcbCallTypeCtor(

const assignment = new TcbExpr(`${propertyName}: ${expr.wrapForTypeChecker().print()}`);
assignment.addParseSpanInfo(input.sourceSpan);
literal += assignment.print();
} else {
// A type constructor is required to be called with all input properties, so any unset
// inputs are simply assigned a value of type `any` to ignore them.
literal += `${propertyName}: 0 as any`;
parts.push(assignment.print());
} else if (!input.isSignal) {
parts.push(`${propertyName}: 0 as any`);
}
}

for (let i = 0; i < parts.length; i++) {
const isLast = i === parts.length - 1;
literal += parts[i];
literal += `${isLast ? '' : ','} `;
}

Expand Down
Loading