Search code examples
javascripttypescripttypestype-inference

why can't typescript figure out that the possible actual types of a union type correspond to the available prototypes for a function? Workaround?


This (playground link) doesn't work:

type ReplaceAll2ndArgType = string | ((substring: string, ...args: unknown[]) => string)

export async function renderHTMLTemplate(
    args: Record<string, ReplaceAll2ndArgType>
): Promise<string> {
    let template = "some template text"
    for (const key in args) {
        const val = args[key]
        template = template.replaceAll(`%${key}%`, val)
    }
    return template
}

The typescript compiler give this error:

No overload matches this call.
  Overload 1 of 2, '(searchValue: string | RegExp, replaceValue: string): string', gave the following error.
    Argument of type 'ReplaceAll2ndArgType' is not assignable to parameter of type 'string'.
      Type '(substring: string, ...args: unknown[]) => string' is not assignable to type 'string'.
  Overload 2 of 2, '(searchValue: string | RegExp, replacer: (substring: string, ...args: any[]) => string): string', gave the following error.
    Argument of type 'ReplaceAll2ndArgType' is not assignable to parameter of type '(substring: string, ...args: any[]) => string'.
      Type 'string' is not assignable to type '(substring: string, ...args: any[]) => string'.

The problem appears to be that typescript can't figure out that the union type ReplaceAll2ndArgType consists of a set of types that fit the set of prototypes available for template.replaceAll, which seems a little surprising. It looks like it just considers the prototypes one at a time. Or am I not understanding the problem correctly?

The only workaround I have so far looks like this (playground link):

type ReplaceAll2ndArgType = string | ((substring: string, ...args: unknown[]) => string)

export async function renderHTMLTemplate(
    args: Record<string, ReplaceAll2ndArgType>
): Promise<string> {

        let template = "some template text"

    for (const key in args) {
            const val = args[key]
            switch ( typeof val ) {
                case "string":
                    template = template.replaceAll(`%${key}%`, val)
                    break
                case "function":
                    template = template.replaceAll(`%${key}%`, val)
                    break
            }
    }
    return template
}

which seems really poor especially since the case "function" isn't really doing much useful narrowing.

Is there some better way that I'm missing?


Solution

  • You've run into the issue reported at microsoft/TypeScript#44919.

    For whatever reason, the TypeScript library typings for the replaceAll() string method are written as a pair of overloads:

    interface String {
      replaceAll(
        searchValue: string | RegExp, 
        replaceValue: string
      ): string;
      replaceAll(
        searchValue: string | RegExp, 
        replacer: (substring: string, ...args: any[]) => string
      ): string;
    }
    

    TypeScript currently only resolves calls to overloads one call signature at a time. Your call has to match at least one of the call signatures. But you are calling replaceAll() with a second argument of a union type. Each member of that union matches one of the call signatures, but the union itself doesn't. Until and unless TypeScript supports calling multiple call signatures at once, as requested in microsoft/TypeScript#14107, you'll need to work around it somehow.

    One workaround is as you've shown in your question: use control flow to distinguish the type of the second argument so that the call can match one of the overloads.

    Another workaround is to take the approach requested in microsoft/TypeScript#44919, and make replaceAll() have an appropriate call signature. You can't get rid of the existing pair of call signatures, but you can merge a third call signature in for your own code base:

    declare global {
      interface String {
        replaceAll(
          searchValue: string | RegExp, 
          replacerOrValue: string | ((substring: string, ...args: any[]) => string)
        ): string;
      }
    }
    

    All I've done there is combine the two call signatures manually. Once you do that your call starts working because it matches the new call signature:

    template = template.replaceAll(`%${key}%`, val) // okay
    

    Playground link to code