Search code examples
javascripttypescripttypesbundlertemplate-literals

Template Literal type equivalent


I have an enum like so:

export enum ApiFunctions {
  "setHidden" = "HIDE",
  "setReadOnly" = "SET_READ_ONLY",
  "setVisible" = "SHOW",
  "setDescription" = "SET_DESCRIPTION",
  "setName" = "SET_NAME",
  "makeRequest" = "MAKE_REQUEST"
}

Earlier today I created a new type from this enum like so:

export type ApiActions = Exclude<`${ApiFunctions}`, "MAKE_REQUEST">

This type returns all the values of the keys except "MAKE_REQUEST" (SET_DESCRIPTION,....)

The problem is Template literal types were released on ts 4.1 and the current bundler's ts version is 3.9.7 and I can't really update it since it is externally provided.

I have tried replicating this type by doing:

export type Something = Exclude<typeof ApiFunctions[keyof typeof ApiFunctions], "MAKE_REQUEST">

But this type instead of giving me the actual string value of each key SET_NAME | SET_DESCRIPTION ... gives me something in the lines of ApiFunctions.setName | ApiFunctions.setDescription ...

Is there a way of achieving exactly the same type created by the template literal on any other way?


Solution

  • No, before TypeScript 4.1 there was no programmatic way to widen string enum types to their corresponding string literal types.


    However, you probably shouldn't want to do such a thing (even with TypeScript 4.1 and above) and there might be a better approach. TypeScript enums are most appropriate for situations where their values are opaque to your TypeScript code base. So other than the enum definition itself, your code should not care about their specific values. The only way you should access these values is through the enum. So always ApiFunctions.setHidden and never "HIDE".

    That means you should be able to change the values and nothing inside the TypeScript code should break. Of course, if those values are important to some external system like an API, then communication with that system would no longer work. But conversely, if the external system changes so that it wants "CONCEAL" instead of "HIDE", the only thing you should have to change in your TS code is setHidden = "CONCEAL"; you shouldn't need to do a global find-and-replace of the "HIDE" string literal with the "CONCEAL" literal.

    After all, as you've seen, the compiler doesn't make it easy to refer to the literal values of an enum. It takes the view that ApiFunctions.setHidden is more specific than "HIDE", and so you can't use a value of type "HIDE" where a value of type ApiFunctions.setHidden is required. And fighting with the compiler to produce one from the other is a lot of effort for questionable benefit. Let enums stay enums, if at all possible.

    With that in mind, my suggestion for ApiActions would be:

    export enum ApiFunctions {
      setHidden = "HIDE",
      setReadOnly = "SET_READ_ONLY",
      setVisible = "SHOW",
      setDescription = "SET_DESCRIPTION",
      setName = "SET_NAME",
      makeRequest = "MAKE_REQUEST"
    }
    
    export type ApiActions = Exclude<ApiFunctions, ApiFunctions.makeRequest>;
    /* type ApiActions = ApiFunctions.setHidden | ApiFunctions.setReadOnly | 
         ApiFunctions.setVisible | ApiFunctions.setDescription | ApiFunctions.setName */
    

    On the other hand, if your TypeScript code really does care about the specific string literal types, then enum might not be right for your use case. Since TypeScript 3.4 introduced const assertions, it's been pretty easy to get an enum-like object. It looks like this:

    export const ApiFunctions = {
      setHidden: "HIDE",
      setReadOnly: "SET_READ_ONLY",
      setVisible: "SHOW",
      setDescription: "SET_DESCRIPTION",
      setName: "SET_NAME",
      makeRequest: "MAKE_REQUEST"
    } as const;
    

    From this you can programmatically make a union type also called ApiFunctions:

    export type ApiFunctions =
      typeof ApiFunctions[keyof typeof ApiFunctions];
    

    These two definitions behave similarly to the enum version of ApiFunctions, except now the compiler lets you refer to the values both through the enum and as literal types:

    export type ApiActions = Exclude<ApiFunctions, "MAKE_REQUEST">;
    /* type ApiActions = "SET_NAME" | "HIDE" | "SET_READ_ONLY" | "SHOW" | "SET_DESCRIPTION" */
    

    Which approach, if any, works better for you depends on your use case. I tend to avoid enums entirely wherever I can, because they're not part of JavaScript, and they have surprising behavior (especially numeric enums). But that's personal preference.

    Playground link to code