How to transform union type to tuple type
Asked Answered
P

7

63

For example, I have a type:

type abc = 'a' | 'b' | 'c';

How to make a tuple type that contains all elements of the union at compile time?

type t = ['a','b', 'c'];
Plerre answered 12/3, 2019 at 17:2 Comment(4)
Unions can only contain a value that is one of a list of types. So you can't have a union that contains all values, by definition. What are you trying to achieve?Jerrylee
"keyof {a: number, b: number, c: number}" returns 'a'|'b'|'c', but i need tuple to make some metaprogramming stuffPlerre
What is your actual use case? Can you give a small example for how you’d plan to use the tuple in your code?Interfile
I thougnt about converting poco objects to arrays with type safety.Plerre
I
121

DISCLAIMER: DON'T DO THIS!! If someone tells you to use the code they found in this answer to do anything except demonstrate why this is a bad idea, RUN AWAY!!


It's easy to convert from a tuple type to a union type; for example, see this question. But the opposite, converting from a union to a tuple is one of those Truly Bad Ideas that you shouldn't try to do. (See microsoft/TypeScript#13298 for a discussion and canonical answer) Let's do it first and scold ourselves later:

// oh boy don't do this
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type LastOf<T> =
  UnionToIntersection<T extends any ? () => T : never> extends () => (infer R) ? R : never

// TS4.0+
type Push<T extends any[], V> = [...T, V];

// TS4.1+
type TuplifyUnion<T, L = LastOf<T>, N = [T] extends [never] ? true : false> =
  true extends N ? [] : Push<TuplifyUnion<Exclude<T, L>>, L>

type abc = 'a' | 'b' | 'c';
type t = TuplifyUnion<abc>; // ["a", "b", "c"] 

Playground link

That kind of works, but I really really REALLY recommend not using it for any official purpose or in any production code. Here's why:

  • You can't rely on the ordering of a union type. It's an implementation detail of the compiler; since X | Y is equivalent to Y | X, the compiler feels free to change one to the other. And sometimes it does:

      type TypeTrue1A = TuplifyUnion<true | 1 | "a">; // [true, 1, "a"] 🙂
      type Type1ATrue = TuplifyUnion<1 | "a" | true>; // [true, 1, "a"]!! 😮
    

    So there's really no way to preserve the order. And please don't assume that the output will at least always be [true, 1, "a"] above; there's no guarantee of that. It's an implementation detail and so the specific output can change from one version of TypeScript to the next, or from one compilation of your code to the next. And this actually does happen for some situations: for example, the compiler caches unions; seemingly unrelated code can affect which ordering of a union gets put into the cache, and thus which ordering comes out. Order is not simply not reliable.

  • You might not be happy with what the compiler considers a union and when it collapses or expands. "a" | string will just be collapsed to string, and boolean is actually expanded to false | true:

      type TypeAString = TuplifyUnion<"a" | string>; // [string]
      type TypeBoolean = TuplifyUnion<boolean>; // [false, true]
    

    So if you were planning to preserve some existing number of elements, you should stop planning that. There's no general way to have a tuple go to a union and back without losing this information as well.

  • There's no supported way to iterate through a general union. The tricks I'm using all abuse conditional types. First I convert a union A | B | C into a union of functions like ()=>A | ()=>B | ()=>C, and then use an intersection inference trick to convert that union of functions into an intersection of functions like ()=>A & ()=>B & ()=>C, which is interpreted as a single overloaded function type, and using conditional types to pull out the return value only grabs the last overload. All of that craziness ends up taking A | B | C and pulling out just one constituent, probably C. Then you have to push that onto the end of a tuple you're building up.

So there you go. You can kind of do it, but don't do it. (And if you do do it, don't blame me if something explodes. 💣)

