-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Convert to named parameters #30089
Convert to named parameters #30089
Conversation
…pyComment function
This is definitely optional, but it would be very nice if we simplified |
Another 'bad case' to note: #29791. For generic functions, tsc currently currently makes assumptions about parameter order to infer the parameter types. With a named params object this ordering is lost, so type inference may fail on the refactored version. |
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.
Questions about textChanges
src/services/textChanges.ts
Outdated
@@ -178,11 +194,12 @@ namespace ts.textChanges { | |||
|
|||
function getAdjustedEndPosition(sourceFile: SourceFile, node: Node, options: ConfigurableEnd) { | |||
const { end } = node; | |||
if (options.useNonAdjustedEndPosition || isExpression(node)) { | |||
const { endPosition } = options; | |||
if (endPosition === TrailingTriviaOption.Exclude || (isExpression(node) && endPosition !== TrailingTriviaOption.Include)) { |
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 the only place I can see TrailingTriviaOption.IncludeIfLineBreak
being consumed and I don't understand how it relates to expressions.
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 don't know why it is necessary to check if node
is an expression, but for the behavior I needed for this function (when the TrailingTriviaOption.Include
is used), it should not matter if node
is an expression. So I just tried not to break existing code. The same goes for the isLineBreak
check below, I don't know what is the reasoning behind it.
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 you got rid of IncludeIfLineBreak
.
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.
Comments about utilities.ts.
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.
Initial comments on convertToNamedParameters.
} | ||
return false; | ||
|
||
function isValidParameterNodeArray(parameters: NodeArray<ParameterDeclaration>): parameters is ValidParameterNodeArray { |
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.
How did you decide which helper functions would be nested and which would be siblings?
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 ones that don't close over anything should be moved outside to avoid excess closure allocations.
const references = flatMap(names, name => FindAllReferences.getReferenceEntriesForNode(-1, name, program, program.getSourceFiles(), cancellationToken)); | ||
let groupedReferences = groupReferences(references); | ||
|
||
// if the refactored function is a constructor, we must also go through the references to its 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.
Didn't getDeclarationNames
return the class name? I would have thought it was already incorporated into references
.
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 separated getDeclarationNames
into getClassNames
and getFunctionNames
, and now there's a single pass through the references which checks for both function and (if necessary) class references, so the code flow should be more clear now.
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.
Looks good so far. I've only reviewed convertToNamedParameters so far.
/*questionToken*/ undefined, | ||
thisParameter.type); | ||
|
||
suppressLeadingAndTrailingTrivia(newThisParameter.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.
Can you explain why we need to suppress trivia and then copy comments instead of just using newThisParameter.name
as-is?
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 we don't suppress trivia, trailing comments will be emitted but leading comments will disappear. In essence, I do not know what behavior to expect from trivia when reusing nodes. So I am suppressing both leading and trailing and explicitly copying the comments.
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 believe you're still polishing but it generally LGTM.
const classExpression = constructorDeclaration.parent; | ||
const variableDeclaration = constructorDeclaration.parent.parent; | ||
const className = classExpression.name; | ||
if (className) return [className, variableDeclaration.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.
Why do we need the class name if there's a variable name? Is that for ctor calls within the class itself?
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.
yes, that's for checking the references to the class within the class expression itself.
function getGroupedReferences(functionDeclaration: ValidFunctionDeclaration, program: Program, cancellationToken: CancellationToken): GroupedReferences { | ||
const functionNames = getFunctionNames(functionDeclaration); | ||
const classNames = isConstructorDeclaration(functionDeclaration) ? getClassNames(functionDeclaration) : []; | ||
const names = deduplicate([...functionNames, ...classNames], equateValues); |
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'm not sure I understand why there would be duplicates.
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.
when refactoring the constructor of a class expression, the variable name to which the class is assigned is both a function name and a class name.
////var foo = new Foo("c", "d"); | ||
|
||
goTo.select("a", "b"); | ||
/* The expected new content is currently wrong. |
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 this tracked by a bug? It's a good idea to file one after merging this PR.
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.
Looks great overall. Polling the issue on naming so hold off on changing that, but please do move the functions out one level where appropriate.
@@ -4871,5 +4871,9 @@ | |||
"Enable the 'experimentalDecorators' option in your configuration file": { | |||
"category": "Message", | |||
"code": 95074 | |||
}, | |||
"Convert to named parameters": { |
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 we agreed on "Convert to parameters object" ?
} | ||
return false; | ||
|
||
function isValidParameterNodeArray(parameters: NodeArray<ParameterDeclaration>): parameters is ValidParameterNodeArray { |
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 ones that don't close over anything should be moved outside to avoid excess closure allocations.
After spending almost two days in finding the issue, I ran across a few TypeScript issues on their GitHub page: - Loss of type inference converting to named parameters object microsoft/TypeScript#29791 - Parameter of a callback without a specified type next to it breaks code. microsoft/TypeScript#29799 - Convert to named parameters microsoft/TypeScript#30089 It became clear that TypeScript is unable to infer method return arguments if a generic type is used more than once in generic parameter object. Instead it returns {}. For example, the following would fail on line 28: type Convert<A, B> = (value: A) => B interface IParams<C, D> { value: C convert: Convert<C, D> doConvert: (value: C, convert: this['convert']) => D } function doSomething<E, F>(value: E, convert: Convert<E, F>) { return convert(value) } function build<G, H>(params: IParams<G, H>) { const {value, convert} = params return params.doConvert(value, convert) } const outerResult = build({ value: { a: { value: 1, }, b: 'string', }, convert: value => value.a, doConvert: (value, convert) => { const innerResult = doSomething(value, convert) innerResult.value console.log('innerResult:', innerResult) return innerResult }, }) console.log('outerResult:', outerResult) With the message: Property 'value' does not exist on type '{}'. If we replace parameter object IParams with regular ordered function parameters, the compilation succeeds. RyanCavanough (TS project lead) from GitHub commented: > We don't have a separate pass to say "Go dive into the function and > check to see if all its return statements don't rely on its parameter > type" - doing so would be expensive in light of the fact that extremely > few real-world functions actually behave like that in practice. Source: microsoft/TypeScript#29799 (comment) These modifications bring type safety to TestUtils.tsx, and therefore client-side tests of React components, while keeping almost the same ease of use as before.
Fixes #23552
Convert to "named parameters" refactor
A function-like declaration with parameters is refactored to receive a single parameter object with properties corresponding to its old parameters. Thus a function's arguments can be named when calling it. This applies to functions, methods, constructors, arrow functions and function expressions.
Example:
would be refactored to
Refactor availability
The refactor will be listed as available on:
The refactor will not be available on:
References
This refactor changes a function's signature, so it affects the way a function should be called either directly or indirectly. The refactor also changes assignability to and from a function's type, since its type will change.
Because of that, there are several situations where performing the refactor could cause a breaking change that would in the best scenario result in type errors or, in the worst scenario, runtime errors and wrong behavior.
The current solution to this problem is to adopt a conservative approach: the refactor will perform changes only if those changes are guaranteed not to break the code. Otherwise, it will not perform any change.
The refactor has to look at all the references of a function (through
findAllReferences
) to determine if changing it is safe. If a constructor is being refactored, the refactor also looks at all the references of the parent class declaration or class expression.However, finding all references is too slow to be performed when the user is checking what are the available refactors. So the refactor will only look at all references if the user tries to apply it.
Therefore, the refactor can fail to perform changes even if it was listed as available and the user tried to applied it. Currently there is no way to inform a user why a refactor has failed.
Related issue: Extend tsserver interface to allow refactoring failure reporting #28410
Good cases
Direct calls
The refactor can change direct calls to functions to conform to the new function signature. This is done by taking the old argument list and replacing it with an argument object whose properties have values corresponding to the old arguments.
Example:
1.
becomes
becomes
becomes
Imports & Exports
Since
findAllReferences
can track imports/exports across a project, importing/exporting the refactored function should be allowed.Update: done in Handle imports and exports in 'convert parameters to destructured object' #30475.
Other good cases
Good class cases
If a constructor is being refactored, the refactor will also check if all the references to the corresponding class are valid usages.
Currently, for a class declaration (
class C { ... }
), the following usages are recognized:For a class expression (
const c = class { ... }
), the following usages are recognized:JSDoc
Currently the refactor will not do anything with JSDoc, it will treat it as regular comments.
Example:
should become something like
There are also issues (JSDoc support for destructured parameters #11859, JSDoc comment for destructuring param: description text not displayed #24746) that should be resolved to support the JSDoc syntax with destructuring in both JS and TS.
Example:
Since the parameter names above will become binding elements in a destructuring pattern, we cannot inline the existing JSDoc,
so the refactor will break inline JSDoc.
Question: Should it just break the JSDoc? Try to convert it to non-inline JSDoc?
Rest parameters
The refactor works for rest parameters as illustrated below. The rest parameter will be converted to an optional property of the new parameter object, and the corresponding variable will be initialized to an empty array. This ensures that the variable is not undefined in case no rest arguments are provided, which reflects how rest parameters behave.
Example:
becomes
This parameter
A function can have a
this
parameter as its first parameter. In this case, the refactor should ignore this parameter and only modify the other parameters.Example:
becomes
Parameters with initializers
A parameter with an initializer is considered an optional member of the new parameter type. If a parameter has an initializer but has no type annotation, its type is inferred (using
checker.getTypeAtLocation(node)
).Example:
should be
Optional parameters
When a parameter is optional (when it is followed by a ? or has an initializer), the corresponding property in the generated parameter object type should also be optional.
In addition, if all parameters are optional, the new parameter object should be optional. This is achieved by adding an empty object initializer.
Example:
becomes
and
becomes
JavaScript
Currently the refactor is not available on a JavaScript file, but we would like the refactor to work on JavaScript as well. Essentially the difference in the refactor would be the omission of type annotations.
Example:
becomes
JS code can be harder to analyze and to validate the refactor's changes.
So, if the refactor does something wrong, it would be harder for the user to notice.
If the refactored function has annotations, it becomes easy to apply the refactor.
Some bad cases
Aliasing
As shown above, aliasing makes it not possible to track down all calls and usages of the refactored function.
Example:
after the refactor:
Other example:
after refactoring the constructor:
Class expression example:
after refactoring the constructor:
Types
Since the refactor changes the type of the function, there might be type errors.
Example:
becomes:
Nameless declarations (anonymous functions/classes)
Anonymous functions and classes are tricky when looking for references.
Example:
In the example above, if we refactor the arrow function assigned to foo on line 1, then we should refactor the call on line 2. But later on
foo
is reassigned, so this will cause a type error. The same goes for constructors of class expressions.bind
/call
/apply
callsCalling
bind
,call
orapply
on a function that has been refactored might go wrong.Example:
becomes
The compiler will emit the errors above only if
strictBindCallApply
is on.Overloads
A function which has overloads is tricky to refactor. Changing a function overload or a function implementation which has overloads can be a breaking change. The overloads and the implementation should be considered together when refactoring. It is not clear what the refactor should do with overloads, so the refactor will not be available in that case.
Example:
might become
or
Overrides
As with overloads, refactoring overrided methods can also be a breaking change. Refactoring a method which overrides another can mean that the refactored method's type is no longer assignable to the type of the method it overrides. Similarly, changing a method which is overriden by another can mean that the overriding methods' types are no longer assignable to the refactored method.
Example:
would become
That scenario is currently avoided by the refactor. Once it checks all the references and finds a declaration different than the one where the refactor should be applied, it does not perform any changes.
Inference
When a function is refactored to have an object parameter instead of a list of parameters, inference can no longer proceed left-to-right on the parameter list, so the compiler no longer reuses inferences from previous parameters for the next parameter.
Because of that, inference can become worse and cause breaks in rare cases (see #29791).
Known issues
Formatting
In some cases involving comments and line breaks, the refactor generates an argument object that is ill-formatted.
The
refactorConverToNamedParameters_callComments2.ts
test is currently wrong because of that.Inherited constructors
Currently
findAllReferences
will not track usage of an inherited constructor.Example:
refactoring
Foo
's constructor, becomes:The
refactorConvertToNamedParameters_inheritedConstructor.ts
test is currently wrong because of that.However,
findAllReferences
tracks inherited methods, which causes the refactor to behave differently in similar situations when it should behave the same. This leads me to believe thatfindAllReferences
should be able to find references to inherited constructors.Update: solved in #30514.
Type errors when refactoring a method
When a class/object literal's method is refactored, its type changes.
Because of that, we can get type errors where the new class/object literal type is not compatible with the expected type.
Example:
After refactoring
fn
onBad
:Open questions
Type Literal or Interface
Should the refactor generate a type literal for the new parameter object or an interface?
In the case where this refactor generates a type literal,
this refactor should be composed with a refactor for extracting a type literal into an interface.
Example:
could be refactored to
or
Name
What should the refactor be called?
Currently it is 'named parameters', but 'parameter object' was suggested.
Update: done in #30401.
Dealing with bad cases
The refactor's current approach is to not apply changes when it finds a reference it does not recognize.
This approach comes from the idea that a refactor should not break user's code, i.e. it should 'do the right thing'.
So, when the refactor does not know the right thing to do, it does nothing.
Some cons:
The question is: is this the best approach?
In some situations, the refactor could break the user's code, and the breaking changes can be detected by the type checker or not.
But if the user then has type errors that indicate all the parts of the code that have to be fixed,
could it be better to apply the refactor and have the user do the remaining necessary changes?
Good cases
For the current approach, what are other usages of the refactored function that the refactor should recognize?
JavaScript
If the JavaScript code is not type annotated, what should we do?