14

Given an enum that looks like this:

export enum UsedProduct {
    Yes = 'yes',
    No = 'no',
    Unknown = 'unknown',
}

I'd like to write a function that takes a set of string literals and returns an instance of UsedProduct. So far, I wrote a function like this:

export function parseUsedProduct(usedProdStr: 'yes' | 'no' | 'unknown'): UsedProduct {
    switch (usedProdStr) {
        case 'yes':
            return UsedProduct.Yes;
        case 'no':
            return UsedProduct.No;
        case 'unknown':
            return UsedProduct.Unknown;
        default:
            return unknownUsedProductValue(usedProdStr);
    }
}

function unknownUsedProductValue(usedProdStr: never): UsedProduct {
    throw new Error(`Unhandled UsedProduct value found ${usedProdStr}`);
}

This implementation isn't great because I have to redefine the possible values of the enum. How can I rewrite this function so that I don't have to define 'yes' | 'no' | 'unknown'?

Jim Englert
  • 163
  • 1
  • 1
  • 6

3 Answers3

16

TS4.1 ANSWER:

type UsedProductType = `${UsedProduct}`;

PRE TS-4.1 ANSWER:

TypeScript doesn't make this easy for you so the answer isn't a one-liner.

An enum value like UsedProduct.Yes is just a string or number literal at runtime (in this case, the string "yes"), but at compile time it is treated as a subtype of the string or number literal. So, UsedProduct.Yes extends "yes" is true. Unfortunately, given the type UsedProduct.Yes, there is no programmatic way to widen the type to "yes"... or, given the type UsedProduct, there is no programmatic way to widen it to "yes" | "no" | "unknown". The language is missing a few features which you'd need to do this.

There is a way to make a function signature which behaves like parseUsedProduct, but it uses generics and conditional types to achieve this:

type Not<T> = [T] extends [never] ? unknown : never
type Extractable<T, U> = Not<U extends any ? Not<T extends U ? unknown : never> : never>

declare function asEnum<E extends Record<keyof E, string | number>, K extends string | number>(
  e: E, k: K & Extractable<E[keyof E], K>
): Extract<E[keyof E], K>

const yes = asEnum(UsedProduct, "yes"); // UsedProduct.yes
const no = asEnum(UsedProduct, "no"); // UsedProduct.no
const unknown = asEnum(UsedProduct, "unknown"); // UsedProduct.unknown
const yesOrNo = asEnum(UsedProduct, 
  Math.random()<0.5 ? "yes" : "no"); // UsedProduct.yes | UsedProduct.no

const unacceptable = asEnum(UsedProduct, "oops"); // error

Basically it takes an enum object type E and a string-or-number type K, and tries to extract the property value(s) of E that extend K. If no values of E extend K (or if K is a union type where one of the pieces doesn't correspond to any value of E), the compiler will give an error. The specifics of how Not<> and Extractable<> work are available upon request.

As for the implementation of the function you will probably need to use a type assertion. Something like:

function asEnum<E extends Record<keyof E, string | number>, K extends string | number>(
  e: E, k: K & Extractable<E[keyof E], K>
): Extract<E[keyof E], K> {
  // runtime guard, shouldn't need it at compiler time
  if (Object.values(e).indexOf(k) < 0)
    throw new Error("Expected one of " + Object.values(e).join(", "));
  return k as any; // assertion
}

That should work. In your specific case we can hardcode UsedProduct:

type Not<T> = [T] extends [never] ? unknown : never
type Extractable<T, U> = Not<U extends any ? Not<T extends U ? unknown : never> : never>
function parseUsedProduct<K extends string | number>(
  k: K & Extractable<UsedProduct, K>
): Extract<UsedProduct, K> {
  if (Object.values(UsedProduct).indexOf(k) < 0)
    throw new Error("Expected one of " + Object.values(UsedProduct).join(", "));
  return k as any;
}

const yes = parseUsedProduct("yes"); // UsedProduct.yes
const unacceptable = parseUsedProduct("oops"); // error

Hope that helps. Good luck!

dtech
  • 13,741
  • 11
  • 48
  • 73
jcalz
  • 264,269
  • 27
  • 359
  • 360
1

You can use the getKeyOrThrow-method from ts-enum-util. Not sure how it's implemented, but you can look at it here.

Here's a stackblitz I made to demonstrate the usage in your case.

ShamPooSham
  • 2,301
  • 2
  • 19
  • 28
  • I might be being naive, but I can't quite see how this answers the question; the question wants to know how, in a function call, you can use the values of an enum in place of a string literal. Could you add something to the answer to demonstrate how this would work? – OliverRadini Sep 17 '18 at 15:26
  • Looks like that library expects enums to have numeric values instead of string values. The stackblitz shows a compiler error. – jcalz Sep 17 '18 at 17:36
  • @OliverRadini Not sure I understand the question then. The values of the enum in this case are string literals, and OP seems to want a function to get the enum keys by giving the values as input. That's what `getKeyOrThrow` does. Of course, you will not have the type safety from defining the possible inputs individually, and maybe that's what OP wants too. – ShamPooSham Sep 18 '18 at 07:08
  • @jcalz Yes, but the documentation shows how to do it on string values, so I think it's just a type definition error in the library. You could take a look at their code and make your own implementation, because the code works. – ShamPooSham Sep 18 '18 at 07:10
1

With Typescript 4.1, it can be done in a simpler way

type UnionToEnum<E extends string, U extends `${E}`> = {
  [enumValue in E as `${enumValue & string}`]: enumValue
}[U]

enum UsedProduct {
  Yes = 'yes',
  No = 'no',
  Unknown = 'unknown',
}

function parseUsedProduct<K extends `${UsedProduct}`>(k: K): UnionToEnum<UsedProduct, K> {
  if (Object.values(UsedProduct).indexOf(k as UsedProduct) < 0)
    throw new Error("Expected one of " + Object.values(UsedProduct).join(", "));
  return k as UsedProduct as UnionToEnum<UsedProduct, K>;
}

// x is of type UsedProduct.Yes
let x = parseUsedProduct('yes');
// error
let c = parseUsedProduct('noo');

playground

The key here is `${UsedProduct}`, which removes the 'enumness' of the enum values and convert them to a string literal.

Caveat: This only works with string enum values, not number enum values.

coyotte508
  • 9,175
  • 6
  • 44
  • 63