Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does the `Equals` work in typescript?

Tags:

typescript

I found an Equals utils mentioned at: https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650

export type Equals<X, Y> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? true : false;

It could be used to check if two types are equal such as:

type R1 = Equals<{foo:string}, {bar:string}>; // false
type R2 = Equals<number, number>; // true

It is hard for me to understand how this works and what does the T mean in the expression.

Could someone please explain this?

like image 992
jayatubi Avatar asked Aug 28 '21 06:08

jayatubi


1 Answers

First let's add a couple of parentheses

export type Equals<X, Y> =
    (
      (<T>() => (T extends /*1st*/ X ? 1 : 2)) extends /*2nd*/
      (<T>() => (T extends /*3rd*/ Y ? 1 : 2)) 
    )
        ? true 
        : false;

Now when you substitute some types for X and Y what this second extends keyword is doing is basically asking a question: "Is a variable of type <T>() => (T extends X ? 1 : 2) assignable to a variable of type (<T>() => (T extends Y ? 1 : 2))? In other words

declare let x: <T>() => (T extends /*1st*/ X ? 1 : 2) // Substitute an actual type for X
declare let y: <T>() => (T extends /*3rd*/ Y ? 1 : 2) // Substitute an actual type for Y
y = x // Should this be an error or not?

The author of the comment you provided says that

The assignability rule for conditional types <...> requires that the types after extends be "identical" as that is defined by the checker

Here they are talking about the first and the third extends keywords. The checker would only allow that x be assignable to y if the types after them, namely X and Y, are identical. If you substitute number for both

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <T>() => (T extends number ? 1 : 2)
y = x // Should this be an error or not?

Of course it shouldn't be an error, because there are 2 variables of the same type. Now if you substitute number for X and string for Y

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <T>() => (T extends string ? 1 : 2)
y = x // Should this be an error or not?

Now types after extends are not identical, so there will be an error.


Now let's see why the types after extends must be identical for the variables to be assignable. If they are identical, then everything should be clear, because you just have 2 variables of the same type, they will always be assignable to each other. As for the other case, consider the last situation I described, with Equals<number, string>. Imagine this were not an error

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <T>() => (T extends string ? 1 : 2)
y = x // Imagine this is fine

Consider this code snippet:

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <T>() => (T extends string ? 1 : 2)

const a = x<string>() // "a" is of type "2" because string doesn't extend number
const b = x<number>() // "b" is of type "1"

const c = y<string>() // "c" is of type "1" because string extends string
const d = y<number>() // "d" is of type "2"

y = x
// According to type declaration of "y" we know, that "e" should be of type "1"
// But we just assigned x to y, and we know that "x" returns "2" in this scenario
// That's not correct
const e = y<string>() 
// Same here, according to "y" type this should be "2", but since "y" is now "x",
// this is actually "1"
const f = y<number>()

It's similar if types are not string and number, which have nothing in common, but something more sophisticated. Let's try {foo: string, bar: number} for X and {foo: string} for Y. Note that here X is actually assignable to Y

declare let x: <T>() => (T extends {foo: string, bar: number} ? 1 : 2)
declare let y: <T>() => (T extends {foo: string} ? 1 : 2)

// "a" is of type "2" because {foo: string} doesn't extend {foo: string, bar: number}
const a = x<{foo: string}>()

// "b" is of type "1"
const b = y<{foo: string}>()

y = x
// According to type declaration of "y" this should be of type "1", but we just
// assigned x to y, and "x" returns "1" in this scenario
const c = y<{foo: string}>()

If you switch the types and try {foo: string} for X and {foo: string, bar: number} for Y, then there will again be a problem calling y<{foo: string}>(). You can see that there is always something wrong.

To be more precise, if X and Y are not identical, there will always be some type that extends one of them, and doesn't extend the other. And if you try to use this type for T you get non-sense. Actually if you try to assign y = x, the compiler gives you an error like this:

Type '<T>() => T extends number ? 1 : 2' is not assignable to type '<T>() => T extends string ? 1 : 2'.
  Type 'T extends number ? 1 : 2' is not assignable to type 'T extends string ? 1 : 2'.
    Type '1 | 2' is not assignable to type 'T extends string ? 1 : 2'.
      Type '1' is not assignable to type 'T extends string ? 1 : 2'.

Since there is always a type that is assignable to one of X and Y and not the other, it is forced to treat the return type of x as 1 | 2, which is not assignable to T extends ... ? 1 : 2, because T could extend this ... or it could not.

This is basically what this Equals type boils down to, hope it's more or less clear, how it works.


UPD:

Speaking about why Equals<{x: 1} & {y: 2}, {x: 1, y: 2}> is false

tl;dr As far as I understand, it is an implementation detail (not sure if I should call it a bug, this may be intended)

Theoretically, of course, this should be true. As I described above, Equals returns false (in theory) if and only if there exists a type C such that C is assignable to one of X and Y, but not to the other. In this case in the example above if you do x = y and stick it in (x<C>() and y<C>()), you get wrong typings. Here, however, this is not the case, everything that is assignable to {x: 1} & {y: 2} is assignable to {x: 1, y: 2}, so in theory Equals should return true.

In practice, however, it seems like typescript implementation takes a lazier approach when deciding whether types are identical. I should note, that this is a bit of speculation, I never contributed to typescript and don't know its source code, but here is what I found during the last 10 minutes, I could totally miss some details, but the idea should be correct.

The file that does the type checking in ts repository is checker.ts (the link leads to the version of the file between ts 4.4 and 4.5, in future this can change). Here line 19130 seems like the place where the T extends X ? 1 : 2 and T extends Y ? 1 : 2 parts are compared. Here are the relevant parts:

// Line 19130
// Two conditional types 'T1 extends U1 ? X1 : Y1' and 'T2 extends U2 ? X2 : Y2' are related if
// one of T1 and T2 is related to the other, U1 and U2 are identical types, X1 is related to X2,
// and Y1 is related to Y2.
// ...
let sourceExtends = (source as ConditionalType).extendsType;
// ...
// Line 19143
if (isTypeIdenticalTo(sourceExtends, (target as ConditionalType).extendsType) && /* ... */) {
  // ...
}

The comment says that these types are related, if, among other conditions, U1 and U2, in our case X and Y, are identical, this is exactly what we are trying to check. On line 19143 you can see the types after extends are being compared, which leads to isTypeIdenticalTo function, which in turn calls isTypeRelatedTo(source, target, identityRelation):

function isTypeRelatedTo(source: Type, target: Type, relation: /* ... */) {
    // ...
    if (source === target) {
        return true;
    }
    if (relation !== identityRelation) {
        // ...
    }
    else {
        if (source.flags !== target.flags) return false;
        if (source.flags & TypeFlags.Singleton) return true;
    }
    // ...
}

You can see that first it checks if they are exactly the same type (which {x: 1} & {y: 2} and {x: 1, y: 2} are not as far as ts implementation is concerned), then it compares their flags. If you look at the definition of the Type type here you will find that flags is of type TypeFlags which is defined here and would you look at that: intersection has it own flag. So {x: 1} & {y: 2} has a flag Intersection, {x: 1, y: 2} does not, therefore they are not related, therefore Equals returns false although in theory it should not.

like image 189
Alex Chashin Avatar answered Nov 15 '22 10:11

Alex Chashin