A TypeScript module is a file with at least one top-level import or export. Everything declared in it is private to the file unless you export it, and other files import what they need. The syntax is JavaScript's ES module syntax plus a few type-only forms.
Named and Default Exports
A project has many files, so the importing side looks like this. The default export is imported without braces and can take any name; named exports go in braces and keep their names unless you rename them with as.
// main.ts
import describe, { distance, ORIGIN, type Point } from "./math.js";
import { distance as dist } from "./math.js"; // renamed on import
import * as math from "./math.js"; // everything, as one object
const p: Point = { x: 6, y: 8 };
console.log(describe(p), distance(ORIGIN, p), dist(p, p), math.ORIGIN);
| Form | What it imports |
|---|---|
import { a, b } from "./m.js" | named exports a and b |
import x from "./m.js" | the default export, under the name x |
import * as m from "./m.js" | a namespace object holding every export |
import { a as b } from "./m.js" | a, renamed to b in this file |
import type { T } from "./m.js" | types only, removed from the output |
import "./setup.js" | runs the file for its side effects |
export { a } from "./m.js" | re-exports a without importing it |
export * from "./m.js" | re-exports every named export |
Re-exports let one file (often index.ts) collect the public API of a folder. Plain JavaScript module behavior, such as live bindings and module caching, is covered in ES modules.
import type and export type
Types do not exist at run time, so an import used only as a type has nothing to load. import type says so explicitly, and the statement disappears from the JavaScript output. The type modifier also works on a single name inside a normal import.
import type { User } from "./models.js"; // whole statement erased
import { saveUser, type Settings } from "./api.js"; // only saveUser survives
export type { User }; // re-export a type only
export type UserId = User["id"];
Without the keyword, TypeScript still removes names it can see are only types. Two settings make the keyword required:
verbatimModuleSyntax: truekeeps every import that is not markedtype, so an unmarked type import is error TS1484:'Point' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.- Running
.tsfiles directly in Node (type stripping) removes type annotations but does not look at other files. An unmarkedimport { Point }stays in the code, and Node fails at run time withSyntaxError: The requested module './math.ts' does not provide an export named 'Point'.
Writing type on every type-only import works in both cases, so it is the habit to build.
Cannot Find Module
When the path in an import does not lead to a file or a package with types, the compiler stops with error TS2307. Run this one to see it:
The output is index.ts(2,29): error TS2307: Cannot find module './utils.js' or its corresponding type declarations. The usual causes:
- A typo in a relative path, or a missing
./(without it, the name is looked up innode_modules). - A JavaScript package with no bundled types: install
@types/{package}if it exists, or write a declaration file for it. - A subpath the package does not list in the
exportsfield of itspackage.json. Undernode16,nodenextandbundlerresolution, only listed entry points can be imported.
ES Modules or CommonJS Output
You always write import and export. The module option in tsconfig.json decides what JavaScript comes out.
// Source, the same in both cases
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package with "type": "module"
import { add } from "./math.js";
console.log(add(1, 2));
// module: nodenext, in a package without "type": "module"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const math_js_1 = require("./math.js");
console.log((0, math_js_1.add)(1, 2));
With node16, node18, node20 or nodenext, TypeScript follows Node's own rules for each file:
| File | Output format |
|---|---|
.ts in a package with "type": "module" | ES module |
.ts in a package without it | CommonJS |
.mts | always ES module, emitted as .mjs |
.cts | always CommonJS, emitted as .cjs |
With module: "esnext" or "preserve", the output keeps import/export, which is what bundlers such as Vite and esbuild expect. The differences between the two module systems at run time are in CommonJS vs ESM.
File Extensions in Imports
Under module: node16 or nodenext, an ES module file must name the file Node will actually load, and that is the compiled .js file. TypeScript maps ./math.js back to math.ts for type checking.
import { add } from "./math"; // error TS2835 in an ES module file
import { add } from "./math.js"; // correct: the path as it exists after compiling
The error text is Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './math.js'? CommonJS files under the same settings may leave the extension out, because require tries extensions itself.
Two other setups change the rule:
moduleResolution: "bundler"(withmoduleset toesnext,preserveorcommonjs) accepts./mathwith no extension, since the bundler resolves it.rewriteRelativeImportExtensions: truelets you write./math.ts, the same path that works when Node runs the.tsfile directly, and rewrites it to./math.jsin the output.
Module Resolution and paths
For a bare name like "zod", TypeScript looks in node_modules, reads the package's package.json (its exports and types fields), and falls back to node_modules/@types/zod. Relative names (./, ../) are resolved from the importing file. A .json file can be imported as well; the settings that needs are on the JSON page.
paths in tsconfig.json adds aliases for your own folders:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@lib/*": ["./src/lib/*"]
}
}
}
paths only affects type checking. The emitted file still says import { v } from "@lib/util", so something else must resolve it at run time: a bundler configured with the same alias, or Node's imports field in package.json (aliases that start with #, like "#lib/*": "./dist/lib/*"), which works without any extra tool. baseUrl is gone in TypeScript 7 (error TS5102); write the paths entries relative to the tsconfig.json with a leading ./.
Frequently Asked Questions
What is the difference between import and import type in TypeScript?
import type { User } from "./user.js" can only bring in types, and the whole statement is removed from the JavaScript output. A plain import can bring in values and types; TypeScript drops the names that are only used as types, but with verbatimModuleSyntax it requires you to mark those with type so the output is exactly what you wrote.
Why does TypeScript want .js in import paths?
Under module: node16 or nodenext, an ES module file must import with the real file name Node will load at run time, and that file is the compiled .js. TypeScript resolves ./math.js to math.ts while type checking. Leaving the extension out in an ES module file is error TS2835.
Should I use export default or named exports in TypeScript?
Both work. Many teams prefer named exports: the name is the same in every file that imports it, editors auto-import them reliably, and renaming is a refactor rather than a search. A default export lets each importer pick its own name.
Does the paths option in tsconfig change the import in the output?
No. paths only tells the type checker where to find a module. The emitted JavaScript keeps "@lib/util" as written, so a bundler, or Node's imports field in package.json, has to resolve it at run time.
How do I fix "Cannot find module" in TypeScript?
Error TS2307 means the path does not point to a file TypeScript can find, or a package ships no types. Check the relative path and extension, install the package's @types/... package if it has no built-in types, or write a small declaration file for it.