1

I've noticed something weird with TypeScript. I've got a type union which contains some array types (string[], number[]) and some non-array types (string, number). If I use type inference, everything works as expected:

type bar = string | number | string[] | number[];
declare foo: bar;

if (Array.isArray(foo))
{
    foo // string[] | number[]
}
else
{
    foo // string | number
}

But if I want to restrict the type directly to array types and use a type intersection, I get something I didn't expect:

declare foo: bar & any[];

// expected type: string[] | number[]

foo // (string & any[]) | (number & any[]) | (string[] & any[]) | (number[] & any[])

Why is that?
Shouldn't string & any[] evaluate to never and string[] & any[] to string[]?

[link to playground]

m93a
  • 8,866
  • 9
  • 40
  • 58
  • You don't have a typeguard in `bar & any[]`, so your expected output will not work in the first place. – Murat Karagöz Nov 29 '18 at 13:59
  • Sorry, I was experimenting with `unknown` instead of `any` (which doesn't work either) and left it there by accident. I fixed the link, so now the playground code is equivalent to the code in my question. However, I don't know what type guard you are talking about. The only type guard in my question is `Array.isArray`. – m93a Nov 29 '18 at 14:09
  • 1
    `string` and `any[]` have keys of type `number` in common. – Madara's Ghost Nov 29 '18 at 14:09
  • @MadaraUchiha This would be relevant if we were talking about type union, not intersection. Type union allows me to only access the common keys and is a *looser* type than both of the types used. Type intersection means that the variable has to have all the properties of both `string` and `any[]`. And there doesn't seem to be any way to achieve this in JavaScript. – m93a Nov 29 '18 at 14:32

2 Answers2

5

It's reasonable to expect that intersections of completely disjoint types should evaluate to never, given the intuition that the intersection of two non-overlapping sets is empty. This has been requested before at various times.

In fact this reduction to never has been partially implemented since TypeScript 2.6. Specifically, a type like ("a" | "b") & "c" becomes never, while "a" & "c" does not. This was done so that combining unions and intersections wouldn't lead to enormous union types.

But the description of the pull request introducing this implementation gives some insight into the answer to your question: "why doesn't the compiler do this all the time"? The quotes below are from Anders Hejlsberg, one of the head maintainers/architects of the TypeScript language.

Here's one issue he mentioned:

We could in theory be more aggressive about removing empty intersection types, but we don't want to break code that uses intersections to "tag" primitive types.

This "tagging" or "branding" is a way to simulate nominal typing in TypeScript. TypeScript uses structural typing to compare types, meaning the compiler doesn't distinguish two types A and B if they have the same shape. Sometimes you want to be able to make two otherwise-identical types be treated differently by the compiler (the default behavior in a nominally-typed language like Java, where just the names A and B are enough to distinguish the types). Well, if you intersect one of the types with some extra property like type AA = A & {randomPropName: any}, now you can distinguish AA from B. This sort of branding is mentioned a lot, and even used in the TypeScript compiler code itself.

So somewhere people are relying on string & {hoobydooby: true} to be distinguished from string & {scoobydooby: false}. If both of those are reduced to never, everything breaks. So they don't do that.


Another issue he mentioned:

We allow such types to exist primarily to make it easier to discover their origin (e.g. an intersection of object types containing two properties with the same name).

So if you have some type like {foo: string} & {foo: number}, this could be reduced to {foo: never} or even just never (after all, no value of type {foo: never} should exist), but I guess error messages become less understandable:

interface A {foo: string}
interface B {foo: number}
type C = A & B;
const c: C = {foo: "hello"}; // error! string is not assignable to string & number;

That gives you some idea that something expects foo to be both a string and a number, which is impossible, but points you to investigate the C type. Otherwise:

const c: C = {foo: "hello"}; // error! string is not assignable to never

This is less understandable, I guess.

Personally I think that this a weaker reason than the first one, but it's part of the "definitive" answer to your question.


There are other reasons why the compiler doesn't perform operations that developers want it to; the most generic reason is time. Even if you can show that a hypothetical compiler operation doesn't break anyone's code and helps your use case, you need to demonstrate that it doesn't seriously damage the compiler's performance. In this case, how aggressively should the compiler check for possible reductions of intersections to never? If A & B is, in general, not very likely to reduce to never, then most of your checks for this will be wasted effort. So the check had better be very quick.

This performance issue turns out to be a very common reason why feature proposals and suggestions get turned down or ultimately don't make it into the language. I don't see it specifically listed in any discussion of this particular issue, but I'd be very surprised if it's not a big factor.


Okay, hope that helps. Good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Awesome, this was a really exhaustive answer, thank you! – m93a Nov 30 '18 at 18:39
  • Maybe another reason is that TypeScript, [even in version 3.2](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#generic-spread-expressions-in-object-literals), still uses type union instead of object spread type. This way, even if the result of `Object.assign({foo: 'a'}, {foo: 4})` is unusable, at least it lets you know that “foo should have been either string or a number, but we don't know which one came first”. Changing `string & number` to `never` before this issue gets resolved wouldn't be a reasonable thing to do. – m93a Nov 30 '18 at 18:45
2

I think what you are seeing here is the distributed nature of union types. e.g. If you intersect a union A | B | C with something say D, the intersection gets distributed as A & D | B & D | C & D.

type U = A | B | C;
type Z = U & D; // distributes as A & D | B & D | C & D

The 2nd nuance is that intersection types don't get "collapsed" (not sure what the actual term would be here) in the way you are thinking. A simpler example would be type X = Array<number> & Array<any>;. Type X doesn't collapse to Array<number> but remains as the intersection originally declared.

Worth noting that this seems unrelated to whether an intersection type results in a never scenario or not. For example type X = { x: string } & { y: string }; also remains as declared instead of being shown as { x: string, y: string }

The part where the compiler kicks in to restrict your intersection is when you actually try to assign something that doesn't satisfy the intersection type.

e.g.

type X = Array<number> & Array<any>; // this type will remain as declared
const x: X = ['some string']; // this will complain
const y: X = [4]; // this will work

If you want to restrict your type, you can use a conditional type instead of the intersection to filter the union.

type Bar = string | number | string[] | number[];

declare const foo: Exclude<Bar, Array<any>>; // string | number
bingles
  • 11,582
  • 10
  • 82
  • 93
  • The standard name for `Omit` is `Exclude` I think. – m93a Nov 29 '18 at 14:20
  • I'm asking specifically why aren't the intersection types "collapsed", as you call it. – m93a Nov 29 '18 at 14:21
  • 1
    Ah I'd forgotten the built in Exclude type vs Omit which is typically used to filter out keys. Updated my answer. Unfortunately I don't know the answer as to the "why". – bingles Nov 29 '18 at 17:51
  • 1
    I don't think it's related to whether it's a never scenario or not though. Even this type doesn't "collapse" `type X = { x: string } & { y: string };` even though human could infer it as `{ x: string, y: string }` – bingles Nov 29 '18 at 17:52