DEV Community

Cover image for Modules, Dependencies, and the npm Ecosystem (The Messy Part)
Gabriel Anhaia
Gabriel Anhaia

Posted on

Modules, Dependencies, and the npm Ecosystem (The Messy Part)


If you thought Gradle was complicated, wait until you see the JavaScript module system. The good news: in 2026, it's finally calming down.

This is the post in the series I've been dreading the most. Not because modules are hard conceptually. They aren't. Java has packages. PHP has namespaces and Composer autoloading. C# has assemblies. You already understand the idea of splitting code into units and importing what you need. The problem is that JavaScript had no module system for its first 15 years, and then got several competing ones at once. The fallout from that chaos is still visible everywhere.

But if you're starting TypeScript in 2026, you mostly don't need to care about the historical mess. There's one module system that won (ESM), one package manager you'll probably use (npm, though pnpm is gaining ground), and one set of tsconfig settings that work. I'll point out where the old weirdness leaks through, but we're going to focus on the modern setup.

ESM vs CJS: The Quick Version

JavaScript originally ran only in browsers and had no import statement. Every script shared a global scope. Then Node.js came along in 2009 and needed a module system, so it invented one called CommonJS (CJS):

// CommonJS — the Node.js way (before 2020-ish)
const express = require("express");
const { readFile } = require("fs");

module.exports = { myFunction };
Enter fullscreen mode Exit fullscreen mode

Meanwhile, the language spec committee designed an official module system called ES Modules (ESM):

// ESM — the standard way
import express from "express";
import { readFile } from "fs";

export { myFunction };
Enter fullscreen mode Exit fullscreen mode

For years, both existed in parallel. Libraries shipped in CJS, or ESM, or both. Node.js slowly added ESM support. Bundlers like webpack papered over the differences. It was a mess.

As of 2026, ESM won. Node.js fully supports it. New libraries ship ESM-first. TypeScript 6.0 dropped support for AMD, UMD, and SystemJS (the other contenders that briefly existed). You'll still run into CJS in older packages and legacy codebases, but for anything new, you're writing ESM.

If you're coming from Java, think of it this way: import in TypeScript is like import in Java, but with one key difference. Java modules are package-based. You import com.example.users.UserService and the compiler figures out where the file lives based on the package structure. JavaScript modules are file-based. You import from a specific file path or package name. There's no automatic mapping from namespace to directory.

// Java: package-based, compiler finds the file
import com.example.users.UserService;

// TypeScript: file-based, you specify the path
import { UserService } from "./services/user-service.js";
Enter fullscreen mode Exit fullscreen mode

Yes, you need the .js extension even though you're writing .ts files. More on that in the module resolution section.

import and export Syntax

ESM gives you two kinds of exports: named and default.

Named exports (you can have as many as you want per file):

// services/user-service.ts
export interface User {
  id: string;
  email: string;
}

export function createUser(email: string): User {
  return { id: crypto.randomUUID(), email };
}

export const MAX_USERS = 10_000;
Enter fullscreen mode Exit fullscreen mode
// somewhere else
import { User, createUser, MAX_USERS } from "./services/user-service.js";
Enter fullscreen mode Exit fullscreen mode

Default exports (one per file):

