Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const ANY_EXPRESSION = ts.factory.createAsExpression(
ts.factory.createNumericLiteral('0'),
ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
);

const UNDEFINED = ts.factory.createIdentifier('undefined');

const UNARY_OPS = new Map<string, ts.PrefixUnaryOperator>([
Expand Down
25 changes: 17 additions & 8 deletions packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2902,7 +2902,9 @@ function tcbCallTypeCtor(
const typeCtor = tcb.env.typeCtorFor(dir);

// Construct an array of `ts.PropertyAssignment`s for each of the directive's inputs.
const members = inputs.map((input) => {
let members: ts.PropertyAssignment[] = [];
let boundInputs: ts.TypeNode[] = [];
for (const input of inputs) {
const propertyName = ts.factory.createStringLiteral(input.field);

if (input.type === 'binding') {
Expand All @@ -2918,18 +2920,25 @@ function tcbCallTypeCtor(
wrapForDiagnostics(expr),
);
addParseSpanInfo(assignment, input.sourceSpan);
return assignment;
} 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.
return ts.factory.createPropertyAssignment(propertyName, ANY_EXPRESSION);
members.push(assignment);
boundInputs.push(ts.factory.createLiteralTypeNode(propertyName));
}
});
}

const boundInputTypeArgument =
boundInputs.length > 0
? ts.factory.createUnionTypeNode(boundInputs)
: ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword);
const typeCtorInferrer = ts.factory.createCallExpression(
typeCtor,
[boundInputTypeArgument],
undefined,
);

// Call the `ngTypeCtor` method on the directive class, with an object literal argument created
// from the matched inputs.
return ts.factory.createCallExpression(
/* expression */ typeCtor,
/* expression */ typeCtorInferrer,
/* typeArguments */ undefined,
/* argumentsArray */ [ts.factory.createObjectLiteralExpression(members)],
);
Expand Down
127 changes: 103 additions & 24 deletions packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,51 @@ import {ReferenceEmitEnvironment} from './reference_emit_environment';
import {checkIfGenericTypeBoundsCanBeEmitted} from './tcb_util';
import {tsCreateTypeQueryForCoercedInput} from './ts_util';

/**
* From
* ```ts
* const _ngTypeCtor = <T>(init: Pick<NgForOf<T>, 'ngForOf'>): NgForOf<T>;
* ```
* changed to
* ```ts
* const _ngTypeCtor = <K extends keyof i0.Dir<never>>() => <T = string>(init: Pick<i0.Dir<T>, K>) => i0.Dir<T>;
* ```
*
* and from
* ```ts
* static ngTypeCtor<T>(init: Pick<NgForOf<T>, 'ngForOf'>): NgForOf<T>;
* ```
* changed to
* ```ts
* static ngTypeCtor<K extends keyof i0.Dir<never>>(): <T = string>(init: Pick<i0.Dir<T>, K>) => i0.Dir<T>;
* ```
*
* The idea is to make the type constructor context-sensitive w.r.t. the actually bound inputs, as injecting
* unbound inputs using either `0 as any` or `0 as never` has unintended side-effects, as does using `Partial` as it
* clobbers the input types with `undefined`, harming inference results.
*/

export function generateTypeCtorDeclarationFn(
env: ReferenceEmitEnvironment,
meta: TypeCtorMetadata,
nodeTypeRef: ts.EntityName,
typeParams: ts.TypeParameterDeclaration[] | undefined,
): ts.Statement {
const rawTypeArgs = typeParams !== undefined ? generateGenericArgs(typeParams) : undefined;
const rawType = ts.factory.createTypeReferenceNode(nodeTypeRef, rawTypeArgs);

const initParam = constructTypeCtorParameter(env, meta, rawType);

const typeParameters = typeParametersWithDefaultTypes(typeParams);
const boundInputsTypeParam = createTypeCtorBoundInputsTypeParam(nodeTypeRef, typeParams);
const boundInputsType = ts.factory.createTypeReferenceNode(boundInputsTypeParam.name);
const inferenceSignature = createTypeCtorInferenceSignature(
env,
meta,
nodeTypeRef,
typeParams,
boundInputsType,
);

if (meta.body) {
const fnType = ts.factory.createFunctionTypeNode(
/* typeParameters */ typeParameters,
/* parameters */ [initParam],
/* type */ rawType,
/* typeParameters */ [boundInputsTypeParam],
/* parameters */ [],
/* type */ inferenceSignature,
);

const decl = ts.factory.createVariableDeclaration(
Expand All @@ -52,14 +79,55 @@ export function generateTypeCtorDeclarationFn(
/* modifiers */ [ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword)],
/* asteriskToken */ undefined,
/* name */ meta.fnName,
/* typeParameters */ typeParameters,
/* parameters */ [initParam],
/* type */ rawType,
/* typeParameters */ [boundInputsTypeParam],
/* parameters */ [],
/* type */ inferenceSignature,
/* body */ undefined,
);
}
}

function createTypeCtorInferenceSignature(
env: ReferenceEmitEnvironment,
meta: TypeCtorMetadata,
nodeTypeRef: ts.EntityName,
typeParams: ReadonlyArray<ts.TypeParameterDeclaration> | undefined,
boundInputsType: ts.TypeNode,
) {
const rawTypeArgs = typeParams !== undefined ? generateGenericArgs(typeParams) : undefined;
const rawType = ts.factory.createTypeReferenceNode(nodeTypeRef, rawTypeArgs);

const initParam = constructTypeCtorParameter(env, meta, rawType, boundInputsType);

const typeParameters = typeParametersWithDefaultTypes(typeParams);

return ts.factory.createFunctionTypeNode(
/* typeParameters */ typeParameters,
/* parameters */ [initParam],
/* type */ rawType,
);
}

function createTypeCtorBoundInputsTypeParam(
nodeTypeRef: ts.EntityName,
typeParams: ReadonlyArray<ts.TypeParameterDeclaration> | undefined,
) {
let neverTypeParams: ts.TypeNode[] | undefined = undefined;
if (typeParams !== undefined && typeParams.some((param) => param.default !== undefined)) {
neverTypeParams = typeParams.map(() =>
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword),
);
}

const parameterizedType = ts.factory.createTypeReferenceNode(nodeTypeRef, neverTypeParams);
const constraint = ts.factory.createTypeOperatorNode(
ts.SyntaxKind.KeyOfKeyword,
parameterizedType,
);

return ts.factory.createTypeParameterDeclaration(undefined, 'NgBoundInputs', constraint);
}

