Search code examples
typescripttypescript-typings

derive union type from a non-literal array (object.keys and array.map)


Is it possible to derive a union from a non-literal array?

I've tried this:

const tokens = {
  "--prefix-apple": "apple",
  "--prefix-orange": "orange"
};

const tokenNames = Object.keys(tokens).map(token => token.replace("--prefix-", ""));

type TokenNamesWithoutPrefix = typeof tokenNames[number] 

However, it returns type string when I would like "apple" | "orange"


Solution

  • You need to be able to do this operation at the type level because the built in functions you are using here just return string types. So you need generic types that will deal with string literal types.

    Let's start with this:

    type StripPrefix<T extends string, Prefix extends string> =
      T extends `${Prefix}${infer Result}`
        ? Result
        : T
    
    type Test = StripPrefix<'--prefix-foo-bar', '--prefix-'>
    //   ^? 'foo-bar'
    

    This is a type that checks if a string literal type starts with a prefix, and then infers the string literal type that comes after that prefix.

    If no match is found, then the type just resolves to T, unaltered.


    Now we need a function to do this operation and enforce these types.

    function removePrefix<
      T extends string,
      Prefix extends string
    >(token: T, prefix: Prefix): StripPrefix<T, Prefix> {
      return token.replace(prefix, "") as StripPrefix<T, Prefix>
    }
    

    Here the token to start with is captured as type T, and the prefix to trim off is captured as type Prefix.

    But we need a type assertion here on the return value with as. This is because the replace method on strings always just returns the type string. So if you know the types going into that we can enforce something better, which is our new StripPrefix type.


    One more problem is that Object.keys always returns string[] and never the specific keys. This is because the actual object may have more keys than the type knows about.

    const obj1 = { a: 123, b: 456 }
    const obj2: { a: number } = obj1 // fine
    const keys = Object.keys(obj2) // ['a', 'b']
    

    In that snippet, obj2 has two keys, but the type only knows about one.

    So if you are sure this situation will not happen, or you are sure that it won't matter if it does, then you can use another type assertion to strongly type the array of keys:

    const myObjectKeys = Object.keys(tokens) as (keyof typeof tokens)[]
    

    And then map over that with the function from before and it should work like you expect.

    const tokenNames = tokenKeys.map(token => removePrefix(token, "--prefix-"));
    //    ^? ('apple' | 'orange')[]
    

    See Typescript playground