Search code examples
typescript

Why the constructor overload signature is not compatible with its implementation singature in TypeScript?


class Person {
  private name: string;
  private age: number;

  constructor();
  constructor(name: string, age: number);
  constructor(options: { name: string; age: number }); //constructor signature is not compatible with implementation signature

  // Single implementation
  constructor(name?: string, age?: number, options?: { name: string; age: number }) {
    if (options) {
      this.name = options.name;
      this.age = options.age;
    } else {
      this.name = name || "";
      this.age = age || 0;
    }
  }
}

As show above, I got an error telling me this constructor overload signature: constructor(options: { name: string; age: number }) is not compatible with its implementation signature.

In the implementation signature, all three inputs are optional, which means it is possible for the constructor to receive only one input that is an object in shape of {name: string; age:number}, why are they not compatible?


Solution

  • In a TypeScript function call signature or construct signature, the parameter names are basically only for documentation purposes. They do not affect assignability of function or constructor types, and they have nothing to do with the parameter names chosen for the implementation of a function or a constructor. Only the position and types of the parameters matter:

    let f: { (a: string[], b: number, c: boolean): string };
    
    f = function (arr: string[], idx: number, toUpperCase: boolean) {
        const str = arr[idx] ?? ""; return toUpperCase ? str.toUpperCase() : str;
    } // okay
    
    f = function (x: string[], y: number, c: boolean) {
        return c ? y.toFixed(2) : x.join("");
    } // okay
    

    Here the type of f is a function that accepts three arguments. The first is an array of strings, the second is a number, and the third is a boolean. The call signature names the parameters corresponding to those arguments a, b, and c, but those names are just for documentation. The actual implementation can call them arr, idx, and toUpperCase, or x, y, and z, or anything. It is only the position and types of the parameters that matter.

    This used to be documented in the now-obsolete language spec:

    When comparing call or construct signatures, parameter names are ignored

    I don't know if there's any easy-to-find current documentation that makes this clear, but it's still true.

    Obviously when you call a function, it doesn't matter what names, if any, you gave to the arguments in the call signature. The call signature doesn't even exist at runtime, since that's erased along with the rest of the type system. Only the position of each argument matters.

    const a = false;
    const b = ["foo", "bar", "baz"];
    const c = 1;
    // f(a, b, c); // no!
    f(b, c, a); // yes!
    

    When you overload functions, methods, or constructors, you are providing some number of call signatures, and then the implementation has its own signature. The implementation signature must be compatible with each call signature, and this compatibility does not include the names of the parameters. Just their positions and types.

    The reason the following is unacceptable:

    class Person {
        name: string;
        age: number;
    
        constructor();
        constructor(name: string, age: number);
        constructor(options: { name: string; age: number }); 
    
        constructor(name?: string, age?: number, options?: { name: string; age: number }) {
            if (options) {
                this.name = options.name;
                this.age = options.age;
            } else {
                this.name = name || "";
                this.age = age || 0;
            }
        }
    
    }
    

    Is that you can either call new Person() or new Person(name, age) or new Person(options), but the implementation is expecting new Person(name, age, options). If someone calls new Person(options), the options object is the first and only argument. It will never be the third argument, so the options parameter in the implementation will always be undefined. This is bad news for the name parameter in the implementation, which might turn out to receive an options object. But since name is only expected to be string or undefined, the third call signature is a problem. It's incompatible with its implementation.

    This is easily seen by the test:

    const p = new Person({ name: "John", age: 30 });
    try {
        console.log(p.name.toUpperCase()) // compiles fine, but:
    } catch (e) {
        console.log(e) // 💥 TypeError: p.name.toUpperCase is not a function
    }
    

    Oops. Inside the constructor implementation, options is undefined, so you end up with this.name = name || "", which assigns that options object to this.name. It's an object, not a string, and you can get a runtime explosion when treating it like a string.

    You might have wanted options to be the third parameter, but it just isn't.


    The right way to implement an overloaded function or constructor is to make the implementation pay attention to the positions and types of the call signatures. That makes the implementation ugly-ish:

        constructor(optionsOrName?: string | { name: string, age: number }, age?: number) {
            if (typeof optionsOrName === "object") {
                this.name = optionsOrName.name;
                this.age = optionsOrName.age;
            } else {
                this.name = optionsOrName || "";
                this.age = age || 0;
            }
        }
    

    The first parameter is either a string or an options object or undefined. The second parameter is either a number or undefined. If the first parameter is an object, then you index into it to get the name and age properties. Otherwise you look at each parameter separately.

    Possibly a little less terrible is to implement it with a rest parameter whose type is a union of tuple types, using labeled tuples for documentation purposes:

        constructor(...args:
            [] |
            [name: string, age: number] |
            [options: { name: string; age: number }]
        ) {
            if (args.length === 1) {
                const [options] = args;
                this.name = options.name;
                this.age = options.age;
            } else {
                const [name, age] = args;
                this.name = name || "";
                this.age = age || 0;
            }
        }
    

    Here we identify the options object argument by the length of args, and use destructuring assignment to preserve your original implementation as much as possible.

    Either way will give correct results:

    const p = new Person({ name: "John", age: 30 });
    console.log(p.name.toUpperCase()) // JOHN
    

    Playground link to code