/**
* Generate an inline type constructor for the given class and metadata.
*
Expand All @@ -72,16 +140,20 @@ export function generateTypeCtorDeclarationFn(
*
* An inline type constructor for NgFor looks like:
*
* ```ts
* static ngTypeCtor<T>(init: Pick<NgForOf<T>, 'ngForOf'|'ngForTrackBy'|'ngForTemplate'>):
* NgForOf<T>;
* ```
*
* A typical constructor would be:
*
* ```ts
* NgForOf.ngTypeCtor(init: {
* ngForOf: ['foo', 'bar'],
* ngForTrackBy: null as any,
* 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.
Expand All @@ -103,11 +175,15 @@ export function generateInlineTypeCtor(
// Build rawType, a `ts.TypeNode` of the class with its generic parameters passed through from
// the definition without any type bounds. For example, if the class is
// `FooDirective<T extends Bar>`, its rawType would be `FooDirective<T>`.
const rawTypeArgs =
node.typeParameters !== undefined ? generateGenericArgs(node.typeParameters) : undefined;
const rawType = ts.factory.createTypeReferenceNode(node.name, rawTypeArgs);

const initParam = constructTypeCtorParameter(env, meta, rawType);
const boundInputsTypeParam = createTypeCtorBoundInputsTypeParam(node.name, node.typeParameters);
const boundInputsType = ts.factory.createTypeReferenceNode(boundInputsTypeParam.name);
const inferenceSignature = createTypeCtorInferenceSignature(
env,
meta,
node.name,
node.typeParameters,
boundInputsType,
);

// If this constructor is being generated into a .ts file, then it needs a fake body. The body
// is set to a return of `null!`. If the type constructor is being generated into a .d.ts file,
Expand All @@ -125,9 +201,9 @@ export function generateInlineTypeCtor(
/* asteriskToken */ undefined,
/* name */ meta.fnName,
/* questionToken */ undefined,
/* typeParameters */ typeParametersWithDefaultTypes(node.typeParameters),
/* parameters */ [initParam],
/* type */ rawType,
/* typeParameters */ [boundInputsTypeParam],
/* parameters */ [],
/* type */ inferenceSignature,
/* body */ body,
);
}
Expand All @@ -136,6 +212,7 @@ function constructTypeCtorParameter(
env: ReferenceEmitEnvironment,
meta: TypeCtorMetadata,
rawType: ts.TypeReferenceNode,
inferredBoundInputs: ts.TypeNode,
): ts.ParameterDeclaration {
// initType is the type of 'init', the single argument to the type constructor method.
// If the Directive has any inputs, its initType will be:
Expand Down Expand Up @@ -215,6 +292,8 @@ function constructTypeCtorParameter(
if (initType === null) {
// Special case - no inputs, outputs, or other fields which could influence the result type.
initType = ts.factory.createTypeLiteralNode([]);
} else {
initType = ts.factory.createTypeReferenceNode('Pick', [initType, inferredBoundInputs]);
}

// Create the 'init' parameter itself.
Expand Down Expand Up @@ -249,7 +328,7 @@ export function requiresInlineTypeCtor(
* fails. This can happen when inferring a complex type from 'any'. For example, if `NgFor`'s
* inference is done with the TCB code:
*
* ```
* ```ts
* class NgFor<T> {
* ngForOf: T[];
* }
Expand All @@ -260,14 +339,14 @@ export function requiresInlineTypeCtor(
*
* An invocation looks like:
*
* ```
* ```ts
* var _t1 = ctor({ngForOf: [1, 2], ngForTrackBy: null as any, ngForTemplate: null as any});
* ```
*
* This correctly infers the type `NgFor<number>` for `_t1`, since `T` is inferred from the
* assignment of type `number[]` to `ngForOf`'s type `T[]`. However, if `any` is passed instead:
*
* ```
* ```ts
* var _t2 = ctor({ngForOf: [1, 2] as any, ngForTrackBy: null as any, ngForTemplate: null as
* any});
* ```
Expand All @@ -278,7 +357,7 @@ export function requiresInlineTypeCtor(
* Adding a default type to the generic declaration in the constructor solves this problem, as
* the default type will be used in the event that inference fails.
*
* ```
* ```ts
* declare function ctor<T = any>(o: Pick<NgFor<T>, 'ngForOf'>): NgFor<T>;
*
* var _t3 = ctor({ngForOf: [1, 2] as any});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,9 @@ describe('type check blocks', () => {
];
const actual = tcb(TEMPLATE, DIRECTIVES);
expect(actual).toContain(
'const _ctor1: <T extends string = any>(init: Pick<i0.Dir<T>, "fieldA" | "fieldB">) => i0.Dir<T> = null!;',
);
expect(actual).toContain(
'var _t1 = _ctor1({ "fieldA": (((this).foo)), "fieldB": 0 as any });',
'const _ctor1: <NgBoundInputs extends keyof i0.Dir>() => <T extends string = any>(init: Pick<Pick<i0.Dir<T>, "fieldA" | "fieldB">, NgBoundInputs>) => i0.Dir<T> = null!;',
);
expect(actual).toContain('var _t1 = _ctor1<"fieldA">()({ "fieldA": (((this).foo)) });');
});

it('should handle multiple bindings to the same property', () => {
Expand Down Expand Up @@ -214,10 +212,12 @@ describe('type check blocks', () => {

const actual = tcb(TEMPLATE, DIRECTIVES);
expect(actual).toContain(
'const _ctor1: <T extends string = any>(init: Pick<i0.Dir<T>, "input">) => i0.Dir<T> = null!;',
'const _ctor1: <NgBoundInputs extends keyof i0.Dir>() => <T extends string = any>(init: Pick<Pick<i0.Dir<T>, "input">, NgBoundInputs>) => i0.Dir<T> = null!;',
);
expect(actual).toContain(
'var _t2 = _ctor1({ "input": (null!) }); ' + 'var _t1 = _t2; ' + '_t2.input = (_t1);',
'var _t2 = _ctor1<"input">()({ "input": (null!) }); ' +
'var _t1 = _t2; ' +
'_t2.input = (_t1);',
);
});

Expand Down Expand Up @@ -246,12 +246,12 @@ describe('type check blocks', () => {
];
const actual = tcb(TEMPLATE, DIRECTIVES);
expect(actual).toContain(
'const _ctor1: <T extends string = any>(init: Pick<i0.DirA<T>, "inputA">) => i0.DirA<T> = null!; const _ctor2: <T extends string = any>(init: Pick<i0.DirB<T>, "inputB">) => i0.DirB<T> = null!;',
'const _ctor1: <NgBoundInputs extends keyof i0.DirA>() => <T extends string = any>(init: Pick<Pick<i0.DirA<T>, "inputA">, NgBoundInputs>) => i0.DirA<T> = null!; const _ctor2: <NgBoundInputs extends keyof i0.DirB>() => <T extends string = any>(init: Pick<Pick<i0.DirB<T>, "inputB">, NgBoundInputs>) => i0.DirB<T> = null!;',
);
expect(actual).toContain(
'var _t4 = _ctor1({ "inputA": (null!) }); ' +
'var _t4 = _ctor1<"inputA">()({ "inputA": (null!) }); ' +
'var _t3 = _t4; ' +
'var _t2 = _ctor2({ "inputB": (_t3) }); ' +
'var _t2 = _ctor2<"inputB">()({ "inputB": (_t3) }); ' +
'var _t1 = _t2; ' +
'_t4.inputA = (_t1); ' +
'_t2.inputB = (_t3);',
Expand Down Expand Up @@ -725,10 +725,10 @@ describe('type check blocks', () => {
];
const block = tcb(TEMPLATE, DIRECTIVES);
expect(block).toContain(
'const _ctor1: <T extends string = any>(init: Pick<i0.TwoWay<T>, "input">) => i0.TwoWay<T> = null!',
'const _ctor1: <NgBoundInputs extends keyof i0.TwoWay>() => <T extends string = any>(init: Pick<Pick<i0.TwoWay<T>, "input">, NgBoundInputs>) => i0.TwoWay<T> = null!',
);
expect(block).toContain(
'var _t1 = _ctor1({ "input": (i1.ɵunwrapWritableSignal(((this).value))) });',
'var _t1 = _ctor1<"input">()({ "input": (i1.ɵunwrapWritableSignal(((this).value))) });',
);
expect(block).toContain('_t1.input = i1.ɵunwrapWritableSignal((((this).value)));');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ TestClass.ngTypeCtor({value: 'test'});
const typeCtor = TestClassWithCtor.members.find(isTypeCtor)!;
const ctorText = typeCtor.getText().replace(/[ \r\n]+/g, ' ');
expect(ctorText).toContain(
'init: Pick<TestClass, "foo"> & { bar: typeof TestClass.ngAcceptInputType_bar; baz: boolean | string; }',
'init: Pick<Pick<TestClass, "foo"> & { bar: typeof TestClass.ngAcceptInputType_bar; baz: boolean | string; }, NgBoundInputs>',
);
});
});
Expand Down
Loading