Modules & Imports

TypeScript · Lesson 21 of 28 7 min read Updated

Real projects are split into many files: one for billing, one for users, one for the database. Each file is a module that exports what other files need and imports what it uses. TypeScript uses the standard JavaScript module syntax (import and export) and adds a few things: importing types, checking that every import exists, and settings that decide how import paths are found.

In this lesson you will export and import values and types, learn why import type matters, see how the module setting affects file extensions, and recognise older patterns such as require and namespaces. For the JavaScript basics of modules, see Modules.

Exporting and importing

Any file that contains import or export is a module. Everything inside it is private to the file unless it is exported. You can export values (functions, constants, classes) and types in the same way:

TypeScriptRun it yourself
// billing.ts
export type Item = { name: string; price: number; qty: number };

export const GST_RATE = 0.18;

export function lineTotal(item: Item): number {
  return item.price * item.qty;
}

export default function formatBill(items: Item[]): string {
  const subtotal = items.reduce((sum, item) => sum + lineTotal(item), 0);
  const gst = Math.round(subtotal * GST_RATE);
  return `Subtotal ₹${subtotal} + GST ₹${gst} = ₹${subtotal + gst}`;
}

// Another file would import these like this:
// import formatBill, { lineTotal, GST_RATE, type Item } from "./billing.ts";

console.log(formatBill([
  { name: "Notebook", price: 60, qty: 5 },
  { name: "Geometry box", price: 249, qty: 1 },
]));
Output
Subtotal ₹549 + GST ₹99 = ₹648

The Run button runs this single file as a module, which is why export is allowed here. In a project, main.ts would import from it:

TypeScript
// main.ts
import formatBill, { lineTotal, type Item } from "./billing.ts";

const cart: Item[] = [{ name: "Pen", price: 20, qty: 10 }];
console.log(lineTotal(cart[0]!), formatBill(cart));

Named exports (export function lineTotal) are imported in braces with the same name. A default export (export default) is imported without braces, under any name you like. Many teams prefer named exports only, because the names stay the same everywhere and editors can find and rename them reliably.

Importing types

Types do not exist at run time. When the types are stripped, an import of a type has nothing to point at. That is why TypeScript has type-only imports:

TypeScript
// Option 1: the whole import is types only
import type { Item } from "./billing.ts";

// Option 2: mixed, one value and one type
import { lineTotal, type Item } from "./billing.ts";

// Re-exporting a type from another module
export type { Item } from "./billing.ts";

Tools that compile one file at a time (Node.js type stripping, Vite, esbuild, Babel) cannot look inside billing.ts to see whether Item is a type or a value. The type keyword tells them it can be deleted safely.

The verbatimModuleSyntax option (recommended by tsc --init) makes this a rule: imports without type stay in the JavaScript output exactly as written, and importing a type without type is an error:

Output
src/main.ts(1,19): error TS1484: 'Item' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

That message came from import { withGst, Item } from "./billing.js";. The fix is import { withGst, type Item } from "./billing.js";.

Module settings and file extensions

How TypeScript finds the file behind "./billing" depends on the module and moduleResolution settings in tsconfig.json. In TypeScript 7 there are two common setups:

SettingUse it whenRelative import paths
"module": "nodenext" (resolution follows Node.js)Code runs in Node.jsMust include the extension
"module": "esnext" or "preserve" (resolution bundler)Vite, webpack or another bundler builds the appExtension optional

The older "moduleResolution": "node" (also called node10) and "classic" settings were removed in TypeScript 7.

With nodenext, Node.js rules apply, and Node.js never guesses extensions. Leaving the extension out is an error:

Output
src/main.ts(1,31): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './billing.js'?

This confuses many people at first: the source file is billing.ts, yet TypeScript suggests ./billing.js. When tsc compiles your project, billing.ts becomes billing.js, and the import must point at the file that will exist at run time. You have two options:

  • Write ./billing.js in your .ts files and let tsc emit JavaScript (the classic setup).
  • Write ./billing.ts and turn on rewriteRelativeImportExtensions, which rewrites the extension when emitting. This also works when Node.js runs the .ts files directly, as shown in Run TypeScript in Node.js.

In bundler projects (React with Vite, for example), you can write import { lineTotal } from "./billing" without an extension, because the bundler resolves it.

Paths and package imports

Long relative paths like ../../../utils/money.ts are hard to read. Two solutions exist:

JSON
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }
  }
}

The paths option lets you write import { money } from "@/utils/money". In TypeScript 7 paths entries are relative to the tsconfig.json folder; the old baseUrl option was removed. Remember that paths only teaches TypeScript where files are: your bundler must know the same alias, and Node.js ignores it completely.

For Node.js projects, use subpath imports in package.json instead. Names start with #, and both Node.js and TypeScript understand them:

JSON
{
  "type": "module",
  "imports": { "#utils/*": "./src/utils/*" }
}

Then import { money } from "#utils/money.ts" works everywhere.

Scripts versus modules

A file with no import or export is treated as a script: its top-level names are global and can clash with names in other files. You might see errors like "Cannot redeclare block-scoped variable" because two script files both declare total. The moduleDetection: "force" option, set by tsc --init, treats every file as a module and avoids this. Otherwise, add export {} to a file to make it a module.

Common mistakes

  • Importing a type without type. It breaks per-file compilers and Node.js; verbatimModuleSyntax catches it.
  • Leaving out the extension with nodenext. Write ./billing.js, or ./billing.ts with rewriteRelativeImportExtensions.
  • Expecting paths to work at run time. Configure the same alias in your bundler, or use # subpath imports for Node.js.
  • Mixing default and named exports randomly. Pick one style per project; named exports are easier to refactor.

Key points

  • A file with import or export is a module; exported values and types can be imported elsewhere.
  • Use import type or the inline type keyword for types, and turn on verbatimModuleSyntax to enforce it.
  • With module: nodenext, relative imports need extensions (.js, or .ts with rewriteRelativeImportExtensions); bundler resolution does not.
  • paths gives import aliases to TypeScript only; # subpath imports work in Node.js too. baseUrl is gone in TypeScript 7.
  • require, import = require() and value namespaces are older patterns; prefer ES modules.

Exercise

Split a small project for a tuition centre in Rohtak into two files. In fees.ts, export a type Student (name, monthlyFee, monthsPaid) and a function pending(s: Student, months: number): number. In main.ts, import them correctly for a nodenext project that Node.js runs directly, create two students and log their pending fees for a 12-month year.

Show solution
TypeScript
// fees.ts
export type Student = { name: string; monthlyFee: number; monthsPaid: number };

export function pending(s: Student, months: number): number {
  return Math.max(months - s.monthsPaid, 0) * s.monthlyFee;
}
TypeScript
// main.ts
import { pending, type Student } from "./fees.ts";

const students: Student[] = [
  { name: "Aarav", monthlyFee: 1500, monthsPaid: 12 },
  { name: "Meera", monthlyFee: 1500, monthsPaid: 9 },
];

for (const s of students) {
  console.log(`${s.name}: ₹${pending(s, 12)} pending`); // Aarav: ₹0, Meera: ₹4500
}

Student is imported with type because it is only a type, and the import uses the .ts extension so that node main.ts can find the file. In tsconfig.json, set rewriteRelativeImportExtensions so tsc accepts and rewrites it.

Quick Quiz

1. Why should import { type Item } include the word type?

2. With "module": "nodenext", what is wrong with import { x } from "./utils"?

3. What does the paths option change?

4. A file has no import or export. How does TypeScript treat it by default?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call