// services/email-service.ts
export default class EmailService {
  send(to: string, body: string): void {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode
// somewhere else — you pick the name on import
import EmailService from "./services/email-service.js";
import WhateverIWant from "./services/email-service.js"; // also valid
Enter fullscreen mode Exit fullscreen mode

Default exports are controversial, and I've come around to the "avoid them" camp. Since the importer picks the name, your IDE's auto-import can't reliably suggest the right one. Refactoring tools struggle too. Named exports give you a single canonical name that works with search, auto-imports, and find-all-references. Coming from PHP, where every class has one namespace and one class name, named exports feel natural.

Re-exports let you create barrel files that aggregate exports from multiple modules:

// services/index.ts
export { createUser, MAX_USERS } from "./user-service.js";
export { sendEmail } from "./email-service.js";
export type { User } from "./user-service.js"; // type-only re-export
Enter fullscreen mode Exit fullscreen mode
// now consumers can do
import { createUser, sendEmail } from "./services/index.js";
Enter fullscreen mode Exit fullscreen mode

This is similar to how you might organize a Java package with a facade, but it's manual. TypeScript won't auto-export everything in a directory.

One thing that tripped me up: the export type syntax. When you're re-exporting something that's only a type (an interface, a type alias), use export type. This tells the compiler it can safely erase the import at build time since there's no runtime value involved.

package.json Explained for Backend Devs

If you're coming from Maven, package.json is your pom.xml. From PHP, it's your composer.json. From Gradle, it's your build.gradle. It's the file that defines your project, its dependencies, and how to run things.

Here's a realistic one:

{
  "name": "my-api-service",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "test": "vitest run",
    "lint": "eslint src/"
  },
  "dependencies": {
    "express": "^5.1.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "typescript": "^6.0.2",
    "@types/express": "^5.0.2",
    "tsx": "^4.20.0",
    "vitest": "^3.2.0",
    "eslint": "^9.20.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

The fields that matter:

name and version: same as Maven's groupId/artifactId and version. If you're publishing a library, these define how others install it. If it's a private app, they barely matter.

"type": "module": this is the one that'll trip you up. This field tells Node.js that .js files in this project use ESM (import/export) rather than CJS (require/module.exports). If you don't set this, Node.js defaults to CJS mode, and your compiled TypeScript that uses import statements will crash at runtime with an error about import not being recognized.

Coming from Composer or Maven, this feels weird. There's no equivalent of "tell the runtime which module format you're using" in Java. It just... knows. In Node.js, the runtime needs this hint because CJS and ESM are fundamentally different evaluation models, not just syntax differences.

Set "type": "module" in every new project. There's almost no reason not to in 2026.

scripts: these are like the scripts section in composer.json or custom Gradle tasks. You run them with npm run <name>. The build, start, and test scripts are conventional and most projects have them.

dependencies: packages your code needs at runtime. The caret (^) means "compatible with this version" and allows minor and patch updates. Similar to Composer's ^ constraint.

devDependencies: packages you only need during development (the compiler, test framework, linter). In production deployments, you can skip these with npm install --production. This is the same split as Maven's <scope>provided</scope> or Composer's require-dev.

npm Basics

If you've used Composer or Maven, npm's workflow will feel familiar. The commands are different, the concepts are the same.

# Install all dependencies from package.json (like "composer install")
npm install

# Add a runtime dependency (like "composer require express")
npm install express

# Add a dev dependency (like "composer require --dev phpunit/phpunit")
npm install -D typescript

# Remove a package
npm uninstall express

# Run a script defined in package.json
npm run build
Enter fullscreen mode Exit fullscreen mode

node_modules is where dependencies get installed. It's equivalent to PHP's vendor/ directory or Maven's ~/.m2/repository, but it lives inside your project, and it's enormous. A medium-sized project can have 300MB of files and 50,000+ directories in there. This is normal. Don't try to fix it. Don't commit it. Add it to .gitignore and move on.

package-lock.json is your lockfile, equivalent to Composer's composer.lock or Gradle's dependency locks. It pins exact versions of every direct and transitive dependency. Commit it. Every team member and CI server needs to install the same versions. Running npm install with a lockfile present respects the pinned versions. Without it, you get whatever matches the version ranges in package.json, which can differ between machines.

One gotcha: unlike Maven's ~/.m2/repository, npm has no global cache by default. Each project gets its own node_modules. Ten projects using express means ten copies. This is one reason people switch to pnpm, which uses a global content-addressed store and symlinks into node_modules. Same API as npm, much less disk usage.

TypeScript Module Resolution

This section confused me for longer than I'd like to admit.

When you write import { something } from "./utils.js", TypeScript needs to figure out which actual file that refers to. The --moduleResolution option controls this. In TypeScript 6.0, the old --moduleResolution node is deprecated. You have two choices:

nodenext: use this for Node.js applications and libraries.

// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "./dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

With nodenext, TypeScript follows Node.js's actual module resolution algorithm. This means:

  • You must include file extensions in relative imports: import { foo } from "./utils.js" (not "./utils")
  • Yes, you write .js even though the source file is .ts. TypeScript knows to look for utils.ts during compilation, and the .js extension will be correct in the compiled output
  • It respects the exports field in package.json for third-party packages
  • It distinguishes between ESM and CJS based on file extension (.mts/.cts) and package.json "type" field

The .js extension requirement makes every backend dev go "wait, what?" You're importing a file that doesn't exist yet. It's confusing but consistent: your import paths match what will exist at runtime.

bundler: use this when a bundler (Vite, webpack, esbuild) processes your code before it runs.

// tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "outDir": "./dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

With bundler, TypeScript is more relaxed:

  • File extensions in imports are optional: import { foo } from "./utils" works
  • It still resolves node_modules correctly
  • It doesn't enforce the strict ESM/CJS boundary checks

Use bundler for frontend projects or anything that goes through a build tool before running. Use nodenext for Node.js servers, CLI tools, and libraries.

Here's a decision table:

Project type module moduleResolution
Node.js server/API "nodenext" "nodenext"
npm library "nodenext" "nodenext"
Frontend (Vite/webpack) "esnext" "bundler"
Monorepo package depends on consumer match module

Subpath Imports with #/

TypeScript 6.0 added support for Node.js subpath imports using the # prefix. This gives you clean import aliases without configuring paths in tsconfig, which was always a brittle setup that required matching path mappings in your bundler too.

Here's how to set it up. In your package.json:

{
  "name": "my-api",
  "type": "module",
  "imports": {
    "#/services/*": "./src/services/*",
    "#/models/*": "./src/models/*",
    "#/utils/*": "./src/utils/*"
  }
}
Enter fullscreen mode Exit fullscreen mode

Now you can use these aliases anywhere in your codebase:

// instead of this relative path nightmare
import { UserService } from "../../../services/user-service.js";
import { User } from "../../models/user.js";

// you write this
import { UserService } from "#/services/user-service.js";
import { User } from "#/models/user.js";
Enter fullscreen mode Exit fullscreen mode

This is a Node.js feature, not a TypeScript invention. The imports field in package.json is part of the Node.js module resolution spec, and TypeScript 6.0 understands it natively when you use moduleResolution: "nodenext".

Coming from PHP with PSR-4 autoloading, this feels like finally getting the namespace-to-directory mapping that's been missing. In Composer, you'd set "App\\": "src/" and imports just work. The #/ prefix achieves something similar.

The old approach was configuring paths in tsconfig.json:

// the OLD way — still works but don't use it for new projects
{
  "compilerOptions": {
    "paths": {
      "@services/*": ["./src/services/*"],
      "@models/*": ["./src/models/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem with paths was that TypeScript didn't rewrite the import paths in compiled output. You needed a separate tool (like tsc-alias or a bundler) to make the paths work at runtime. The imports field in package.json works at both compile time and runtime without extra tooling.

Dealing with Untyped Packages

This is one of the most annoying parts of working with TypeScript, and there's no equivalent in Java or C#.

When you install a package from npm, it might not include type definitions. If it doesn't, TypeScript can't tell you what functions exist or what they return. Your code still works, but you lose the type safety that's the whole point.

The community solved this with DefinitelyTyped, a repository of type definitions for popular packages. These get published as @types/* packages:

# express doesn't ship its own types (though this may change)
npm install express
npm install -D @types/express

# lodash also needs separate types
npm install lodash
npm install -D @types/lodash
Enter fullscreen mode Exit fullscreen mode

How do you know if you need @types/*? Usually your editor will tell you. When you import a package and see a red squiggly with "Could not find a declaration file for module 'whatever'", that's your cue to check if @types/whatever exists.

Many modern packages ship their own types. You'll see a types or typings field in their package.json pointing to a .d.ts file. Packages like zod, drizzle-orm, and prisma are fully typed out of the box. The trend is moving toward packages shipping their own types, so @types/* is becoming less necessary over time.

For packages that have no types at all (no bundled types, no @types/* package), you can write a quick declaration yourself:

// src/types/obscure-lib.d.ts
declare module "obscure-lib" {
  export function doSomething(input: string): Promise<void>;
  export interface Config {
    timeout: number;
    retries: number;
  }
}
Enter fullscreen mode Exit fullscreen mode

This tells TypeScript "trust me, this module exists and has these exports." You don't need to type the entire package, just the parts you actually use. It's tedious but manageable.

If you just want to unblock yourself and don't care about the types:

// the nuclear option — disables all type checking for this module
declare module "obscure-lib";
Enter fullscreen mode Exit fullscreen mode

Now every import from obscure-lib will be typed as any. You lose type safety, but your code compiles. I've used this for prototyping and regretted it every time.

Declaration Files (.d.ts)

Declaration files are TypeScript's equivalent of C header files. They describe the shape of code without the implementation.

When you run tsc with "declaration": true, the compiler generates .d.ts files alongside your .js output:

// src/user.ts (what you write)
export interface User {
  id: string;
  email: string;
}

export function createUser(email: string): User {
  return { id: crypto.randomUUID(), email };
}
Enter fullscreen mode Exit fullscreen mode
// dist/user.d.ts (what tsc generates)
export interface User {
  id: string;
  email: string;
}

export declare function createUser(email: string): User;
Enter fullscreen mode Exit fullscreen mode

The .d.ts file has the type information; the .js file has the runtime code. Consumers of your library import the .js file and TypeScript uses the matching .d.ts for type checking.

When would you write .d.ts files manually? Two scenarios:

  1. Adding types for an untyped package (the declare module approach from the previous section)
  2. Typing non-TypeScript code in your project, like a config file that's actually plain JavaScript, or a JSON module you're importing

For everything else, let the compiler generate them. If you're publishing an npm package, set "declaration": true and point to your declarations with "types": "./dist/index.d.ts" in your package.json.

Putting It All Together

Here's what a clean TypeScript 6.0 Node.js project looks like, from the config perspective:

// package.json
{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "imports": {
    "#/*": "./src/*"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts"
  },
  "dependencies": {
    "fastify": "^5.3.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "typescript": "^6.0.2",
    "tsx": "^4.20.0",
    "vitest": "^3.2.0"
  }
}
Enter fullscreen mode Exit fullscreen mode
// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2025",
    "outDir": "./dist",
    "declaration": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}
Enter fullscreen mode Exit fullscreen mode
// src/index.ts
import { createApp } from "#/app.js";

const app = await createApp();
await app.listen({ port: 3000 });
console.log("Running on http://localhost:3000");
Enter fullscreen mode Exit fullscreen mode
// src/app.ts
import Fastify from "fastify";
import { userRoutes } from "#/routes/users.js";

export async function createApp() {
  const app = Fastify({ logger: true });
  await app.register(userRoutes, { prefix: "/users" });
  return app;
}
Enter fullscreen mode Exit fullscreen mode

Clean imports, no relative path gymnastics, modern module resolution, everything typed. It took the ecosystem fifteen years to get here, but it actually works now.

What's Coming Next

The module system was the roughest part of the learning curve for me. If you got through this post, the rest gets easier from here.

In Post 6, we'll cover async patterns and error handling: async/await, Promise, and why TypeScript doesn't have checked exceptions (and what you can do instead). If you're coming from Java's exception hierarchy or C#'s Task<T>, there are some interesting differences in how the TypeScript world approaches failures.

What tripped you up the most with JS modules? For me it was the .js extension in TypeScript imports -- I stared at that red squiggly for twenty minutes before I accepted it was correct. Drop your "wait, what?" moment in the comments.

I'm building Hermes IDE, an open-source AI-powered dev tool built with TypeScript and Rust. If you want to see these patterns in a real codebase, check it out on GitHub. A star helps a lot. You can follow my work at gabrielanhaia.

Top comments (0)