28

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

4 Answers 4

31

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

Sign up to request clarification or add additional context in comments.

6 Comments

Isn't the condition [T] extends [UnionToIntersection<T>] sufficient?
@TitianCernicova-Dragomir Oh, it seems it is, indeed :D
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.
Why are both sides of [T] extends [UnionToIntersection<T>] tupled? Wouldn't T extends UnionToIntersection<T> work?
@3dGrabber because if we omit the square brackets, then we will have a naked type parameter T, which will be "distributed" by Typescript. More: typescriptlang.org/docs/handbook/… stackoverflow.com/questions/51651499/…
|
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.

2 Comments

I'm confused that what does T extends unknown do? Once I remove this judgement, it failed.
@RickShao See typescriptlang.org/docs/handbook/2/… - 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.
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.

1 Comment

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<boolean> being true is initially surprising, but only initially. boolean is true | false, which is a union.
3

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

5 Comments

Any idea how to make IsUnion<1|2> equal to false?
@kungfooman why should it be false? type 1 | 2 is union. // false type a = IsUnion<1 | 2 | number>;
Because I would like string|union differences and not this or that number unions.
@kungfooman I do not understand why you need it cause you did not provide clear description. But I have some guesses: type IsStringUnion<T> = T extends string ? IsUnion<T> : false; type HasStringSubunion<T> = IsUnion<Extract<T, string>>; type UnionIncludesStringLiteral<T> = (T extends string ? string extends T ? false : true : false) extends false ? false : true; type IsUnionExceptNumber<T> = IsUnion<Exclude<T, number>>;
@kamikoto00 could you explain why you need [T, never] extends [infer U, never] without the , never it does not work but i can't figure it out ?

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.