Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Declare a constructor to correctly infer generic type from (keyof) argument in TypeScript

I know that in TypeScript I can declare functions like these:

function doSomething<E extends Element>(el : E) : E;
function doSomething<N extends keyof ElementTagNameMap>(selector : N) : ElementTagNameMap[N];
function doSomething<E extends Element>(el : E | keyof ElementTagNameMap) {
  if(typeof el === 'string') {
    return document.createElement(el) as Element as E;
  } else {
    return el;
  }
}

Their usage will then be correctly typed

doSomething(document.querySelector('option')!) // return value typed as HTMLOptionElement
doSomething(new Image()); // return value typed as HTMLImageElement
doSomething('input'); // return value typed as HTMLInputElement

How can I achieve the same thing with constructors of generic classes?

class Some<E extends Element> {
  public element : E;

  constructor(el : E | keyof ElementTagNameMap) {
    if(typeof el === 'string') {
      this.element = document.createElement(el) as Element as E;
    } else {
      this.element = el;
    }
  }
}


new Some(document.querySelector('option')!); // Works, type is Some<HTMLOptionElement>
new Some(new Image()); // Works, type is Some<HTMLImageElement>

But I can’t seem to get the following to work:

new Some('input'); // Type is Some<Element> (the fallback) instead of Some<HTMLInputElement>

(Of course, using new Some<HTMLInputElement>('input') works but I should not have to explicitly type this if I already have the ElementTagNameMap doing this for me.)

I have tried adding overloads to the constructor, like I did to the function in the previous example:

constructor<N extends keyof ElementTagNameMap>(el : N) : Some<ElementTagNameMap[N]>;
// ⇒ Error: Type parameters cannot appear on a constructor function
constructor<N extends keyof ElementTagNameMap>(this : Some<ElementTagNameMap[N]>, el : N);
// ⇒ Error: A constructor cannot have a `this` parameter

I know I could create a helper function createSome:

function createSome<E extends Element>(el : E) : Some<E>;
function createSome<N extends keyof ElementTagNameMap>(selector : N) : Some<ElementTagNameMap[N]>;
function createSome<E extends Element>(el : E | keyof ElementTagNameMap) {
  return new Some(el);
}

createSome(document.querySelector('option')!); // Works: type is Some<HTMLOptionElement>
createSome(new Image()); // Works: type is Some<HTMLImageElement>
createSome('input'); // Works too now: type is Some<HTMLInputElement>

But isn’t there a way to achieve this directly? It seems counter-intuitive that I need to add a run-time construct (helper function) to get a specific compile-time behaviour (type inference).

like image 627
Raphael Schweikert Avatar asked Dec 18 '17 11:12

Raphael Schweikert


People also ask

How do you define a generic type in TypeScript?

Generic types have type parameters that need to be specified before you can use them as a specific type. For example: type GenType<T> = (x: T) => T[]; declare const oops: GenType; // error declare const genT: GenType<string>; // okay const strArr = genT("hello"); // string[]; const numArr = genT(123); // error!

How do you write a constructor in TypeScript?

The TypeScript docs have a great example of constructor usage: class Greeter { greeting: string; constructor(message: string) { this. greeting = message; } greet() { return "Hello, " + this. greeting; } } let greeter = new Greeter("world");

Does TypeScript have generics?

The implementation of generics in Typescript give us the ability to pass in a range of types to a component, adding an extra layer of abstraction and re-usability to your code. Generics can be applied to functions, interfaces and classes in Typescript.

Can a TypeScript class have multiple constructors?

In TypeScript, we cannot define multiple constructors like other programming languages because it does not support multiple constructors.


1 Answers

Yes, you can't put a generic parameter on the constructor function because the type parameter for the constructor would collide with any possible type parameter for the class itself:

new Some<WAT>('input'); // Is WAT the class param or the constructor param?

There are definitely places in TypeScript where the only way to get the compiler to understand what you're doing is to add runtime constructs (user-defined type guards are an example of this). So, your helper function createSome() is reasonable. If you make the constructor private and include createSome() as a static method on the class, you've exposed only the behavior you want and it is still packaged fairly nicely:

