Fully qualified names vs a jungle of imports

Gajus Kuizinas - Sep 11 '22 - - Dev Community

Originally published on Contra

Open any Node.js project and any deeply nested file within that project and the first thing you will see is a variation of:

import { configureScope } from '@sentry/node';
import { serializeError } from 'serialize-error';
import { type CommonQueryMethods, sql } from 'slonik';
import { z } from 'zod';
import { Logger } from '../../Logger';
import { type CustomerIOService } from '../services';
import { type SendPlainEmailOptions } from '../types';
import { fetchUserSettings } from './fetchUserSettings';
Enter fullscreen mode Exit fullscreen mode

What you see is a a ton of imports from various paths across the application.
Honestly, I don't know why we are we doing it that way. Being explicit is nice, but it comes at the cost of constant guess game when trying to figure out how many ../ to add to your import path, and in general, the need to remember where everything lives.
In TypeScript, we can diminish the guess game by using path mapping. A common configuration is going to be "paths":{"@/*":["*"]} which now allows to import everything using absolute paths. This helps to rewrite the above code to something along the lines of:

import { configureScope } from '@sentry/node';
import { serializeError } from 'serialize-error';
import { type CommonQueryMethods, sql } from 'slonik';
import { z } from 'zod';
import { Logger } from '@/Logger';
import { type CustomerIOService } from '@/customer/services';
import { type SendPlainEmailOptions } from '@/customer/types';
import { fetchUserSettings } from '@/customer/routines/fetchUserSettings';
Enter fullscreen mode Exit fullscreen mode

I would argue that the above is already easier to understand because we have the entire context to know what is being imported just by looking at the code. Whereas, with the relative paths, we need to do mental gymnastics to compute what the relative paths resolve to. However, this pattern still suffers from the need to remember where everything lives. Why? What good does it do? Perhaps there is a better way…

Using fully qualified names

Wouldn't it be easier if we could just import all project dependencies from a single file?

import {
  configureScope,
  serializeError,
  type CommonQueryMethods,
  sql,
  z,
  Logger,
  type CustomerIOService,
  type SendPlainEmailOptions,
  fetchUserSettings
} from '@/index';
Enter fullscreen mode Exit fullscreen mode

The benefit of this approach is that we never need to remember the path of something in order to import it – we just need to know what we want to import. This also makes it helluva easier to stub dependencies and monkey patch dependencies when dealing with bugs.

The downside is that it requires that every method and variable in the codebase that has a public interface has to have a unique name (thus the fully qualified). Despite this not being the norm, I would definitely argue that this is a benefit because it forces to pick descriptive function names, and when you cannot, it probably signals a code smell.

I cannot see a good reason why we wouldn't be doing this.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .