[P in keyof T]: T[P] not accepting inferred base type via extends#42382
[P in keyof T]: T[P] not accepting inferred base type via extends#42382jonhue wants to merge 5 commits intomicrosoft:mainfrom
Conversation
| optional<T>({ foo }) // no error as { foo: T['foo'] } <: OptionalGenericMap<T> | ||
| optional<T>({ bar }) // no error as { bar: T['bar'] } <: OptionalGenericMap<T> |
There was a problem hiding this comment.
Strictly speaking, these two cases aren't in the scope of the original issue. As you're able to see from the baselines, the current behavior here is to throw errors. Intuitively, however, the behavior I described in the comments makes more sense to me.
I would be interested in your thoughts on this matter 🙂
Regarding implementation, I wasn't able to figure out how to check whether a mapped type is optional (includes the ?). Do we have a property that tracks that or should we check whether the value type includes undefined?
There was a problem hiding this comment.
The change to allow assignability for the optional case should be okay, but the other changes are not (see other comments).
I think the information about optional mapped types may be attached to the SymbolFlags for symbol of the key, but I'm not sure since I have not looked at that part of the typechecker before.
There was a problem hiding this comment.
For these two cases not to result in an error, we would have to something like
isTypeAssignableTo(source, partial(objectTypeConstraint))
instead of isTypeAssignableTo(source, objectTypeConstraint).
In other words, we'd have to check that each property of source is assignable to a corresponding property in objectTypeConstraint, but we do not want to check that each property in objectTypeConstraint also appears in source.
Are we already doing something similar somewhere in the type checker?
There was a problem hiding this comment.
I think if you stop overeagerly jumping to the constraint, you find you have to partialize the object type we're comparing against. (To propagate forward the partial modifiers found on the target mapped type itself). Now... actually doing that may turn out to be counterproductive, since then you'd be unwrapping a partial mapped type.... into a partial mapped type; which is just a circular definition, and thus not a useful simplification. So!
Instead of the constraint stuff, I think you'd find it's more useful to break down the source - if every Source["prop"] is related to ObjectType["prop"], then the relation is good. And not just the concrete keys either - each element returned by keyof Source needs to be individually indexed-access'd up and compared, since we have arbitrarily many index signatures nowadays.
| const optional = <T>(x: OptionalGenericMap<T>) => x | ||
|
|
||
| const withinConstraint = <T extends Constraint>(foo: T['foo']) => { | ||
| required<T>({ foo }) // no error as { foo: T['foo'] } <: GenericMap<T> |
There was a problem hiding this comment.
This is incorrect, tsc should reject this call. T could be {foo: string, bar: number}. Then {foo: string} should definitely not be assignable to {foo: string, bar: number}.
It's not the case that {foo: T['foo'] } is definitely a subtype of GenericMap<T>. It's only definitely a subtype of OptionalGenericMap<T> (that that in TS's type system today; it is possible that this could change in the future).
This was the meaning of my original comment on #37670 ; tsc should reject this code, that's not a defect in tsc.
Only optional calls below are fine.
There was a problem hiding this comment.
Yes, you're absolutely right! I did overlook that.
| x = y; | ||
| y = x; // Error | ||
| ~ | ||
| !!! error TS2322: Type 'Readonly<Thing>' is not assignable to type 'Readonly<T>'. |
There was a problem hiding this comment.
This error should definitely still be here! We have
type Thing = { a: string, b: string };so for example, T = { a: "x" | "y" | "z", b: "hello" | "goodbye" } is a subtype of Thing, but you can't allow y = x, since x could be e.g. { a: "foo", b: "bar" } which is definitely not a Readonly<T>.
I think all of the other errors in this file should also stay, but have not looked carefully at them; none of them are using Partial so the same reasoning should apply in all cases: the errors are correct.
There was a problem hiding this comment.
yup, thanks for explaining this!
| const objectType = (<IndexedAccessType>template).objectType; | ||
| if (objectType === source) return Ternary.True; | ||
| const objectTypeConstraint = getConstraintOfType(objectType); | ||
| if (objectTypeConstraint && isTypeAssignableTo(source, objectTypeConstraint) && target.declaration.questionToken?.kind === SyntaxKind.QuestionToken) return Ternary.True; |
There was a problem hiding this comment.
I'm sure there is a nicer (and safer!) way of determining whether the properties of a mapped type are optional. Right now I'm looking at the syntax node. But this approach does not work with +? for example.
There was a problem hiding this comment.
getMappedTypeModifiers as called just above here. The modifiers variable already in-scope should have them.
| if (template.flags & TypeFlags.IndexedAccess && (<IndexedAccessType>template).indexType === getTypeParameterFromMappedType(target)) { | ||
| const objectType = (<IndexedAccessType>template).objectType; | ||
| if (objectType === source) return Ternary.True; | ||
| const objectTypeConstraint = getConstraintOfType(objectType); |
There was a problem hiding this comment.
I think you don't want to look at the target's constraints at all - knowing that e.g. T is a subtype of {x: string} doesn't help you assign anything to a T, since T could have stricter requirements (like x: "yes" | "no").
We don't actually care about any of the constraint's attributes/properties for the assignability relation. The only reason we even need the T extends { foo: string } constraint is so that it's possible to write T["foo"] in the signature, which is handled elsewhere. So I think the correct/complete version of this code won't look at the constraint type of the target at all.
Note, here's an example of a function that is currently rejected, and should continue to be rejected after this change. It doesn't have a extends clause on T, but that's not the reason for it:
function shouldReject<T, K extends keyof T>(key: K, v: T[K]): {[k in keyof T]?: T[k]} {
return { [key]: v }
// Type '{ [x: string]: T[K]; }' is not assignable to type '{ [k in keyof T]?: T[k] | undefined; }'.(2322)
}in this case, we don't know whether K is just one value, or a union of possible values. For example, if we called shouldReject<{foo: string, bar: number, qux: boolean}, "foo" | "bar"> then T[K] is string | number, which means that we could pass in "(foo", 123) and produce a value that's clearly not a Partial<T>.
I actually proposed #41363 a while ago to deal with this; the correct typing for the function would be T[assign K] instead of T[K] in the argument, but that's clearly beyond the scope of this PR, it's just vaguely relevant.
There was a problem hiding this comment.
I agree with the more general point that it is rarely useful to look at the constraints of types when determining whether one is an instance of the other. I do think, however, that this is one of the rare cases where it actually is useful.
I completely agree with all the other points you're making 🙂
I also added your example to the test, although I don' think it will be immediately impacted by the change.
There was a problem hiding this comment.
I'd rather just use the objectType directly and let relationship checking break down the type to its constraints over the course of it's usual decomposition paths.
| !!! error TS2345: Argument of type '{ foo: T["foo"]; bar: T["bar"]; }' is not assignable to parameter of type 'GenericMap<T>'. | ||
| optional<T>({}) // no error as {} <: OptionalGenericMap<T> | ||
| optional<T>({ foo }) // no error as { foo: T['foo'] } <: OptionalGenericMap<T> | ||
| ~~~~~~~ |
There was a problem hiding this comment.
Looks like this case is not handled yet, even though it should be. I think it's because you're currently relying on the "constraint" type to do too much work; you should not need to look at the constraint for the target at all, it's rarely useful for assignability to a generic type.
There was a problem hiding this comment.
Yeah, that's what I mentioned in #42382 (comment).
|
@sandersn I think this has to be moved back to needs review :) |
| ~~~~~~~~~~~~ | ||
| !!! error TS2345: Argument of type '{ foo: T["foo"]; bar: T["bar"]; }' is not assignable to parameter of type 'GenericMap<T>'. | ||
| optional<T>({}) // no error as {} <: OptionalGenericMap<T> | ||
| optional<T>({ foo }) // no error as { foo: T['foo'] } <: OptionalGenericMap<T> |
There was a problem hiding this comment.
Maybe another hint for the reviewers because the threads might look a little convoluted: I would consider this line not erroring a nice to have but not a necessity to fix the referenced issue. So it is probably best to leave this unaddressed here and improve this behavior further in a follow-up. 🙂
Here are my thoughts on this matter up to this point: #42382 (comment)
|
There is an obvious semantics for |
|
@isiahmeadows I agree with your statement. But does it apply to this specific case where we have |
|
@jonhue I would also expect the usual application/abstraction identity to hold, too, just like // The `U` types below should be equivalent for all `T`
type U = {[P in keyof T]: T[P]}
type F<X> = {[P in keyof X]: X[P]}
type U = F<T>Of course, when checking |
|
@isiahmeadows I agree with that too 😄 I do think though that it might be better to track this in a separate pr to keep each individual change as small as possible. |
weswigham
left a comment
There was a problem hiding this comment.
I like the idea of fixing this, but this method seems a little incomplete to me. I think something that relies less on the constraint and more on comparing generated indexed accesses derived from keyof the source would be more consistent.
| const objectType = (<IndexedAccessType>template).objectType; | ||
| if (objectType === source) return Ternary.True; | ||
| const objectTypeConstraint = getConstraintOfType(objectType); | ||
| if (objectTypeConstraint && isTypeAssignableTo(source, objectTypeConstraint) && target.declaration.questionToken?.kind === SyntaxKind.QuestionToken) return Ternary.True; |
There was a problem hiding this comment.
getMappedTypeModifiers as called just above here. The modifiers variable already in-scope should have them.
| if (template.flags & TypeFlags.IndexedAccess && (<IndexedAccessType>template).indexType === getTypeParameterFromMappedType(target)) { | ||
| const objectType = (<IndexedAccessType>template).objectType; | ||
| if (objectType === source) return Ternary.True; | ||
| const objectTypeConstraint = getConstraintOfType(objectType); |
There was a problem hiding this comment.
I'd rather just use the objectType directly and let relationship checking break down the type to its constraints over the course of it's usual decomposition paths.
| optional<T>({ foo }) // no error as { foo: T['foo'] } <: OptionalGenericMap<T> | ||
| optional<T>({ bar }) // no error as { bar: T['bar'] } <: OptionalGenericMap<T> |
There was a problem hiding this comment.
I think if you stop overeagerly jumping to the constraint, you find you have to partialize the object type we're comparing against. (To propagate forward the partial modifiers found on the target mapped type itself). Now... actually doing that may turn out to be counterproductive, since then you'd be unwrapping a partial mapped type.... into a partial mapped type; which is just a circular definition, and thus not a useful simplification. So!
Instead of the constraint stuff, I think you'd find it's more useful to break down the source - if every Source["prop"] is related to ObjectType["prop"], then the relation is good. And not just the concrete keys either - each element returned by keyof Source needs to be individually indexed-access'd up and compared, since we have arbitrarily many index signatures nowadays.
|
Do you want to keep working on this? Otherwise I'd like to close it to reduce the number of open PRs. |
|
I don't habe the time at the moment. |
Fixes #37670
First of all, no program whose type checks were successful before should be affected by this change.
Secondly, an argument as to why soundness is preserved by this change (borrowing types from the original issue):
{id: T['id'] } <: Query<T>it is enough if{id: T['id']} <: Trather than requiring{id: T['id']} = TTis not a union, I'm interested in your thoughts on that.