Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Type relationship via `extends` in TypeScript?

Tags:

typescript

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?

like image 435
Trident D'Gao Avatar asked Oct 16 '25 13:10

Trident D'Gao


1 Answers

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.

like image 159
STerliakov Avatar answered Oct 19 '25 06:10

STerliakov



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!