ES Modules in TypeScript

ES modules let you split TypeScript code into files that import and export values. Learn the syntax, how TypeScript adds type checking, and how to set up your first multi-file project.

7 min read

ES modules in TypeScript are the standard way to split code into separate files that import and export values. TypeScript uses the same import and export keywords you already know from JavaScript, but adds compile-time type checking across module boundaries.

A file becomes a module when it has at least one top-level import or export statement. Everything declared inside a module is private to that module unless you explicitly export it.

Here is the simplest two-file setup. The first file exports a function, and the second file imports it:

typescripttypescript
// math.ts
export function add(a: number, b: number): number {
  return a + b;
}

The import path in the consuming file uses .js rather than .ts. TypeScript resolves the .js extension back to the .ts source during compilation.

typescripttypescript
// app.ts
import { add } from "./math.js";
 
console.log(add(2, 3));

The import path uses ./math.js, not ./math.ts. TypeScript resolves the .js extension to the .ts source file during compilation, then emits the same path in the output JavaScript. At runtime, the browser or Node.js loads the compiled .js file.

Running this code prints the number 5 to the console. TypeScript checks that the function named add exists in math.ts, that it accepts two numbers, and that it returns a number. If you pass a string, TypeScript reports the error before the code ever runs.

How TypeScript Handles Modules

TypeScript does not invent a new module system. It uses ECMAScript modules, the same standard that browsers and Node.js use. The compiler's job is to check types across files and then produce JavaScript that the target runtime understands.

TypeScript module compilation flow

The diagram shows the flow: source files go into the compiler, type errors come out if anything is wrong, and plain JavaScript files come out if everything checks out. The compiler reads every .ts file, resolves all import and export statements, and checks that the types line up.

Type annotations, interfaces, and type aliases are completely removed from the output. Only the runtime JavaScript remains.

Import Syntax

TypeScript supports every ECMAScript import style. Each has a different use case.

Named Imports

Import specific exports by name. This is the most common style because it gives you exactly what you need and nothing else.

typescripttypescript
import { add, subtract } from "./math.js";
 
console.log(add(5, 3));
console.log(subtract(5, 3));

When you run this, you see 8 and 2 printed. TypeScript checks that both add and subtract exist as named exports in math.ts. If you misspell a name or import something that was never exported, TypeScript reports an error at compile time before any code runs.

Default Imports

Import the default export from a module. A module can have at most one default export, which makes this style useful when a file has a single main purpose.

typescripttypescript
import multiply from "./multiply.js";
 
console.log(multiply(4, 5));

This prints 20. The name you choose for the import is local to your file. It does not need to match the original name in the source module, though keeping names consistent across the project is good practice.

Namespace Imports

Import everything a module exports as a single namespace object. This is handy when you need most of what a module offers and want each export accessible through a clear prefix.

typescripttypescript
import * as MathUtils from "./math.js";
 
console.log(MathUtils.add(10, 2));
console.log(MathUtils.subtract(10, 2));

The output is 12 and 8. The MathUtils object contains every named export from math.ts as a property. TypeScript knows the exact shape of this object, so autocomplete works for every property and you cannot accidentally access a name that does not exist.

Importing Types Only

When you only need a type from another module, mark the import as type-only. This makes it explicit that the import is erased at compile time and produces no JavaScript.

typescripttypescript
import type { User } from "./models.js";
 
function greet(user: User): string {
  return `Hello, ${user.name}`;
}

The import type statement is completely removed from the compiled JavaScript. It exists only to tell TypeScript what shape User has so the compiler can check that user.name is a valid property access. For a deeper look, see type-only imports in TypeScript.

Side-Effect Imports

Import a module just for its side effects, without using any of its exports. The module runs once when first imported.

typescripttypescript
import "./setup.js";

This runs setup.ts once, even though you do not grab any exported values. It is useful for modules that register global styles, polyfills, or configuration that other parts of the application depend on.

Export Syntax

Every export style mirrors the corresponding import style. Here is a summary of the available patterns.

Export styleSyntaxImport style
Named exportexport function add() {}import { add } from "./math.js"
Default exportexport default function() {}import add from "./math.js"
Inline named exportexport { add, subtract }import { add, subtract } from "./math.js"
Rename exportexport { add as sum }import { sum } from "./math.js"
Re-exportexport { add } from "./math.js"Same as importing from original module

Named exports are the most common and the most predictable because the name is checked at both ends. Default exports are convenient for modules that export a single main value. For a detailed comparison, see named exports in TypeScript and default exports in TypeScript.

Module Format Detection

TypeScript needs to know whether a file is an ES module or a CommonJS module. It determines this automatically based on your project configuration.

