Search code examples
typescriptrecursionfunctional-programming

Typescript circular function reference


This function is actually quite simple, it either returns a function or a string depending on a inner function parameter.

function strBuilder(str: string) {
  return function next(str2?: string) {
    if(typeof str2 === "string") {
      return strBuilder(str + str2)
    } else {
      return str
    }
  }
}

However there is a type error when this expression is executed

strBuilder("Hello, ")("World")()

I would like to know how to properly annotate this function so that the type error is removed since inferencing is not working so nicely.

This type is something that I have in mind but I can not find a way to tell the function to be of that specific type.

type StringBuilder<S extends string> = (
  str: S
) => (str2?: S) => S extends string ? ReturnType<StringBuilder<S>> : string

Any kind of help is appreciated.

Here is the playground link which demonstrates the issue https://www.typescriptlang.org/play?#code/C4TwDgpgBAysBOBLAdgcwEIFdEBsAmE8APDFBAB7ATJ4DOUtCKqAfFALxQAUAUFAwgBcsHgEoObLo3gAmAPzCY49m1IUqNetOZQ5UAEoRgmeMgAq4CCSZosuAsRgs2w7Wh48AZpmQBjYIgA9sgC8Hb4hFJCocziAN58UPBGJiHefgHBUMjqUbIKMWjxifyInlygkIGeoTIc7JwARG6ojcX8HUkppqHhDnlQANS1oiVQAL5kOLTQCZ38ycY90mPjiWtrPNJ9kY0AEhA4OIEANFBtXI0A6oHw+BejQA


Solution

  • Probably the easiest approach here is to express the return type of strBuilder() as an overloaded function type:

    interface StringBuilder {
      (str: string): StringBuilder;
      (): string;
    }
    
    function strBuilder(str: string): StringBuilder {
      return function next(str2?: string) {
        if (typeof str2 === "string") {
          return strBuilder(str + str2)
        } else {
          return str
        }
      } as StringBuilder // <-- assert
    }
    

    We need an assertion there because the compiler can't accurately verify that function implementations conform to overloaded call signatures (see How to correctly overload functions in TypeScript? ).

    Anyway the above compiles and results in the desired call behavior:

    const str = strBuilder("Hello, ")("World")();
    console.log(str.toUpperCase())
    

    You could write it as a generic conditional type, but that's more complicated and isn't necessarily more desirable (it really depends on use cases):

    interface StringBuilder {
      <S extends string | undefined = undefined>(str?: S):
         S extends string ? StringBuilder : string;
    }
    

    That's similar to your version, except I'm just representing the return type directly (so as not to use ReturnType and so as not to worry about using the same S inside the original function and inside the returned function, which is not what you want) and I'm giving a default to S in the case when someone calls the function with no argument (otherwise it would fall back to string | undefined which isn't useful).

    You still need a type assertion inside the body of strBuilder() because the compiler also cannot accurately verify that function implementations conform to generic conditional return types; see microsoft/TypeScript#33912. So it's not like you gain or lose anything in terms of type safety here.

    Anyway it behaves the same for the above example:

    const str = strBuilder("Hello, ")("World")();
    console.log(str.toUpperCase())
    

    I'd probably stick with overloads unless you run into some specific trouble with it.

    Playground link to code