Search code examples
typescriptenumsrecordtyping

Create a type with same keys as an Enum but each key has a different value


how can I create a type with the same keys as an enum, but each key has a different value, with this code :

export enum DataType {
    TEST = "test",
    OTHER = "other",
}

type ReturnTypes = {
    [DataType.TEST]: number,
    [DataType.OTHER]: string,
};

type DataTypeResolver<T extends DataType> = T | Lowercase<T>;

I get an error Type 'Lowercase<T>' cannot be used to index type 'ReturnTypes'. when I try to use it in a method like this :

export async function getThing<T extends DataType>(dataType: DataTypeResolver<T>, text: string): Promise<ReturnTypes[Lowercase<T>] | null>

I want dataType to be both uppercase and lowercase version of the key. (Maybe I should stick to only Lowercase, but I'm not sure.)


Solution

  • Enums are generally intended for situations where you only access the values via the enum keys; the actual values should be opaque to the TypeScript developer. They are one of the few places in TypeScript where types are treated nominally as opposed to structurally. In your DataType enum, even though DataType.TEST evaluates to "test" at runtime, it is an error to assign "test" to something that expects a value of the type DataType:

    let example = DataType.TEST;
    example = "test"; // error! Type '"test"' is not assignable to type 'DataType'.
    

    If that error is a source of frustration as opposed to a desirable feature, it's an indication that you don't really want enums. If you care about the particular values of an enum, it's an indication that you don't really want enums. Since you are relying on the fact that the values are lowercase versions of the keys, and since you want to be able to assign Lowercase<T> to a place that expects DataType, it seems that you don't really want enums.


    In such situations you can often replace an enum with a const-asserted object and get the desired behavior:

    export const DataType = {
        TEST: "test",
        OTHER: "other",
    } as const;
    
    /* const DataType: {
        readonly TEST: "test";
        readonly OTHER: "other";
    } */
    

    The const assertion tells the compiler to keep track of the literal types of the property values. So DataType is known at compile time to be an object with a property with key "TEST" and value "test" and a property with key "OTHER" and value "other".

    Now it is fine to use the value "test" in a place that expects a value of the same type as DataType.TEST, because those are the same type:

    let example = DataType.TEST;
    example = "test"; // okay
    

    Once you do that things become a lot easier. Your ReturnTypes type can stay the same,

    type ReturnTypes = {
        [DataType.TEST]: number,
        [DataType.OTHER]: string,
    };
    

    and now you can define the DataType type as a union of the types of the keys and the types of the values of the DataType object:

    type DataType = keyof typeof DataType | typeof DataType[keyof typeof DataType];
    // type DataType = "test" | "other" | "TEST" | "OTHER"
    

    And the compiler will be happy letting you index into ReturnTypes with Lowercase<DataType>:

    declare function getThing<K extends DataType>(
        dataType: K, text: string): Promise<ReturnTypes[Lowercase<K>] | null> // okay
    

    And you can see that getThing() behaves as desired:

    async function test() {
        (await getThing("other", ""))?.toUpperCase(); // okay
        (await getThing("TEST", ""))?.toFixed(); // okay
    }
    

    Playground link to code