Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to move typescript classes with circular dependency into separate files

In an angular project, we have a base class, from which several concrete classes are derived. In few occasions, these classes refer to each other, in a circular way.

When these files are in different classes, the project compiles successfully but fails to run in the browser.

When all classes are taken to a single .ts file everything works fine, but we have a very long, hard to maintain file.

Is there a way to separate these files? Even an existing pre-compile task that merges the files (in a smart way) will be appreciated.

EDIT: Im' familiar with imports, modules, etc. The problem is circular dependency. Also it can't elegantly be handled by more abstraction. I try to add a minimal reproduction code.

EDIT: Here's a reproduction, creating a warning: It also shows the reason why I'm having the circular dependency to begin with.

base-class.ts:

import {StorageModel} from './storage-model';
import {SubClass1} from './sub-class-1';

export abstract class BaseClass {
  children: BaseClass[];

  abstract loadFromModel(model: StorageModel);

  doSomething() {
    if (this.children[0] instanceof SubClass1) {
      (this.children[0] as SubClass1).action1();
    }
  }
}

sub-class-1.ts:

import {BaseClass} from './base-class';
import {StorageModel} from './storage-model';
import {SubClass2} from './sub-class-2';

export class SubClass1 extends BaseClass {
  title: string = 'hello';
  action1() {
  }

  loadFromModel(model: StorageModel) {
     (this.children[0] as SubClass2).action2();
  }
}

and a similar `sub-class-2.ts. I get the following warning in angular cli console:

WARNING in Circular dependency detected:
src/models/sub-class-1.ts -> src/models/base-class.ts -> src/models/sub-class-1.ts

and in browser console:

[WDS] Warnings while compiling.

Circular dependency detected:
src/models/base-class.ts -> src/models/sub-class-1.ts -> src/models/base-class.ts

That said, the code is working. I could move the load fromModel method to a visitor class (which I actually have in the project) but then I would not benefit from overloading and locality (the load logic is physically close to all the related code).

However the original code-base creates the following error:

Uncaught TypeError: Object prototype may only be an Object or null: undefined
at setPrototypeOf (<anonymous>)
at __extends (navigation-list-filter-node.ts:7)

__extends function is not my code:

var __extends = (this && this.__extends) || (function () {
    var extendStatics = Object.setPrototypeOf ||
        ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
       function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
    return function (d, b) {
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = 
b.prototype, new __());
    };
})();
like image 620
Alireza Avatar asked Apr 09 '18 07:04

Alireza


1 Answers

Problem

The problem is that Webpack, that is internally used by Angular CLI, has strict rules about imports by its architecture. The order of the imports is important, and if the final JavaScript code has a circular dependency (module 1 imports module 2 which imports module 1) then the import mechanism is broken.

It's interesting that not every time you import some circular dependency it will become a problem. Circular dependency is a runtime JavaScript problem, not the TypeScript's problem. So, the main criteria is: do not let the imported circular dependency come through to JavaScript. That means: use them as types, but don't compare to them, don't call them, don't pass them as functions arguments, etc.

Valid usage of circular dependency (doesn't lead to an error):

const a: SubClass1 = this; // type casting -> TS only
const b = <SubClass1>this; // type casting -> TS only

Why doesn't this lead to a JavaScript circular dependency? Because after TypeScript gets compiled this SubClass1 will disappear: JavaScript does not have a type declaration.

Invalid usage (leads to circular dependency error):

const c = instanceof SubClass1 // passing as argument -> JS
const d = new SubClass1() // calling -> JS
const e = SubClass1 // saving as value -> JS

And now coming back to the original problem:

doSomething() {
  if (this.children[0] instanceof SubClass1) {
    (this.children[0] as SubClass1).action1();
  }
}

To be precise, it is only at this line if (this.children[0] instanceof SubClass1) { because the line below where you cast the type will be cut off after typescript compilation. The instanceof in a base class is a typical reason for a circular dependency and there are multiple ways to resolve it. Mostly, you need to get rid of instanceof in favour of something else.

Solution 1. Add a qualifier property on each subclass and force it to be implemented:

export abstract class BaseClass {

  abstract get qualifier(): string;

  doSomething() {
    if (this.qualifier === 'SubClass1') { ... }
  }

}

export class SubClass1 extends BaseClass {

  get qualifier() {
    return 'SubClass1';
  }

}

Ugly, yet functionable. Helps in case there are lots of classes and you need to distinguish between all at any moment.

Solution 2. Add a e.g. boolean qualifier, that clearly describes a particular intention (typical example is some group of the classes requires the same feature that is implemented in superclass), and instantiate it with some default value:

export abstract class BaseClass {

  get isDuck() {
    return false; // by default is not a duck
  }

  doSomething() {
    if (this.isDuck) { ... }
  }

}

// this one is a duck
export class SubClass1 extends BaseClass {

  get isDuck() {
    return true;
  }

}

// this one is not a duck. Why the hell should it be a duck? :D
export class SubClass2 extends BaseClass {}

The second solution is a little bit better because the qualifying is not done by a superclass, but by a child class itself, so the child class defines who / what it is in a more granular manner. The only thing that superclass does in this case is deciding based on a functional property what to do.

Solution 3. Simply move the logic away to the children classes; let each of them implement its logic

export abstract class BaseClass {
  children: BaseClass[];

  abstract loadFromModel(model: StorageModel);

  abstract doSomething(): void;
}

export class SubClass1 extends BaseClass {
  action1() {
  }

  doSomething() {
    this.action1();
  }
}

This one is the most trivial, however not always sufficient.

like image 180
smnbbrv Avatar answered Oct 22 '22 22:10

smnbbrv