Module syntax
The TypeScript compiler recognizes standard ECMAScript module syntax in TypeScript and JavaScript files and many forms of CommonJS syntax in JavaScript files.
There are also a few TypeScript-specific syntax extensions that can be used in TypeScript files and/or JSDoc comments.
Importing and exporting TypeScript-specific declarations
Type aliases, interfaces, enums, and namespaces can be exported from a module with an export
modifier, like any standard JavaScript declaration:
ts
// Standard JavaScript syntax...export function f() {}// ...extended to type declarationsexport type SomeType = /* ... */;export interface SomeInterface { /* ... */ }
They can also be referenced in named exports, even alongside references to standard JavaScript declarations:
ts
export { f, SomeType, SomeInterface };
Exported types (and other TypeScript-specific declarations) can be imported with standard ECMAScript imports:
ts
import { f, SomeType, SomeInterface } from "./module.js";
When using namespace imports or exports, exported types are available on the namespace when referenced in a type position:
ts
import * as mod from "./module.js";mod.f();mod.SomeType; // Property 'SomeType' does not exist on type 'typeof import("./module.js")'let x: mod.SomeType; // Ok
Type-only imports and exports
When emitting imports and exports to JavaScript, by default, TypeScript automatically elides (does not emit) imports that are only used in type positions and exports that only refer to types. Type-only imports and exports can be used to force this behavior and make the elision explicit. Import declarations written with import type
, export declarations written with export type { ... }
, and import or export specifiers prefixed with the type
keyword are all guaranteed to be elided from the output JavaScript.
ts
// @Filename: main.tsimport { f, type SomeInterface } from "./module.js";import type { SomeType } from "./module.js";class C implements SomeInterface {constructor(p: SomeType) {f();}}export type { C };// @Filename: main.jsimport { f } from "./module.js";class C {constructor(p) {f();}}
Even values can be imported with import type
, but since they won’t exist in the output JavaScript, they can only be used in non-emitting positions:
ts
import type { f } from "./module.js";f(); // 'f' cannot be used as a value because it was imported using 'import type'let otherFunction: typeof f = () => {}; // Ok
A type-only import declaration may not declare both a default import and named bindings, since it appears ambiguous whether type
applies to the default import or to the entire import declaration. Instead, split the import declaration into two, or use default
as a named binding:
ts
import type fs, { BigIntOptions } from "fs";// ^^^^^^^^^^^^^^^^^^^^^// Error: A type-only import can specify a default import or named bindings, but not both.import type { default as fs, BigIntOptions } from "fs"; // Ok
import()
types
TypeScript provides a type syntax similar to JavaScript’s dynamic import
for referencing the type of a module without writing an import declaration:
ts
// Access an exported type:type WriteFileOptions = import("fs").WriteFileOptions;// Access the type of an exported value:type WriteFileFunction = typeof import("fs").writeFile;
This is especially useful in JSDoc comments in JavaScript files, where it’s not possible to import types otherwise:
ts
/** @type {import("webpack").Configuration} */module.exports = {// ...}
export =
and import = require()
When emitting CommonJS modules, TypeScript files can use a direct analog of module.exports = ...
and const mod = require("...")
JavaScript syntax:
ts
// @Filename: main.tsimport fs = require("fs");export = fs.readFileSync("...");// @Filename: main.js"use strict";const fs = require("fs");module.exports = fs.readFileSync("...");
This syntax was used over its JavaScript counterparts since variable declarations and property assignments could not refer to TypeScript types, whereas special TypeScript syntax could:
ts
// @Filename: a.tsinterface Options { /* ... */ }module.exports = Options; // Error: 'Options' only refers to a type, but is being used as a value here.export = Options; // Ok// @Filename: b.tsconst Options = require("./a");const options: Options = { /* ... */ }; // Error: 'Options' refers to a value, but is being used as a type here.// @Filename: c.tsimport Options = require("./a");const options: Options = { /* ... */ }; // Ok
Ambient modules
TypeScript supports a syntax in script (non-module) files for declaring a module that exists in the runtime but has no corresponding file. These ambient modules usually represent runtime-provided modules, like "fs"
or "path"
in Node.js:
ts
declare module "path" {export function normalize(p: string): string;export function join(...paths: any[]): string;export var sep: string;}
Once an ambient module is loaded into a TypeScript program, TypeScript will recognize imports of the declared module in other files:
ts
// 👇 Ensure the ambient module is loaded -// may be unnecessary if path.d.ts is included// by the project tsconfig.json somehow./// <reference path="path.d.ts" />import { normalize, join } from "path";
Ambient module declarations are easy to confuse with module augmentations since they use identical syntax. This module declaration syntax becomes a module augmentation when the file is a module, meaning it has a top-level import
or export
statement (or is affected by --moduleDetection force
or auto
):
ts
// Not an ambient module declaration anymore!export {};declare module "path" {export function normalize(p: string): string;export function join(...paths: any[]): string;export var sep: string;}
Ambient modules may use imports inside the module declaration body to refer to other modules without turning the containing file into a module (which would make the ambient module declaration a module augmentation):
ts
declare module "m" {// Moving this outside "m" would totally change the meaning of the file!import { SomeType } from "other";export function f(): SomeType;}
A pattern ambient module contains a single *
wildcard character in its name, matching zero or more characters in import paths. This can be useful for declaring modules provided by custom loaders:
ts
declare module "*.html" {const content: string;export default content;}
The module
compiler option
This section discusses the details of each module
compiler option value. See the Module output format theory section for more background on what the option is and how it fits into the overall compilation process. In brief, the module
compiler option was historically only used to control the output module format of emitted JavaScript files. The more recent node16
and nodenext
values, however, describe a wide range of characteristics of Node.js’s module system, including what module formats are supported, how the module format of each file is determined, and how different module formats interoperate.
node16
, nodenext
Node.js supports both CommonJS and ECMAScript modules, with specific rules for which format each file can be and how the two formats are allowed to interoperate. node16
and nodenext
describe the full range of behavior for Node.js’s dual-format module system, and emit files in either CommonJS or ESM format. This is different from every other module
option, which are runtime-agnostic and force all output files into a single format, leaving it to the user to ensure the output is valid for their runtime.
A common misconception is that
node16
andnodenext
only emit ES modules. In reality,node16
andnodenext
describe versions of Node.js that support ES modules, not just projects that use ES modules. Both ESM and CommonJS emit are supported, based on the detected module format of each file. Becausenode16
andnodenext
are the onlymodule
options that reflect the complexities of Node.js’s dual module system, they are the only correctmodule
options for all apps and libraries that are intended to run in Node.js v12 or later, whether they use ES modules or not.
node16
and nodenext
are currently identical, with the exception that they imply different target
option values. If Node.js makes significant changes to its module system in the future, node16
will be frozen while nodenext
will be updated to reflect the new behavior.
Module format detection
.mts
/.mjs
/.d.mts
files are always ES modules..cts
/.cjs
/.d.cts
files are always CommonJS modules..ts
/.tsx
/.js
/.jsx
/.d.ts
files are ES modules if the nearest ancestor package.json file contains"type": "module"
, otherwise CommonJS modules.
The detected module format of input .ts
/.tsx
/.mts
/.cts
files determines the module format of the emitted JavaScript files. So, for example, a project consisting entirely of .ts
files will emit all CommonJS modules by default under --module nodenext
, and can be made to emit all ES modules by adding "type": "module"
to the project package.json.
Interoperability rules
- When an ES module references a CommonJS module:
- The
module.exports
of the CommonJS module is available as a default import to the ES module. - Properties (other than
default
) of the CommonJS module’smodule.exports
may or may not be available as named imports to the ES module. Node.js attempts to make them available via static analysis. TypeScript cannot know from a declaration file whether that static analysis will succeed, and optimistically assumes it will. This limits TypeScript’s ability to catch named imports that may crash at runtime. See #54018 for more details.
- The
- When a CommonJS module references an ES module:
require
cannot reference an ES module. For TypeScript, this includesimport
statements in files that are detected to be CommonJS modules, since thoseimport
statements will be transformed torequire
calls in the emitted JavaScript.- A dynamic
import()
call may be used to import an ES module. It returns a Promise of the module’s Module Namespace Object (what you’d get fromimport * as ns from "./module.js"
from another ES module).
Emit
The emit format of each file is determined by the detected module format of each file. ESM emit is similar to --module esnext
, but has a special transformation for import x = require("...")
, which is not allowed in --module esnext
:
ts
// @Filename: main.tsimport x = require("mod");
js
// @Filename: main.jsimport { createRequire as _createRequire } from "module";const __require = _createRequire(import.meta.url);const x = __require("mod");
CommonJS emit is similar to --module commonjs
, but dynamic import()
calls are not transformed. Emit here is shown with esModuleInterop
enabled:
ts
// @Filename: main.tsimport fs from "fs"; // transformedconst dynamic = import("mod"); // not transformed
js
// @Filename: main.js"use strict";var __importDefault = (this && this.__importDefault) || function (mod) {return (mod && mod.__esModule) ? mod : { "default": mod };};Object.defineProperty(exports, "__esModule", { value: true });const fs_1 = __importDefault(require("fs")); // transformedconst dynamic = import("mod"); // not transformed
Implied and enforced options
--module nodenext
ornode16
implies and enforces themoduleResolution
with the same name.--module nodenext
implies--target esnext
.--module node16
implies--target es2022
.--module nodenext
ornode16
implies--esModuleInterop
.
Summary
node16
andnodenext
are the only correctmodule
options for all apps and libraries that are intended to run in Node.js v12 or later, whether they use ES modules or not.node16
andnodenext
emit files in either CommonJS or ESM format, based on the detected module format of each file.- Node.js’s interoperability rules between ESM and CJS are reflected in type checking.
- ESM emit transforms
import x = require("...")
to arequire
call constructed from acreateRequire
import. - CommonJS emit leaves dynamic
import()
calls untransformed, so CommonJS modules can asynchronously import ES modules.
preserve
In --module preserve
(added in TypeScript 5.4), ECMAScript imports and exports written in input files are preserved in the output, and CommonJS-style import x = require("...")
and export = ...
statements are emitted as CommonJS require
and module.exports
. In other words, the format of each individual import or export statement is preserved, rather than being coerced into a single format for the whole compilation (or even a whole file).
While it’s rare to need to mix imports and require calls in the same file, this module
mode best reflects the capabilities of most modern bundlers, as well as the Bun runtime.
Why care about TypeScript’s
module
emit with a bundler or with Bun, where you’re likely also settingnoEmit
? TypeScript’s type checking and module resolution behavior are affected by the module format that it would emit. Settingmodule
gives TypeScript information about how your bundler or runtime will process imports and exports, which ensures that the types you see on imported values accurately reflect what will happen at runtime or after bundling. See--moduleResolution bundler
for more discussion.
Examples
ts
import x, { y, z } from "mod";import mod = require("mod");const dynamic = import("mod");export const e1 = 0;export default "default export";
js
import x, { y, z } from "mod";const mod = require("mod");const dynamic = import("mod");export const e1 = 0;export default "default export";
Implied and enforced options
--module preserve
implies--moduleResolution bundler
.--module preserve
implies--esModuleInterop
.
The option
--esModuleInterop
is enabled by default in--module preserve
only for its type checking behavior. Since imports never transform into require calls in--module preserve
,--esModuleInterop
does not affect the emitted JavaScript.
es2015
, es2020
, es2022
, esnext
Summary
- Use
esnext
with--moduleResolution bundler
for bundlers, Bun, and tsx. - Do not use for Node.js. Use
node16
ornodenext
with"type": "module"
in package.json to emit ES modules for Node.js. import mod = require("mod")
is not allowed in non-declaration files.es2020
adds support forimport.meta
properties.es2022
adds support for top-levelawait
.esnext
is a moving target that may include support for Stage 3 proposals to ECMAScript modules.- Emitted files are ES modules, but dependencies may be any format.
Examples
ts
// @Filename: main.tsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
js
// @Filename: main.jsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
commonjs
Summary
- You probably shouldn’t use this. Use
node16
ornodenext
to emit CommonJS modules for Node.js. - Emitted files are CommonJS modules, but dependencies may be any format.
- Dynamic
import()
is transformed to a Promise of arequire()
call. esModuleInterop
affects the output code for default and namespace imports.
Examples
Output is shown with
esModuleInterop: false
.
ts
// @Filename: main.tsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
js
// @Filename: main.js"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.e1 = void 0;const mod_1 = require("mod");const mod = require("mod");const dynamic = Promise.resolve().then(() => require("mod"));console.log(mod_1.default, mod_1.y, mod_1.z, mod);exports.e1 = 0;exports.default = "default export";
ts
// @Filename: main.tsimport mod = require("mod");console.log(mod);export = {p1: true,p2: false};
js
// @Filename: main.js"use strict";const mod = require("mod");console.log(mod);module.exports = {p1: true,p2: false};
system
Summary
- Designed for use with the SystemJS module loader.
Examples
ts
// @Filename: main.tsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
js
// @Filename: main.jsSystem.register(["mod"], function (exports_1, context_1) {"use strict";var mod_1, mod, dynamic, e1;var __moduleName = context_1 && context_1.id;return {setters: [function (mod_1_1) {mod_1 = mod_1_1;mod = mod_1_1;}],execute: function () {dynamic = context_1.import("mod");console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);exports_1("e1", e1 = 0);exports_1("default", "default export");}};});
amd
Summary
- Designed for AMD loaders like RequireJS.
- You probably shouldn’t use this. Use a bundler instead.
- Emitted files are AMD modules, but dependencies may be any format.
- Supports
outFile
.
Examples
ts
// @Filename: main.tsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
js
// @Filename: main.jsdefine(["require", "exports", "mod", "mod"], function (require, exports, mod_1, mod) {"use strict";Object.defineProperty(exports, "__esModule", { value: true });exports.e1 = void 0;const dynamic = new Promise((resolve_1, reject_1) => { require(["mod"], resolve_1, reject_1); });console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);exports.e1 = 0;exports.default = "default export";});
umd
Summary
- Designed for AMD or CommonJS loaders.
- Does not expose a global variable like most other UMD wrappers.
- You probably shouldn’t use this. Use a bundler instead.
- Emitted files are UMD modules, but dependencies may be any format.
Examples
ts
// @Filename: main.tsimport x, { y, z } from "mod";import * as mod from "mod";const dynamic = import("mod");console.log(x, y, z, mod, dynamic);export const e1 = 0;export default "default export";
js
// @Filename: main.js(function (factory) {if (typeof module === "object" && typeof module.exports === "object") {var v = factory(require, exports);if (v !== undefined) module.exports = v;}else if (typeof define === "function" && define.amd) {define(["require", "exports", "mod", "mod"], factory);}})(function (require, exports) {"use strict";var __syncRequire = typeof module === "object" && typeof module.exports === "object";Object.defineProperty(exports, "__esModule", { value: true });exports.e1 = void 0;const mod_1 = require("mod");const mod = require("mod");const dynamic = __syncRequire ? Promise.resolve().then(() => require("mod")) : new Promise((resolve_1, reject_1) => { require(["mod"], resolve_1, reject_1); });console.log(mod_1.default, mod_1.y, mod_1.z, mod, dynamic);exports.e1 = 0;exports.default = "default export";});
The moduleResolution
compiler option
This section describes module resolution features and processes shared by multiple moduleResolution
modes, then specifies the details of each mode. See the Module resolution theory section for more background on what the option is and how it fits into the overall compilation process. In brief, moduleResolution
controls how TypeScript resolves module specifiers (string literals in import
/export
/require
statements) to files on disk, and should be set to match the module resolver used by the target runtime or bundler.
Common features and processes
File extension substitution
TypeScript always wants to resolve internally to a file that can provide type information, while ensuring that the runtime or bundler can use the same path to resolve to a file that provides a JavaScript implementation. For any module specifier that would, according to the moduleResolution
algorithm specified, trigger a lookup of a JavaScript file in the runtime or bundler, TypeScript will first try to find a TypeScript implementation file or type declaration file with the same name and analagous file extension.
Runtime lookup | TypeScript lookup #1 | TypeScript lookup #2 | TypeScript lookup #3 | TypeScript lookup #4 | TypeScript lookup #5 |
---|---|---|---|---|---|
/mod.js |
/mod.ts |
/mod.tsx |
/mod.d.ts |
/mod.js |
./mod.jsx |
/mod.mjs |
/mod.mts |
/mod.d.mts |
/mod.mjs |
||
/mod.cjs |
/mod.cts |
/mod.d.cts |
/mod.cjs |
Note that this behavior is independent of the actual module specifier written in the import. This means that TypeScript can resolve to a .ts
or .d.ts
file even if the module specifier explicitly uses a .js
file extension:
ts
import x from "./mod.js";// Runtime lookup: "./mod.js"// TypeScript lookup #1: "./mod.ts"// TypeScript lookup #2: "./mod.d.ts"// TypeScript lookup #3: "./mod.js"
See TypeScript imitates the host’s module resolution, but with types for an explanation of why TypeScript’s module resolution works this way.
Relative file path resolution
All of TypeScript’s moduleResolution
algorithms support referencing a module by a relative path that includes a file extension (which will be substituted according to the rules above):
ts
// @Filename: a.tsexport {};// @Filename: b.tsimport {} from "./a.js"; // ✅ Works in every `moduleResolution`
Extensionless relative paths
In some cases, the runtime or bundler allows omitting a .js
file extension from a relative path. TypeScript supports this behavior where the moduleResolution
setting and the context indicate that the runtime or bundler supports it:
ts
// @Filename: a.tsexport {};// @Filename: b.tsimport {} from "./a";
If TypeScript determines that the runtime will perform a lookup for ./a.js
given the module specifier "./a"
, then ./a.js
will undergo extension substitution, and resolve to the file a.ts
in this example.
Extensionless relative paths are not supported in import
paths in Node.js, and are not always supported in file paths specified in package.json files. TypeScript currently never supports omitting a .mjs
/.mts
or .cjs
/.cts
file extension, even though some runtimes and bundlers do.
Directory modules (index file resolution)
In some cases, a directory, rather than a file, can be referenced as a module. In the simplest and most common case, this involves the runtime or bundler looking for an index.js
file in a directory. TypeScript supports this behavior where the moduleResolution
setting and the context indicate that the runtime or bundler supports it:
ts
// @Filename: dir/index.tsexport {};// @Filename: b.tsimport {} from "./dir";
If TypeScript determines that the runtime will perform a lookup for ./dir/index.js
given the module specifier "./dir"
, then ./dir/index.js
will undergo extension substitution, and resolve to the file dir/index.ts
in this example.
Directory modules may also contain a package.json file, where resolution of the "main"
and "types"
fields are supported, and take precedence over index.js
lookups. The "typesVersions"
field is also supported in directory modules.
Note that directory modules are not the same as node_modules
packages and only support a subset of the features available to packages, and are not supported at all in some contexts. Node.js considers them a legacy feature.
paths
Overview
TypeScript offers a way to override the compiler’s module resolution for bare specifiers with the paths
compiler option. While the feature was originally designed to be used with the AMD module loader (a means of running modules in the browser before ESM existed or bundlers were widely used), it still has uses today when a runtime or bundler supports module resolution features that TypeScript does not model. For example, when running Node.js with --experimental-network-imports
, you can manually specify a local type definition file for a specific https://
import:
json
{"compilerOptions": {"module": "nodenext","paths": {"https://esm.sh/lodash@4.17.21": ["./node_modules/@types/lodash/index.d.ts"]}}}
ts
// Typed by ./node_modules/@types/lodash/index.d.ts due to `paths` entryimport { add } from "https://esm.sh/lodash@4.17.21";
It’s also common for apps built with bundlers to define convenience path aliases in their bundler configuration, and then inform TypeScript of those aliases with paths
:
json
{"compilerOptions": {"module": "esnext","moduleResolution": "bundler","paths": {"@app/*": ["./src/*"]}}}
paths
does not affect emit
The paths
option does not change the import path in the code emitted by TypeScript. Consequently, it’s very easy to create path aliases that appear to work in TypeScript but will crash at runtime:
json
{"compilerOptions": {"module": "nodenext","paths": {"node-has-no-idea-what-this-is": ["./oops.ts"]}}}
ts
// TypeScript: ✅// Node.js: 💥import {} from "node-has-no-idea-what-this-is";
While it’s ok for bundled apps to set up paths
, it’s very important that published libraries do not, since the emitted JavaScript will not work for consumers of the library without those users setting up the same aliases for both TypeScript and their bundler. Both libraries and apps can consider package.json "imports"
as a standard replacement for convenience paths
aliases.
paths
should not point to monorepo packages or node_modules packages
While module specifiers that match paths
aliases are bare specifiers, once the alias is resolved, module resolution proceeds on the resolved path as a relative path. Consequently, resolution features that happen for node_modules
package lookups, including package.json "exports"
field support, do not take effect when a paths
alias is matched. This can lead to surprising behavior if paths
is used to point to a node_modules
package:
ts
{"compilerOptions": {"paths": {"pkg": ["./node_modules/pkg/dist/index.d.ts"],"pkg/*": ["./node_modules/pkg/*"]}}}
While this configuration may simulate some of the behavior of package resolution, it overrides any main
, types
, exports
, and typesVersions
the package’s package.json
file defines, and imports from the package may fail at runtime.
The same caveat applies to packages referencing each other in a monorepo. Instead of using paths
to make TypeScript artificially resolve "@my-scope/lib"
to a sibling package, it’s best to use workspaces via npm, yarn, or pnpm to symlink your packages into node_modules
, so both TypeScript and the runtime or bundler perform real node_modules
package lookups. This is especially important if the monorepo packages will be published to npm—the packages will reference each other via node_modules
package lookups once installed by users, and using workspaces allows you to test that behavior during local development.
Relationship to baseUrl
When baseUrl
is provided, the values in each paths
array are resolved relative to the baseUrl
. Otherwise, they are resolved relative to the tsconfig.json
file that defines them.
Wildcard substitutions
paths
patterns can contain a single *
wildcard, which matches any string. The *
token can then be used in the file path values to substitute the matched string:
json
{"compilerOptions": {"paths": {"@app/*": ["./src/*"]}}}
When resolving an import of "@app/components/Button"
, TypeScript will match on @app/*
, binding *
to components/Button
, and then attempt to resolve the path ./src/components/Button
relative to the tsconfig.json
path. The remainder of this lookup will follow the same rules as any other relative path lookup according to the moduleResolution
setting.
When multiple patterns match a module specifier, the pattern with the longest matching prefix before any *
token is used:
json
{"compilerOptions": {"paths": {"*": ["./src/foo/one.ts"],"foo/*": ["./src/foo/two.ts"],"foo/bar": ["./src/foo/three.ts"]}}}
When resolving an import of "foo/bar"
, all three paths
patterns match, but the last is used because "foo/bar"
is longer than "foo/"
and ""
.
Fallbacks
Multiple file paths can be provided for a path mapping. If resolution fails for one path, the next one in the array will be attempted until resolution succeeds or the end of the array is reached.
json
{"compilerOptions": {"paths": {"*": ["./vendor/*", "./types/*"]}}}
baseUrl
baseUrl
was designed for use with AMD module loaders. If you aren’t using an AMD module loader, you probably shouldn’t usebaseUrl
. Since TypeScript 4.1,baseUrl
is no longer required to usepaths
and should not be used just to set the directorypaths
values are resolved from.
The baseUrl
compiler option can be combined with any moduleResolution
mode and specifies a directory that bare specifiers (module specifiers that don’t begin with ./
, ../
, or /
) are resolved from. baseUrl
has a higher precedence than node_modules
package lookups in moduleResolution
modes that support them.
When performing a baseUrl
lookup, resolution proceeds with the same rules as other relative path resolutions. For example, in a moduleResolution
mode that supports extensionless relative paths a module specifier "some-file"
may resolve to /src/some-file.ts
if baseUrl
is set to /src
.
Resolution of relative module specifiers are never affected by the baseUrl
option.
node_modules
package lookups
Node.js treats module specifiers that aren’t relative paths, absolute paths, or URLs as references to packages that it looks up in node_modules
subdirectories. Bundlers conveniently adopted this behavior to allow their users to use the same dependency management system, and often even the same dependencies, as they would in Node.js. All of TypeScript’s moduleResolution
options except classic
support node_modules
lookups. (classic
supports lookups in node_modules/@types
when other means of resolution fail, but never looks for packages in node_modules
directly.) Every node_modules
package lookup has the following structure (beginning after higher precedence bare specifier rules, like paths
, baseUrl
, self-name imports, and package.json "imports"
lookups have been exhausted):
- For each ancestor directory of the importing file, if a
node_modules
directory exists within it:- If a directory with the same name as the package exists within
node_modules
:- Attempt to resolve types from the package directory.
- If a result is found, return it and stop the search.
- If a directory with the same name as the package exists within
node_modules/@types
:- Attempt to resolve types from the
@types
package directory. - If a result is found, return it and stop the search.
- Attempt to resolve types from the
- If a directory with the same name as the package exists within
- Repeat the previous search through all
node_modules
directories, but this time, allow JavaScript files as a result, and do not search in@types
directories.
All moduleResolution
modes (except classic
) follow this pattern, while the details of how they resolve from a package directory, once located, differ, and are explained in the following sections.
package.json "exports"
When moduleResolution
is set to node16
, nodenext
, or bundler
, and resolvePackageJsonExports
is not disabled, TypeScript follows Node.js’s package.json "exports"
spec when resolving from a package directory triggered by a bare specifier node_modules
package lookup.
TypeScript’s implementation for resolving a module specifier through "exports"
to a file path follows Node.js exactly. Once a file path is resolved, however, TypeScript will still try multiple file extensions in order to prioritize finding types.
When resolving through conditional "exports"
, TypeScript always matches the "types"
and "default"
conditions if present. Additionally, TypeScript will match a versioned types condition in the form "types@{selector}"
(where {selector}
is a "typesVersions"
-compatible version selector) according to the same version-matching rules implemented in "typesVersions"
. Other non-configurable conditions are dependent on the moduleResolution
mode and specified in the following sections. Additional conditions can be configured to match with the customConditions
compiler option.
Note that the presence of "exports"
prevents any subpaths not explicitly listed or matched by a pattern in "exports"
from being resolved.
Example: subpaths, conditions, and extension substitution
Scenario: "pkg/subpath"
is requested with conditions ["types", "node", "require"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) in a package directory with the following package.json:
json
{"name": "pkg","exports": {".": {"import": "./index.mjs","require": "./index.cjs"},"./subpath": {"import": "./subpath/index.mjs","require": "./subpath/index.cjs"}}}
Resolution process within the package directory:
- Does
"exports"
exist? Yes. - Does
"exports"
have a"./subpath"
entry? Yes. - The value at
exports["./subpath"]
is an object—it must be specifying conditions. - Does the first condition
"import"
match this request? No. - Does the second condition
"require"
match this request? Yes. - Does the path
"./subpath/index.cjs"
have a recognized TypeScript file extension? No, so use extension substitution. - Via extension substitution, try the following paths, returning the first one that exists, or
undefined
otherwise:./subpath/index.cts
./subpath/index.d.cts
./subpath/index.cjs
If ./subpath/index.cts
or ./subpath.d.cts
exists, resolution is complete. Otherwise, resolution searches node_modules/@types/pkg
and other node_modules
directories in an attempt to resolve types, according to the node_modules
package lookups rules. If no types are found, a second pass through all node_modules
resolves to ./subpath/index.cjs
(assuming it exists), which counts as a successful resolution, but one that does not provide types, leading to any
-typed imports and a noImplicitAny
error if enabled.
Example: explicit "types"
condition
Scenario: "pkg/subpath"
is requested with conditions ["types", "node", "import"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) in a package directory with the following package.json:
json
{"name": "pkg","exports": {"./subpath": {"import": {"types": "./types/subpath/index.d.mts","default": "./es/subpath/index.mjs"},"require": {"types": "./types/subpath/index.d.cts","default": "./cjs/subpath/index.cjs"}}}}
Resolution process within the package directory:
- Does
"exports"
exist? Yes. - Does
"exports"
have a"./subpath"
entry? Yes. - The value at
exports["./subpath"]
is an object—it must be specifying conditions. - Does the first condition
"import"
match this request? Yes. - The value at
exports["./subpath"].import
is an object—it must be specifying conditions. - Does the first condition
"types"
match this request? Yes. - Does the path
"./types/subpath/index.d.mts"
have a recognized TypeScript file extension? Yes, so don’t use extension substitution. - Return the path
"./types/subpath/index.d.mts"
if the file exists,undefined
otherwise.
Example: versioned "types"
condition
Scenario: using TypeScript 4.7.5, "pkg/subpath"
is requested with conditions ["types", "node", "import"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) in a package directory with the following package.json:
json
{"name": "pkg","exports": {"./subpath": {"types@>=5.2": "./ts5.2/subpath/index.d.ts","types@>=4.6": "./ts4.6/subpath/index.d.ts","types": "./tsold/subpath/index.d.ts","default": "./dist/subpath/index.js"}}}
Resolution process within the package directory:
- Does
"exports"
exist? Yes. - Does
"exports"
have a"./subpath"
entry? Yes. - The value at
exports["./subpath"]
is an object—it must be specifying conditions. - Does the first condition
"types@>=5.2"
match this request? No, 4.7.5 is not greater than or equal to 5.2. - Does the second condition
"types@>=4.6"
match this request? Yes, 4.7.5 is greater than or equal to 4.6. - Does the path
"./ts4.6/subpath/index.d.ts"
have a recognized TypeScript file extension? Yes, so don’t use extension substitution. - Return the path
"./ts4.6/subpath/index.d.ts"
if the file exists,undefined
otherwise.
Example: subpath patterns
Scenario: "pkg/wildcard.js"
is requested with conditions ["types", "node", "import"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) in a package directory with the following package.json:
json
{"name": "pkg","type": "module","exports": {"./*.js": {"types": "./types/*.d.ts","default": "./dist/*.js"}}}
Resolution process within the package directory:
- Does
"exports"
exist? Yes. - Does
"exports"
have a"./wildcard.js"
entry? No. - Does any key with a
*
in it match"./wildcard.js"
? Yes,"./*.js"
matches and setswildcard
to be the substitution. - The value at
exports["./*.js"]
is an object—it must be specifying conditions. - Does the first condition
"types"
match this request? Yes. - In
./types/*.d.ts
, replace*
with the substitutionwildcard
../types/wildcard.d.ts
- Does the path
"./types/wildcard.d.ts"
have a recognized TypeScript file extension? Yes, so don’t use extension substitution. - Return the path
"./types/wildcard.d.ts"
if the file exists,undefined
otherwise.
Example: "exports"
block other subpaths
Scenario: "pkg/dist/index.js"
is requested in a package directory with the following package.json:
json
{"name": "pkg","main": "./dist/index.js","exports": "./dist/index.js"}
Resolution process within the package directory:
- Does
"exports"
exist? Yes. - The value at
exports
is a string—it must be a file path for the package root ("."
). - Is the request
"pkg/dist/index.js"
for the package root? No, it has a subpathdist/index.js
. - Resolution fails; return
undefined
.
Without "exports"
, the request could have succeeded, but the presence of "exports"
prevents resolving any subpaths that cannot be matched through "exports"
.
package.json "typesVersions"
A node_modules
package or directory module may specify a "typesVersions"
field in its package.json to redirect TypeScript’s resolution process according to the TypeScript compiler version, and for node_modules
packages, according to the subpath being resolved. This allows package authors to include new TypeScript syntax in one set of type definitions while providing another set for backward compatibility with older TypeScript versions (through a tool like downlevel-dts). "typesVersions"
is supported in all moduleResolution
modes; however, the field is not read in situations when package.json "exports"
are read.
Example: redirect all requests to a subdirectory
Scenario: a module imports "pkg"
using TypeScript 5.2, where node_modules/pkg/package.json
is:
json
{"name": "pkg","version": "1.0.0","types": "./index.d.ts","typesVersions": {">=3.1": {"*": ["ts3.1/*"]}}}
Resolution process:
- (Depending on compiler options) Does
"exports"
exist? No. - Does
"typesVersions"
exist? Yes. - Is the TypeScript version
>=3.1
? Yes. Remember the mapping"*": ["ts3.1/*"]
. - Are we resolving a subpath after the package name? No, just the root
"pkg"
. - Does
"types"
exist? Yes. - Does any key in
"typesVersions"
match./index.d.ts
? Yes,"*"
matches and setsindex.d.ts
to be the substitution. - In
ts3.1/*
, replace*
with the substitution./index.d.ts
:ts3.1/index.d.ts
. - Does the path
./ts3.1/index.d.ts
have a recognized TypeScript file extension? Yes, so don’t use extension substitution. - Return the path
./ts3.1/index.d.ts
if the file exists,undefined
otherwise.
Example: redirect requests for a specific file
Scenario: a module imports "pkg"
using TypeScript 3.9, where node_modules/pkg/package.json
is:
json
{"name": "pkg","version": "1.0.0","types": "./index.d.ts","typesVersions": {"<4.0": { "index.d.ts": ["index.v3.d.ts"] }}}
Resolution process:
- (Depending on compiler options) Does
"exports"
exist? No. - Does
"typesVersions"
exist? Yes. - Is the TypeScript version
<4.0
? Yes. Remember the mapping"index.d.ts": ["index.v3.d.ts"]
. - Are we resolving a subpath after the package name? No, just the root
"pkg"
. - Does
"types"
exist? Yes. - Does any key in
"typesVersions"
match./index.d.ts
? Yes,"index.d.ts"
matches. - Does the path
./index.v3.d.ts
have a recognized TypeScript file extension? Yes, so don’t use extension substitution. - Return the path
./index.v3.d.ts
if the file exists,undefined
otherwise.
package.json "main"
and "types"
If a directory’s package.json "exports"
field is not read (either due to compiler options, or because it is not present, or because the directory is being resolved as a directory module instead of a node_modules
package) and the module specifier does not have a subpath after the package name or package.json-containing directory, TypeScript will attempt to resolve from these package.json fields, in order, in an attempt to find the main module for the package or directory:
"types"
"typings"
(legacy)"main"
The declaration file found at "types"
is assumed to be an accurate representation of the implementation file found at "main"
. If "types"
and "typings"
are not present or cannot be resolved, TypeScript will read the "main"
field and perform extension substitution to find a declaration file.
When publishing a typed package to npm, it’s recommended to include a "types"
field even if extension substitution or package.json "exports"
make it unnecessary, because npm shows a TS icon on the package registry listing only if the package.json contains a "types"
field.
Package-relative file paths
If neither package.json "exports"
nor package.json "typesVersions"
apply, subpaths of a bare package specifier resolve relative to the package directory, according to applicable relative path resolution rules. In modes that respect [package.json "exports"
], this behavior is blocked by the mere presence of the "exports"
field in the package’s package.json, even if the import fails to resolve through "exports"
, as demonstrated in an example above. On the other hand, if the import fails to resolve through "typesVersions"
, a package-relative file path resolution is attempted as a fallback.
When package-relative paths are supported, they resolve under the same rules as any other relative path considering the moduleResolution
mode and context. For example, in --moduleResolution nodenext
, directory modules and extensionless paths are only supported in require
calls, not in import
s:
ts
// @Filename: module.mtsimport "pkg/dist/foo"; // ❌ import, needs `.js` extensionimport "pkg/dist/foo.js"; // ✅import foo = require("pkg/dist/foo"); // ✅ require, no extension needed
package.json "imports"
and self-name imports
When moduleResolution
is set to node16
, nodenext
, or bundler
, and resolvePackageJsonImports
is not disabled, TypeScript will attempt to resolve import paths beginning with #
through the "imports"
field of the nearest ancestor package.json of the importing file. Similarly, when package.json "exports"
lookups are enabled, TypeScript will attempt to resolve import paths beginning with the current package name—that is, the value in the "name"
field of the nearest ancestor package.json of the importing file—through the "exports"
field of that package.json. Both of these features allow files in a package to import other files in the same package, replacing a relative import path.
TypeScript follows Node.js’s resolution algorithm for "imports"
and self references exactly up until a file path is resolved. At that point, TypeScript’s resolution algorithm forks based on whether the package.json containing the "imports"
or "exports"
being resolved belongs to a node_modules
dependency or the local project being compiled (i.e., its directory contains the tsconfig.json file for the project that contains the importing file):
- If the package.json is in
node_modules
, TypeScript will apply extension substitution to the file path if it doesn’t already have a recognized TypeScript file extension, and check for the existence of the resulting file paths. - If the package.json is part of the local project, an additional remapping step is performed in order to find the input TypeScript implementation file that will eventually produce the output JavaScript or declaration file path that was resolved from
"imports"
. Without this step, any compilation that resolves an"imports"
path would be referencing output files from the previous compilation instead of other input files that are intended to be included in the current compilation. This remapping uses theoutDir
/declarationDir
androotDir
from the tsconfig.json, so using"imports"
usually requires an explicitrootDir
to be set.
This variation allows package authors to write "imports"
and "exports"
fields that reference only the compilation outputs that will be published to npm, while still allowing local development to use the original TypeScript source files.
Example: local project with conditions
Scenario: "/src/main.mts"
imports "#utils"
with conditions ["types", "node", "import"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) in a project directory with a tsconfig.json and package.json:
json
// tsconfig.json{"compilerOptions": {"moduleResolution": "node16","resolvePackageJsonImports": true,"rootDir": "./src","outDir": "./dist"}}
json
// package.json{"name": "pkg","imports": {"#utils": {"import": "./dist/utils.d.mts","require": "./dist/utils.d.cts"}}}
Resolution process:
- Import path starts with
#
, try to resolve through"imports"
. - Does
"imports"
exist in the nearest ancestor package.json? Yes. - Does
"#utils"
exist in the"imports"
object? Yes. - The value at
imports["#utils"]
is an object—it must be specifying conditions. - Does the first condition
"import"
match this request? Yes. - Should we attempt to map the output path to an input path? Yes, because:
- Is the package.json in
node_modules
? No, it’s in the local project. - Is the tsconfig.json within the package.json directory? Yes.
- Is the package.json in
- In
./dist/utils.d.mts
, replace theoutDir
prefix withrootDir
../src/utils.d.mts
- Replace the output extension
.d.mts
with the corresponding input extension.mts
../src/utils.mts
- Return the path
"./src/utils.mts"
if the file exists. - Otherwise, return the path
"./dist/utils.d.mts"
if the file exists.
Example: node_modules
dependency with subpath pattern
Scenario: "/node_modules/pkg/main.mts"
imports "#internal/utils"
with conditions ["types", "node", "import"]
(determined by moduleResolution
setting and the context that triggered the module resolution request) with the package.json:
json
// /node_modules/pkg/package.json{"name": "pkg","imports": {"#internal/*": {"import": "./dist/internal/*.mjs","require": "./dist/internal/*.cjs"}}}
Resolution process:
- Import path starts with
#
, try to resolve through"imports"
. - Does
"imports"
exist in the nearest ancestor package.json? Yes. - Does
"#internal/utils"
exist in the"imports"
object? No, check for pattern matches. - Does any key with a
*
match"#internal/utils"
? Yes,"#internal/*"
matches and setsutils
to be the substitution. - The value at
imports["#internal/*"]
is an object—it must be specifying conditions. - Does the first condition
"import"
match this request? Yes. - Should we attempt to map the output path to an input path? No, because the package.json is in
node_modules
. - In
./dist/internal/*.mjs
, replace*
with the substitutionutils
../dist/internal/utils.mjs
- Does the path
./dist/internal/utils.mjs
have a recognized TypeScript file extension? No, try extension substitution. - Via extension substitution, try the following paths, returning the first one that exists, or
undefined
otherwise:./dist/internal/utils.mts
./dist/internal/utils.d.mts
./dist/internal/utils.mjs
node16
, nodenext
These modes reflect the module resolution behavior of Node.js v12 and later. (node16
and nodenext
are currently identical, but if Node.js makes significant changes to its module system in the future, node16
will be frozen while nodenext
will be updated to reflect the new behavior.) In Node.js, the resolution algorithm for ECMAScript imports is significantly different from the algorithm for CommonJS require
calls. For each module specifier being resolved, the syntax and the module format of the importing file are first used to determine whether the module specifier will be in an import
or require
in the emitted JavaScript. That information is then passed into the module resolver to determine which resolution algorithm to use (and whether to use the "import"
or "require"
condition for package.json "exports"
or "imports"
).
TypeScript files that are determined to be in CommonJS format may still use
import
andexport
syntax by default, but the emitted JavaScript will userequire
andmodule.exports
instead. This means that it’s common to seeimport
statements that are resolved using therequire
algorithm. If this causes confusion, theverbatimModuleSyntax
compiler option can be enabled, which prohibits the use ofimport
statements that would be emitted asrequire
calls.
Note that dynamic import()
calls are always resolved using the import
algorithm, according to Node.js’s behavior. However, import()
types are resolved according to the format of the importing file (for backward compatibility with existing CommonJS-format type declarations):
ts
// @Filename: module.mtsimport x from "./mod.js"; // `import` algorithm due to file format (emitted as-written)import("./mod.js"); // `import` algorithm due to syntax (emitted as-written)type Mod = typeof import("./mod.js"); // `import` algorithm due to file formatimport mod = require("./mod"); // `require` algorithm due to syntax (emitted as `require`)// @Filename: commonjs.ctsimport x from "./mod"; // `require` algorithm due to file format (emitted as `require`)import("./mod.js"); // `import` algorithm due to syntax (emitted as-written)type Mod = typeof import("./mod"); // `require` algorithm due to file formatimport mod = require("./mod"); // `require` algorithm due to syntax (emitted as `require`)
Implied and enforced options
--moduleResolution node16
andnodenext
must be paired with their correspondingmodule
value.
Supported features
Features are listed in order of precedence.
import |
require |
|
---|---|---|
paths |
✅ | ✅ |
baseUrl |
✅ | ✅ |
node_modules package lookups |
✅ | ✅ |
package.json "exports" |
✅ matches types , node , import |
✅ matches types , node , require |
package.json "imports" and self-name imports |
✅ matches types , node , import |
✅ matches types , node , require |
package.json "typesVersions" |
✅ | ✅ |
Package-relative paths | ✅ when exports not present |
✅ when exports not present |
Full relative paths | ✅ | ✅ |
Extensionless relative paths | ❌ | ✅ |
Directory modules | ❌ | ✅ |
bundler
--moduleResolution bundler
attempts to model the module resolution behavior common to most JavaScript bundlers. In short, this means supporting all the behaviors traditionally associated with Node.js’s CommonJS require
resolution algorithm like node_modules
lookups, directory modules, and extensionless paths, while also supporting newer Node.js resolution features like package.json "exports"
and package.json "imports"
.
It’s instructive to think about the similarities and differences between --moduleResolution bundler
and --moduleResolution nodenext
, particularly in how they decide what conditions to use when resolving package.json "exports"
or "imports"
. Consider an import statement in a .ts
file:
ts
// index.tsimport { foo } from "pkg";
Recall that in --module nodenext --moduleResolution nodenext
, the --module
setting first determines whether the import will be emitted to the .js
file as an import
or require
call, then passes that information to TypeScript’s module resolver, which decides whether to match "import"
or "require"
conditions in "pkg"
’s package.json "exports"
accordingly. Let’s assume that there’s no package.json in scope of this file. The file extension is .ts
, so the output file extension will be .js
, which Node.js will interpret as CommonJS, so TypeScript will emit this import
as a require
call. So, the module resolver will use the require
condition as it resolves "exports"
from "pkg"
.
The same process happens in --moduleResolution bundler
, but the rules for deciding whether to emit an import
or require
call for this import statement will be different, since --moduleResolution bundler
necessitates using --module esnext
or --module preserve
. In both of those modes, ESM import
declarations always emit as ESM import
declarations, so TypeScript’s module resolver will receive that information and use the "import"
condition as it resolves "exports"
from "pkg"
.
This explanation may be somewhat unintuitive, since --moduleResolution bundler
is usually used in combination with --noEmit
—bundlers typically process raw .ts
files and perform module resolution on untransformed import
s or require
s. However, for consistency, TypeScript still uses the hypothetical emit decided by module
to inform module resolution and type checking. This makes --module preserve
the best choice whenever a runtime or bundler is operating on raw .ts
files, since it implies no transformation. Under --module preserve --moduleResolution bundler
, you can write imports and requires in the same file that will resolve with the import
and require
conditions, respectively:
ts
// index.tsimport pkg1 from "pkg"; // Resolved with "import" conditionimport pkg2 = require("pkg"); // Resolved with "require" condition
Implied and enforced options
--moduleResolution bundler
must be paired with--module esnext
or--module preserve
.--moduleResolution bundler
implies--allowSyntheticDefaultImports
.
Supported features
paths
✅baseUrl
✅node_modules
package lookups ✅- package.json
"exports"
✅ matchestypes
,import
/require
depending on syntax - package.json
"imports"
and self-name imports ✅ matchestypes
,import
/require
depending on syntax - package.json
"typesVersions"
✅ - Package-relative paths ✅ when
exports
not present - Full relative paths ✅
- Extensionless relative paths ✅
- Directory modules ✅
node10
(formerly known as node
)
--moduleResolution node
was renamed to node10
(keeping node
as an alias for backward compatibility) in TypeScript 5.0. It reflects the CommonJS module resolution algorithm as it existed in Node.js versions earlier than v12. It should no longer be used.
Supported features
paths
✅baseUrl
✅node_modules
package lookups ✅- package.json
"exports"
❌ - package.json
"imports"
and self-name imports ❌ - package.json
"typesVersions"
✅ - Package-relative paths ✅
- Full relative paths ✅
- Extensionless relative paths ✅
- Directory modules ✅
classic
Do not use classic
.