Search code examples
typescripttypescript-generics

Using `keyof typeof enum` as parameter to a function


I've run across this issue that I cannot seem to get figured out in TS. And I'd love if someone could point me in the right direction. I have an API that returns a series of enum values, and in order to work with them I modeled them in Typescript like so:

enum condition = {NEW, USED}

But when trying to work with data from the API I need to hint them like typeof keyof condition, and accessing condition[0] (in this case equivalent with condition[condition[NEW]] which leads to the error message

Argument of type 'string' is not assignable to parameter of type '"NEW" | "USED"'.(2345)

Typescript exports the condition object as

var condition;
(function (condition) {
    condition[condition["NEW"] = 0] = "NEW";
    condition[condition["USED"] = 1] = "USED";
})(condition || (condition = {}));
;

Which means that condition.NEW is 0 and condition[0] is NEW. I tried forcing the type by passing it with the as keyof typeof condition like this:

enum condition {NEW, USED};

function test(param: keyof typeof condition) {
    console.log(param);
}

test(condition[condition.NEW]); // Fails linting, should pass
test(condition[condition.NEW] as keyof typeof condition); // Lint success
test('a' as keyof typeof condition); // Lint success, should fail

(Link to the playground: Playground )

But this seems like a hack at best, since it will essentially ignore the type being fed to it. I'd worry that now passing an invalid string won't be properly detected. How can I get TS to lint test(condition[condition.NEW]); as valid, and test('a' as keyof typeof condition); as invalid?


Solution

  • You've run into a missing feature of TypeScript, requested at microsoft/TypeScript#38806 and microsoft/TypeScript#50933. Numeric enums are given reverse mappings where you can index into the enum object with the numeric value of the enum and get back the corresponding string key. But the type system doesn't encode this reverse mapping very strongly; instead of the specific value-key pairings, it just gives the enum object a numeric index signature whose property type is string. So while Condition[0] evaluates to "NEW" at runtime, TypeScript only knows that it's of type string, which is not enough for your purposes.

    Until and unless stronger reverse typings are implemented, you'll need to work around it. You could always write your own utility function which provides such typings:

    function strongReverseEnumMapping<const T extends Record<keyof T, PropertyKey>>(e: T) {
        return e as
            { readonly [K in string & keyof T]: T[K] } & 
            { readonly [K in keyof T as T[K] extends number ? T[K] : never]: K };
    }
    

    Here strongReverseEnumMapping() just returns its input at runtime, but the type is asserted to be stronger, as an intersection of the part of the enum object where the numeric values are remapped to keys and the keys are remapped to values.

    Then you can define rename original enum out of the way and pass it to the helper function to get a stronger-typed enum out:

    enum _Condition { NEW, USED };
    const Condition = strongReverseEnumMapping(_Condition);   
    /* const Condition: {
      readonly NEW: _Condition.NEW;
      readonly USED: _Condition.USED;
    } & {
      readonly 0: "NEW";
      readonly 1: "USED";
    } */
    type Condition = _Condition;
    

    Now Condition is known to have "NEW" at key 0 and "USED" at key 1, and your operation Condition[Condition.NEW] works as you expect:

    const c = Condition[Condition.NEW]
    //    ^? const c: "NEW";
    

    Playground link to code