Search code examples
javascripttypescriptcircular-dependencyfactory-pattern

Typescript/Javascript Circular dependency: Class extends value undefined is not a constructor or null


Question

Hello, the following code example throws the error:

TypeError: Super constructor null of SecondChild is not a constructor
    at new SecondChild (<anonymous>:8:19)
    at <anonymous>:49:13
    at dn (<anonymous>:16:5449)

Currently i am trying to understand what exactly the issue is about and how to implement the factory pattern in typescript/javascript. There are several behaviours which i don´t quite understand:

  1. If I remove the second-child of the code everything works fine except if I swap the order of the export statements in the index.ts file of the child and parent export.
  2. If I combine the files child.ts and second-child.ts into one single file which defines both classes the exception is also gone
  • Is the error linked to the circular dependencies? And if so, what exactly is the problem behind circular dependencies in typescript/javascript?
  • Can someone explain the behaviour of the code example to me?

It would be possible to prevent the error if i implemented the parent class without using the type "this" in combination with the factory Method but it seems pretty convenient. The goal behind the abstracted method duplicate is that it returns a subclass instance. I could implement it in every subclass but in my real world project the complexity is much higher.

Project structure:

src/
  child.ts
  factory.ts
  index.ts
  parent.ts
  main.ts
  second-child.ts

File Content

main.ts:

// Throws error
import { Child, Parent, SecondChild  } from './index'


console.log(new Child().duplicate())
console.log(new SecondChild().duplicate())
console.log(new Parent().duplicate())

// Works as intended:
// import { Child, Parent} from './index'


// console.log(new Child().duplicate())
// console.log(new Parent().duplicate())

parent.ts

import { factory } from './factory'

export class Parent {
  isChild = 0;
  duplicate(): this {
    return factory(this.isChild) as unknown as this;
  }
}

child.ts

import {Parent} from './parent'

export class Child extends Parent{
  override isChild = 1;
}

second-child.ts

import {Parent} from './parent'

export class SecondChild extends Parent{
  override isChild = 2;
}

factory.ts

import { Child } from './child'
import { SecondChild } from './second-child'
import { Parent } from './parent'

export function factory(child: number):Child;
export function factory(...args: unknown[]):Parent {
  switch (args[0]) {
    case 1: {
      return new Child()
    }
    case 2:  {
      return new SecondChild()
    }
    default: {
      return new Parent();
    }
  }
}

index.ts

export * from './child'
export * from './second-child'
export * from './parent'
export * from './factory'

Project Description

  • Entry point: main.ts
  • index.ts exports the content of child.ts, factory.ts, parent.ts, second-child.ts

example Playground


Solution

  • Circular dependencies between modules are a complex beast. The order of evaluation depends on the order of the import statements and the entry point, which is very fragile. JS runs the code in a module after its dependencies are met, doing a DFS graph traversal, but it has to ignore dependencies that were already visited and are still waiting for evaluation of their dependencies.

    In your case, that means

    • main.ts imports index.ts
      • index.ts imports child.ts
        • child.ts imports parent.ts
          • parent.ts imports factory.ts
            • factory.ts imports child.ts, but it won't wait for its evaluation (we're already trying to evaluate it)
            • factory.ts imports second-child.ts
              • second-child.ts imports parent.ts, but it won't wait for its evaluation (we're already trying to evaluate it)
              • second-child.ts has no remaining imports, so its code is evaluated. The Parent variable is already set up but still not initialised, throwing an exception.

    In your case, you can fix this by changing the imports in factory.ts to

    import { Child, SecondChild, Parent } from './index';
    

    Now, the traversal looks as follows:

    • main.ts imports index.ts
      • index.ts imports child.ts
        • child.ts imports parent.ts
          • parent.ts imports factory.ts
            • factory.ts imports index.ts, but it won't wait for its evaluation (we're already trying to evaluate it)
            • factory.ts has no remaining imports, so its code is evaluated. It declares the factory function, which - since it is not immediately called - does not attempt to access the uninitialised imported variables
          • parent.ts has no remaining imports, so its code is evaluated and initialises Parent.
        • child.ts has no remaining imports, so its code is evaluated and initialises Child (given Parent is already initialised)
      • index.ts imports second-child.ts
        • second-child.ts imports parent.ts, which already is evaluated
        • second-child.ts has no remaining imports, so its code is evaluated and initialises SecondChild
      • index.ts imports parent.ts, which is already evaluated
      • index.ts imports factory.ts, which is already evaluated
      • index.ts has no remaining imports, so its code is evaluated (which is empty and does nothing)
    • main.ts has no remaining imports, so its code is evaluated which creates a few instances and calls their methods which call the factory() function, whose imports are now initialised

    A tool like dpdm will help you understand this even for large dependency graphs. And it'll urge you to avoid the circular dependencies, they're more trouble than they're worth, if you can easily avoid them.

    In your case I would recommend to implement duplicate without factory but rather by conventional cloning (see here or there), or to implement factory by using a class registry that the classes can register themselves with instead of importing them all into factory.ts.