In the previous lesson, we learn about the standard module system used by TypeScript which also coincides with the standard module system of JavaScript standardized by the ECMAScript.
When the module
compiler-option (or the --module
flag) is set to CommonJS
, the TypeScript compiler converts the ECMAScript module statements such as import
and export
to equivalent require
and exports
statements for Node
. Else, it is left intact so that it can work inside browsers that support native ECMAScript modules.
The benefit of using a module system in a project is that you can split reusable logic and application logic between multiple files. However, this also means your runtime environment must support one of these module systems.
If you want the compiled JavaScript program to run on Node
, you can set the module
compiler-option to CommonJS
. If you want to run the program in a browser environment, you can use ES2015
or ES2020
value. However, you can’t really achieve isomorphic JavaScript that can run both on the Node
and inside a browser at the moment using one of these module systems.
When a standard module system is not required or can’t be implemented but we still want to add some modularity to our project, namespaces are a way to go. Namespaces are a TypeScript feature that compiles to pure JavaScript without require
or import
statements in the output code.
Since they do not use a platform-dependent module system and they compile to vanilla JavaScript that we are used to since the stone age, they are called internal modules. Let’s dive into it.
What are Namespaces?
You might be familiar with namespaces in programming languages like C++
or Java
. A namespace is like a region out of which things can not escape. The namespace
keyword in TypeScript creates such a region.
// a.ts
var _version = "1.0.0";
function getVersion() {
return _version;
}
console.log(_version); // 1.0.0
console.log(getVersion()); // 1.0.0
In the above example, the _version
variable is accessible by everyone which probably should have been only accessible in the getVersion
function. Also, we do not want getVersion
function to be in the global scope since a function with the same name could’ve been added by a third-party library in the global scope. We need to add some encapsulation over these values.
// a.ts
namespace MyLibA {
const _version = "1.0.0";
function getVersion() {
return _version;
}
}
console.log(_version); // ❌ ERROR
console.log(getVersion()); // ❌ ERROR
In the above modification, we have wrapped the application logic code inside the namespace MyLibA
. The namespace { ... }
block is like a prison for the code inside. It can’t escape outside which means it can’t pollute the global scope. Therefore, nobody from the outside can access the values inside.
$ tsc a.ts
a.ts:9:14 - error TS2304: Cannot find name '_version'.
console.log( _version ); // 1.0.0
~~~~~~~~
a.ts:10:14 - error TS2304: Cannot find name 'getVersion'
console.log( getVersion() ); // 1.0.0
~~~~~~~~~~
If we try to compile the program, the TypeScript compiler will not allow it since the _version
and getVersion
values are not defined in the global scope. To get access to them, we need to access them from the namespace.
namespace MyLibA {
const _version = "1.0.0";
export function getVersion() {
return _version;
}
}
console.log(MyLibA._version); // ❌ ERROR
console.log(MyLibA.getVersion()); // 1.0.0
In the above example, we have added export
keyword before the getVersion
function to make it publically accessible from the namespace. However, the _version
value is not exported, so it won’t be accessible on the namespace.
To access a value exported from the namespace, we use <ns>.<value>
expression. The MyLibA.getVersion
returns the getVersion
function since it was exported from the MyLibA
namespace but MyLibA._version
won’t be accessible since it was not exported.
The syntax to export values from a namespace is as simple as putting export
before a declaration whether it is a let
, var
or const
variable declaration or class
, function
or even enum
declaration as illustrated below.
namespace <name> {
const private = 1;
function privateFunc() { ... };
export const public = 2;
export function publicFunc() { ... };
}
So the time for the ultimate reveal. What do namespaces look like in the compiled javascript code? The answer should be obvious. Since we access public values of a namespace using <ns>.<value>
expression, the ns
most probably should be an object.
// a.js
var MyLibA;
(function (MyLibA) {
var _version = "1.0.0";
function getVersion() {
return _version;
}
MyLibA.getVersion = getVersion;
})(MyLibA || (MyLibA = {}));
console.log(MyLibA.getVersion()); // 1.0.0
When we compile the a.ts
program, we get the above output. Every namespace in a TypeScript program produces an empty variable declaration (with the same name as the namespace) and an IIFE in the compile JavaScript.
The IIFE contains the code written inside a namespace, therefore the values do not pollute the global scope since they are scoped to the function. The export
statements inside a namespace are converted to the property assignation statements as shown below.
MyLibA.getVersion = getVersion;
This makes getVersion
value inside the IIFE available on the MyLibA
global object and therefore anyone can access it. In contrast to that, the _version
value isn’t accessible outside the IIFE.
Exporting Types and Namespaces
Like a module, you can also export types from a namespace.
// a.ts
namespace MyLibA {
export interface Person {
name: string;
age: number;
}
export function getPerson(name: string, age: number): Person {
return { name, age };
}
}
const ross: MyLibA.Person = MyLibA.getPerson("Ross", 30);
In the above example, we are exporting the Person
interface from the MyLibA
namespace, therefore we can use MyLibA.Person
in a type annotation expression. However, since MyLibA.Person
is a type, it won’t exist in the compiled JavaScript code.
// a.js
var MyLibA;
(function (MyLibA) {
function getPerson(name, age) {
return { name: name, age: age };
}
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
var ross = MyLibA.getPerson("Ross", 30);
Since a namespace is also a value, you can also export a namespace from within a namespace. These are called nested namespaces.
// a.ts
namespace MyLibA {
export namespace Types {
export interface Person {
name: string;
age: number;
}
}
export namespace Functions {
export function getPerson(name: string, age: number): Types.Person {
return { name, age };
}
}
}
const ross: MyLibA.Types.Person = MyLibA.Functions.getPerson("Ross Geller", 30);
In the above example, the namespace MyLibA
exports two namespaces viz. Types
and Functions
. Namespaces are lexically scoped, therefore the getPerson
function can access Types.Person
from the outer scope.
// a.js
var MyLibA;
(function (MyLibA) {
var Functions;
(function (Functions) {
function getPerson(name, age) {
return { name: name, age: age };
}
Functions.getPerson = getPerson;
})((Functions = MyLibA.Functions || (MyLibA.Functions = {})));
})(MyLibA || (MyLibA = {}));
var ross = MyLibA.Functions.getPerson("Ross Geller", 30);
Aliasing
In the previous example, we can see that the nested namespace can get messy and difficult to use pretty quickly. The MyLibA.Functions.getPerson
syntax is quite a mouthful. To shorten this, we can reference it using a variable.
// a.ts
namespace MyLibA {
export namespace Types {
export interface Person {
name: string;
age: number;
}
}
export namespace Functions {
export function getPerson(name: string, age: number): Types.Person {
return { name, age };
}
}
}
var Person = MyLibA.Types.Person; // ❌ ERROR
var API = MyLibA.Functions;
const ross: Person = API.getPerson("Ross Geller", 30);
In the above example, we have saved MyLibA.Functions
into a constant API which is short, cute, and easy to use. However, the same doesn’t work for Person since Person is declared as a variable but MyLibA.Types.Person
is a type. You could use type Person = MyLibA.Types.Person
to make this work.
But TypeScript provides an easier syntax to create aliases for namespaces that works well with both exported types and values. Instead of var <alias> =
, we need to use import <alias> =
expression.
// a.ts
namespace MyLibA {
export namespace Types {
export interface Person {
name: string;
age: number;
}
}
export namespace Functions {
export function getPerson(name: string, age: number): Types.Person {
return { name, age };
}
}
}
import Person = MyLibA.Types.Person;
import API = MyLibA.Functions;
const ross: Person = API.getPerson("Ross Geller", 30);
In the above example, we have just changed the var <alias>
declarations to import <alias>
expression. This shouldn’t be compared with ES6 import
statement. This is just a syntactical sugar to create an alias for namespaces.
// a.js
var MyLibA;
(function (MyLibA) {
var Functions;
(function (Functions) {
function getPerson(name, age) {
return { name: name, age: age };
}
Functions.getPerson = getPerson;
})((Functions = MyLibA.Functions || (MyLibA.Functions = {})));
})(MyLibA || (MyLibA = {}));
var API = MyLibA.Functions;
var ross = API.getPerson("Ross Geller", 30);
As you can see from the above output, aliasing a namespace export using import
creates a variable that references the exported value. If an alias references a type, it is simply ignored in the compiled output.
Importing Namespaces
To separate the application logic from reusable logic, we normally create different files and place them in separate directories. In the example above, we place namespace declarations in a separate file and import them using an import
statement. Since namespaces are regular values, we can import them.
// a.ts
import { MyLibA } from "./b";
const ross: MyLibA.Person = MyLibA.getPerson("Ross Geller", 30);
// b.ts
export namespace MyLibA {
export interface Person {
name: string;
age: number;
}
export function getPerson(name: string, age: number): Person {
return { name, age };
}
}
The problem with this again is that we are becoming platform-dependent since the compiled JavaScript would need a module system at runtime as shown in the below output for the CommonJS
module system.
// a.js
var b_1 = require("./b");
var ross = b_1.MyLibA.getPerson("Ross Geller", 30);
// b.js
var MyLibA;
(function (MyLibA) {
function getPerson(name, age) {
return { name: name, age: age };
}
MyLibA.getPerson = getPerson;
})((MyLibA = exports.MyLibA || (exports.MyLibA = {})));
Since the b.ts file is already imported inside a.ts, you can simply use the tsc —module CommonJS a.ts command to compile this project and TypeScript compiler will automatically include b.ts in the compilation process.
Modularization
TypeScript provides triple-slash directives which are nothing but JavaScript comments that help the TypeScript compiler to locate other TypeScript files and include them in the compilation process.
/// <reference path="./b.ts" />
You can compare these with the preprocessor directives used in C
and C++
language such as #include "stdio.h"
. These must appear at the top of the file to attain special meaning. Since these directives are just comments, their job only exists at the compile-time.
The path
attribute of this reference
directive points to another TypeScript file to create a dependency. This is kinda similar to importing b.ts
using import
statement but without having to mention the import members.
When a reference of another TypeScript file is given using the reference
directive, TypeScript automatically includes that files in the compilation process much like the import
statement. All the global values of that file become available in the file where it was referenced.
// a.ts
/// <reference path="./b.ts"/>
const ross: MyLibA.Person = MyLibA.getPerson("Ross Geller", 30);
// b.ts
namespace MyLibA {
export interface Person {
name: string;
age: number;
}
export function getPerson(name: string, age: number): Person {
return { name, age };
}
}
In the above example, we have referenced b.ts
inside a.ts
using reference
directive. Using this, all the values inside b.ts
that are in the global scope will be accessible inside a.ts
. Also, we do not need to add b.ts
in the compilation process, therefore the tsc a.ts
command will do the job.
// a.js
var ross = MyLibA.getPerson("Ross Geller", 30);
// b.js
var MyLibA;
(function (MyLibA) {
function getPerson(name, age) {
return { name: name, age: age };
}
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
We can’t run this project on Node
since these are two separate files and we do not have require()
statements in the compiled code that Node can use to load dependent files. We would first need to combine them as a single bundle and run using the command $ node *bundle.js*
.
In the browser environment, we need to load b.js
first and then a.js
since a.js
depends on b.js
for MyLibA
object initialization. So the <script>
statement should look like this.
<script src="./b.js" />
<script src="./a.js" />
However, you can use --outFile
flag with the tsc
command to generate a bundle (_such as tsc --outFile bundle.js a.ts
). The typeScript compiler will automatically figure out the order of code precedence in the compiled JavaScript based on the order of reference
directives. Therefore reference
directives are absolutely necessary for internal modules.
// bundle.js
// (from b.ts)
var MyLibA;
(function (MyLibA) {
function getPerson(name, age) {
return { name: name, age: age };
}
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
// (from a.ts)
var ross = MyLibA.getPerson("Ross Geller", 30);
Extending Namespaces
In the interfaces lesson, we saw that when multiple interfaces with the same name are declared in the same module (file), TypeScript coerces them into one declaration by merging their properties together.
You can extend a predefined namespace by referencing the file which contains the original namespace (using the reference
directive) and redeclaring the namespace with new values.
// a.ts
/// <reference path="./b.ts"/>
const john: MyLibA.Person = MyLibA.defaultPerson;
const ross: MyLibA.Person = MyLibA.getPerson("Ross Geller", 30);
console.log(john); // {name: 'John Doe', age: 21}
console.log(ross); // {name: 'Ross Geller', age: 30}
// b.ts
/// <reference path="./c.ts" />
namespace MyLibA {
export const defaultPerson: Person = getPerson("John Doe", 21);
}
// c.ts
namespace MyLibA {
export interface Person {
name: string;
age: number;
}
export function getPerson(name: string, age: number): Person {
return { name, age };
}
}
In the above example, since b.ts
references c.ts
, it has access to MyLibA
namespace and it adds defaultPerson
public value to this namespace. If you notice, the MyLibA
namespace in the b.ts
has access to all the public values (exported only) of the same namespace defined in c.ts
.
The a.ts
references b.ts
and for it, the MyLibA
namespace has Person
, getPerson
and defaultPerson
members. When we bundle this project using the tsc --outFile bundle.js a.ts
command, we get the following output.
// bundle.js
// (from c.ts)
var MyLibA;
(function (MyLibA) {
function getPerson(name, age) {
return { name: name, age: age };
}
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
// (from b.ts)
var MyLibA;
(function (MyLibA) {
MyLibA.defaultPerson = MyLibA.getPerson("John Doe", 21);
})(MyLibA || (MyLibA = {}));
// (from a.ts)
var john = MyLibA.defaultPerson;
var ross = MyLibA.getPerson("Ross Geller", 30);
Extending the namespace makes much more sense when we see the compiled JavaScript code. As you can see, the middle section belong to the b.ts
file which adds defaultPerson
property to the MyLibA
object.
Now the million-dollar question, where we should use namespaces? My suggestion would be to avoid it whenever you can. We have a standard for modules in JavaScript now. Node.js would also one day fully support it but for now, you can set --module
to CommonJS
, it is that easy.
Namespaces predate JavaScript modules, therefore it’s not worth investing your money into something that will soon become obsolete. However, namespaces would be a good fit for cross-browser JavaScript applications where the ECMAScript module system is not available in older browsers but we have Webpack and other bundling tools to make ECMAScript module cross-platform and backward compatible.
TypeScript doesn’t allow bundling of ECMAScript or CommonJS modules, which means you can’t have a single bundle file of a TypeScript project that uses these module systems. If having a single bundle file is mission-critical, then you can opt-in for namespaces. Again, Webpack or other bundling tools can help you create a bundle without having to sacrifice anything.