How to extend TypeScript interfaces of a dependency

Viewed 197

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.

5 Answers

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.

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);
    }
}

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);



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.

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

Related