0

I'm attempting to write a generic function to merge multiple enums. The hope is that this function would accomplish the same as the following:

enum Mammals {
  Humans = 'Humans',
  Bats = 'Bats',
  Dolphins = 'Dolphins',
}

enum Reptiles {
  Snakes = 'Snakes',
  Alligators = 'Alligators',
  Lizards = 'Lizards',
}

const Animals = {
 ...Mammals,
 ...Reptiles,
}

type Animals = Mammals | Reptiles;

First Attempt:

export const mergeEnums = <T extends any[]>(...enums: T): T[number] => {
  return {
    ...enums,
  };
};

// Results in Animals: typeof Mammals | typeof Reptiles
const Animals = mergeEnums(Mammals, Reptiles);

Unfortunately, the union type is not quite right. TypeScript does not allow the keys to be accessed. Example type error: Property 'Snakes' does not exist on type 'typeof Mammals | typeof Reptiles'.

Second Attempt:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;

export const mergeEnums = <T extends any[]>(
  ...enums: T
): UnionToIntersection<T[number]> => {
  return {
    ...enums,
  } as UnionToIntersection<T[number]>;
};

// Results in Animals: typeof Mammals & typeof Reptiles
const Animals = mergeEnums(Mammals, Reptiles);

This does allow for key access, but results in a return type of never when the same key exists in more than one enum, which is a possibility in my usage.

Is it possible to achieve a solution that's functionally the same as Animals: Mammals | Reptiles?

cfly24
  • 1,882
  • 3
  • 22
  • 56
  • "when the same key exists in more than one enum, which is a possibility in my usage" seems like a weird edge case; why would you be doing this or allowing it? In any case, other than the variadic input, it looks like [this question](https://stackoverflow.com/questions/49682569/typescript-merge-object-types). I'd be happy to write up a new answer which uses recursion to do it on a tuple of inputs, but I don't really see it as much of an improvement over just `UnionToIntersection` unless you can articulate why you want to be allowing enum keys to collide that way. – jcalz Oct 27 '20 at 16:58
  • I agree with jcalz. What _would_ you expect when the same key appears in two enums? Its value cannot be both the value from the first enum _and_ the value from another enum at the same time, since two non-identical primitives have no overlap. `never` is the only type that makes sense in such scenario. – Karol Majewski Oct 27 '20 at 17:02
  • Eh, I wrote up an answer anyway – jcalz Oct 27 '20 at 17:12
  • I'm merging supported asset tickers from various financial exchanges into a global list of tickers as part of my actual usage. – cfly24 Oct 27 '20 at 17:14

1 Answers1

0

Assuming you want to try to emulate in the type system what the spread operator does at runtime with colliding properties, you could do it by adapting my answer to this question. It starts out like this:

type OptionalPropertyNames<T> =
  { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];

type SpreadProperties<L, R, K extends keyof L & keyof R> =
  { [P in K]: L[P] | Exclude<R[P], undefined> };

type Id<T> = T extends infer O ? { [K in keyof O]: O[K] } : never

// Type of { ...L, ...R }
type Spread<L, R> = Id<
  // Properties in L that don't exist in R
  & Pick<L, Exclude<keyof L, keyof R>>
  // Properties in R with types that exclude undefined
  & Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
  // Properties in R, with types that include undefined, that don't exist in L
  & Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
  // Properties in R, with types that include undefined, that exist in L
  & SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
>;

Optional properties are quite the headache, since if I have an object like {a: string} and spread an object of type {a?: number} into it, the resulting object will be an {a: string | number}. All the caveats from the other question apply here: there are very many edge cases. The default intersection you get from Object.assign() or a generic spread is, frankly, not much worse than the mess above and is a lot simpler. I'd only recommend going with Spread<L, R> in place of L & R if you are sure your use case warrants it.


Anyway, moving on to the variadic tuple part. TypeScript 4.1 will introduce recursive conditional types (as implemented in microsoft/TypeScript#40002) so you can represent your Merge<T> as a recursive operation on Spread<L, R>:

type Merge<T extends readonly any[]> = 
  T extends readonly [infer H, ...infer R] ? Spread<H, Merge<R>> : {}

export const mergeEnums = <T extends any[]>(
  ...enums: T
) => {
  return {
    ...enums,
  } as any as Merge<T>;
};

This should work as you expect:

enum Mammals {
  Humans = 'Humans',
  Bats = 'Bats',
  Dolphins = 'Dolphins',
}

enum Reptiles {
  Snakes = 'Snakes',
  Alligators = 'Alligators',
  Lizards = 'Lizards',
}

const Animals = mergeEnums(Mammals, Reptiles);
type Animals = typeof Animals[keyof typeof Animals];

const Animals = mergeEnums(Mammals, Reptiles);
/* const Animals: {
    readonly Humans: Mammals.Humans;
    readonly Bats: Mammals.Bats;
    readonly Dolphins: Mammals.Dolphins;
    readonly Snakes: Reptiles.Snakes;
    readonly Alligators: Reptiles.Alligators;
    readonly Lizards: Reptiles.Lizards;
} */
type Animals = typeof Animals[keyof typeof Animals];
// type Animals = Mammals | Reptiles

and if you allow for collision, you get what I hope is your desired outcome:

enum Reptiles {
  Humans = 'They Have Discovered That We Are Alien Invaders; KILL THEM ALL',
  Snakes = 'Snakes',
  Alligators = 'Alligators',
  Lizards = 'Lizards',
}

const Animals = mergeEnums(Mammals, Reptiles);
/* const Animals: {
    readonly Bats: Mammals.Bats;
    readonly Dolphins: Mammals.Dolphins;
    readonly Humans: Reptiles.Humans;
    readonly Snakes: Reptiles.Snakes;
    readonly Alligators: Reptiles.Alligators;
    readonly Lizards: Reptiles.Lizards;
} */

type Animals = typeof Animals[keyof typeof Animals];
// type Animals = Mammals.Bats | Mammals.Dolphins | Reptiles

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360