typescript

How to write a constructor for a type


In TypeScript, I can define a type like this:

type Person = {
    givenName: string
    familyName: string
}

And I can define a constructor type for my type like this:

type PersonConstructor = {
    new (givenName: string, familyName: string): Person
}

But I can't figure out the proper syntax for the constructor function implementation. The code below is invalid, and produces an error:

const NamedPerson: PersonConstructor = (givenName: string, familyName: string) => {
    return { givenName, familyName }
}
Type '(givenName: string, familyName: string) => { givenName: string; familyName: string; }' is not assignable to type 'PersonConstructor'.
    Type '(givenName: string, familyName: string) => { givenName: string; familyName: string; }' provides no match for the signature 'new (givenName: string, familyName: string): Person'.ts(2322)":

I can call the constructor, no problem, so the constructor type definition looks ok I think:

const me = new NamedPerson('First', 'Last');

What is the proper syntax for an implementation of PersonConstructor?


Solution

  • The correct way to implement a construct signature is with a class declaration or expression:

    const NamedPerson: PersonConstructor = class {
      constructor(public givenName: string, public familyName: string) { }
    }
    
    const person = new NamedPerson("John", "Doe");
    console.log(person.familyName.toUpperCase()) // "DOE"
    console.log(person instanceof NamedPerson); // true
    

    In the above class expression I've used parameter properties to make the definition a little easier, but you don't need to use this.


    If you want to use an arbitrary function instead, TypeScript doesn't want to make that easy for you. ES5-style constructor functions work at runtime (where they assign properties to this instead of returning anything) but TypeScript doesn't want to add support for that and the advice is "use classes": see microsoft/TypeScript#2310. Returning values from your constructor also works at runtime, but TypeScript doesn't currently let you mark class constructors as producing something other than its this type: see microsoft/TypeScript#27594 for the relevant feature request. And TypeScript can't really tell the difference between function expressions (which are newable) and arrow functions (which explode at runtime if you new them, even if you never try to use this inside them), so even if the above two issues were addressed, you'd need to be careful that your function wasn't the wrong kind of function.

    Right now, handling all of this requires some kind of wrapper function with type assertions to suppress compiler warnings, and an implementation that makes arrow functions usable. Maybe like this:

    function toConstructor<A extends any[], R extends object>(
      fn: (...args: A) => R): new (...args: A) => R {
      return class {
        constructor(...args: any) {
          return fn(...args);
        }
      } as any;
    }
    

    And then you'd use it like

    const NamedPerson: PersonConstructor =
      toConstructor((givenName: string, familyName: string) => {
        return { givenName, familyName }
      });
    
    const person = new NamedPerson("John", "Doe");
    console.log(person.familyName.toUpperCase()) // "DOE"
    console.log(person instanceof NamedPerson); // false
    

    Oh, yeah, person instanceof NamedPerson is false, which is confusing. That's one of the drawbacks of constructors that return things instead of assigning to this; the prototype isn't set properly. So even if TypeScript made it easy-ish to use arbitrary functions as class constructors, they'll lead to weird behavior.


    If you want to minimize problems at runtime and with the compiler, you should use classes as classes and functions as functions and not mix them together. If you've got a function then you'll have a better time just using it as a function without involving the new operator at all:

    const namedPerson = (givenName: string, familyName: string): Person => {
      return { givenName, familyName }
    }
    
    const person = namedPerson("John", "Doe");
    console.log(person.familyName.toUpperCase()) // "DOE"
    

    Playground link to code