typescript

Typescript: Pass a union type to an overloaded function?


I have this class:

type Params = {
    data: string | string[]
}

class Test {
    data: string | string[]

    constructor(params: Params) {
        this.data = this.initData(params.data)
    }

    initData(data: string): string
    initData(data: string[]): string[]
    initData(data: string | string[]): string | string[] {
        if (typeof data === 'string') {
            return data as string
        } else {
            return data as string[]
        }
    }
}

But I am getting this error:

No overload matches this call.
  Overload 1 of 2, '(data: string): string', gave the following error.
    Argument of type 'string | string[]' is not assignable to parameter of type 'string'.
      Type 'string[]' is not assignable to type 'string'.
  Overload 2 of 2, '(data: string[]): string[]', gave the following error.
    Argument of type 'string | string[]' is not assignable to parameter of type 'string[]'.
      Type 'string' is not assignable to type 'string[]'.ts(2769)

Untitled-1(38, 5): The call would have succeeded against this implementation, but implementation signatures of overloads are not externally visible.

I found a stupid workaround that helps:

constructor(params: Params) {
    this.data = this.initData(params.data)

    if (typeof params.data === 'string') {
        this.data = this.initData(params.data)
    }
    else {
        this.data = this.initData(params.data)
    }
}

And I kinda get why this works, but maybe there is a better way to solve this?


Solution

  • There is a longstanding open feature request at microsoft/TypeScript#14107 to support calling an overloaded function with an appropriate union of its arguments. That is, to distribute overload resolution over unions of argument types. For now it's not part of the language and there are only workarounds.

    If you want to call an overloaded function today you must make sure the call is appropriate for at least one of its call signatures. So the most common workaround is to add the missing call signature corresponding to your failed call:

    class Test {
      data: string | string[]
    
      constructor(params: Params) {
        this.data = this.initData(params.data)
      }
    
      // call signatures
      initData(data: string): string
      initData(data: string[]): string[]
      initData(data: string | string[]): string | string[]; // <-- add this
    
      // implementation
      initData(data: string | string[]): string | string[] {
        return data
      }
    }
    

    That's easy enough to do here because you only have to add one more call signature. But if you had 𝒏 call signatures to start with, combining every possible collection of them would result in something on the order of 2𝒏 call signatures. That grows very quickly with 𝒏. To avoid that exponential growth you could have just a single generic call signature that uses a distributive conditional type to compute the return type:

    class Test {
      data: string | string[]
    
      constructor(params: Params) {
        this.data = this.initData(params.data)
      }
    
      // call signature
      initData<T extends string | string[]>(data: T): 
        T extends string ? string :
        T extends string[] ? string[] :
        never;
    
      // implementation
      initData(data: string | string[]): string | string[] {
        return data
      }
    }
    

    Now the single call signature can automatically distribute over unions and it scales linearly with the number of original call signatures. It's still implemented as an overload, though, because the compiler isn't currently able to verify the bodies of functions returning generic conditional types. See microsoft/TypeScript#33912 for a discussion about that. So we are using the "looseness" of overloaded function implementation to avoid type assertions here.

    Playground link to code