TypeScript Ambient Module

E.Y.
6 min readJul 5, 2020

--

how do make your own declaration

Photo by Nerfee Mirandilla on Unsplash

Not long ago, I was involved in a project when I need to create a repo from scratch, and not surprisingly there were a lot of configuration work to be done. Since the project is written in TypeScript, one thing I encounter was the .d.ts extension file.

So what is this and how it can be used?

From TypeScript documentation:

To describe the shape of libraries not written in TypeScript, we need to declare the API that the library exposes. Because most JavaScript libraries expose only a few top-level objects, namespaces are a good way to represent them.

We call declarations that don’t define an implementation “ambient”. Typically these are defined in .d.ts files. If you’re familiar with C/C++, you can think of these as .h files. So to write type declarations for code that is already written in JavaScript we have to write an ambient module. As stated above these are almost always defined in a file ending with a .d.ts extension.

So basically ambient declarations is a promise that you are making with the compiler. If we have a 3rd party library do not exist at runtime and we try to use them, things will break without warning.

Sounds a bit complex right? But the good thing is most of time, we don’t need to specify a d.ts file ourselves. For most popular JavaScript libraries, there should already be such type declaration file:

  • the 3rd party library includes the type declaration itself
  • the 3rd party library has separate type definitions contributed by community from DefinitelyTyped . So when you install the library, you also need to install @types/library-name .

But there are cases when such type declaration is not available. So we need to create such file ourselves. But how?

There are a few scenarios:

Ambient Variable

Let’s say you have a variable serves as an api endpoint in your file. If you simply write:

const response = await fetch(PRODUCTS_ENDPOINT, {
body: JSON.stringify(params),
headers: {
“content-type”: “application/json; charset=UTF-8”,
},
method: “POST”,
});

You will get an error from TypeScript: Name PRODUCTS_ENDPOINT not found Now declare it as below, then the problem is solved.

declare var PRODUCTS_ENDPOINT: string;

This is basically telling TypeScript that you declare the type of a variable without implementation. (How you implement it TypeScript doesn’t really care).

Ambient Module

Now let’s say you have a 3rd party module something like Math.js , in order for it to work with TypeScript, you need to create a corresponding file with extension .d.ts .

Configuration

But before we write it, we need to tell TypeScript where to find this declaration file. To do this go to the tsconfig.json file, and add the typeRoots property under the compilerOptions property. Note that when typeRoots is specified, TypeScript will only include packages under the path of typeRoots (By default it will scan through the whole src folder looking for files with .ts and d.ts )

{
"compilerOptions": {
//
"typeRoots": [
"src/types",
"node_modules/@types"
]
}
}

When the property is excluded, TypeScript is automatically searching for types in the node_modules/@types folder of your project. But in this case we will add the src/types folder as well where we are going to put the .d.ts files. But note this not works for ES-style modules with import and export keywords. In order for this kind to work, using baseUrl or paths property as well as include property(we can use files property as well but it’s not shown here).

{“extends”: “@trussle/typescript”,“compilerOptions”: {
“declaration”: true,
“declarationDir”: “types”,
“esModuleInterop”: true,
“jsx”: “react”,
“module”: “es6”,
“target”: “es6”,
“baseUrl”: “.”
"paths": {
"@abc/internal-components": ["src"]
}
},
include”: [“src/types/**/*.ts”]
}

The baseUrl which refer to the directory that you need your application to consider as a root directory. In our case it’s src folder, since it’s where tsconfig.json resides in.

The other property ispaths which holds an object of shortcuts for the TypeScript transpiler and editor checkers, in our case it means any import starts with @abc/internal-components just look into the src folder.

Note that if you specify paths you have to specify baseUrl as well since the first is defined relative to baseUrl .

ES-modules

Now we can finally write some files. Inside src folder, create a .d.ts file. You can name them with the name of the library you are declaring types on, but note that the name does not actually matter.

But still there’s a caveat. We need to distinguish between the confusing Namespace and Modules first.

In TypeScript handbook, Internal modules” are same as“namespaces”. “External modules” are now referring to“modules”,

In the example from the handbook:

//Moduleinterface StringValidator {
isAcceptable(s: string): boolean;
}
..
// Validators to use
let validators: { [s: string]: StringValidator; } = {};
//Namespacenamespace Validation {
export interface StringValidator {
isAcceptable(s: string): boolean;
}
...
// Validators to use
let validators: { [s: string]: Validation.StringValidator; } = {};

In the handbook, it points out that Namespaces that aren’t in an external module can span files, so if you’re in the same namespace you can refer to anything exported by the namespace without needing any sort of import.

//Module://typescript
export class Validaton {
...
}
//becomes, in javascript:
export class Validation {
...
}
//Namespace:// typescript:
namespace Validation {
const foo = 123;
}
//This becomes, in es6:
(function(Validation) {
Validation.foo = 123;
})(Validation || (Validation = {}))

But in the handbook new update, as starting with ECMAScript 2015, modules are native part of the language, and should be supported by all compliant engine implementations. Thus, for new projects modules would be the recommended code organization mechanism.

It even says that a key feature of modules in TypeScript is that two different modules will never contribute names to the same scope. Because the consumer of a module decides what name to assign it, there’s no need to proactively wrap up the exported symbols in a namespace.

So in conclusion ES-modules should be preferred comparing to Namespace.

You may wonder, why there’s Namespace at the beginning?

That’s because before ECMAScript 2015, JavaScript has no concept of module. Every variable you create will end up on the global scope, which will create collisions.

So finally create our math.d.ts like below:

declare module 'math' {
function add(args: number[]): number;
export default add;
}

Note that for a module (i.e. a file with at least one top-level import ot export) to be exposed at a global scope for the TypeScript traspiler to find it, it needs to export the declarations otherwise the declaration is only kept within its own module scope.

Also a special use case is like below when you don’t define an export for a declaration module:

declare module “*.png”;

or

declare module “dayjs”;

Then you can use import directly like:

import dayjs from “dayjs”;

Ambient Namespace

Using namespace, all its members are exported by default. There is no need to include the export keyword as a result.

declare namespace math {
declare function add(args: number[]): number;
}

Then somewhere in your code, just use

math.add

Note that while this is convenient, it can create the global namespace pollution.

One last trick worth mentioning

Keeping in mind that there’s more assets to be loaded in our project other than 3rd party JavaScript libraries, for example, image and css assets:

import logo from "./logo.svg"
import styles from "./styles.css";

TypeScript will throw an error!

So you can define ambient modules like :

declare module “*.scss” {
const styles: { [className: string]: string };
export default styles;
}
declare module "*.svg";

Short and sweet!

Hope you like this blog — as TypeScript releases new version, this might become obsolete. Kindly tag me in the comment area if you notice any information above no longer applies — thanks and happy reading!

.

--

--