13

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 __());
    };
})();
Alireza
  • 5,421
  • 5
  • 34
  • 67
  • 2
    If you need a component in several modules you need an extra module to declare and export that component and just import this module where ever you need that component. – Fussel Apr 09 '18 at 07:26
  • Yes you can separate classes into separate .ts files.. you just have to use export key word to export the class. And you can import your class uding import {yourclassname} from 'path' – Anuradha Gunasekara Apr 09 '18 at 07:32
  • 2
    Please post the minimal example that reproduces your particular issue – smnbbrv Apr 09 '18 at 07:47
  • @smnbbrv added warning reproduction code – Alireza Apr 10 '18 at 08:39
  • 1
    you need to think about your structure / logic. Inheritance doesn't make any sense if the base class depends on the classes inheriting it. The point of inheritance is that the base doesn't need to know anything about it's extensions or implementations – bryan60 Apr 10 '18 at 19:24
  • @bryan60 In general I agree with your point. There are however, cases when the problem you're facing forces you to make compromises. The logic I'm implementing is a bit tricky and will evolve over time, so I preferred to keep the relevant parts of code close together. This will greatly facilitate the type of maintenance I expect the code to go through. – Alireza Apr 11 '18 at 14:21
  • 1
    In my opinion these compromises are never necessary and just require better architecture / data modeling. If you expect large maintenance then it’s worth taking the time to do it right and will hurt you in the long run if you don’t. Inheritance can be a useful tool but it’s a major red flag that you’re doing something wrong if the base is tightly coupled with the implementations – bryan60 Apr 11 '18 at 14:28
  • @bryan60 I'd rather use "almost" before "never". Please look at this code: [Expression.cs](https://github.com/dotnet/corefx/blob/master/src/System.Linq.Expressions/src/System/Linq/Expressions/Expression.cs) from the source code of corefx. The base class refers to subclasses. By the way I appreciate the emphasis you put on good design. – Alireza Apr 11 '18 at 15:02

3 Answers3

23

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.

smnbbrv
  • 23,502
  • 9
  • 78
  • 109
  • 4
    wow. This is what I call a perfect SO answer. It explains the mechanism that causes the issue and approaches to solve it. I'll study the answer more deeply and come back later. – Alireza Apr 10 '18 at 20:52
  • 1
    Your answer helped me remove runtime error. I still got the compiler warning, which I fix by declaring classes as suggested [here](https://stackoverflow.com/a/48419531/742775). – Alireza Apr 15 '18 at 11:56
0

The typescript loader should fix the circular dependencies for you. So splitting it up over multiple files should be no problem. (just use imports typescript loader will figure it out, like python).

That said, most of the time this is not a good idea and signals that you probably want to abstract something (make interface and refer to this) or that you have multiple classes that should just be one class.

Joel Harkes
  • 10,975
  • 3
  • 46
  • 65
  • 4
    The problem is not in the Typescript, the problem comes in the runtime, where Typescript is transpiled to javascript. – smnbbrv Apr 09 '18 at 07:50
  • I'd make the same assumption whenever I encounter circular dependencies, but sometimes you really need it in (I do have a dirty solution, but keeping all classes in the same file is IMHO less dirty!) – Alireza Apr 09 '18 at 09:07
0

Okey I'm copy-pasting my answer here from the other thread I posted it to, since I guess it is relevant and the previous answers do not really provide a simple solution to the problem.

By adding a couple new methods to the base class and an additional type property I managed to remove the instanceof checks and therefore removing the circular dependencies (because as said earlier the type definitions are removed when the code is compiled).

Eg

class A {
  constructor() {
    this.type = 'A'
  }
  isInstanceOfA(): this is A {
    return this.type === 'A'
  }
  isInstanceOfB(): this is B {
    return this.type === 'B'
  }
}

class B extends A {
  constructor() {
    super()
    this.type = 'B'
  }
}
TeemuK
  • 2,095
  • 1
  • 18
  • 17