class Some<E extends Element> {
  public element: E;

  private constructor(el: E | keyof ElementTagNameMap) {
    if (typeof el === 'string') {
      this.element = document.createElement(el) as Element as E;
    } else {
      this.element = el;
    }
  }

  static create<E extends Element>(el: E): Some<E>;
  static create<N extends keyof ElementTagNameMap>(selector: N): Some<ElementTagNameMap[N]>;
  static create<E extends Element>(el: E | keyof ElementTagNameMap) {
    return new Some(el);
  }
}

It isn't even that unwieldy for users:

// not so bad
Some.create(document.querySelector('option')!); // Some<HTMLOptionElement>
Some.create(new Image()); // Some<HTMLImageElement>
Some.create('input'); // Some<HTMLInputElement>

The only workaround I can think of that allows you to use a "generic constructor" without any runtime overhead would be to put the generic parameters in the class definition instead; and since there aren't any class "overload" definitions, you have to make a single signature that works for all cases. Here's an example:

interface ElementTagNameMapWithDefault extends ElementTagNameMap {
  '***default***': Element;
}

class Some<E extends ElementTagNameMapWithDefault[N], N extends keyof ElementTagNameMapWithDefault = '***default***'> {
  public element: E;

  constructor(el: N);
  constructor(el: E);
  constructor(el: E | N) {
    if (typeof el === 'string') {
      this.element = document.createElement(el) as E;
    } else {
      this.element = el;
    }
  }
}

This works because the Some<E,N> class carries around both the E and the N type parameters, where the N parameter is only ever used in the constructor and then just gets dragged around later. Since N has a default value you don't need to specify it (so you can just write Some<E>), and since the structural type of the class doesn't depend on N you can safely assign a Some<E,N1> to a Some<E,N2> and vice versa.

The new ElementTagNameMapWithDefault interface includes a made-up key '***default***' (you can probably use a Symbol instead, but this is just a proof-of-concept) that allows the specified E type to include Element. And since N defaults to '***default***', the default value of E will be Element.

Let's make sure it works:

new Some(document.querySelector('option')!); // Some<HTMLOptionElement, "***default***">
new Some(new Image()); // Some<HTMLImageElement, "***default***">
new Some('input'); // Some<HTMLInputElement, "input">

All good. Of course it allows this nonsense:

new Some('***default***'); // don't do this

which is why you'd probably want a private Symbol if you did this in practice.

But don't do this in practice; it's ugly and terrible. The static create() method is probably the least messy solution in TypeScript.


If you really want generic constructors, you might head over to Github and try to reopen Microsoft/TypeScript#10860 or make a new issue that references it. Perhaps a proposal about how to deal with distinguishing constructor type parameters from class parameters would be needed? (new<CtorParams> Class<ClassParams> ?)

Anyway, hope that helps; good luck!


UPDATE

Oh, wow, I have a better solution for you. First, rename your class to something else so you can use the real name later. Here, I've changed Some to _Some:

class _Some<E extends Element> {
  public element: E;

  constructor(el: E | keyof ElementTagNameMap) {
    if (typeof el === 'string') {
      this.element = document.createElement(el) as Element as E;
    } else {
      this.element = el;
    }
  }
}

Now, define the Some<E> and SomeConstructor interfaces specifying the types exactly as you want. Note how in this case the constructor can indeed be overloaded and generified:

interface Some<E extends Element> extends _Some<E> {

}

interface SomeConstructor {
  new <N extends keyof ElementTagNameMap>(el: N): Some<ElementTagNameMap[N]>
  new <E extends Element>(el: E): Some<E>
}

Finally, just declare that _Some is a SomeConstructor and give it the name Some:

const Some: SomeConstructor = _Some;

This is such a tiny amount of runtime overhead that I hope you find it acceptable (var Some = _Some at emit)

Now, watch it work:

new Some(document.querySelector('option')!); // Some<HTMLOptionElement>
new Some(new Image()); // Some<HTMLImageElement>
new Some('input'); // Some<HTMLInputElement>

What do you think of that?

like image 184
jcalz Avatar answered Sep 28 '22 01:09

jcalz