Description
π Search Terms
- type reduction fails
- generic function return type inference fails
Couldn't find any issues that match this one sufficiently.
π Version & Regression Information
This is the behavior in every version I tried up to 5.3.2.
β― Playground Link
π» Code
import { default as $ } from 'jquery';
import JQuery from 'jquery';
const t1: string | JQuery<HTMLElement> | undefined = $(); // no error
const t2: ((string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)) | undefined = $(); // error
// ~~
// Type 'JQuery<string | HTMLElement>' is not assignable to type '((string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)) | undefined'.
// Type 'JQuery<string | HTMLElement>' is not assignable to type 'JQuery<HTMLElement>'.
// Type 'string | HTMLElement' is not assignable to type 'HTMLElement'.
// Type 'string' is not assignable to type 'HTMLElement'.(2322)
const t11: string | JQuery<HTMLElement> = $(); // no error
const t12: (string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined) = $(); // error
// ~~
// Type 'JQuery<string | HTMLElement>' is not assignable to type 'string | JQuery<HTMLElement> | (string & JQuery<HTMLElement>) | (JQuery<HTMLElement> & string)'.
// Type 'JQuery<string | HTMLElement>' is not assignable to type 'JQuery<HTMLElement>'.
const JQuery_string = JQuery<string>; // error
// Type 'string' does not satisfy the constraint 'Element'.(2344)
// Type 'string' does not satisfy the constraint 'HTMLElement'.(2344)
// Type 'string' does not satisfy the constraint 'PlainObject<any>'.(2344)
π Actual behavior
-
Even though the type declarations for
t1
andt2
are the same (after reduction), onlyt2
shows an error. The same relationship exists betweent11
andt12
. -
The return type of assigned to
t1
isstring | JQuery<HTMLElement> | undefined
.
π Expected behavior
-
t1
andt2
should have the same error status. Likewise fort11
andt12
. -
The jQuery function
$()
has a generic signature<TElement=HTMLElement>() JQuery<TElement>
. So the return type assigned tot1
should beJQuery<string> | JQuery<HTMLElement> | JQuery<undefined>
. -
As shown at the bottom of the code,
JQuery<string>
is judged to be an invalid instantiation of JQuery. If so, the lines witht1
andt2
should both be errors, but they are not. However, the actual declaration for the interface JQuery seems to be
interface JQuery<TElement = HTMLElement> extends Iterable<TElement>
which actually doesn't look as though it should constraint the domain of JQuery to HTMLElement, so perhaps JQuery<string>
should not be an error? Furthermore, from within a DefinitelyTyped/semantic-ui
test file, JQuery<string>
does not incur an error.
Additional information about the issue
Origin of strange declaration type
I explain where the weird declaration types (e.g. (string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)
) came from.
The following is quoted and condensed from filles under DefinitelyTyped/semantic-ui-search
import { default as $ } from 'jquery';
interface _Impl {
stateContext: string | JQuery;
};
type Param =
& (
| Pick<_Impl, "stateContext">
)
& Partial<Pick<_Impl, keyof _Impl>>;
The type of Param["stateContext"]
is indeed the weird type:
type StateContext = Param["stateContext"];
// ^? (string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)
The error can then be incurred as follows:
const p: Param = {
stateContext: $()
// ~~
// Type 'JQuery<string | HTMLElement>' is not assignable
// to type '(string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)'.(2322)
}
Relation to issue #56013 and pull #56373
The submitted pull pull #56373 for the bug in issue #56013 resulted in similar errors to this report. That's because the bug #56013 is due to improper caching of generic call expression arguments, resulting in the call expression not be re-evaluated when the context changed. When the fix allows that later evaluation, those latent errors appear in the existing test code under DefinitelyTyped/semantic-ui
.
An obvious "patchy" fix would be to write new code to force reduction just in time at the critical point - but it could be called multiple times. A better fix would be to do the reduction once when the declaration is parsed - but there is likely some reason it wasn't done that way. Some feedback from the original designers would be helpful.
UPDATE:
Stand alone code with pseudo jQuery
interface JQuery<TElement = HTMLElement> {
something: any;
// Change `[Symbol.iterator]` to `other` and the error goes away
[Symbol.iterator]: () => {
// Change `next` to `foo` and the error goes away
next(): {
value: TElement;
}
| {
done: true;
value: any;
};
}
}
declare function jQuery<TElement = HTMLElement>(): JQuery<TElement>;
const t11: string | JQuery<HTMLElement> = jQuery(); // no error
const t12: (string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined) = jQuery(); // error
// ~~
// Type 'JQuery<string | HTMLElement>' is not assignable to type '(string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)'.
// Type 'JQuery<string | HTMLElement>' is not assignable to type 'JQuery<HTMLElement>'.ts(2322)
const ta: string = jQuery(); // error
// ?^ jQuery<HTMLElement>(): JQuery<HTMLElement>
const tb: JQuery<HTMLElement> = jQuery(); // no error
// ^? jQuery<HTMLElement>(): JQuery<HTMLElement>
const tc: string & JQuery<HTMLElement> = jQuery(); // error
// ^? // jQuery<HTMLElement>(): JQuery<HTMLElement>
const td: JQuery<HTMLElement> & string = jQuery(); // error
// ?^ // jQuery<string>(): JQuery<string>
Debugging analyis using the above code for the psuedo-JQuery
In the declaration
t12: (string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined) = jQuery()
the type
(string | JQuery<HTMLElement>) & (string | JQuery<HTMLElement> | undefined)
is reduced to union of these 4 types
string
JQuery<HTMLElement>
string & JQuery<HTMLElement>
JQuery<HTMLElement> & string
inferTypes
tries to infer TElement
in
<TElement = HTMLElement>jQuery(): JQuery<TElement>
by independently inferring for TElement
from each of the 4 types above,
to get the following candidates:
- source:
string
, target:JQuery<TElement>
- candidate for
TElement
: (none)
- candidate for
- source:
JQuery<HTMLElement>
, target:JQuery<TElement>
- candidate for
TElement
:HTMLElement
- candidate for
- source:
string & JQuery<HTMLElement>
, target:JQuery<TElement>
- candidate for
TElement
:HTMLElement
(repeat, ignored)
- candidate for
- source:
JQuery<HTMLElement> & string
, target:`JQuery``- candidate for
TElement
:string
- candidate for
Then it takes the union string | HTMLElement
so the final return type is JQuery<string | HTMLElement>
.
The most noticeable problem is the result for:
- source:
JQuery<HTMLElement> & string
, target:JQuery<TElement>
- candidate for
TElement
:string
- candidate for
That happens because inferTypes
calls getApparentType(JQuery<HTMLElement> & string)
which promotes string
to String
.
Then JQuery<HTMLElement> & String
is object whose properties are compared to the properties of JQuery<TElemet>
.
They both share [[Symbol.iterator]]
as a property the the properties
- source:
(() => { next(): { done: true; value: any; } | { value: HTMLElement; }; }) & (() => IterableIterator<string>)
- target:
"() => { next(): { done: true; value: any; } | { value: TElement; }; }"
become a valid source-target pair for inference in the function
function inferFromSignatures(source: Type, target: Type, kind: SignatureKind) {
const sourceSignatures = getSignaturesOfType(source, kind);
const sourceLen = sourceSignatures.length;
if (sourceLen > 0) {
// We match source and target signatures from the bottom up, and if the source has fewer signatures
// than the target, we infer from the first source signature to the excess target signatures.
const targetSignatures = getSignaturesOfType(target, kind);
const targetLen = targetSignatures.length;
for (let i = 0; i < targetLen; i++) {
const sourceIndex = Math.max(sourceLen - targetLen + i, 0);
inferFromSignature(getBaseSignature(sourceSignatures[sourceIndex]), getErasedSignature(targetSignatures[i]));
}
}
}
- Here the source intersection is treated as an array of signatures.
- Notice that the source signatures are of length 2, but the target signature is of length 1.
- The code matches each of the target signatures to some source signature, but because there are more source signatures than target signature, the first source signature is ignored.
Each match potentially contributes a candidates. In this case the deep match stack is as follows:
-
(...args: [] | [undefined]) => IteratorResult<string, any>
() => { done: true; value: any; } | { value: TElement; }
-
IterableIterator<string>
{ next(): { done: true; value: any; } | { value: TElement; }; }
-
IteratorYieldResult<string>
{ value: TElement; }
-
string
TElement
and so string
gets added as an inferred candidate for TElement
.
There are a couple of remarkable things going on here.
One remarkable thing
Using the the return type of the objects iterator for to infer the typeArgument for the object itself.
By coincide String
and JQuery
both have iterators, but the inference seems to be an extraordinary overreach.
- Suggestion: Resolve
string & <SomeObject>
(SomeObject
exceptingEnum
) tonever
ingetReducedType
. In the rare cases whereString & <SomeObject>
is actually intended to represent their common properties,String
can be explicitly specified instead ofstring
.- Test: Ran runtests to test this suggestion.
- Result:
- It does eliminate the bug noted in this post.
- However there are 44 tests errors, many of which were cases of type abstractions such as "tagged strings" which have no relation to actual JS runtime types, and could be replaced with some other tagging method such as
WeakMap
or using a straightforwardinterface TaggedString<T> {string:string, tag:T}
. Overloading the&
symbol, which already has a specific meaning in flow and inference, to represent other concepts is not a clean design, so TypeScript should not be obliged to support it.
Another remarkable thing, that does not directly cause the main topic bug
There are a lot assumptions in inferFromSignatures
, and because of that these two inferences give difference results;
- source:
string & JQuery<HTMLElement>
, target:JQuery<TElement>
- candidate for
TElement
:HTMLElement
(repeat, ignored)
- candidate for
- source:
JQuery<HTMLElement> & string
, target:JQuery<TElement>
- candidate for
TElement
:string
- candidate for
The result should not depend upon the order around the &
symbol. (However, changes to this "remarkable thing" would not prevent the bug which is the main topic of this post.)
- Suggestion: If the source and target don't share the same symbol and thus share the same overloads, then in if a full NxM comparison of signatures can not be made, it might be better to do nothing than risk unpredictable behavior. Instead, detect potential inferences from
tsserver
, and offer to fill in corresponding template constraints as an auto-completion. What I mean is not the final type (which is obviously may be undetermined), but the location in terms of object member path.