Skip to Content
Course content

155: How TypeScript Models JavaScript Modules

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was onboarding a junior dev who had spent a good three hours scratching their head over a "Cannot redeclare block-scoped variable" error. They had two separate files—user-service.ts and auth-service.ts—and both had a top-level constant named API_URL. They were baffled. In their mind, these were two different files, so they should have two different scopes. But TypeScript was screaming that API_URL had already been defined.

The problem wasn't a bug in the compiler; it was a fundamental misunderstanding of how TypeScript models the JavaScript module system. They were treating their files like isolated silos, but TypeScript was treating them as one giant, global script.

The invisible line between Scripts and Modules

In TypeScript, there is a massive distinction between a "script" and a "module." If a file doesn't contain any import or export statements, TypeScript treats it as a script. This means every variable, function, or interface declared at the top level is dumped into the global namespace. This is why our junior dev was seeing that error; both files were scripts, so they were sharing the same global bucket.

The moment you add a single export {} (even an empty one) or import something from another file, TypeScript flips a switch. That file is now a module. It gets its own isolated scope. Variables declared inside are no longer global; they are private to that module unless explicitly exported. I usually tell people to get into the habit of treating every single file as a module from day one. It saves you from the nightmare of naming collisions as your project grows from five files to five hundred.

// This is a script. 'config' is global.
const config = { port: 8080 };

// This is a module. 'config' is local to this file.
export const config = { port: 8080 };

How TypeScript maps the module graph

When you write import { UserService } from './services/user', TypeScript doesn't just blindly look for a file. It performs a resolution process. It looks for user.ts, then user.tsx, then maybe a user.d.ts. This is where things can get hairy if your tsconfig.json isn't aligned with your runtime.

If you're targeting Node.js, you might be using CommonJS (require), but if you're targeting the browser or modern Node, you're likely using ESM (import/export). TypeScript models both, but the moduleResolution setting in your config tells the compiler how to behave. If you set it to node (or node16/nodenext), TypeScript mimics the way Node.js searches for packages in node_modules. If you've ever seen a "Module not found" error despite the file being right there, it's almost always a mismatch between how you're importing the file and how the resolver is configured to find it.

Modeling external code with Ambient Declarations

Now, not every module is something you wrote. Sometimes you're using a legacy JavaScript library that doesn't have types. TypeScript models these using "ambient declarations"—those .d.ts files. These files don't generate any JavaScript; they are purely a map for the compiler.

When you install @types/lodash, you're essentially giving TypeScript a blueprint of that module's shape. It tells the compiler, "Trust me, when the code runs, a module named 'lodash' will exist, and it will have a function called chunk that takes these specific arguments." This allows TypeScript to model the module's interface without ever needing to see the actual implementation code. It's a layer of abstraction that lets the type system stay decoupled from the actual JS execution.




📋 Practical Task

Fixing the Global Scope Leak in a Config Project

You have been handed a small project with two files that are causing a naming collision because they are being treated as scripts rather than modules. Your task is to isolate their scopes so the project compiles without errors.

File 1: database.ts

const connectionString = "mongodb://localhost:27017";
function connect() {
    console.log("Connecting to " + connectionString);
}

File 2: cache.ts

const connectionString = "redis://localhost:6379";
function connect() {
    console.log("Connecting to " + connectionString);
}

The Goal:

  • Modify both files so that they are treated as modules rather than scripts.
  • Ensure that connectionString and connect can exist in both files without a "Cannot redeclare" error.
  • Export the connect function from both files so they could theoretically be used elsewhere.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.