Search code examples
typescript

Type relationship via `extends` in 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?

UPDATE:

  • question answered, the solution is in the comments

Solution

  • 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.