How to resolve circular dependencies in typescript
Asked Answered
A

2

5

Setup:

Imagine the following setup:

There is an api that contains let's say a folder foo and bar. These folders export all their public stuff to their local index.ts which will just re-export the public stuff via export * from [...] to make it more convenient.

In my example, there is a circular dependency, because foo.ts requires a part of bar and vice-versa - and I totally understand why this is the case.

See screenshot below:

enter image description here

Question:

How can I resolve this in an environment with hundreds of classes, functions, constants, types, enums, etc. effectively with TypeScript? I imagine that I need some kind of helper file to resolve the commonalities.

Even if I created some kind of foobar folder that requires foo and bar and then exports everything into one big export file it'll probably get messy really soon. What if I need only bar or only foo? Is a named export good enough?

I also want to avoid problems in the future, so I am looking for a robust solution. The call precedence is not the main issue that I try to tackle here. It's more about how to set up the dependencies in a smart way.

Goal:

I'd like to use both foo and bar separately and they should be able to share functions/types/enums/interfaces etc. with each other.

A very simple code snippet can be found here:

codesandbox.io

Amesace answered 9/3, 2022 at 17:20 Comment(0)
D
3

Sorry for the misunderstanding about naming. Unfortunately, I had a chance to see similar names in real apps and somehow wrongly assumed that you also want to use this convention. When it comes to "ending up either with huge files that have everything piled up or super-tiny files". This is a matter of finding a good balance. I don't mind a lot of small files (js modules), that are focused on a single functionality - it is a sign that one has correctly distilled smaller responsibilities from some bigger use case. This produces code that is simpler to understand, test and maintain. The big files (js modules) or big classes/functions are often a sign that SRP is broken. Regarding sandbox.io example - I can't wrap my head around it and don't understand the intentions behind hello and world functions. They are just simple functions that recursively call each other (causing stack overflow). The simplest refactor would be to just use a shared function like e.g. buildGreeting(msg1, msg2) placed in foobar directory. Export const world = 'world' from foo directory, and const hello = 'hello' from bar directory, then in some other sibling directory create a module with a call like:


import {hello} from '../foo'
import {word} from '../bar'

buildGreeting(hello, word);

However, it is challenging to illustrate any meaningful improvement over this example code, because it does not illustrate any real use case.

Daviddavida answered 12/3, 2022 at 19:14 Comment(1)
You are right. It is challenging and my question is not explicit enough. I still kind of lack the experience here, so it feels hard to describe, too. I could resolve some circular dependencies by removing them from the modules index.ts files and directly importing them with a fully-qualified-path import. I think there is also an issue with re-exporting stuff from another module inside of a module's index.ts. I still gotta find out what causes all these problems.Pennipennie
D
6

Your example seems to be quite generic, so I try to describe some general rules of thumbs, but they may be somehow opinionated.

  1. If there is a circular dependency between foo and bar the simplest approach to eliminate it is to extract all circularly dependent units into a separate module/directory e.g. foobar. You mentioned this solution, but the direction of dependencies should be different. Both foo and bar should require foobar that provides the extracted shared code. This way you can still import separately foo and bar (transiently importing foobar).
  2. This applies also to every functions/types/enums/interfaces you mentioned - if something is used by both foo and bar then it should be placed in a separate module/directory.
  3. Regarding names like functions/types/enums/interfaces - I would consider using such names as a last resort (e.g. as names for leaves directories in directories tree), because they communicate information that most often is irrelevant. A much better approach is to organize (colocate) code around business domain concepts instead of names related to implementation details. This increases code discoverability and makes code organization less fragile. e.g. instead of
<sourcesRoot>/api/functions/getUser.ts
<sourcesRoot>/api/functions/getPosts.ts
<sourcesRoot>/api/enums/UserRole.ts
<sourcesRoot>/api/types/User.ts
<sourcesRoot>/api/types/Post.ts
<sourcesRoot>/api/index.ts

... consider using:

<sourcesRoot>/users/api/getUser.ts
<sourcesRoot>/users/model/User.ts
<sourcesRoot>/users/model/UserRole.ts
<sourcesRoot>/users/index.ts

<sourcesRoot>/posts/api/getPosts.ts
<sourcesRoot>/posts/model/Post.ts
<sourcesRoot>/posts/index.ts
  1. Keep foo and bar small. Try to distill some sub-domains/areas/contexts from foo or bar into separate directories. This should generate smaller index.ts files.
  2. This one may be opinionated and unpopular... consider avoiding barrel files. In my opinion barrel file works nicely if you publish a library. In such a case you can specify precisely public members (assuming CommonJS module). However, barrel files on lower levels in the directories tree don't provide much value in my opinion. You can still import modules directly (bypassing barrel file). They make refactoring slower because in many cases you need to manually update barrel files. If you forget to update them, they occasionally create a very nasty circular dependency that at the runtime manifests itself in the form of undefined values. You may have imports paths that look cleaner but in practice one very rarely works manually with imports paths. This is a job for the IDE.
Daviddavida answered 9/3, 2022 at 23:11 Comment(2)
Thanks for your effort. The thing is I have to refactor a huge code base and I just tried to generify and simplify as much as possible for the question. But it is really hard to break the whole thing down into a S.O. Question. Regarding 3 there was a misunderstanding I have these data-structures it's not that I want to name it that way :) Regarding the approach with moving it to different files: It will kind of work. But will you not end up either with huge files that have everything piled up or super-tiny files for each interface, enum, type, class etc.?Pennipennie
It would like to know how you would resolve the actual code on sandbox.io though? Could you maybe add some more thoughts about that?Pennipennie
D
3

Sorry for the misunderstanding about naming. Unfortunately, I had a chance to see similar names in real apps and somehow wrongly assumed that you also want to use this convention. When it comes to "ending up either with huge files that have everything piled up or super-tiny files". This is a matter of finding a good balance. I don't mind a lot of small files (js modules), that are focused on a single functionality - it is a sign that one has correctly distilled smaller responsibilities from some bigger use case. This produces code that is simpler to understand, test and maintain. The big files (js modules) or big classes/functions are often a sign that SRP is broken. Regarding sandbox.io example - I can't wrap my head around it and don't understand the intentions behind hello and world functions. They are just simple functions that recursively call each other (causing stack overflow). The simplest refactor would be to just use a shared function like e.g. buildGreeting(msg1, msg2) placed in foobar directory. Export const world = 'world' from foo directory, and const hello = 'hello' from bar directory, then in some other sibling directory create a module with a call like:


import {hello} from '../foo'
import {word} from '../bar'

buildGreeting(hello, word);

However, it is challenging to illustrate any meaningful improvement over this example code, because it does not illustrate any real use case.

Daviddavida answered 12/3, 2022 at 19:14 Comment(1)
You are right. It is challenging and my question is not explicit enough. I still kind of lack the experience here, so it feels hard to describe, too. I could resolve some circular dependencies by removing them from the modules index.ts files and directly importing them with a fully-qualified-path import. I think there is also an issue with re-exporting stuff from another module inside of a module's index.ts. I still gotta find out what causes all these problems.Pennipennie

© 2022 - 2024 — McMap. All rights reserved.