Skip to content
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

Type guard fails to refine due to the presence of another method #33051

Closed
webstrand opened this issue Aug 23, 2019 · 8 comments
Closed

Type guard fails to refine due to the presence of another method #33051

webstrand opened this issue Aug 23, 2019 · 8 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@webstrand
Copy link
Contributor

TypeScript Version: 3.7.0-dev.20190823

Search Terms: type guard, refine, fail, assignment

Code

interface Map1<K, HK extends K> {
	has(key: K): this is Map2<K, any>;
	get(key: HK): any;
}
declare let map1: Map1<string, never>;
if(map1.has("foo")) {
	let x = map1.get("foo"); // okay
}

interface Map2<K, HK extends K> {
	has(key: K): this is Map2<K, any>;
	get(key: HK): any;

	// If you remove this line, Map2 behaves just like Map1
	behavior(): HK;
}
declare let map2: Map2<string, never>;
if(map2.has("foo")) {
	let x = map2.get("foo"); // fail: map is still Map2<string, never>
}

Expected behavior:
map2.has should refine the type just the same as map1.has does, regardless of the presence of other methods on the interface.

Actual behavior:
map2.has doesn't refine the type at all so long as behavior has the return type HK.

Playground Link:
playground

@jack-williams
Copy link
Collaborator

jack-williams commented Aug 23, 2019

In the first case the predicate type is a subtype of source type, so it narrows to the predicate.

This is not true in the second case: the additional method make the predicate unassignable to the source because any is not assignable to never.

The method has an effect because it means the predicate does not produce a more precise type: the type of behaviour is less precise in the predicate type.

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Aug 23, 2019
@webstrand
Copy link
Contributor Author

I was wondering if the problem wasn't related to assignability. I also tried has(this: any, key: K): this is Map2<K, any>, but that also has an issue even though Map2<K, any> is a more precise type than any.

@RyanCavanaugh
Copy link
Member

As usual I don't have much useful to add to @jack-williams's analysis. Map2 is invariant on HK so it'd be unsound to narrow it to Map2<string, any>

@webstrand
Copy link
Contributor Author

The example above is a reduced version of:

// If T is a refinement of C (T !== C, T is a subtype of C), produce T, otherwise never.
type Refines<T extends C, C> = C extends T ? never : T;

interface Map2<K, HK extends K> {
	has<RK extends K>(key: RK): this is Map2<K, Refines<RK, K> | HK>;
	get(key: HK): number;
	get(key: K): number | undefined;
}

declare let map2: Map2<string, never>;
if(map2.has("foo")) {
	let x: number = map2.get("foo"); // fail: map is still Map2<string, never>
}

The issue goes away if I use Refines<RK | HK, K> or RK | HK in place of Refines<RK, K> | HK. I'm not sure if this is an unrelated issue compared to the above.

@jack-williams
Copy link
Collaborator

jack-williams commented Aug 24, 2019

TLDR: K extends any ? never : any does not get eagerly simplified to any when relating to itself.

Not 100% sure on that, but what I think is happening:

First thing to note is that, ignoring has, Map2 is bivariant in HK because get is a method rather than a function property. With the addition of has, Map2 is covariant in HK.

This is most likely because type predicates are covariant, so this is Map2<K, Refines<RK, K> | HK1> is only related to this is Map2<K, Refines<RK, K> | HK2> when HK1 is related to HK2.

As to why Refines<RK | HK, K> and RK | HK restore bivariance of HK, my suspicion is that it's due to the erasure of RK to any when the generic signatures of has are compared during variance measurements.

Here is an inlined expansion of what I think is going on:

// corresponds to Refines<RK, K> | HK
interface FooCO<K, HK extends K> {
	foo: (x: any) => HK | (K extends (any) ? never : (any));
}

declare const one: FooCO<string, never>;
declare const two: FooCO<string, 'foo'>;

const a: FooCO<string, never> = two; // error, not contra in HK
const b: FooCO<string, 'foo'> = one; // co in HK

// corresponds to Refines<RK | HK, K>
interface FooBI<K, HK extends K> {
	foo: (x: any) => (K extends (HK|any) ? never : (HK|any));
}

declare const one2: FooBI<string, never>;
declare const two2: FooBI<string, 'foo'>;

const a2: FooBI<string, never> = two2; // contra in HK
const b2: FooBI<string, 'foo'> = one2; // co in HK

// corresponds to RK | HK
interface FooBI2<K, HK extends K> {
	foo: (x: any) => (HK | any);
}

declare const one3: FooBI2<string, never>;
declare const two3: FooBI2<string, 'foo'>;

const a3: FooBI2<string, never> = two3; // contra in HK
const b3: FooBI2<string, 'foo'> = one3; // co in HK

So, getting to the point: I think that when relating HK | (K extends (any) ? never : (any)) to itself (or a sub/super type of HK) it's not simplifying the conditional type so it must be the case that HK is related to it's sub/super type. With the other two options the union of any is applied directly to HK, so HK ends up getting subsumed by any and the resulting measurement is bivariant.

If you change the definition of Refines to

type Refines<T extends C, C> = [C] extends [T] ? never : T

You should get the intended behaviour because the conditional type eagerly resolves to any in the measurement check which then subsumed HK.

@jack-williams
Copy link
Collaborator

And I'll just add this as a piece of relevant work #30461: it's not related to your specific bugs/questions, but it is related to your wider goal --- maybe it's interesting.

@typescript-bot
Copy link
Collaborator

This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow.

1 similar comment
@typescript-bot
Copy link
Collaborator

This issue has been marked as 'Question' and has seen no recent activity. It has been automatically closed for house-keeping purposes. If you're still waiting on a response, questions are usually better suited to stackoverflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

4 participants