3

I have a main TypeScript project distributed as a package that can be enriched by plugins (which are just other TypeScript packages).

My main project exposes an API with a definition like below

class Dealer {
    public buyCar (model: string, options: any): void {
        // ...
    }
}

So let's suppose the main project allows to buy cars from a dealer, and that the plugins can add new car models available to buy. How can I extend the buyCar definition from the plugin package in order to add more specific types?

Something like

class Dealer {
    public buyCar (model: "Toyota", options: ToyotaOptions): void;
}

I know I can do this from the main project, but the point is that the main project shouldn't be aware of the plugins, so plugins must extend the interfaces.

4
  • How is the plugin loaded into the Dealer instance? Commented Jun 20, 2022 at 0:38
  • 1
    Try declaration merging of an interface Commented Jun 20, 2022 at 0:39
  • 1
    If the method already accepts any, there's nothing you can do, really. Whoever wrote that method signature wanted to accept absolutely anything in options, and you can't "extend" that to make it reject things, even with declaration merging. I assume the Dealer class should maybe be generic instead, like this approach shows. Would that meet your needs? If so I could write up an answer; if not, what am I missing? Commented Jun 20, 2022 at 18:30
  • "the plugins can add new car models available to buy" - how exactly does that work? Does the Dealer have something like a plugin registry? How will it select the plugin depending on the model parameter of the buyCar method? If you could show this implementation, you might get better answers. Commented Jul 8, 2022 at 15:43

5 Answers 5

2
+50

You can try to use generics to specify what type of models and options the class expects to receive.

interface Dealer<M extends string> {
    buyCar<T = unknown>(model: M, options: T): void;
}


type ToyotaModel = "Yaris" | "Auris" | "Camry";
interface ToyotaOptions {
    engine?: "petrol" | "diesel" | "hybrid";
}

class ToyotaDealer implements Dealer<ToyotaModel> {
    public buyCar<ToyotaOptions>(model: ToyotaModel, options: ToyotaOptions) {
        console.log(model, options);
    }
}

const toyota = new ToyotaDealer();
toyota.buyCar("Auris", { engine: "petrol" });

The entire class is annotated by M generic to make all the methods within the class to access this type and use the same model set. The buyCar method is annotated by another generic T to allow to specify the interface for "buy car" options for this method.

TypeScript playground with the example is here.

Sign up to request clarification or add additional context in comments.

Comments

0

I am not user but I think you can combine namespace & declaration merging to achieve it. Something like this:

type ToyotaOptions = "Toyota";
namespace Dealer {
    export function buyCar(model: string, options: ToyotaOptions): void;
    export function buyCar(model: string, options: any): void {
        Dealer.buyCar(model, options);
    }
}

Comments

0

I'm not sure if I understood you correctly, but I think there is some misconception about how you described the plugin architecture. You shouldn't have to extend/modify/override the plugin's code to use it (e.g., by using namespace & declaration merging because it violates the open-closed principle). To make plugins closed for modification, I would introduce a shared interface that all the plugin packages will use. e.g.

// package: purchases

export interface CarPurchase {
   buy():void
}

The package that provides details for purchasing Toyota cars could look like this.

// package: toyota

import { CarPurchase } from "purchases"

export type ToyotaYarisPurchaseOptions {
  engine?: "petrol" | "diesel" | "hybrid";
}

export class ToyotaYarisPurchase implements CarPurchase {

   constructor(options: ToyotaYarisPurchaseOptions){}

   buy():void {
      //implementations details goes here
   }
}

export type ToyotaCamryPurchaseOptions {
  engine?: "petrol" | "diesel" | "hybrid";
}

export class ToyotaCamryPurchase implements CarPurchase {

   constructor(options: ToyotaCamryPurchaseOptions){}

   buy():void {
      //implementations details goes here
   }
}

Then, in your main app, I could implement the Dealer class that runs purchases.

