10

Consider the types FooBar1 and FooBar2 defined as follows:

type Foo = { foo: string };
type Bar = { bar: number };
type FooBar1 = Foo & Bar;
type FooBar2 = { foo: string; bar: number };

Question: What is the difference between FooBar1 and FooBar2?


My attempt / research:

  • They are bidirectionally assignable to each other! (checked manually and with tsd - see here)
  • Still, they are not identical to each other! (checked with tsd - see here)
  • VSCode's intellisense does not collapse { foo } & { bar } automatically into { foo, bar }, while it does collapse other complex types to simpler forms, such as NonNullable<string | undefined> to string:
// |------------------------------------------------------------|
// | let x: {                                                   |
// |     a: string;                                             |
// | } & {                                                      |
// |     b: string;                                             |
// | }                                                          |
// | -----------------------------------------------------------|
//  ^
//  | When hovering `x` here:
//  |
let x: { a: string } & { b: string };

Edit: Difference between extending and intersecting interfaces in TypeScript? has been suggested as a duplicate but I disagree. Instead of comparing an intersection to the extension of an interface, I am comparing an intersection to another raw type, no interfaces or extensions involved.

Pedro A
  • 3,989
  • 3
  • 32
  • 56
  • 1
    After noticing two votes to close, I have edited the question making it simpler and to the point. Hopefully these votes can be retracted now. Let me know if I should improve anything else. – Pedro A Apr 21 '20 at 22:19
  • Which typescript compiler version do you use? With `3.8.3` it compiles without warning like expected (Your attempt from Github). – WolverinDEV Apr 28 '20 at 11:18
  • @WolverinDEV Hi, thanks, yes I use 3.8.3 too, it compiles, yes, but compiling is not the issue. – Pedro A Apr 28 '20 at 11:54
  • 1
    I'm pretty sure this is a bug in TSD and it just cannot figure out those two are identical. – Johannes H. May 04 '20 at 08:40
  • 1
    `tsd` uses `isTypeIdenticalTo` from `tsc` to check whether two types are identical, but the check gets really complex really fast so I didn't check why those two types aren't identical. The current language versions don't define this relationship, so it might not be a relevant question to ask for a language user, and it's just a compiler detail. The compiler uses some flags to distinguish Object from Intersection types. The distinction may become relevant with something like "complement types" (e.g not (A & B) == not A | not B) – Tiberiu Maran May 04 '20 at 09:54
  • @artcorpse Hi, any chance you can expand your comment into an answer? – Pedro A May 04 '20 at 12:44

1 Answers1

3

In your example, we can say that FooBar1 and FooBar2 are equal. And we can indeed prove that:

type Equals<A, B> =
    A extends B
    ? B extends A
      ? true
      : false
    : false

type test0 = Equals<{a: 1} & {b: 2}, {a: 1, b: 2}> // true

But for a general answer, we can only say that they are not always equal. Why? Because intersections can resolve to never in some cases. If ts finds an intersection to be valid, it proceeds with it, otherwise returns never.

import {O} from "ts-toolbelt"

type O1 = {a: 1, b: 2}
type O2 = {a: 1} & {b: 2}       // intersects properly
type O3 = {a: 1, b: 2} & {b: 3} // resolved to `never`

type test0 = Equals<O1, O2>
// true

type test1 = O.Merge<{a: 1, b: 2}, {b: 3, c: 4}>
// {a: 1, b: 2, c: 4}

Here type O3 resolved to never because b is 3 and it cannot overlap with 2. Let's change our example to show that an intersection would work if you had:

import {A} from "ts-toolbelt"

type O4 = A.Compute<{a: 1, b: number} & {b: 2}> // {a: 1, b: 2}
type O5 = A.Compute<{a: 1, b: 2} & {b: number}> // {a: 1, b: 2}

This example also highlights how intersections work -- like union intersections. TypeScript will go over all the property types and intersect them. We've forced TypeScript to compute the result of the intersection with A.Compute.

In short, if ts cannot overlap ALL OF the members, then the product of that intersection is never. For this reason, it might not be suitable as a merging tool:

type O3 = {a: 1, b: 2} & {b: 3} // resolved to `never`

So remember, & is not a merging tool. A & B is only equal to {...A, ...B} only if they have keys that do not overlap. If you need a merging type, use O.Merge.

millsp
  • 1,259
  • 1
  • 10
  • 23
  • Thanks for the answer, but I am confused. What is direct answer to the question? What is the difference between FooBar1 and FooBar2? – Pedro A Sep 04 '20 at 16:10
  • Also, what is the difference between "intent to merge" and "intent to use &"? Thanks :) – Pedro A Sep 04 '20 at 16:11
  • The right answer is that they are not always equal. – millsp Sep 04 '20 at 16:44
  • For most people, when they use `&` is to merge two types, or narrow a union. But the real intent of `&` is to intersect, not merge. This is not very useful on objects since it can easily yield you a `never`. The `&` operator behaves on objects the very same way it behaves on unions. The thing to remember is while `&` can be used for merging to a certain extent, it's not a merging tool. So if you want to merge objects, use ts-toolbelt which uses `&` (internally) responsibly to achieve just that. – millsp Sep 04 '20 at 16:52
  • You are saying that "they are not always equal", I think I get that, you're talking about usage of & in general. But I want to understand my specific example. Either FooBar1 and FooBar2 are equal or are different. Which is it? – Pedro A Sep 04 '20 at 17:05
  • Also, can you edit your code to include what does `test1` resolve to (you said it "works" but it's not clear what that means). Thanks :) – Pedro A Sep 04 '20 at 17:06
  • I've updated it. Let me know if I can help with anything else – millsp Sep 04 '20 at 17:32
  • Hi again, sorry for the delay to reply. In your first sentence you "proved" that they are equal by showing that they both extend each other. But as mentioned in the question, I already know that they are assignable to each other. This is indeed a necessary condition for them to be equal, but it is not sufficient. – Pedro A Sep 12 '20 at 04:23
  • For example, `string` and `any` both extend each other as well, and they are definitely not equal. So maybe you could improve your proof by saying that *"they both extend each other and are not `any`"*, but how do you know that's enough? How do you know there isn't yet another subtle condition? Since the `tsd` library is saying they are not equal, and it uses TS internals under the hood, it seems to me that there must be a difference. – Pedro A Sep 12 '20 at 04:23
  • This is precisely what I used in the ts-toolbelt https://github.com/millsp/ts-toolbelt/blob/master/src/Any/Equals.ts and that they use in tsd. This is a stricter version of equals is explained in the comments of https://stackoverflow.com/a/52473108/3570903 – millsp Sep 13 '20 at 11:20
  • While this is very useful to check strict equality (readonly properties, any, etc...), it is mostly to pass tests on types (on the ts-toolbelt). For programmers, at program-time, the more lenient version of Equals should be enough. – millsp Sep 13 '20 at 11:23
  • So to answer you question. What is different about the two types? It is that they do not share the same structure, but they are structurally equivalent. TypeScript has a structural type system. stackoverflow.com/a/52473108/3570903 effectively uses a clever technique to discern between equivalence and pure equality. – millsp Sep 13 '20 at 11:32