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
feat(ivy): input type coercion for template type-checking #33243
Conversation
cb90829
to
fea1589
Compare
if (!member.name.startsWith('ngCoerceInput_')) { | ||
return null !; | ||
} | ||
return member.name.split('_', 2)[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.
What if the @Input()
property name itself contains _
?
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.
That's a good point. Fixed :)
fea1589
to
4af30d2
Compare
4af30d2
to
e709743
Compare
Is there any compile-time error if the coercion function doesn't type-match the input (eg in your example if it is typed to return boolean)? The fact that it requires a static function also is a bit awkward for component inheritance as static functions are not inherited. It also prevents using any non-static methods for the coercion (which a setter does have access to). Overall this feels like a workaround to remove type safety in the template type checking. Material could just as well have pipes for common coercion ( |
I can't test this right now since I'm on mobile, but wouldn't the following also work? This might actually be an error, in which case disregard.
It binds the wider type as a setter for ease of use in templates but uses the narrower type for the actual property on the class of the same name as the input. Conceptually this is basically the same as the proposal but doesn't require new APIs or static functions. Edit: This does seem to work. Is there any reason why this wouldn't be used? |
I noticed that the produced error message refers to arguments and parameters, which looks a bit weird from the template's perspective. We could introduce some TypeScript magic to obtain the type of the coercion's input class TestDir {
value!: number;
static ngCoerceInput_value: (param: string | number) => number;
}
type CoercedInput<Fn> = Fn extends (type: infer T) => any ? T : never;
// Before, without any coercion:
const _ctor: (p: Pick<TestDir, 'value'>) => TestDir = null!;
_ctor({
value: '1',
});
_ctor({
value: true,
});
// After, with coercion magic:
type TestDir_Bindings = Pick<TestDir, 'value'> | { value: CoercedInput<typeof TestDir.ngCoerceInput_value> };
const _ctorCoerce: (p: TestDir_Bindings) => TestDir = null!;
_ctorCoerce({
value: '1',
});
_ctorCoerce({
value: true,
}); Note that the return type of I am also interested in @Airblader's approach, which does not require additional magic from Angular's compiler side and is more strict wrt the directive's implementation, at the expense of an awkward syntax AFAIK. If, however, TypeScript starts to allow setters with a wider type than the getter this issue may eventually resolve itself. |
I won't argue it's a bit awkward, but I don't think it's more awkward than the proposal in the PR. :-) |
Actually static class methods are indeed inherited in JavaScript. |
You're right; static functions are used so rarely I misremembered that. My other points still stand, though. If this PR should move forward either way I would question why it has to be a static method. Logically speaking there's no reason, I assume it's technical due to the ahead of time compilation? |
if (!member.name.startsWith('ngCoerceInput_')) { | ||
return null !; | ||
} | ||
return afterUnderscore(member.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.
blank line before return ?
This comment has been minimized.
This comment has been minimized.
@Airblader this got merged by accident - we haven't finished reviewing the PR. I asked @matsko to revert and reopen it. Sorry about sending wrong signals. |
…gular#33243) This reverts commit 1b4eaea.
This was pre-maturely merged. My mistake. This is open again now. |
e709743
to
ae50c91
Compare
export declare class MatInput { | ||
value: string; | ||
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatInput, '[matInput]', never, {'value': 'value'}, {}, never>; | ||
static ngAcceptInputType_value: string|number; |
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 about ngInputTypeOverride
?
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.
or even more intuitive would be if we passed in the type info via the @Input
decorator:
@Input<string|null>()
set value(value: string) { ... }
get value(): string { ... }
Considering that there is a number of options to pick from and this being a feature that we don't strictly need for v9, I think we should defer this until 9.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.
@IgorMinar the problem with this approach is that the decorator gets removed during compilation, so nothing carries the type forward into the .d.ts
file. So the compiler would have to add it in this case.
For performance, we need the field to actually exist in the .d.ts
file when a library ships.
I'm happy to defer this, but know that this effectively blocks using strictInputTypes
for Material. We should probably disable that option by default, then, so users don't trip over fullTemplateTypeCheck
giving unfixable error messages for Material.
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 asked @jelbourn to weigh in on the current PR and impact of deferring this to 9.1. Let's get his input and reevaluate what to do.
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.
@jelbourn tested the PR and gave 👍
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.
Jeremy also argued that ngAcceptInputType_
is a more descriptive name than other suggestions.
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.
) Often the types of an `@Input`'s field don't fully reflect the types of assignable values. This can happen when an input has a getter/setter pair where the getter always returns a narrow type, and the setter coerces a wider value down to the narrow type. For example, you could imagine an input of the form: ```typescript @input() get value(): string { return this._value; } set value(v: {toString(): string}) { this._value = v.toString(); } ``` Here, the getter always returns a `string`, but the setter accepts any value that can be `toString()`'d, and coerces it to a string. Unfortunately TypeScript does not actually support this syntax, and so Angular users are forced to type their setters as narrowly as the getters, even though at runtime the coercion works just fine. To support these kinds of patterns (e.g. as used by Material), this commit adds a compiler feature called "input coercion". When a binding is made to the 'value' input of a directive like MatInput, the compiler will look for a static function with the name ngCoerceInput_value. If such a function is found, the type-checking expression for the input will be wrapped in a call to the function, allowing for the expression of a type conversion between the binding expression and the value being written to the input's field. To solve the case above, for example, MatInput might write: ```typescript class MatInput { // rest of the directive... static ngCoerceInput_value(value: {toString(): string}): string { return null!; } } ``` FW-1475 #resolve PR Close angular#33243
…gular#33243) (angular#33299) This reverts commit 1b4eaea. PR Close angular#33299
ae50c91
to
678b2f0
Compare
@Airblader your proposed would require breaking changes for all component libraries that would want to be compatible with strict template type-checking. The approach we are looking for is something that can be incrementally added to component libraries and would enable strict type checking of all the inputs, while still supporting the common cases of input type coercing. One point you bring up that should however be addressed is that the type used for type checking the setter should be a super set of the type specified on the getter. Otherwise things we could get into bizarre situation where the setter claims to accept e.g. |
Thanks, I appreciate it. I have a couple more points / questions:
|
@Airblader IDEs would report these fields as used/unused in the same way the report component class properties that are used only in templates. The field doesn't have be public (private fields are present in d.ts files), but this is part of the public api, so making them private would hide important public api info. |
Well, it's part of a public API: the template-facing one. The following if I'm not mistaken:
… will continue not to work, right? In the actual input setter since the more narrow type is used, TS will not be able to type-check that a conversion of the wider type to the narrower type actually ever happens, i.e. the following will compile just fine and cause the very bugs TS is designed to prevent:
Admittedly this is about the same situation as we have without strict template type checking. In any case, I have said my peace and do not want to cause too much trouble going forward with this. |
@Airblader your input is greatly appreciated! Responses to your points:
No, because the field is not a
I actually had no idea Unfortunately no, like with |
Maybe a moot point now, but upon reconsidering I realized I don't understand where the breaking change would lie in that approach. In fact the API would look the same coming from either a template or programmatic access. The only difference (besides allowing the wider type in templates) is that there's an additional setter, but that's not breaking and also not much different from the change in this PR. Perhaps I'm missing something, though. |
I don't think it's a breaking change in terms of the semantics today (though arguably, renaming an
To your other point:
This is definitely true. To me, it's like any other type assertion - it can be used to improve the typings if applied properly, or to subvert them if applied improperly. I added a line about this at the end of the new docs for this feature. |
I agree this would be bizarre, and I can't think of a good reason for it. But nothing precludes you from doing something like that in JS: _value: number;
get value(): number /* number */ {
return this._value;
}
set value(in: number /* string */) {
this._value = parseInt(in as any as string);
}
static ngAcceptInputType_value: string; Theoretically, the above code would be typechecked properly. |
678b2f0
to
69d11fa
Compare
Caretaker: I am the approver. |
Presubmit |
69d11fa
to
87bc229
Compare
Presubmit for real this time |
Often the types of an `@Input`'s field don't fully reflect the types of assignable values. This can happen when an input has a getter/setter pair where the getter always returns a narrow type, and the setter coerces a wider value down to the narrow type. For example, you could imagine an input of the form: ```typescript @input() get value(): string { return this._value; } set value(v: {toString(): string}) { this._value = v.toString(); } ``` Here, the getter always returns a `string`, but the setter accepts any value that can be `toString()`'d, and coerces it to a string. Unfortunately TypeScript does not actually support this syntax, and so Angular users are forced to type their setters as narrowly as the getters, even though at runtime the coercion works just fine. To support these kinds of patterns (e.g. as used by Material), this commit adds a compiler feature called "input coercion". When a binding is made to the 'value' input of a directive like MatInput, the compiler will look for a static field with the name ngAcceptInputType_value. If such a field is found the type-checking expression for the input will use the static field's type instead of the type for the @input field,allowing for the expression of a type conversion between the binding expression and the value being written to the input's field. To solve the case above, for example, MatInput might write: ```typescript class MatInput { // rest of the directive... static ngAcceptInputType_value: {toString(): string}; } ``` FW-1475 #resolve
87bc229
to
46b5112
Compare
I just want to make one more note related to this feature. The primary reason for it is not to allow people to bypass typechecking everywhere, but to enable existing libraries that do accept wider type than the one defined via the getter to fully typecheck with strict template type checking. So essentially the code that works today, should keep on working and typecheck. It is up to the library authors to decide if they want to take advantage of type coersion for inputs, considering that without the extra type information, they new code will fail to compile making it obvious that the setter type does not match the type used at runtime. |
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
Often the types of an
@Input
's field don't fully reflect the types ofassignable values. This can happen when an input has a getter/setter pair
where the getter always returns a narrow type, and the setter coerces a
wider value down to the narrow type.
For example, you could imagine an input of the form:
Here, the getter always returns a
string
, but the setter accepts any valuethat can be
toString()
'd, and coerces it to a string.Unfortunately TypeScript does not actually support this syntax, and so
Angular users are forced to type their setters as narrowly as the getters,
even though at runtime the coercion works just fine.
To support these kinds of patterns (e.g. as used by Material), this commit
adds a compiler feature called "input coercion". When a binding is made to
the 'value' input of a directive like MatInput, the compiler will look for a
static function with the name ngCoerceInput_value. If such a function is
found, the type-checking expression for the input will be wrapped in a call
to the function, allowing for the expression of a type conversion between
the binding expression and the value being written to the input's field.
To solve the case above, for example, MatInput might write:
FW-1475 #resolve