TypeScript module augmentation and handling nested JavaScript files

Learn the basics of module augmentation in TypeScript, and how to add type definitions for nested JavaScript files.
Gao
GaoFounder
November 01, 20234 min read
TypeScript module augmentation and handling nested JavaScript files

TypeScript has been a valuable addition to the JavaScript ecosystem for over a decade, gaining popularity due to its ability to provide type safety for JavaScript code.

Types for JavaScript packages

For backward compatibility and flexibility, TypeScript allows package authors to provide type definitions for their JavaScript packages. Such type definitions are usually stored in the "DefinitelyTyped" repository. You can use the @types scope to install these type definitions, for instance, @types/react is the type definition for the React package.

After installing these type definitions, you can use the package with confidence in type safety. Here's an example of defining a React component with type safety:

import React from 'react';

type Props = {
  name: string;
};

const Hello: React.Component<Props> = ({ name }) => {
  return <div>Hello, {name}!</div>;
};

Module augmentation

While most popular JavaScript packages have readily available type definitions in the @types scope, some packages may lack type definitions or have outdated ones. In such cases, TypeScript provides a powerful feature known as "module augmentation" to add type definitions for these packages. Let's take a look at an example:

// src/types/foo.d.ts
declare module 'foo' {
  export const foo: string;
}

Now you can use this augmented type definition in your code:

import { foo } from 'foo';

console.log(foo); // `foo` is a string

Module augmentation not only allows you to add type definitions for packages but also extends existing type definitions. For example, you can add a new property to the window object:

// src/types/window.d.ts
interface Window {
  foo: string;
}

And use it in your code:

console.log(window.foo); // `foo` is a string

In a previous post, we have talked about a type issue in the React Router library, and we had to overcome the impact by extending and augmenting the existing type through a custom declaration file.

Remember that when using module augmentation, it's crucial to ensure the accuracy of your type definitions by closely inspecting the JavaScript source code. Incorrect type definitions can lead to runtime errors. In the above example, the window.foo property must be a string that exists on the window object.

Global augmentation

Sometimes you may encounter scripts that introduce global variables, and you may want to provide type definitions for these global variables to use them in your TypeScript code. For instance, if you have a script that sets a global variable called __DEV__:

<script>
  const __DEV__ = true;
</script>

You can add type definitions for this global variable like so:

// src/types/global.d.ts
declare const __DEV__: boolean;

Now you can use it in your TypeScript code:

if (__DEV__) {
  // ...
}

By combining module augmentation and global augmentation, you can even extend type definitions for JavaScript prototypes. However, this is generally not recommended as it can pollute the global scope.

// src/types/global.d.ts
declare global {
  interface Array<T> {
    first(): T;
  }
}
// src/helpers/array.ts
Array.prototype.first = function () {
  return this[0];
};

The power of module augmentation allows for such extensions, but exercise caution to prevent global scope pollution.

Nested JavaScript files

In the examples mentioned earlier, we assumed that imports could be resolved through the package's entry file. However, some packages export nested JavaScript files without corresponding type definitions. Consider a package called foo with the following structure:

foo/
  package.json
  index.js
  bar/
    index.js
    baz.js

The foo package exports the index.js file as the entry point and also exports the bar directory.

To augment the type definitions for the foo package, you can create a foo.d.ts file:

// src/types/foo.d.ts
declare module 'foo' {
  export const foo: string;
}

However, if you attempt to import the baz.js file in TypeScript, you'll encounter an error:

import { baz } from 'foo/bar/baz.js';
// Cannot find module 'foo/bar/baz.js' or its corresponding type declarations.
The example assumes that we use ES modules.

To augment the type definitions for the baz.js file, you need to create a separate baz.d.ts file:

// src/types/foo/index.d.ts
declare module 'foo' {
  export const foo: string;
}

// src/types/foo/bar/baz.d.ts
declare module 'foo/bar/baz.js' {
  export const baz: string;
}

This ensures that TypeScript can locate the module and its associated type definitions:

import { baz } from 'foo/bar/baz.js';

console.log(baz); // `baz` is a string

To maintain organized type definitions, you can mimic the package's structure in your type definition files:

src/
  types/
    foo/
      index.d.ts
      bar/
        baz.d.ts

This approach keeps your TypeScript code structured and type-safe, even when dealing with nested JavaScript files.

TypeScript will still report an error if you put two module augmentations (declare module ...) in the same file.