Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript - Circular dependency on decorator

I get a circular dependency on my decorators because my class ThingA has a relation with ThingB and vice versa. I've read several questions about this issue:

  • Beautiful fix for circular dependecies problem in Javascript / Typescript

  • TypeScript Decorators and Circular Dependencies

But I wasn't able to find a valid solution for my case. I tried as many people suggest to change from @hasOne(ThingA) to @hasOne(() => ThingA) to force a lazy loading and break the dependency, but this solution doesn't work because I'm not able to get the constructor name. I need the name of the constructor (example: 'ThingA') to add it in metadata of constructor ThingB.

Following my original code (without lazyload modification)

ThingA

@hasAtLeast(0, ThingB)
export class ThingA extends RTContent {
    @IsEmail()
    email: string;
}

ThingB

@hasOne(ThingA)
export class ThingB extends RTContent {
    @IsString()
    description: string;
}

Decorators:

export type LinkConstraint<C extends RTContent> = {
    content: string; // name of the constructor (ex. 'ThingA')
    maxOccurrences: number;
    minOccurrences: number;
    constructor: { new(...args: any[]): C };
}

function constraintFactory<C extends RTContent>(minOccurrences: number, maxOccurrences: number, associated: { new(...args: any[]): C }) {
    return (constructor: Function) => {
        const constraints = Reflect.getMetadata('linkConstraints', constructor) || [];
        const constraint: LinkConstraint<C> = {
            content: associated?.name,
            minOccurrences,
            maxOccurrences,
            constructor: associated
        };
        constraints.push(constraint);
        Reflect.defineMetadata('linkConstraints', constraints, constructor)
    }
}

export function hasOne<C extends RTContent>(associated: { new(...args: any[]): C }) {
    return constraintFactory(1, 1, associated)
}

export function hasAtLeast<C extends RTContent>(minOccurrences: number, associated: { new(...args: any[]): C }) {
    return constraintFactory(minOccurrences, Infinity, associated)
}
like image 236
gio Avatar asked Mar 22 '26 16:03

gio


1 Answers

I see that your decorator doesn't actually modify the constructor, it merely runs some side-effect code to add some metadata entry. Thus the @decorator syntax isn't a must.

My advice is that you decorate neither ThingA nor ThingB, just export them as-is. You defer the decoration in another module, which should be the common parent of both ThingA and ThingB. This way circular dependency is resolved.

For example, in './things/index.ts' you do:

import { ThingA } from './things/ThingA';
import { ThingB } from './things/ThingB';

hasOne(ThingA)(ThingB);
hasAtLeast(0, ThingB)(ThingA);

export { ThingA, ThingB }

Now other part of your code can import from './things/index.ts', instead of directly from './things/ThingA(or B).ts'. This would ensure the decoration is executed before instantiation of classes.


If you must use decorator, well lazy load is your best bet. @hasOne(() => ThingA) should does the trick, but you need to modify the implementation of hasOne accordingly, a little hack.

function hasOne(target) {
  if (typeof target === "function") {
    setTimeout(() => {
      _workOnTarget(target())
    }, 0)
  } else {
    _workOnTarget(target)
  }
}

The key is to delay accessing variable value.

For this hack to work, we still rely on the fact that these decorators are side-effect only, don’t modify the constructor. So this is NOT a general solution to circular dependency problem. More general pattern is off course lazy evaluation. More complicated though, if you really need, pls ask in comments.

For your case, above impl should work. But you must not instantiate ThingA or B right inside any module’s top level, cuz that would happen before setTimeout callback, thus break the hack.

like image 180
hackape Avatar answered Mar 24 '26 05:03

hackape