Fully qualified names vs a jungle of imports

Gajus Kuizinas

Node.js

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';

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';

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';

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.

What about collisions?

One question I was asked immediately after posting this was: What about collisions?

chances are you have dependencies with colliding exports within your project

Answer: This is a non-issue because you can (and you should) re-alias the exports.

The @/index file example I've used would look something like this:

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

As you are re-exporting everything that is consumed within the project, this gives you a single place to manage naming conflicts as well giving more descriptive names to what could be otherwise generic exports.

2022

Partner With Gajus
View Services

More Projects by Gajus