Skip to content

Commit 95809db

Browse files
committed
Quote all property accesses on types with index types.
TypeScript 2.3 allows users to use `dotted.access` for types that (only) have a string index type declared, e.g. `{[k: string]: ...}`. This breaks renaming in tools such as Closure Compiler (which tsickle targets), as some locations might access the property with quotes, some without. This is an incomplete fix: TypeScript allows assigning values with properties of the matching type into types with index signatures. Users can then access the original value with dots, but the aliased value with quotes, which breaks, too. This is probably not fixable without a global aliasing analysis of the program. See also: microsoft/TypeScript#14267 microsoft/TypeScript#15206
1 parent 1eca154 commit 95809db

File tree

4 files changed

+128
-25
lines changed

4 files changed

+128
-25
lines changed

src/tsickle.ts

+39-25
Original file line numberDiff line numberDiff line change
@@ -488,12 +488,15 @@ class Annotator extends ClosureRewriter {
488488
/** Exported symbol names that have been generated by expanding an "export * from ...". */
489489
private generatedExports = new Set<string>();
490490

491+
private typeChecker: ts.TypeChecker;
492+
491493
constructor(
492494
program: ts.Program, file: ts.SourceFile, options: Options,
493495
private pathToModuleName: (context: string, importPath: string) => string,
494496
private host?: ts.ModuleResolutionHost, private tsOpts?: ts.CompilerOptions) {
495497
super(program, file, options);
496498
this.externsWriter = new ExternsWriter(program, file, options);
499+
this.typeChecker = program.getTypeChecker();
497500
}
498501

499502
annotate(): Output {
@@ -552,7 +555,7 @@ class Annotator extends ClosureRewriter {
552555
}
553556
const declNames = this.getExportDeclarationNames(node);
554557
for (let decl of declNames) {
555-
const sym = this.program.getTypeChecker().getSymbolAtLocation(decl);
558+
const sym = this.typeChecker.getSymbolAtLocation(decl);
556559
const isValue = sym.flags & ts.SymbolFlags.Value;
557560
const declName = getIdentifierText(decl);
558561
if (node.kind === ts.SyntaxKind.VariableStatement) {
@@ -599,15 +602,14 @@ class Annotator extends ClosureRewriter {
599602
this.writeRange(node.getFullStart(), node.getStart());
600603
this.emit('export');
601604
let exportedSymbols: NamedSymbol[] = [];
602-
const typeChecker = this.program.getTypeChecker();
603605
if (!exportDecl.exportClause && exportDecl.moduleSpecifier) {
604606
// It's an "export * from ..." statement.
605607
// Rewrite it to re-export each exported symbol directly.
606608
exportedSymbols = this.expandSymbolsFromExportStar(exportDecl);
607609
this.emit(` {${exportedSymbols.map(e => unescapeName(e.name)).join(',')}}`);
608610
} else {
609611
if (exportDecl.exportClause) {
610-
exportedSymbols = this.getNamedSymbols(exportDecl.exportClause.elements, typeChecker);
612+
exportedSymbols = this.getNamedSymbols(exportDecl.exportClause.elements);
611613
this.visit(exportDecl.exportClause);
612614
}
613615
}
@@ -677,8 +679,7 @@ class Annotator extends ClosureRewriter {
677679
case ts.SyntaxKind.GetAccessor:
678680
case ts.SyntaxKind.SetAccessor:
679681
let fnDecl = <ts.FunctionLikeDeclaration>node;
680-
let tags =
681-
hasExportingDecorator(node, this.program.getTypeChecker()) ? [{tagName: 'export'}] : [];
682+
let tags = hasExportingDecorator(node, this.typeChecker) ? [{tagName: 'export'}] : [];
682683

683684
if (!fnDecl.body) {
684685
if (hasModifierFlag(fnDecl, ts.ModifierFlags.Abstract)) {
@@ -732,7 +733,7 @@ class Annotator extends ClosureRewriter {
732733
return true;
733734
case ts.SyntaxKind.NonNullExpression:
734735
const nnexpr = node as ts.NonNullExpression;
735-
let type = this.program.getTypeChecker().getTypeAtLocation(nnexpr.expression);
736+
let type = this.typeChecker.getTypeAtLocation(nnexpr.expression);
736737
if (type.flags & ts.TypeFlags.Union) {
737738
const nonNullUnion =
738739
(type as ts.UnionType)
@@ -751,7 +752,7 @@ class Annotator extends ClosureRewriter {
751752
case ts.SyntaxKind.PropertyDeclaration:
752753
case ts.SyntaxKind.VariableStatement:
753754
let docTags = this.getJSDoc(node) || [];
754-
if (hasExportingDecorator(node, this.program.getTypeChecker())) {
755+
if (hasExportingDecorator(node, this.typeChecker)) {
755756
docTags.push({tagName: 'export'});
756757
}
757758

@@ -762,6 +763,21 @@ class Annotator extends ClosureRewriter {
762763
return true;
763764
}
764765
break;
766+
case ts.SyntaxKind.PropertyAccessExpression:
767+
// Convert dotted accesses to types that have an index type declared to quoted accesses, to
768+
// avoid Closure renaming one access but not the other.
769+
// This can happen because TS allows dotted access to string index types.
770+
const pae = node as ts.PropertyAccessExpression;
771+
const t = this.typeChecker.getTypeAtLocation(pae.expression);
772+
if (!t.getStringIndexType()) return false;
773+
this.debugWarn(
774+
pae,
775+
this.typeChecker.typeToString(t) +
776+
` has a string index type but is accessed using dotted access. ` +
777+
`Quoting the access.`);
778+
this.writeNode(pae.expression);
779+
this.emit(`['${getIdentifierText(pae.name)}']`);
780+
return true;
765781
default:
766782
break;
767783
}
@@ -850,7 +866,6 @@ class Annotator extends ClosureRewriter {
850866
private expandSymbolsFromExportStar(exportDecl: ts.ExportDeclaration): NamedSymbol[] {
851867
// You can't have an "export *" without a module specifier.
852868
const moduleSpecifier = exportDecl.moduleSpecifier!;
853-
let typeChecker = this.program.getTypeChecker();
854869

855870
// Gather the names of local exports, to avoid reexporting any
856871
// names that are already locally exported.
@@ -860,7 +875,7 @@ class Annotator extends ClosureRewriter {
860875
// import {foo} from ...
861876
// so the latter is filtered below.
862877
let locals =
863-
typeChecker.getSymbolsInScope(this.file, ts.SymbolFlags.Export | ts.SymbolFlags.Alias);
878+
this.typeChecker.getSymbolsInScope(this.file, ts.SymbolFlags.Export | ts.SymbolFlags.Alias);
864879
let localSet = new Set<string>();
865880
for (let local of locals) {
866881
if (local.declarations &&
@@ -872,7 +887,8 @@ class Annotator extends ClosureRewriter {
872887

873888

874889
// Expand the export list, then filter it to the symbols we want to reexport.
875-
let exports = typeChecker.getExportsOfModule(typeChecker.getSymbolAtLocation(moduleSpecifier));
890+
let exports =
891+
this.typeChecker.getExportsOfModule(this.typeChecker.getSymbolAtLocation(moduleSpecifier));
876892
const reexports = new Set<ts.Symbol>();
877893
for (let sym of exports) {
878894
let name = unescapeName(sym.name);
@@ -907,9 +923,9 @@ class Annotator extends ClosureRewriter {
907923
*/
908924
private emitTypeDefExports(exports: NamedSymbol[]) {
909925
if (this.options.untyped) return;
910-
const typeChecker = this.program.getTypeChecker();
911926
for (let exp of exports) {
912-
if (exp.sym.flags & ts.SymbolFlags.Alias) exp.sym = typeChecker.getAliasedSymbol(exp.sym);
927+
if (exp.sym.flags & ts.SymbolFlags.Alias)
928+
exp.sym = this.typeChecker.getAliasedSymbol(exp.sym);
913929
const isTypeAlias = (exp.sym.flags & ts.SymbolFlags.TypeAlias) !== 0 &&
914930
(exp.sym.flags & ts.SymbolFlags.Value) === 0;
915931
if (!isTypeAlias) continue;
@@ -985,20 +1001,19 @@ class Annotator extends ClosureRewriter {
9851001
// all symbols from this import to be prefixed.
9861002
if (!this.options.untyped) {
9871003
let symbols: NamedSymbol[] = [];
988-
const typeChecker = this.program.getTypeChecker();
9891004
if (importClause.name) {
9901005
// import a from ...;
9911006
symbols = [{
9921007
name: getIdentifierText(importClause.name),
993-
sym: typeChecker.getSymbolAtLocation(importClause.name)
1008+
sym: this.typeChecker.getSymbolAtLocation(importClause.name)
9941009
}];
9951010
} else {
9961011
// import {a as b} from ...;
9971012
if (!importClause.namedBindings ||
9981013
importClause.namedBindings.kind !== ts.SyntaxKind.NamedImports) {
9991014
throw new Error('unreached'); // Guaranteed by if check above.
10001015
}
1001-
symbols = this.getNamedSymbols(importClause.namedBindings.elements, typeChecker);
1016+
symbols = this.getNamedSymbols(importClause.namedBindings.elements);
10021017
}
10031018
this.forwardDeclare(decl.moduleSpecifier, symbols, !!importClause.name);
10041019
}
@@ -1016,15 +1031,13 @@ class Annotator extends ClosureRewriter {
10161031
}
10171032
}
10181033

1019-
private getNamedSymbols(
1020-
specifiers: Array<ts.ImportSpecifier|ts.ExportSpecifier>,
1021-
typeChecker: ts.TypeChecker): NamedSymbol[] {
1034+
private getNamedSymbols(specifiers: Array<ts.ImportSpecifier|ts.ExportSpecifier>): NamedSymbol[] {
10221035
return specifiers.map(e => {
10231036
return {
10241037
// e.name might be renaming symbol as in `export {Foo as Bar}`, where e.name would be 'Bar'
10251038
// and != sym.name. Store away the name so forwardDeclare below can emit the right name.
10261039
name: getIdentifierText(e.name),
1027-
sym: typeChecker.getSymbolAtLocation(e.name),
1040+
sym: this.typeChecker.getSymbolAtLocation(e.name),
10281041
};
10291042
});
10301043
}
@@ -1043,8 +1056,8 @@ class Annotator extends ClosureRewriter {
10431056
const forwardDeclarePrefix = `tsickle_forward_declare_${++this.forwardDeclareCounter}`;
10441057
const moduleNamespace =
10451058
nsImport !== null ? nsImport : this.pathToModuleName(this.file.fileName, importPath);
1046-
const typeChecker = this.program.getTypeChecker();
1047-
const exports = typeChecker.getExportsOfModule(typeChecker.getSymbolAtLocation(specifier));
1059+
const exports =
1060+
this.typeChecker.getExportsOfModule(this.typeChecker.getSymbolAtLocation(specifier));
10481061
// In TypeScript, importing a module for use in a type annotation does not cause a runtime load.
10491062
// In Closure Compiler, goog.require'ing a module causes a runtime load, so emitting requires
10501063
// here would cause a change in load order, which is observable (and can lead to errors).
@@ -1066,7 +1079,8 @@ class Annotator extends ClosureRewriter {
10661079
this.emit(`\ngoog.require('${moduleNamespace}'); // force type-only module to be loaded`);
10671080
}
10681081
for (let exp of exportedSymbols) {
1069-
if (exp.sym.flags & ts.SymbolFlags.Alias) exp.sym = typeChecker.getAliasedSymbol(exp.sym);
1082+
if (exp.sym.flags & ts.SymbolFlags.Alias)
1083+
exp.sym = this.typeChecker.getAliasedSymbol(exp.sym);
10701084
// goog: imports don't actually use the .default property that TS thinks they have.
10711085
const qualifiedName = nsImport && isDefaultImport ? forwardDeclarePrefix :
10721086
forwardDeclarePrefix + '.' + exp.sym.name;
@@ -1106,7 +1120,7 @@ class Annotator extends ClosureRewriter {
11061120
private emitInterface(iface: ts.InterfaceDeclaration) {
11071121
// If this symbol is both a type and a value, we cannot emit both into Closure's
11081122
// single namespace.
1109-
let sym = this.program.getTypeChecker().getSymbolAtLocation(iface.name);
1123+
let sym = this.typeChecker.getSymbolAtLocation(iface.name);
11101124
if (sym.flags & ts.SymbolFlags.Value) return;
11111125

11121126
let docTags = this.getJSDoc(iface) || [];
@@ -1215,7 +1229,7 @@ class Annotator extends ClosureRewriter {
12151229

12161230
// If the type is also defined as a value, skip emitting it. Closure collapses type & value
12171231
// namespaces, the two emits would conflict if tsickle emitted both.
1218-
let sym = this.program.getTypeChecker().getSymbolAtLocation(node.name);
1232+
let sym = this.typeChecker.getSymbolAtLocation(node.name);
12191233
if (sym.flags & ts.SymbolFlags.Value) return;
12201234

12211235
// Write a Closure typedef, which involves an unused "var" declaration.
@@ -1247,7 +1261,7 @@ class Annotator extends ClosureRewriter {
12471261
for (let member of node.members) {
12481262
let memberName = member.name.getText();
12491263
if (member.initializer) {
1250-
let enumConstValue = this.program.getTypeChecker().getConstantValue(member);
1264+
let enumConstValue = this.typeChecker.getConstantValue(member);
12511265
if (enumConstValue !== undefined) {
12521266
members.set(memberName, enumConstValue);
12531267
i = enumConstValue + 1;

test_files/quote_props/quote.js

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
goog.module('test_files.quote_props.quote');var module = module || {id: 'test_files/quote_props/quote.js'};/**
2+
* @record
3+
*/
4+
function Quoted() { }
5+
let /** @type {!Quoted} */ quoted = {};
6+
console.log(quoted['hello']);
7+
quoted['hello'] = 1;
8+
quoted['hello'] = 1;
9+
/**
10+
* @record
11+
* @extends {Quoted}
12+
*/
13+
function QuotedMixed() { }
14+
/** @type {number} */
15+
QuotedMixed.prototype.foo;
16+
// TODO(martinprobst): should 'foo: 1' below be quoted?
17+
let /** @type {!QuotedMixed} */ quotedMixed = { foo: 1 };
18+
console.log(quotedMixed['foo']);
19+
// TODO(martinprobst): should this access to a declared property be quoted?
20+
quotedMixed['foo'] = 1;
21+
// TODO(martinprobst): should this access to a declared property be un-quoted?
22+
quotedMixed['foo'] = 1;

test_files/quote_props/quote.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
interface Quoted {
2+
[k: string]: number;
3+
}
4+
let quoted: Quoted = {};
5+
6+
console.log(quoted.hello);
7+
quoted.hello = 1;
8+
quoted['hello'] = 1;
9+
10+
interface QuotedMixed extends Quoted {
11+
// Assume that foo should be renamed, as it is explicitly declared.
12+
// It's unclear whether it's the right thing to do, user code might
13+
// access this field in a mixed fashion.
14+
foo: number;
15+
}
16+
// TODO(martinprobst): should 'foo: 1' below be quoted?
17+
let quotedMixed: QuotedMixed = {foo: 1};
18+
console.log(quotedMixed.foo);
19+
20+
// TODO(martinprobst): should this access to a declared property be quoted?
21+
quotedMixed.foo = 1;
22+
// TODO(martinprobst): should this access to a declared property be un-quoted?
23+
quotedMixed['foo'] = 1;
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
Warning at test_files/quote_props/quote.ts:6:13: Quoted has a string index type but is accessed using dotted access. Quoting the access.
2+
Warning at test_files/quote_props/quote.ts:7:1: Quoted has a string index type but is accessed using dotted access. Quoting the access.
3+
Warning at test_files/quote_props/quote.ts:18:13: QuotedMixed has a string index type but is accessed using dotted access. Quoting the access.
4+
Warning at test_files/quote_props/quote.ts:21:1: QuotedMixed has a string index type but is accessed using dotted access. Quoting the access.
5+
====
6+
7+
/**
8+
* @record
9+
*/
10+
function Quoted() {}
11+
/* TODO: handle strange member:
12+
[k: string]: number;
13+
*/
14+
interface Quoted {
15+
[k: string]: number;
16+
}
17+
let /** @type {!Quoted} */ quoted: Quoted = {};
18+
19+
console.log(quoted['hello']);
20+
quoted['hello'] = 1;
21+
quoted['hello'] = 1;
22+
/**
23+
* @record
24+
* @extends {Quoted}
25+
*/
26+
function QuotedMixed() {}
27+
/** @type {number} */
28+
QuotedMixed.prototype.foo;
29+
30+
31+
interface QuotedMixed extends Quoted {
32+
// Assume that foo should be renamed, as it is explicitly declared.
33+
// It's unclear whether it's the right thing to do, user code might
34+
// access this field in a mixed fashion.
35+
foo: number;
36+
}
37+
// TODO(martinprobst): should 'foo: 1' below be quoted?
38+
let /** @type {!QuotedMixed} */ quotedMixed: QuotedMixed = {foo: 1};
39+
console.log(quotedMixed['foo']);
40+
41+
// TODO(martinprobst): should this access to a declared property be quoted?
42+
quotedMixed['foo'] = 1;
43+
// TODO(martinprobst): should this access to a declared property be un-quoted?
44+
quotedMixed['foo'] = 1;

0 commit comments

Comments
 (0)