-
Notifications
You must be signed in to change notification settings - Fork 14
(WIP) Private name class field transformation #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
(WIP) Private name class field transformation #8
Conversation
and check that private names not used in parameters Signed-off-by: Max Heiber <[email protected]>
Parse Private Names
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
@@ -3382,12 +3385,17 @@ namespace ts { | |||
addRange(statements, funcStatements, classBodyEnd + 1); | |||
} | |||
|
|||
// Add other class statements (such as the WeakMap declarations output by the 'esnext' | |||
// transformer for private names). | |||
addRange(statements, classStatements, /*start*/ 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could use some guidance here. This was to avoid the WeakMaps being placed after the return statement for the class. How were end of declaration markers ending up in classStatements
? classStatements
is filtered by isVariableStatementWithInitializer
. It seems that end of declaration markers are synthetic nodes, wouldn't they not be variable statements at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I follow, but would be happy to discuss
src/compiler/transformers/esnext.ts
Outdated
if (nameString in environment) { | ||
return environment[nameString].weakMap; | ||
} | ||
throw new Error("Accessing undeclared private name."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I should probably not throw here? This should be caught by a parse error, but what should the transformer do? It seems like the approach that's usually taken is that the transformer should just keep the original node if something like this occurs, in which case, I should change this to return undefined and have callers bail on the transformation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that you said "This should be caught by a parse error":
-
I think what you want is an assertion,
Debug.assertNever
,Debug.assert
, etc. are used elsewhere in the transformer -
What case should be caught by the parser? If you give an example, I should be able to figure out what the parser does
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I'll check out the usage of Debug.assert*
I just mean that if a private name is accessed in a class, but it is not declared, the parser will output something like "Private name not declared (or whatever the actual wording is)."
class X {
something() { this.#myProperty = 1; } // "#myProperty" is not declared.
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've resolved this to emit unchanged output for undeclared private names (which is similar to the behavior of other syntax errors).
src/compiler/transformers/ts.ts
Outdated
setSourceMapRange(statement, moveRangePastModifiers(property)); | ||
setCommentRange(statement, property); | ||
setOriginalNode(statement, property); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like a mistake.
src/compiler/transformers/ts.ts
Outdated
if (isPrivateProperty(property)) { | ||
continue; | ||
} | ||
const statement = createStatement(transformInitializedProperty(property, receiver)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By bailing out early here, it changes the order in which the property initializers are evaluated. We need to preserve a strict top-down evaluation order. Probably the right way to do this is to add the statements for private fields into the same statements
array being added to below, preserving order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Input:
let a = 0;
class C {
foo = ++a;
#x = ++a;
}
Output:
let a = 0;
class C {
constructor() {
_x.set(this, ++a); // BAD: evaluation order is swapped
this.foo = ++a; // BAD: evaluation order is swapped
}
}
var _x = new WeakMap;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is somewhat complicated because the TypeScript transformer will transform class property initializers to constructor statements. If the target output is ESNext, then we need to keep the private named property declaration in the class to keep valid ESNext syntax.
After further thought, I now think that we should move this behavior for ALL properties (not just private names) to the ESNext transformer, since you can now have property initializers in ESNext. (I'm also hoping that the initializer semantics are the same in the ESNext proposal as they are in the TypeScript class implementation). I'd like to get the TypeScript team's thoughts on this idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've now fixed this issue with private-named property initializers. It now relies on the TS transformer moving the initializers to the constructor (in the original order) and initializing the WeakMap values to undefined at the top of the constructor (like _x.set(this, void 0);
).
Could you add some tests? Happy to talking testing strategy |
src/compiler/transformers/ts.ts
Outdated
@@ -1194,6 +1194,11 @@ namespace ts { | |||
); | |||
} | |||
|
|||
function isPrivateProperty(member: ClassElement): member is PropertyDeclaration { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The return type of the function doesn't match what it does: the body checks not just that the member is a property declaration, but that the members' name is a private name. So which is right, the return value or the behavior?
Also, the name could be confusing because private
properties are also private properties: maybe name it something like "privateNamedProperty" would be better?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be more correctly expressed as member is PropertyDeclaration & { name: PrivateName }
. Maybe there should be a PrivateNamedPropertyDeclaration
:
interface PrivateNamedPropertyDeclaration extends PropertyDeclaration {
name: PrivateName;
}
src/compiler/transformers/esnext.ts
Outdated
if (isCompoundAssignment(node.operatorToken.kind)) { | ||
let setReceiver: Expression; | ||
let getReceiver: Identifier; | ||
if (!isIdentifier(node.left.expression) && !isKeyword(node.left.expression.kind)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what happens in the case where the first half of the conjunction is true but the second is false?
In that case node.left.expression would not be an identifier, but you are casting to identifier on line 485. How could we correctly handle this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good catch. I'll see if I can get these types right. For context, this is checking whether the receiver (node.left.expression
) is an identifier or a keyword (like this
). If it's neither, it treats it as a non-trivial expression which could result in modification of the receiver, so we need to create a temporary variable to store the result of this expression.
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
c8090dc
to
fec6ad8
Compare
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Signed-off-by: Joseph Watts <[email protected]>
Added a bunch of test cases and addressed some bugs that I've found. Let me know if there are suggestions for more tests or any perceived issues! |
Signed-off-by: Joseph Watts <[email protected]>
}; | ||
return Test; | ||
}()); | ||
var _field = new WeakMap; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minor: why is the declaration of _field
below the class definition?
console.log(this.#field); | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there a way to test the es2015 emit? I'd like to see what this looks like with a class
class
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
//@target: ES2015
if (isPrivateName(node.name)) { | ||
const weakMapName = accessPrivateName(node.name); | ||
if (!weakMapName) { | ||
return node; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the checker doesn't catch undeclared private names do they just end up in the output unchanged?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The checker should catch them and output errors, but the checker won't have any impact on the transformer. Even if the checker errors, the transformer needs to output some code. If a private name is used and there's no declaration, we just leave it in the output unchanged.
|
||
function endPrivateNameEnvironment(): PrivateNameEnvironment { | ||
const privateNameEnvironment = currentPrivateNameEnvironment(); | ||
// Destroy private name environment. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nothing uses the return value. Do we need it?
function endPrivateNameEnvironment(): PrivateNameEnvironment { | ||
const privateNameEnvironment = currentPrivateNameEnvironment(); | ||
// Destroy private name environment. | ||
delete privateNameEnvironmentStack[privateNameEnvironmentIndex--]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had to look up what delete array[index]
does. It makes the value of the array at index
undefined
. Is that what we want?
- If the privateNameEnvironmentStack is truly a stack, then we could use pop() to remove and
last()
(from core.ts) to get the current environment. We wouldn't need to keep track of an index. - If the privateNameEnvironmentStack is not actually a stack, then we can still avoid having
undefined
s in our array withsplice
- If we do want
undefined
s in our array, I'd prefer the more explicitarr[index] = undefined
@@ -3738,6 +3738,28 @@ namespace ts { | |||
&& isLeftHandSideExpression(node.left); | |||
} | |||
|
|||
export function isCompoundAssignment(kind: BinaryOperator): kind is CompoundAssignmentOperator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
note for other reviewers: these two functions were moved from generators.ts
}); | ||
const ctor = find( | ||
members, | ||
(member) => isConstructorDeclaration(member) && !!member.body |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't able to come up with valid TS where there's a constructor without a body: what is the !!body
check for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This actually shouldn’t be relevant in the ESNext transformer. Constructor without a body is valid syntax with overload signatures but they should be elided by the time the code gets here
@@ -26,6 +26,19 @@ namespace ts { | |||
let enclosingFunctionFlags: FunctionFlags; | |||
let enclosingSuperContainerFlags: NodeCheckFlags = 0; | |||
|
|||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Easier said than done, but I think it would fit the style of the codebase (and also be safer and easier to manage) if we use the stack itself instead of implementing a separate privateNameEnvironmentStack.
This is done in ts.ts using saveStateAndInvoke
but the general pattern is like this (I think):
let privateNameEnvironment = undefined;
function visitClassDeclaration(...) {
const savedPrivateNameEnvironment = privateNameEnvironment
privateNameEnvironment = {}
visitEachChild ....
doStuff()
privateNameEnvrironment = savedPrivateNameEnvironment
}
Written mostly to check my own understanding. I'm experimenting with this on a branch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR for the change: joeywatts#6
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doh! Re your follow-up: silly of me to forget that we have to keep an environment stack around to deal with lexical scoping
|
||
function createClassPrivateFieldGetHelper(context: TransformationContext, receiver: Expression, privateField: Identifier) { | ||
context.requestEmitHelper(classPrivateFieldGetHelper); | ||
return createCall(getHelperName("_classPrivateFieldGet"), /* typeArguments */ undefined, [ receiver, privateField ]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
privateField
here is an AST Node (Identifier) that will be reused in multiple parts of the AST. Is that safe? It's conceivable that a later stage in the transform pipeline might modify one branch of the AST and not expect another branch to change as a result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The transformers that are visiting the AST return new nodes (they don't mutate the existing AST as I understand it). I believe it should be fine to use the Identifier in two places, but let me check to see if there's a place in existing code where that is done. I know there are setTextRange
and setCommentRange
functions that actually can modify AST nodes...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the way you did it is how it's done elsewhere, too, but I'm curious about how it works.
c493238
to
9767522
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@joeywatts, did you intentionally deal with statics in these commits, or is it just natural fallout that initializers of static private-named properties are handled? This transformation doesn't strip the type annotation from static properties when targeting esnext
. If you had to deal with statics explicitly, is there opportunity to split that into a separate PR? If no to either of those things, please amend this PR to strip away the type annotations.
Input:
class C {
static #name: any = 5;
}
Output (--target esnext
):
class C {
static #name: any;
}
C.#name = 5;
Also, is this related? When I use the same input with --target es2015
, I get this:
C:\dev\TypeScript\built\local\tsc.js:83978
throw e;
^
TypeError: Cannot use 'in' operator to search for '#name' in undefined
at accessPrivateName (C:\dev\TypeScript\built\local\tsc.js:66538:28)
at visitBinaryExpression (C:\dev\TypeScript\built\local\tsc.js:66745:35)
at visitorWorker (C:\dev\TypeScript\built\local\tsc.js:66478:28)
at visitorNoDestructuringValue (C:\dev\TypeScript\built\local\tsc.js:66454:20)
at visitNode (C:\dev\TypeScript\built\local\tsc.js:60939:23)
at Object.visitEachChild (C:\dev\TypeScript\built\local\tsc.js:61213:59)
at visitExpressionStatement (C:\dev\TypeScript\built\local\tsc.js:66719:23)
at visitorWorker (C:\dev\TypeScript\built\local\tsc.js:66504:28)
at visitor (C:\dev\TypeScript\built\local\tsc.js:66451:20)
at visitNodes (C:\dev\TypeScript\built\local\tsc.js:60990:48)
Description of Changes
Outside the Scope of this PR
++
,--
) - both prefix & postfixSteps to Test
npm run build:compiler
)npm test
).