tris
tris

Reputation: 1039

Pass generic parameter in Typescript Mixin

When using a mixin that builds up on a generic class, I need to set it's value to unknown, causing the implementing class having a generic parameter of type unknown | concrete.

I've build an example here using Angular, but it's completely Typescript related: https://stackblitz.com/edit/angular-svw6ke?file=src%2Fapp%2Fapp.component.ts

Is there any chance to redesign this mixin (using Typescript 4.4) so the type won't get malformed?

Upvotes: 0

Views: 485

Answers (2)

Coderer
Coderer

Reputation: 27264

You need to carry the generic parameter through the mixin function. Titian's answer here gives a good explanation of how this works. For your problem, it would look something like

export function genericMixin1<
  ModelType,
  ComponentType extends AbstractConstructor<ComponentBase<ModelType>>
>(Base: T) {
  abstract class GenericMixin extends Base {
    //Logic here
  }
  return GenericMixin;
}

It helps to know going in that what he calls the "two call approach" stems from a workaround for a common problem with TypeScript generics -- you can't partially infer generic type parameters when calling a function, but you can sort of "divide" the generic parameters between the one you're calling and a second inner function. For example:

export function genericMixin2<T>() {
  return function<
    ComponentType extends AbstractConstructor<ComponentBase<T>>
  >(Base: ComponentType) {
    abstract class GenericMixin extends Base {
      //Logic here
    }
    return GenericMixin;
  };
}

(ETA: Fixed, Playground example here.)

Now instead of writing const StringMixin = genericMixin1<string, ComponentBase<string>>(ComponentBase); you can write const StringMixin = (genericMixin2<string>()(ComponentBase)). This is only a slight improvement with one generic parameter, but becomes absolutely necessary when you have many generic params. Otherwise, you wind up with const ManyParamsMixin = genericMixin<string, number, boolean, BaseThing<string,number,boolean>>(BaseThing)... it gets ugly fast.

Upvotes: 1

jmartinezmaes
jmartinezmaes

Reputation: 371

Update: simpler solution

You can forgo the extends clause in your mixin function and just use a generic T to represent your BehaviorSubject type:

export abstract class ComponentBase<T> {
  model$ = new BehaviorSubject<T>(null);
}

export function genericMixin<T>(Base: AbstractConstructor<ComponentBase<T>>) {
  abstract class GenericMixin extends Base {
    //Logic here
  }
  return GenericMixin;
}

This works without having to do any casting. Your AppComponent class will also extend a little differently:

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent extends genericMixin<Model>(ComponentBase) {
  name = 'Angular';

  method(): void {
    this.model$.subscribe((v) => console.log(v.value));
  }
}

But your model$ property should still infer correctly. Let me know if that works!

First solution

You can extract the type of your AbstractConstructor base class argument and explicitly annotate your return type from the mixin function, like so:

// An interface that represents what you're mixing in
interface WithModel<T> {
  model$: BehaviorSubject<T>;
}

// Extracting the type of your `AbstractConstructor` parameter
type ExtractModelType<T extends AbstractConstructor<ComponentBase<any>>> =
  T extends AbstractConstructor<ComponentBase<infer U>> ? U : never;

// Then in your mixin function
export function genericMixin<
  T extends AbstractConstructor<ComponentBase<unknown>>
>(Base: T): T & WithModel<ExtractModelType<T>> {
  abstract class GenericMixin extends Base {
    // Logic here
  }
  return GenericMixin as T & WithModel<ExtractModelType<T>>;
}

Now accessing the model$ parameter from derived classes should yield the correct type.

class Model {
  value: string;
}

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent extends genericMixin(ComponentBase)<Model> {
  name = 'Angular';

  method(): void {
    // `model$` is correctly inferred as a `Model` here.
    this.model$.subscribe((v) => console.log(v.value));
  }
}

Upvotes: 2

Related Questions