25

Is it possible to check if a given type is a union?

type IsUnion<T> = ???

Why I need this: in my code, I have the only case when some received type can be a union. I handle it with a distributive conditional type. However, it can be not obvious for one who looks at this code why a DCT is used in the first place. So I want it to be explicit like: IsUnion<T> extends true ? T extends Foo ...

I've made a few attempts with UnionToIntersection, with no results. I've also come up with this one:

type IsUnion<T, U extends T = T> =
    T extends any ?
    (U extends T ? false : true)
    : never

It gives false for non unions, but for some reason it gives boolean for unions... And I have no idea why. I also tried to infer U from T, with no success.

P.S. My use case may seem to someone as not perfect/correct/good, but anyway the question in the title has arised and I wonder if it's possible (I feel that it is, but am having hard time to figure it out myself).

Nurbol Alpysbayev
  • 19,522
  • 3
  • 54
  • 89

4 Answers4

27

So it seems I've come up with an answer myself!

Here is the type (thanks Titian Cernicova-Dragomir for simplifying it!):

type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true

type Foo = IsUnion<'abc' | 'def'> // true
type Bar = IsUnion<'abc'> // false

And again UnionToIntersection of jcalz came in handy!

The principle is based on the fact that a union A | B does not extend an intersection A & B.

Playground

UPD. I was silly enough to not develop my type from the question into this one, which also works fine:

type IsUnion<T, U extends T = T> =
    (T extends any ?
    (U extends T ? false : true)
        : never) extends false ? false : true

It distributes union T to constituents, also T and then checks if U which is a union extends the constituent T. If yes, then it's not a union (but I still don't know why it doesn't work without adding extends false ? false : true, i.e. why the preceding part returns boolean for unions).

Nurbol Alpysbayev
  • 19,522
  • 3
  • 54
  • 89
  • 1
    Isn't the condition `[T] extends [UnionToIntersection] ` sufficient? – Titian Cernicova-Dragomir Dec 28 '18 at 08:13
  • @TitianCernicova-Dragomir Oh, it seems it is, indeed :D – Nurbol Alpysbayev Dec 28 '18 at 08:14
  • 1
    I'm pretty dumb I guess. I looked at this answer, but in my mind I came up with it from scratch. Now I realize that it's virtually identical. But to answer the question of why the extra conditional: `U` is distributed. So youre getting basically a nested for loop here, and the fact that some members don't extend the others includes `true` in the output. When compared against itself, a member will evaluate `false`. Therefore for unions the result is `true | false`, AKA `boolean`. – Keith Layne Jan 10 '20 at 20:37
  • Why are both sides of `[T] extends [UnionToIntersection]` tupled? Wouldn't `T extends UnionToIntersection` work? – 3dGrabber Sep 28 '22 at 11:52
  • 1
    @3dGrabber because if we omit the square brackets, then we will have a *naked* type parameter `T`, which will be "distributed" by Typescript. More: https://www.typescriptlang.org/docs/handbook/advanced-types.html#distributive-conditional-types https://stackoverflow.com/questions/51651499/typescript-what-is-a-naked-type-parameter – Nurbol Alpysbayev Sep 29 '22 at 07:38
  • Sadly this doesn't work for optional unions such as `string | undefined` – rcbevans Oct 19 '22 at 20:40
4

NOTE: This answer was for a case where someone explicitly did not want to use UnionToIntersection. That version is simple and easy to understand, so if you have no qualms about U2I, go with that.

I just looked at this again and with the help of @Gerrit0 came up with this:

// Note: Don't pass U explicitly or this will break.  If you want, add a helper
// type to avoid that.
type IsUnion<T, U extends T = T> = 
  T extends unknown ? [U] extends [T] ? false : true : false;

type Test = IsUnion<1 | 2> // true
type Test2 = IsUnion<1> // false
type Test3 = IsUnion<never> // false

Seemed like it could be further simplified and I'm pretty happy with this. The trick here is distributing T but not U so that you can compare them. So for type X = 1 | 2, you end up checking if [1 | 2] extends [1] which is false, so this type is true overall. If T = never we also resolve to false (thanks Gerrit).

If the type is not a union, then T and U are identical, so this type resolves to false.

Caveats

There are some cases in which this doesn't work. Any union with a member that's assignable to another will resolve to boolean because of the distribution of T. Probably the simplest example of this is when {} is in the union because almost everything (even primitives) are assignable to it. You'll also see it with unions including two object types where one is a subtype of the other, i.e. { x: 1 } | { x: 1, y: 2 }.

Workarounds

  1. Use a third extends clause (like in Nurbol's answer)
(...) extends false ? false : true;
  1. Use never as the false case:
T extends unknown ? [U] extends [T] ? never : true : never;
  1. Invert the extends at the call site:
true extends IsUnion<T> ? Foo : Bar;
  1. Since you probably need a conditional type to use this at the call site, wrap it:
type IfUnion<T, Yes, No> = true extends IsUnion<T> ? Yes : No;

There are a lot of other variations that you can do with this type depending on your needs. One idea is to use unknown for the positive case. Then you can do T & IsUnion<T>. Or you could just use T for that and call it AssertUnion so that the whole type becomes never if it's not a union. The sky's the limit.

Thanks to @Gerrit0 and @AnyhowStep on gitter for finding my bug & giving feedback on workarounds.

Keith Layne
  • 3,688
  • 1
  • 24
  • 28
  • I'm confused that what does `T extends unknown` do? Once I remove this judgement, it failed. – RickShao Apr 14 '23 at 15:55
  • 1
    @RickShao See https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types - this triggers "distribution" of the conditional - kind of like a `map` over type unions. These days I'd usually write `T extends T ? ...` for something like that - it's shorter and maybe a little clearer. Many/most people are surprised by distributive conditional types when they first encounter them. – Keith Layne Apr 15 '23 at 18:08
3

Both answers provide by others here will provide likely unexpected results for the following:

type Foo = IsUnion<'a' | string> // false!?
type Bar = IsUnion<boolean>      // true!?

This is because of the way Typescript collapses types:

type a = 'a' | string // string

So unless one can control that the provided union type is never collapsed IsUnion is not currently possible and is probably a bad idea as it could lead to unexpected and surprising results.

TrevTheDev
  • 2,616
  • 2
  • 18
  • 36
  • 1
    `IsUnion<'a' | string>` being `false` is correct; `"a" | string` is just a long-winded way of writing `string`, completely aside from `IsUnion`: `type X = "a" | string;` makes `X` an alias for `string`. `IsUnion` being `true` is initially surprising, but only initially. `boolean` is `true | false`, which is a union. – T.J. Crowder Dec 16 '22 at 09:09
0

Solution for TypeScript 5:

type IsUnion<T> = (
  [T, never] extends [infer U, never]
    ? U extends unknown ? [T, keyof U] extends [U | boolean, keyof T] ? false : true
    : never
    : never
) extends false ? false : true;

Works with boolean and empty object