Category: Typescript Terrors

  • Typescript Overloading

    I spent a couple of hours recently trying to solve a problem with overloading in TypeScript only to realise there wasn’t actually a problem at all – I had just completely misunderstood how overloading works !

    Unlike other languages where an overload acts as an “alias” for the implementing method, in TypeScript an overload “replaces” / hides the implementing method – the implementing method becomes inaccessible unless called via an overload signature.

    Example

    I’ll try to show a basic example and then go into more details.

    Imagine, for the sake of this example, an equivalent of “parseInt” (that we could call doParseInt) that takes either a number or a string.

    • If a number is passed we return the number directly.
    • If a string is passed however, a second argument “radix” is required.

    We could code this like:

    // Basic function to test overloading: can be called in two ways
    // value is number (radix not used)
    // value is string (radix required)
    
    function doParseInt(value: (number | string), radix?: number): number {
      if (typeof value === 'number') {
        return value;
      }
      if (typeof value === 'string') {
        return parseInt(value, radix);
      }
      throw new Error(`unexpected value: ${value}`);
    }
    

    We can use it like:

    const val1 = doParseInt(123); // OK
    const val2 = doParseInt('123'); // Error - radix is required if value is string
    const val3 = doParseInt(123, 8); // Error - radix is not used if value is number
    const val4 = doParseInt('123', 10); // OK
    const val5 = doParseInt(123 as (number | string)); // OK
    

    This works but is not great from a type-safety point of view – we have no way of forcing to people to pass a radix if value is string, or likewise from not passing a radix if value is numeric.

    Overloading to the rescue – We add our overload signatures to refine the way we can call the implementing function:

    function doParseInt(value: number): number;
    function doParseInt(value: string, radix: number): number;
    

    Note these are not “functions” – they do not have a body!

    They serve purely as “overload signatures” – method signatures that refine (limit) how we can call the implementing method.

    After adding our signatures, everything seems great except “val5” which no longer compiles – it appears the fact of adding an overload signature has made the original implementing method inaccessible !

    const val1 = doParseInt(123); // OK
    // const val2 = doParseInt('123'); // No longer compiles - great
    // const val3 = doParseInt(123, 8); // Likewise, great
    const val4 = doParseInt('123', 10); // OK
    const val5 = doParseInt(123 as (number | string)); // No longer compiles either .... !
    

    Results in:

    error TS2345: Argument of type 'string | number' is not assignable to parameter of type 'number'.
    Type 'string' is not assignable to type 'number'.
    

    The problem comes from val5 – we want to be able to call directly the non-overloaded implementation method.

    However, this is not possible in TypeScript – adding a single overload makes the non-overloaded implementation inaccessible.

    To resolve this, we need to simply add the original method signature as an overload signature of itself.

    The final code looks like:

    function doParseInt(value: number): number;
    
    function doParseInt(value: string, radix: number): number;
    
    function doParseInt(value: (number | string), radix?: number): number;
    
    // Basic function to test overloading: can be called in two ways
    // value is number (radix not used)
    // value is string (radix required)
    function doParseInt(value: (number | string), radix?: number): number {
      if (typeof value === 'number') {
        return value;
      }
      if (typeof value === 'string') {
        return parseInt(value, radix);
      }
      throw new Error(`unexpected value: ${value}`);
    }
    

    In hindsight it seems clearly logical – we add overload signatures to refine/specialise the call, and so it doesn’t make much sense to leave the original implementing method available.

    What would have been great would have been an error message more explicit: Implementing function not available if overload signature exists” for example

    This example is slightly contrived: The actual problem was with array signatures (RxJS combineLatest for example) but that will be the subject of my next post.