18.7 TypeScript Namespaces (Modules): Legacy and Ambient Use
Right, so we need to talk about TypeScript namespaces. I know, I know. You’re probably thinking, “Wait, didn’t we just cover modern ES modules? Why are we going backwards?” Because, my friend, the real world is a messy place filled with legacy code, and you will encounter these. They are TypeScript’s original, pre-ES6 module system, a pattern we now call “namespace modules.” Think of them as a verbose, clunky precursor to import/export that lives entirely in the global scope. They’re like that old, heavy piece of furniture in your codebase that’s too much of a hassle to move, so you just keep building around it.
We use them today for two main reasons: wrangling that old, global-scope-polluting JavaScript library you have to work with, or for creating complex type definition files (d.ts) for ambient declarations. Let’s get our hands dirty.
The Anatomy of a Namespace
At its heart, a namespace is just a named object in the global scope. You define one using the namespace keyword (or its older, identical alias module—yes, that’s confusing, just use namespace).
namespace MyUtility {
export function log(msg: string) {
console.log(`LOG: ${msg}`);
}
export const version = '1.0';
// This function is NOT exported, so it's private to the namespace
function internalHelper() {
// ... do internal stuff
}
}
See the magic word? export. Within a namespace, nothing is visible to the outside world unless you explicitly export it. It’s a way to create a public API for your global object. To use it, you just… reach into the global scope.
MyUtility.log("Hello from the global scope!");
console.log(MyUtility.version); // '1.0'
This is both the strength and the profound weakness of namespaces. It’s simple, but it means your module’s existence is a side effect, and you can easily get naming collisions if two libraries both decide to create a MyUtility namespace. Fun times.
The Multi-File Split and Reference Magic
Here’s where the designers made a… questionable choice. They allowed you to split a single namespace across multiple files using triple-slash directives. This is some serious pre-bundler black magic.
File: utils/stringHelpers.ts
/// <reference path="./numberHelpers.ts" />
namespace MyUtility {
export function reverse(s: string): string {
return s.split('').reverse().join('');
}
// You can use something from another file that's part of the same namespace
export function toCurrencyString(num: number): string {
return `$${MyUtility.formatNumber(num)}`;
}
}
File: utils/numberHelpers.ts
// This reference isn't strictly needed here, but it ensures the files are ordered correctly
namespace MyUtility {
export function formatNumber(num: number): number {
return parseFloat(num.toFixed(2));
}
}
To make this all work, you’d typically have a “bootstrapping” file that references all the parts:
File: app.ts
/// <reference path="./utils/numberHelpers.ts" />
/// <reference path="./utils/stringHelpers.ts" />
MyUtility.reverse('abc'); // 'cba'
MyUtility.toCurrencyString(12.3567); // '$12.36'
You then have to compile all these files together into a single output, often using the --outFile compiler flag: tsc --outFile app.js app.ts. This whole process feels like assembling a contraption with duct tape and hope compared to the clean, file-based semantics of ES modules. It’s a build-time fiction that creates a runtime global.
The Modern Use: Ambient Declarations and Declaration Merging
This is where namespaces actually earn their keep in 2024. When you’re writing type definitions for a old-school JavaScript library that attaches itself to the global scope (like window.$ for jQuery), namespaces are the perfect tool.
// This is an ambient declaration file (e.g., jquery.d.ts)
declare namespace $ {
function ajax(url: string, settings?: any): void;
// ... other jQuery methods
}
// And because of declaration merging, you can also describe jQuery's prototype
declare namespace $ {
interface JQuery {
hide(): JQuery;
show(): JQuery;
css(propertyName: string, value: string): JQuery;
}
}
This brilliantly uses declaration merging to stitch together the complete type definition for the global $ object from multiple declare namespace blocks. It’s the one place where this global-scope-polluting pattern is not just acceptable but the correct solution.
The Pitfall: Never Mix Them with Modern Imports
Here’s the critical rule: Never, ever import something into a namespace-based file if you plan to use --outFile. The moment you write an ES module import or export, that file becomes a module. The TypeScript compiler will now treat it as a separate file with its own scope, utterly breaking the cross-file namespace magic. The compiler will rightfully yell at you, and your build will fall apart. Choose a paradigm: either go all-in on the old-world global namespaces (with /// <reference and --outFile) or live in the modern world of ES modules. Don’t try to have a foot in both camps; it leads to madness and runtime errors.