In Node.js projects, the module format depends on the nearest package.json file. If the type field is set to "module", .ts files are treated as ES modules. If the type field is set to "commonjs" or is absent entirely, .ts files are treated as CommonJS.

You can override this detection with file extensions. Files ending in .mts are always ES modules, regardless of what package.json says. Files ending in .cts are always CommonJS modules.

The following example shows a file that forces ES module format by using the .mts extension:

typescripttypescript
// This file is always an ES module, even without "type": "module" in package.json
// file: logger.mts
 
export function log(message: string): void {
  console.log(`[LOG] ${message}`);
}

The detected module format affects how TypeScript emits JavaScript. An ES module file emits import and export statements. A CommonJS file emits require calls and module.exports assignments.

Module Resolution

TypeScript resolves import paths using the algorithm specified by the moduleResolution compiler option. The most common settings and when to use each one:

SettingUse case
bundlerProjects using Vite, Webpack, Turbopack, or similar bundlers
node16 / nodenextNode.js projects using ES modules or CommonJS
node10Legacy Node.js projects, avoid for new code

With bundler resolution, you can write extensionless imports and directory imports. With node16 or nodenext, you must include the full .js extension in relative imports, matching Node.js behavior. For more on how TypeScript finds modules on disk, see module resolution in TypeScript.

Common Mistakes

Forgetting .js extensions with nodenext. When using nodenext resolution, relative imports must end with .js, never .ts. TypeScript resolves the .js to the .ts source file, but Node.js needs the .js path at runtime. The first import below is wrong; the second is correct:

typescripttypescript
// Wrong with nodenext
import { add } from "./math";
 
// Correct with nodenext
import { add } from "./math.js";

Importing a type as a value. An interface has no runtime representation, so you cannot construct it with new. Here is the code that triggers this mistake:

typescripttypescript
import { User } from "./models.js";
 
const u = new User();

TypeScript catches this at compile time, before any JavaScript is emitted. The interface User only describes a shape; it has no constructor function to call, so the compiler rejects the expression outright:

texttext
error TS2693: 'User' only refers to a type, but is being used as a value here.

Use import type when you only need the type, or make sure the export is a class rather than just an interface. The compiler catches this mistake immediately, so it never reaches the runtime.

Creating circular imports. When module A imports from module B and module B imports from module A, you create a circular dependency that can produce undefined values at runtime. For strategies to prevent this, see avoid circular imports in TypeScript.

Starting a Multi-File TypeScript Project

A minimal multi-file TypeScript project needs three things: a tsconfig.json, at least one module that exports something, and at least one module that imports it.

Start with the configuration file that tells TypeScript how to compile. This minimal tsconfig sets up ES module output with strict type checking:

jsonjson
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

With the configuration in place, create a module that exports a reusable function. This is the file other modules will import from:

typescripttypescript
// src/greeting.ts
export function hello(name: string): string {
  return `Hello, ${name}!`;
}

Finally, create the entry point that imports the greeting function and calls it with a name. This is the file you run to see the whole project work together:

typescripttypescript
// src/index.ts
import { hello } from "./greeting.js";
 
console.log(hello("TypeScript"));

Run tsc to compile, then run the output with Node.js. TypeScript checks that hello accepts a string and returns a string, all before a single line of JavaScript executes.

Rune AI

Rune AI

Key Insights

  • TypeScript uses standard JavaScript import and export syntax for modules.
  • A file with a top-level import or export is a module. A file without one is a script.
  • TypeScript checks types across module boundaries but strips all type syntax from the compiled output.
  • Use .mts to force ES module format and .cts to force CommonJS format.
  • The module compiler option controls the output format, while moduleResolution controls how imports are resolved.
RunePowered by Rune AI

Frequently Asked Questions

Does TypeScript have its own module system?

No. TypeScript uses the same ES module syntax as JavaScript. It adds type checking on top of imports and exports, but the module system is ECMAScript modules, not something TypeScript-specific.

Should I use .ts or .mts file extensions?

Use .ts for most files. Use .mts when you want to force ES module format regardless of your package.json settings, or when you are writing a library that needs explicit module format control. Use .cts when you need to force CommonJS format.

Do I need to include .js extensions in TypeScript imports?

It depends on your moduleResolution setting. With node16 or nodenext, you must include the .js extension in relative imports. With bundler, extensions are optional. The safest modern default is to always include .js extensions.

Conclusion

ES modules in TypeScript work exactly like JavaScript ES modules, with one addition: TypeScript checks the types of everything you import and export. Use import to bring values and types into a file, use export to share them with other files, and let the compiler options control the output module format.