As far as I understand A extends B
implies A
is "at least within" B
. So for unions A
extends B
means at least one case of B
is satisfied by A
and A
has no cases outside of B
, and for products it means that A
has at least the properties of B
.
If that is correct, then I don't understand the following:
function doesExtend<A, B>(_: (A extends B ? 'yes' : 'no')) { }
doesExtend<'a', 'a'>('yes'); // correct: yes, identical
doesExtend<'a' | 'b', 'a' | 'b'>('yes'); // correct: yes, still identical
doesExtend<'b' | 'a', 'a' | 'b'>('yes'); // correct: yes, still identical
doesExtend<'a' | 'b', 'a' | 'b'>('no'); // correct: no, because they are idential right?
doesExtend<'a', 'a' | 'b'>('yes'); // correct: yes, subset is within a superset
doesExtend<'a' | 'b', 'a'>('no'); // correct: no, superset is larger than a subset
doesExtend<'a' | 'b', 'a'>('yes'); // <--- HERE! wtf? (incorrect yes)
Now, to make it even worse:
type WhoExtendsWho<A, B> = A extends B
? B extends A
? 'A extends B, and B extends A'
: 'A extends B, but B does not extend A'
: B extends A
? 'A does not extend B, but B extends A'
: 'A does not extend B, and B does not extend A';
type X = WhoExtendsWho<'a' | 'b', 'a'>; // "A extends B, and B extends A" | "A does not extend B, and B does not extend A"
So my question is how can binary dichotomy give me these 2 answers?
UPDATE:
Typescript extends
is distributive over unions - that is, given
type ExtendsQ<A, B> = A extends B ? true : false;
all the following types are equivalent:
type Original = ExtendsQ<'a' | 'b', 'b'>;
type Aliased = ExtendsQ<'a', 'b'> | ExtendsQ<'b', 'b'>;
type Expanded = ('a' extends 'b' ? true : false) | ('b' extends 'b' ? true : false);
type Simplified = false | true;
type FinalResult = boolean;
(this distribution happens in "extends", not early in the type itself, but I struggle to denote it in a clear fashion)
I believe this is a very confusing and inconvenient feature, but fortunately there is a simple escape hatch: ask the same about 1-tuples of A
and B
. And probably it's a good thing, as going in another direction would have been more difficult if not impossible if they were to default to non-distributive behaviour. Using the same definition as above, let's explore how this works:
type ExtendsQ<A, B> = A extends B ? true : false;
type Res1 = ExtendsQ<'a' | 'b', 'a'>; // boolean
// ^?
type Res2 = ExtendsQ<['a' | 'b'], ['a']>; // false
// ^?
type Res3 = ExtendsQ<['a'], ['a' | 'b']>; // true
// ^?
type Res4 = ExtendsQ<['a'], ['a']>; // true
// ^?
Nice, huh? That seems to match your intent. That's because a top-level component in extends
is no longer a union, so there's nothing to distribute.
Now you can do the same on the alias side to make it the default behavior:
function doesExtend<A, B>(_: ([A] extends [B] ? 'yes' : 'no')) { }
doesExtend<'a', 'a'>('yes'); // correct: yes, identical
doesExtend<'a', 'a' | 'b'>('yes'); // correct: yes, subset extends a superset
doesExtend<'a' | 'b', 'a'>('no'); // correct: no, superset doesn't extend a subset
doesExtend<'a' | 'b', 'a'>('yes'); // error, hooray
type WhoExtendsWho<A, B> = [A] extends [B]
? [B] extends [A]
? 'A <: B, and B <: A'
: 'A <: B, but not B <: A'
: [B] extends [A]
? 'not A <: B, but B <: A'
: 'not A <: B, and not B <: A';
type X = WhoExtendsWho<'a' | 'b', 'a'>; // "not A <: B, but B <: A"
And here's a playground with all code from my answer to try it interactively.