Interfile answered 12/3, 2019 at 19:3 Comment(13)
I've read about intersection interference trick. Compiler infers intersection type with contra-variant, so it should infer union with covarian, right? But this one doesn't make union from interssction type IntersectionToUnion<U> = (U extends any ? U[] : never) extends (infer I)[] ? I : never; Why?Plerre
Because conditional types distribute over unions, not intersections. Currently nothing in the language distributes over intersections.Interfile
It might be bad but sometimes we need to convert type to tuple. Eg., third party library exposes union type for which i need a tuple to make some stuff in my appAdolfo
@Adolfo well I hope you won't blame me when crazy things happen!Interfile
I'd say the union to intersection "trick" is not really a trick but what one would expect from a sound type system (although TS is not sound, in general). It's the overloading thing which is tricky! To avoid the TuplifyUnion<"a" | string> and TuplifyUnion<boolean> problem, you can just wrap each term of the union in a function.Armagnac
Is it the same thing as creating a Union type from an array of values?Transformer
This is suitable for determining the size of a union right? Well I suppose I'd have to add an edge case for boolean, but aside from that, if I'm not using the tuple for nefarious purposes and only getting the length, it should be safe?Absentminded
I’d think anything invariant to the union order would be safe to use. But I’d still be wary of using it in any production code because of the unsavory mucking around with types necessary to do it.Interfile
In my specific use-case, I'm not worried about the order of the tuple. I just have a union of strings and I want to create a strongly typed array that is guaranteed by the compiler to contain all the elements of the union and each element exactly once. For this use-case, I think this solution is acceptable, don't you think?Kaiserism
Without more information, I can't be sure, but I'm highly skeptical. If the array exists at runtime then there is no guarantee that the order of the runtime array matches that of the tuple (whose order can change without notice based on seemingly unrelated code), so you'd just be deceiving the compiler. If the array does not exist at runtime then I fail to see the point of the tuple. Your actual underlying use case probably has better solutions, although again, without more information, who knows? This comment section probably isn't the right place to hash that out, though.Interfile
This is not that all DANGEROUS in certain cases. For example lets say you have a foo object. And you have a key variable that you wanna validate with Zod. You can use z.enum(Object.keys(foo)) but this would error since Zod can't generate any types from this. Then doing z.enum(Object.keys(foo) as TuplifyUnion<keyof typeof foo>) would be useful.Burger
Or you could write z.enum(Object.keys(foo) as [keyof typeof foo]) and not play around with pretending to know the order. It isn't dangerous to get such an order if you immediately throw that order away, but I don't see the point.Interfile
Tuple with one length, sound like its gonna cause an issue not here but somewhere else if a function uses the length of tuple.Burger
P
3

I've sometimes faced a situation in which I want to derive type B from type A but find that either TS does not support it, or that doing the transformation results in code that's hard to follow. Sometimes, the choice of deriving B from A is arbitrary and I could just as well derive in the other direction. Here if you can start with your tuple, you can easily derive a type that covers all the values that the tuple accepts as elements:

type X = ["a", "b", "c"];

type AnyElementOf<T extends any[]> = T[number];

type AnyElementOfX = AnyElementOf<X>;

If you inspect the expansion of AnyElementOfX you'll get "a" | "b" | "c".

Pearly answered 12/3, 2019 at 23:33 Comment(0)
M
2

Taking jcalz answer a little bit further (again, do not use his answer, all his caveats apply there!), you can actually create something safe and predictable.

// Most of this is jcalz answer, up until the magic.
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void)
    ? I
    : never;

type LastOf<T> =
    UnionToIntersection<T extends any ? () => T : never> extends () => infer R
        ? R
        : never;

type Push<T extends any[], V> = [...T, V];

type TuplifyUnion<T, L = LastOf<T>, N = [T] extends [never] ? true : false> =
    true extends N
        ? []
        : Push<TuplifyUnion<Exclude<T, L>>, L>;

// The magic happens here!
export type Tuple<T, A extends T[] = []> =
    TuplifyUnion<T>['length'] extends A['length']
        ? [...A]
        : Tuple<T, [T, ...A]>;

The reason this works is because a tuple in TypeScript is just another Array, and we can compare the length of arrays.

Now, instead of the unpredictable ordering of TuplifyUnion due to compiler behavior, we can create a tuple that is the length of the number of union members where each position is the generic type T.

const numbers: Tuple<2 | 4 | 6> = [4, 6, 2];
// Resolved type: [2 | 4 | 6, 2 | 4 | 6, 2 | 4 | 6]

Admittedly, still imperfect since we can't guarantee that every member is a unique member of the union. But we are now guaranteeing that the count of all members of the union will be covered by the resulting tuple, predictably and safely.

Maxwellmaxy answered 7/9, 2022 at 21:55 Comment(0)
A
0

This approach requires extra work, as it derives the desired tuple type from an actual tuple.

"Um," you are thinking, "what use is it if I have to provide the exact type that I need?"

I agree. However, it's still useful for the related use case: Ensure that a tuple contains all of the elements of a union type (e.g. for unit testing). In this scenario, you need to declare the array anyway, since Typescript cannot produce runtime values from types. So, the "extra" work becomes moot.

Note: As a prerequisite, I'm depending on a TypesEqual operator as discussed here. For completeness, here is the version I'm using, but there are other options:

type FunctionComparisonEqualsWrapped<T> =
  T extends (T extends {} ? infer R & {} : infer R)
  ? { [P in keyof R]: R[P] }
  : never;

type FunctionComparisonEquals<A, B> =
  (<T>() => T extends FunctionComparisonEqualsWrapped<A> ? 1 : 2) extends
   <T>() => T extends FunctionComparisonEqualsWrapped<B> ? 1 : 2
  ? true
  : false;

type IsAny<T> = FunctionComparisonEquals<T, any>;

type InvariantComparisonEqualsWrapped<T> =
  { value: T; setValue: (value: T) => never };

type InvariantComparisonEquals<Expected, Actual> =
  InvariantComparisonEqualsWrapped<Expected> extends
  InvariantComparisonEqualsWrapped<Actual>
  ? IsAny<Expected | Actual> extends true
    ? IsAny<Expected> | IsAny<Actual> extends true
          ? true
          : false
      : true
  : false;