import { CarPurchase } from "purchases"
import { ToyotaYarisPurchase } from "toyota"

class Dealer {
   makeTransaction(purchase: Purchase):void {
      // additional behavior for a dealer - e.g. logging all purchases
      purchase.buy();
   }
}

const dealer = new Dealer();
const toyotaPurchase = new ToyotaYarisPurchase({engine: 'diesel'})
dealer.makeTransaction(toyotaPurchase);

At this point you could also create a factory for purchases in order to hide instantiation details of a purchase:


// purchasesFactory.ts
import { ToyotaYarisPurchaseOptions } from "toyota"
import { CarPurchase } from "purchases"


export class PurchasesFactory {
   create(car: 'yaris', options: ToyotaYarisPurchaseOptions): ToyotaYarisPurchase
   create(car: 'camry', options: ToyotaCamryPurchaseOptions):ToyotaCamryPurchase
   create(car: 'yaris' | 'camry', options: ToyotaYarisPurchaseOptions | ToyotaCamryPurchaseOptions): CarPurchase {
      switch(car) {
        case 'camry':
          return new ToyotaCamryPurchase(options);
        // other cases
        }
   }   
}

so your final app code looks like

import { CarPurchase } from "purchases"
import { ToyotaYarisPurchase } from "toyota"
import { PurchasesFactory } from './purchasesFactory';

class Dealer {
   makeTransaction(purchase: Purchase):void {
      // additional behavior for a dealer - e.g. logging all purchases
      purchase.buy();
   }
}

const dealer = new Dealer();
const purchases = new PurchasesFactory();
const toyotaYarisPurchase = purchases.create('yaris', { engine: 'diesel' });
dealer.makeTransaction(toyotaYarisPurchase);

The presented example illustrates roughly the usage of a command pattern. It may be suitable for your problem. I used OOP on purpose to clearly present the responsibilities of particular classes, but you could write it more concisely using functions with generic types. (Beware that, injecting additional dependencies to purchase functions and dealer's makeTransaction would require using more advanced concepts).


// shared
export type Purchase<TOptions extends object> = <TOptions>(options:TOptions) => void;

// toyota package 
export const buyToyotaYaris: Purchase<ToyotaYarisPurchaseOptions> = options => {
 // some logic here
}

export const buyToyotaCamry: Purchase<ToyotaCamryPurchaseOptions> = options => {
 // some logic here
}


// main app
import { buyToyotaCamry } from 'toyota';

const makeTransaction = <TPurchaseOptions extends object>(options: TPurchaseOptions, purchase: Purchase<T>) => {
  // dealer logic here
  purchase(options)
}

makeTransaction({engine: 'diesel'}, buyToyotaCamry);



Comments

0

I am the opinion you are trying to address your Software Design problem with language specifics constructs. You can solve this in a more general way, applicable to any OOP Language, and then translate it to TypeScript.

Maybe you can get a better answer if you post this on software engineering site.

In any case, I would do as follow:

  1. Define your very basic concepts (needed for a Dealer to work) through interfaces in a base package.
    • Car Model common aspects
    • Car Options commonalities
    • Car Dealer. Make car dealer work with this very basic concepts you already have
  2. Define your Plugin architecture. Maybe this help
    • Plugin register mechanismus to register new car models with its options
    • Allow your main application to request car models to your car (plugin) register
    • Make your car dealer work with these new added (more specific) car models from register

This way, lets say, you can install an npm package with new car definition, then in your application register this new car definition to your car registry, then the dealer can later on use it.

Comments

-1

You might want to do something like this for better readability and maintainability.

import { Dealer as BaseDealer } from './Dealer';

export class Dealer extends BaseDealer {
  public buyCar(model: string, options: any): void {
    super.buyCar(model, options);
  }
}

Demo: https://stackblitz.com/edit/typescript-tpdfyv?file=index.ts

1 Comment

That won't help with ToyotaOptions, especially not in those places that use an original Dealer and not your subclass.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.