Editor’s note: This article was last updated on 7 September 2023 to include information about the “ambiguous module declarations” TypeScript error.
Build processes in TypeScript can be difficult to achieve, especially when manually configuring a project through the tsconfig.json
file. This is often because these configurations require understanding the TypeScript compiler and module system.
Drawing from my experience with TypeScript projects, I’ve identified the most common issues associated with TypeScript modules and, more importantly, how to resolve them effectively. In this article, we’ll cover the following problems and their solutions:
To follow along with this article, it is recommended that you have:
On a normal occasion, the node-modules
directory is usually located in the root directory (i.e., baseUrl
) of the project as shown below:
projectRoot ├── node_modules ├── src │ ├── file1.ts │ └── file2.ts └── tsconfig.json └── package.json
However, sometimes modules are not directly located under baseUrl
. As an example, take a look at the following JavaScript code:
// index.js import express from "express";
Loaders such as webpack use a mapping configuration to map the module name (in this case, express
) to the index.js
file at runtime, thereby translating the snippet above to node_modules/express/lib/express
at runtime.
At this point, when we use the translated snippet above in a TypeScript project, we must then configure the TypeScript compiler to handle the module import using the "paths"
property:
// tsconfig.json { "compilerOptions": { "baseUrl": "./src", "paths": { "express": ["node_modules/express/lib/express"] } } }
However, with the above configuration, the TypeScript compiler will throw the following error:
express module not found
.
Here’s what’s happening under the hood: The TypeScript compiler searches for node_modules
in the src
directory even though node_modules
is located outside the src
directory, thereby determining that the module was not found.
The mapping in "paths"
is resolved relative to "baseUrl"
. Therefore, our configuration should be as follows:
// tsconfig.json { "compilerOptions": { "baseUrl": "./src", // This must be specified if "paths" is. "paths": { "express": ["../node_modules/express/lib/express"] // This mapping is relative to "baseUrl" } } }
When we use this configuration, the TypeScript compiler “jumps” up a directory from the src
directory and locates the node_modules
directory. Alternatively, the configuration below is also valid:
// tsconfig.json { "compilerOptions": { "baseUrl": ".", // This must be specified if "paths" is. "paths": { "express": ["node_modules/express/lib/express"] // This mapping is relative to "baseUrl" } } }
When we use this configuration, the TypeScript compiler will search for the node_modules
directory in the root directory of the project. It is important to maintain a structured project directory as this makes it easy for the compiler to get the required file/module it needs. A structured directory also makes locating necessary files easier for engineers.
Our second common TypeScript module issue also has to do with location. Let’s consider the following project configuration:
projectRoot ├── view │ ├── file1.ts (imports 'view/file2' and 'nav/file3') │ └── file2.ts ├── components │ ├── footer │ └── nav │ └── file3.ts └── tsconfig.json
Here, the view/file2
module is located in the view
directory, and nav/file3
is in the components
directory. Resolving modules from multiple locations can be challenging because, at this point in your code, the TypeScript compiler lacks the necessary context to effectively determine how to locate and use these modules from different parts of your project. In the following section, we will review how to solve this issue.
Using the configuration below, we can tell the compiler to look in two locations (i.e., ["*", "components/*"]
) for any module import in the project:
{ "compilerOptions": { "baseUrl": ".", "paths": { "*": ["*", "components/*"] } } }
In this example, the "*"
value in the array means the exact name of the module, while the "components/*"
value is the module name ("components
) with an appended prefix.
Now, we can instruct the compiler to resolve the two imports as follows:
import 'view/file2'
The compiler will then substitute 'view/file2'
with the first location in the array ("*"
), and combine it with baseUrl
, which results in projectRoot/view/file2.ts
. This will allow the module to be found.
After this step, the compiler will move to the next import:
import 'nav/file3':
Likewise, the compiler will substitute 'nav/file3'
with the first location in the array ("*"
) — i.e., nav/file3
— and combine it with the baseUrl
. This results in projectRoot/nav/file3.ts
.
This time, the file does not exist, so the compiler will substitute 'nav/file3'
with the second location, "components/*"
, and combine it with baseUrl
. This will result in projectRoot/components/nav/file3.ts
, thereby allowing the module to be found.
Though we just showed how TypeScript locates the file, it is still important to structure your files in a way that is easily accessible. Organizing files in structured folders has many advantages:
The rootDirs
property in your tsconfig.json
file allows the merging of multiple directories into a single logical directory for compilation.
Consider an example where two files are stored in different directories: src/views/view1.ts
and generated/templates/views/template1.ts
. Imagine that view1.ts
tries to do a relative import of template1.ts
like so:
//view1.ts import './template1'
This import would not be resolved and instead, would give an error because template1.ts
doesn’t exist in the same folder. To solve this issue, add both folders to the rootDirs
property in the tsconfig.json
file:
"compilerOptions": { "rootDirs": ["src/views", "generated/templates/views"] }
This creates a virtual directory, which enables the import of the .template1.ts
to work at runtime. view1.ts
now knows template1.ts
exists next to it, and is able to import .template1.ts
using a relative name like "./template"
.
This is made possible using the rootDirs
property.
The problem of ambiguous module declarations occurs when there are conflicting or ambiguous module declarations in the codebase. Suppose you have three TypeScript files in your project: log.ts
and log.d.ts
and test.ts
.
This is what the log.ts
file looks like:
// log.ts export const show = (val: string) => val;
Here is the log.d.ts
file:
// log.d.ts declare module 'log' { export function show(val: string): string; }
Finally, here’s the test.ts
file:
import * as log from 'log'; const result = log.show("test"); console.log(result);
An error message “Ambiguous module declarations” would be thrown because TypeScript can’t determine which declaration to use for the 'log'
module: the one from log.ts
or the one from log.d.ts
.
To help address this problem, avoid global/module conflict in declarations. Limit the use of global declarations to only what’s necessary, as overusing global declarations can lead to conflicts. Another fix would be to organize your declaration files into a clear folder structure, using meaningful names for declaration files to to avoid naming conflicts.
Module resolution is the method employed by the TypeScript compiler to determine the target of an import statement. It accomplishes this by following a configurable strategy capable of adapting to various module systems and directory layouts. The following is how TypeScript resolves modules:
tsconfig.json
file to configure module resolution settings, as we saw in Solution 2 above. The baseUrl
setting specifies the base directory used to resolve non-relative module pathsWithout configuring the TypeScript compiler as discussed earlier, TypeScript will adopt the Node.js run-time resolution strategy by default in order to locate the requested modules at compile time.
To accomplish this, the TypeScript compiler will look for .ts, .d.ts, .tsx, and package.json files. If the compiler finds a package.json file, then it will check whether that file contains a types property that points to a typings file. If no such property is found, the compiler will then try looking for index files before moving on to the next folder.
Here’s an example. An import statement like import { b } from 'view/file2'
in /projectRoot/view/file1.ts
would result in attempting the following locations for finding ".view/file2"
:
/projectRoot/view/file2.ts
exist?/projectRoot/view/file2.tsx
exist?/projectRoot/view/file2.d.ts
exist?/projectRoot/view/file2/package.json
(if it specifies a "types"
property) exist?/projectRoot/view/file2/index.ts
exist?/projectRoot/view/file2/index.tsx
exist?/projectRoot/view/file2/index.d.ts
exist?If the module is not found at this point, then the same process will be repeated, jumping a step out of the closest parent folder as follows:
/projectRoot/file2.ts
exist?/projectRoot/file2.tsx
exist?/projectRoot/file2.d.ts
exist?/projectRoot/file2/package.json
(if it specifies a "types"
property) exist?/projectRoot/file2/index.ts
exist?/projectRoot/file2/index.tsx
exist?/projectRoot/file2/index.d.ts
exist?Sometimes, we may run into complex situations where it can be difficult to diagnose why a module is not resolved. In these situations, enabling compiler module resolution tracing using tsc --traceResolution
can provide insight into what happened during the module resolution process, allowing us to choose the correct solution.
With the common TypeScript module problems and their solutions highlighted in this post, I hope that it will become easier to configure the TypeScript compiler to handle modules in your TypeScript projects.
I hope you found this post informative and helpful. You can also check out the official TypeScript documentation for a deep dive into TypeScript module resolution.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]