export type TypesEqual<Expected, Actual> =
  InvariantComparisonEquals<Expected, Actual> extends true
  ? FunctionComparisonEquals<Expected, Actual>
  : false;

export type TypesNotEqual<Expected, Actual> =
  TypesEqual<Expected, Actual> extends true ? false : true;

And here is the actual solution with example usage:

export function rangeOf<U>() {
  return function <T extends U[]>(...values: T) {
    type Result = true extends TypesEqual<U, typeof values[number]> ? T : never;
    return values as Result;
  };
}

type Letters = 'a' | 'b' | 'c';
const letters = rangeOf<Letters>()(['a', 'b', 'c']);
type LettersTuple = typeof letters;

Some caveats: rangeOf does not care about ordering of the tuple, and it allows duplicate entries (e.g. ['b', 'a', 'c', 'a'] will satisfy rangeOf<Letters>()). For unit tests, these issues are likely not worth caring about. However, if you do care, you can sort and de-duplicate values before returning it. This trades a small amount of runtime performance during initialization for a "cleaner" representation.

Abreact answered 10/5, 2022 at 15:37 Comment(0)
P
0

It's possible but you got to sort the union first in either ascending or descending order. You can change the order of characters, or add special characters to the code of this example based on your needs.

Warning: this requires alot of processing power for larger unions

/**
 * An array representing alphanumeric characters in ascending order.
 * It includes uppercase letters, lowercase letters, and digits.
 */
export type AlphaNumericAscendingOrder = [
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
    'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',
    'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd',
    'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
    'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',
    'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7',
    '8', '9'
];

/**
 * A type that finds the first matching substring in a given union type and returns it.
 * @template Union - The union type to search within.
 * @template Cache - The current cache of matching characters.
 * @template Letters - The array of alphanumeric characters.
 * @returns {string} The first matching substring found in the union type.
 */
export type FindMatch<Union extends string, Cache extends string = "", Letters extends string[] = AlphaNumericAscendingOrder> =
    Cache extends Union
        ? Cache
        : Letters extends [infer Letter, ...infer RemainingLetters]
            ? Letter extends string
                ? Extract<Union, `${Cache}${Letter}${string}`> extends never
                    ? RemainingLetters extends []
                        ? never
                        : FindMatch<Exclude<Union, `${Cache}${Letter}${string}`>, Cache, RemainingLetters extends string[] ? RemainingLetters : never>
                    : FindMatch<Extract<Union, `${Cache}${Letter}${string}`>, `${Cache}${Letter}`>
                : never
            : never

/**
 * A type that converts a union type into a tuple by recursively finding and extracting matching substrings.
 * @template Union - The union type to convert to a tuple.
 * @template Tuple - The resulting tuple.
 * @returns {string[]} A tuple containing all non-overlapping matching substrings from the union type.
 */
export type UnionToTuple<Union extends string, Tuple extends string[] = []> = Exclude<Union, FindMatch<Union>> extends never
    ? [...Tuple, Union]
    : [...Tuple, FindMatch<Union>, ...UnionToTuple<Exclude<Union, FindMatch<Union>>>]
Passional answered 18/9, 2023 at 2:24 Comment(0)
A
0

This is a simpler solution for the simplest use cases:

type StringTuple<T extends string[]> = [T[number], ...T]
type A = 'a' | 'b' | 'c'
type I = StringTuple<A[]> // ['a' | 'b' | 'c']

Try it in TypeScript playground

Abra answered 31/12, 2023 at 9:41 Comment(0)
G
-1

Just a kind of solution. This is quite simple, but requires manually writing of boilerplate code.

Any object with type LazyStyleLoader must contain as properties all members of the ThemeName union.

At least, you can be sure that your object always has a property for each member of the union.

export type ThemeName = "default_bootstrap" | "cerulean" | "cosmo" | "cyborg"

type LazyStyleLoader = {
    [key in ThemeName]: () => Promise<typeof import("*?raw")>
}

export const LazyThemeLoader: LazyStyleLoader = {
    default_bootstrap: () => import("bootstrap/dist/css/bootstrap.min.css?raw"),
    cerulean: () => import("bootswatch/dist/cerulean/bootstrap.min.css?raw"),
    cosmo: () => import("bootswatch/dist/cosmo/bootstrap.min.css?raw"),
    cyborg: () => import("bootswatch/dist/cyborg/bootstrap.min.css?raw"),
};

Then you can get a tuple of union members using Object.keys():

const tuple = Object.keys(LazyThemeLoader);
// ["default_bootstrap", "cerulean", "cosmo", "cyborg"]
Genniegennifer answered 13/5, 2022 at 18:43 Comment(2)
@OlegValteriswithUkraine, ah, the op is asking about convertiing a union to a tuple in type context. My solution is for expression context. Anyway, I'll leave it here, maybe it will be usefull for future readers.Genniegennifer
That's ok, I've seen you edited the answer to include the idea of getting a tuple out of it (hence the removal of the note and of the vote). A typeof type query is a possibility here.Jordanna

© 2022 - 2024 — McMap. All rights reserved.