Interfaces in JS with @teqfw

This article was inspired by a correspondence in the comments with my colleague @iliazeus and his question about how @teqfw/di code may depend on an interface, not on its implementation. In my answer I tried to draw parallels with Jason Statham's character from the movie “The Transporter” – Frank Martin. Frank had three rules (contract conditions) and anyone who met these rules (and had enough money) could hire Frank as a transporter.

Frank Martin doesn't care about the details

Frank Martin doesn't care about the details.

Below I will demonstrate using Frank Martin's example how interfaces can work in regular JS (not TS).

Contract

In the first film of the trilogy (note 1) Frank Martin had three rules:

  1. Never change the terms of the deal.

  2. No names.

  3. Never open the package.

The third rule describes the specifics of using interfaces in programming (not only in JS, but in general). All Frank needed to know about the package was its size and weight, and about the trip – the start and end addresses of the route. Based on this information, Frank figured out whether it was worth taking on the job and for how much.

In JS, these conditions can be simplified as follows:

/** @interface */
class Trans_Api_Package {
    /** @return {{length: number, width: number, height: number}} */
    getSize() {}

    /** @return {number} */
    getWeight() {}
}
/** @interface */
class Trans_Api_Route {
    /** @return {string} */
    getPlaceFrom() {}

    /** @return {string} */
    getPlaceTo() {}  
}

At the moment, native interfaces have not yet been introduced into JS, so we have to make do with regular classes (class) and JSDoc annotations – @interface And @implements.

To take an order and fulfill it, Frank only needs to know what he declared in the contract (interfaces). Details outside the agreed contract do not concern him on a professional level – “Never open the package.

Frank's contract for the service provided in JS might look like this:

class Trans_Drive {
    /**
     * @param {Trans_Api_Package} pack
     * @param {Trans_Api_Route} route
     */
    constructor(pack, route}
    ) {...}
}

Translated into plain language: “You give me a package, tell me where to go – and I go.

Frank provides transportation service to anyone person or organization, who matches his demands.

If we translate all of the above into programming language, apart from Frank Martin, but in the context of use @teqfw/diThat:

  • A plugin defines interfaces that objects it can work with (classes in the space) must conform to. Trans_Api).

  • The application that this plugin uses is responsible for implementing the interfaces (Client1, Client2…).

  • Because in @teqfw/di IoC is implemented as constructor-based dependency injection, so plugin classes use interfaces to represent the dependencies expected from the Object Container.

  • Binding implementations to interfaces occurs by configuring the Object Container in the corresponding application (Client1, Client2…).

  • When creating plugin objects at runtime, the Container injects implementations into the locations of the corresponding interfaces.

In the diagram above, each block corresponds to a separate npm package.

Implementation

In this article I proceed from the simplification that one application (any Client) uses plugin (Transporter) for a one-time shipment of a parcel (at least due to the exclusivity of Frank's services and their cost). Thus, the object of the trip Trans_Drive within any application that uses it, it is a singleton object and is injected with the same singleton dependencies when it is created. In terms of @teqfw/di it looks like this:

2export default class Trans_Drive {
    /**
     * @param {Trans_Api_Package} pack
     * @param {Trans_Api_Route} route
     */2
    constructor(
        {
            Trans_Api_Package$: pack,
            Trans_Api_Route$: route,
        }
    ) {...}
}

According to the IoC principles, base objects do not create the dependencies they need, but they enable the Object Container to create and implement these dependencies. Since the description of the required dependencies occurs at the plugin level (namespace Trans_), then, as I noted above, the interface names (prefix) act as dependency identifiers Trans_Api_).

Implementation

An application must implement the corresponding interfaces in order to use the trans plugin.

In JS code this can be expressed as follows:

/** @implements Trans_Api_Package */
export default class Client1_Di_Package {
    /** @return {{length: number, width: number, height: number}} */
    getSize() {
        return {length: 150, width: 50, height: 50};
    }

