User-Defined Extended Enums in TypeScript

59 Views Asked by At

I am writing a TypeScript library that uses enums, like this:

export enum Fruit {
  // enum values must be integers,
  // because the upstream library expects them that way.
  Apple = 0,
  Banana = 1,
}

/** A wrapper function for `upstreamLibrary.useFruit`. */
export function useFruit(fruit: Fruit) {
  upstreamLibrary.useFruit(fruit as number);
  // TODO: Do more stuff.
}

By default, the library ships with Apples and Bananas, but I want end-users to be able to create their own fruit. Right now, they can do that like the following:

import { useFruit } from "fruits";

enum FruitCustom {
  // `upstreamLibrary.getFruitValue` returns an integer.
  // It is guaranteed to never overlap with any other Fruit values.
  Pear = upstreamLibrary.getFruitValue("Pear"),
}

useFruit(FruitCustom.Pear);

However, this will throw a compiler error, because FruitCustom does not match Fruit. So, in my library, I have to type the useFruit function like this:

export function useFruit(fruit: Fruit | number) {}

Unfortunately, Fruit | number resolves to just number, so now I've lost type safety, which sucks!

What's the best way to handle this?

Additional discussion: We can't use enum merging to solve this problem because you can only do that with local enums. Because Fruit comes from a 3rd party library, TypeScript won't let you do that.

Other requirements: If the solution involves not using enum, then it is necessary to be able to iterate over the members of the enum, like you can with normal enums.

1

There are 1 best solutions below

0
James On

Here is one solution from mkantor in the TypeScript Discord.

Instead of using a local enum, end-users would use a local custom object like this:

export const asFruit = (n: number): Fruit => n as Fruit;

const FruitCustom = { 
  Pear: asFruit(90),
  Blueberry: asFruit(91),
  Raspberry: asFruit(92),
}

useFruit(FruitCustom.Pear);

Obviously, this has a lot of boilerplate, since you have to use asFruit for every single enum member. To get rid of the boilerplate, we can use something like:

const FruitCustomRaw = { 
  Pear: 90,
  Grape: 91,
  Blueberry: 92,
}

const turnIntoFruit = <K extends string>(fruits: Record<K, number>): Record<K, Fruit> => fruits as Record<K, Fruit>;

const FruitCustom = turnIntoFruit(FruitCustomRaw);