refactor(compiler-cli): use TypeScript transform to emit type parameters (#42492)

The template type checker is capable of recreating generic type bounds
in a different context, rewriting type references along the way (if
possible). This was previously done using a visitor that only supported
a limited set of types, resulting in the inability to emit all sorts of
types (even if they don't contain type references at all).

The inability to emit generic type bounds was not critical when the type
parameter emitting logic was introduced, as the compiler also has a
fallback strategy of creating inline type constructors. However, this
fallback is not available to the language service, resulting in
inaccurate types when components/directives use a complex generic type.

To mitigate this problem, the specialized visitor has been replaced with
a generalized TypeScript transform, where only type references get
special treatment. This allows for more complex types to be emitted,
such as union and intersection types, object literal types and tuple
types.

PR Close #42492
This commit is contained in:
JoostK 2021-06-05 22:05:03 +02:00 committed by Dylan Hunn
parent f52df99fe3
commit 16aaa23d4e
2 changed files with 53 additions and 55 deletions

View File

@ -20,6 +20,15 @@ export type ResolvedTypeReference = Reference|ts.TypeReferenceNode|null;
*/
export type TypeReferenceResolver = (type: ts.TypeReferenceNode) => ResolvedTypeReference;
/**
* A marker to indicate that a type reference is ineligible for emitting. This needs to be truthy
* as it's returned from `ts.forEachChild`, which only returns truthy values.
*/
type INELIGIBLE = {
__brand: 'ineligible';
};
const INELIGIBLE: INELIGIBLE = {} as INELIGIBLE;
/**
* Determines whether the provided type can be emitted, which means that it can be safely emitted
* into a different location.
@ -32,13 +41,24 @@ export function canEmitType(type: ts.TypeNode, resolver: TypeReferenceResolver):
return canEmitTypeWorker(type);
function canEmitTypeWorker(type: ts.TypeNode): boolean {
return visitTypeNode(type, {
visitTypeReferenceNode: type => canEmitTypeReference(type),
visitArrayTypeNode: type => canEmitTypeWorker(type.elementType),
visitKeywordType: () => true,
visitLiteralType: () => true,
visitOtherType: () => false,
});
return visitNode(type) !== INELIGIBLE;
}
// To determine whether a type can be emitted, we have to recursively look through all type nodes.
// If a type reference node is found at any position within the type and that type reference
// cannot be emitted, then the `INELIGIBLE` constant is returned to stop the recursive walk as
// the type as a whole cannot be emitted in that case. Otherwise, the result of visiting all child
// nodes determines the result. If no ineligible type reference node is found then the walk
// returns `undefined`, indicating that no type node was visited that could not be emitted.
function visitNode(node: ts.Node): INELIGIBLE|undefined {
// Emitting a type reference node in a different context requires that an import for the type
// can be created. If a type reference node cannot be emitted, `INELIGIBLE` is returned to stop
// the walk.
if (ts.isTypeReferenceNode(node) && !canEmitTypeReference(node)) {
return INELIGIBLE;
} else {
return ts.forEachChild(node, visitNode);
}
}
function canEmitTypeReference(type: ts.TypeReferenceNode): boolean {
@ -108,15 +128,17 @@ export class TypeEmitter {
}
emitType(type: ts.TypeNode): ts.TypeNode {
return visitTypeNode(type, {
visitTypeReferenceNode: type => this.emitTypeReference(type),
visitArrayTypeNode: type => ts.updateArrayTypeNode(type, this.emitType(type.elementType)),
visitKeywordType: type => type,
visitLiteralType: type => type,
visitOtherType: () => {
throw new Error('Unable to emit a complex type');
},
});
const typeReferenceTransformer: ts.TransformerFactory<ts.TypeNode> = context => {
const visitNode = (node: ts.Node): ts.Node => {
if (ts.isTypeReferenceNode(node)) {
return this.emitTypeReference(node);
} else {
return ts.visitEachChild(node, visitNode, context);
}
};
return node => ts.visitNode(node, visitNode);
};
return ts.transform(type, [typeReferenceTransformer]).transformed[0];
}
private emitTypeReference(type: ts.TypeReferenceNode): ts.TypeNode {
@ -151,40 +173,3 @@ export class TypeEmitter {
return ts.updateTypeReferenceNode(type, typeName, typeArguments);
}
}
/**
* Visitor interface that allows for unified recognition of the different types of `ts.TypeNode`s,
* so that `visitTypeNode` is a centralized piece of recognition logic to be used in both
* `canEmitType` and `TypeEmitter`.
*/
interface TypeEmitterVisitor<R> {
visitTypeReferenceNode(type: ts.TypeReferenceNode): R;
visitArrayTypeNode(type: ts.ArrayTypeNode): R;
visitKeywordType(type: ts.KeywordTypeNode): R;
visitLiteralType(type: ts.LiteralTypeNode): R;
visitOtherType(type: ts.TypeNode): R;
}
function visitTypeNode<R>(type: ts.TypeNode, visitor: TypeEmitterVisitor<R>): R {
if (ts.isTypeReferenceNode(type)) {
return visitor.visitTypeReferenceNode(type);
} else if (ts.isArrayTypeNode(type)) {
return visitor.visitArrayTypeNode(type);
} else if (ts.isLiteralTypeNode(type)) {
return visitor.visitLiteralType(type);
}
switch (type.kind) {
case ts.SyntaxKind.AnyKeyword:
case ts.SyntaxKind.UnknownKeyword:
case ts.SyntaxKind.NumberKeyword:
case ts.SyntaxKind.ObjectKeyword:
case ts.SyntaxKind.BooleanKeyword:
case ts.SyntaxKind.StringKeyword:
case ts.SyntaxKind.UndefinedKeyword:
case ts.SyntaxKind.NullKeyword:
return visitor.visitKeywordType(type as ts.KeywordTypeNode);
default:
return visitor.visitOtherType(type);
}
}

View File

@ -33,16 +33,21 @@ runInEachFileSystem(() => {
}
function emit(emitter: TypeParameterEmitter) {
const canEmit = emitter.canEmit();
const emitted = emitter.emit(ref => {
const typeName = ts.createQualifiedName(ts.createIdentifier('test'), ref.debugName!);
return ts.createTypeReferenceNode(typeName, /* typeArguments */ undefined);
});
if (emitted === undefined) {
return '';
return canEmit ? '' : null;
}
const printer = ts.createPrinter();
if (!canEmit) {
fail('canEmit must be true when emitting succeeds');
}
const printer = ts.createPrinter({newLine: ts.NewLineKind.LineFeed});
const sf = ts.createSourceFile('test.ts', '', ts.ScriptTarget.Latest);
const generics =
emitted.map(param => printer.printNode(ts.EmitHint.Unspecified, param, sf)).join(', ');
@ -71,6 +76,14 @@ runInEachFileSystem(() => {
.toEqual('<T extends undefined>');
expect(emit(createEmitter(`export class TestClass<T extends string[]> {}`)))
.toEqual('<T extends string[]>');
expect(emit(createEmitter(`export class TestClass<T extends [string, boolean]> {}`)))
.toEqual('<T extends [\n string,\n boolean\n]>');
expect(emit(createEmitter(`export class TestClass<T extends string | boolean> {}`)))
.toEqual('<T extends string | boolean>');
expect(emit(createEmitter(`export class TestClass<T extends string & boolean> {}`)))
.toEqual('<T extends string & boolean>');
expect(emit(createEmitter(`export class TestClass<T extends { [key: string]: boolean }> {}`)))
.toEqual('<T extends {\n [key: string]: boolean;\n}>');
});
it('can emit references into external modules', () => {