    /** @return {number} */
    getWeight() {
        return 50;
    }
}

These parameters correspond to the bag with the daughter of the boss of the Chinese mafia, voiced in the first film: weight – 50 kg, size – one and a half meters by half a meter.

Object Container Configuration

Every application that uses @teqfw/dithe first thing you need to do is configure the Object Container. To begin, specify the name resolution rules:

import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import Container from '@teqfw/di';

const url = new URL(import.meta.url);
const script = fileURLToPath(url);
const current = dirname(script);
const scope = join(current, 'node_modules', '@flancer64');
const container = new Container();
const resolver = container.getResolver();
resolver.addNamespaceRoot('Client1_', join(current, 'src'));
resolver.addNamespaceRoot('Trans_', join(scope, 'demo-di-if-plugin', 'src'));

And then specify the rules for converting interface names into the names of the corresponding implementations:

/**
 * The preprocessor chunk to replace interfaces with the implementations in this app.
 * @implements TeqFw_Di_Api_Container_PreProcessor_Chunk
 */
const replaceChunk = {
    modify(depId, originalId, stack) {
        // FUNCS
        /**
         * @param {TeqFw_Di_DepId} id - structured data about interface
         * @param {string} nsImpl - the namespace for the implementation
         */
        function replace(id, nsImpl) {
            id.moduleName = nsImpl;
            return id;
        }

        // MAIN
        switch (originalId.moduleName) {
            case 'Trans_Api_Package':
                return replace(depId, 'Client1_Di_Package');
            case 'Trans_Api_Route':
                return replace(depId, 'Client1_Di_Route');
        }
        return depId;
    }
};

container.getPreProcessor().addChunk(replaceChunk);

Container of objects in @teqfw/di has the ability to connect a chain of handlers to the preprocessor. Each handler must implement the interface TeqFw_Di_Api_Container_PreProcessor_Chunk and can modify the structure of a dependency identifier before the corresponding object is created:

container.getPreProcessor().addChunk(new Replace());

In our case, the easiest way is to link each interface to the corresponding implementation directly via switch. But in other applications the mapping may be more “curly” (e.g. via external JSON/YAML/XML or via mapping directory structures in the plugin and application: id.moduleName.replace('Trans_Api_', 'Client1_Di_')).

Total

Of course, the code in the plugin depends on the interface.

Of course, the code in the plugin depends on the interface.

The code in the plugin at the time of writing, of course, depends on the interface – the plugin simply knows nothing about other applications and their implementations of its interfaces. But the applications themselves (or rather, their developers) know. And this knowledge allows you to configure the Object Container in the application in such a way that at runtime, the corresponding implementations are used instead of interfaces.

Using plugin-level interfaces reduces code coupling between npm packages and increases the reusability of npm packages across applications.

By the way, it is not necessary to do this using IoC – JSDoc annotations are just as good for navigating through code when “manual” linking objects using regular static import'ov (early binding). But late binding of objects at runtime using the Container gives the developer more room to maneuver due to pre- and especially post-processing of the created and injected dependencies.

Conclusion

  • Container of Objects in @teqfw/di allows you to modify a dependency identifier before creating the corresponding object (chain of handlers in the preprocessor).

  • A plugin used in an application (or by other plugins) declares classes without implementing methods and marks them with JSDoc annotations @interface .

  • Interface classes are essentially documentation and should not normally generate runtime objects.

  • The code inside the plugin itself is tied to interfaces via JSDoc annotations, which allows you to use autocomplete in the IDE.

  • The application (or other plugins) implements the corresponding interface and marks the implementation with a JSDoc annotation @implements to be able to navigate through code in the IDE.

  • The application initializes the Object Container at startup and configures the replacement of interfaces with their implementations at runtime, taking into account all plugins used in the application.

Source code of the demo plugin and applications:

Notes

  1. Personally, I consider “The Transporter” a trilogy, if only because Ed Skrein is not Jason Statham at all.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *