Modules in TypeScript

TypeScript has no module system of its own. It uses the ES module syntax of JavaScript, import and export, and compiles it to either ES modules or CommonJS, depending on the configuration. Everything on modules and npm applies; this page covers what TypeScript adds. A file with at least one import or export is a module: its top-level names are private unless exported.

Two files of a small billing project. The first exports an interface, a constant and a function:

export interface Tariff {
  code: string;
  pricePerKWh: number;
}

export const DAY: Tariff = { code: "DAY", pricePerKWh: 0.32 };

export function cost(kWh: number, t: Tariff): number {
  return kWh * t.pricePerKWh;
}

The second imports them:

import { DAY, cost } from "./tariffs.js";
import type { Tariff } from "./tariffs.js";

const night: Tariff = { code: "NIGHT", pricePerKWh: 0.25 };
console.log(cost(10, DAY).toFixed(2), cost(10, night).toFixed(2));

Extensions in import paths

Note "./tariffs.js" in the import, although the file next to it is tariffs.ts. With "module": "nodenext", the setting for code that runs on Node.js, TypeScript does not rewrite import paths. It writes them into the output unchanged, and at run time Node.js loads tariffs.js, the compiled file. So you write the name the file will have after compiling. In an ES module, an import without an extension fails to compile:

import { DAY } from "./tariffs";

console.log(DAY);
example.ts(1,21): error TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Consider adding an extension to the import path.

In this example the file does not exist at all. When it does, the message (TS2835) even suggests the right path: Did you mean './tariffs.js'? The rules come from Node.js itself, which requires full paths in ES module imports (see import paths).

  • .ts compiles to .js, .mts to .mjs (always an ES module) and .cts to .cjs (always CommonJS). Whether a .js output is an ES module or CommonJS follows the "type" field in the nearest package.json, exactly as Node.js decides it.
  • Since TypeScript 5.7 the option rewriteRelativeImportExtensions lets you write "./tariffs.ts" and has the compiler rewrite it to .js. That is convenient when the same files are also run directly with Node.js type stripping (see TypeScript in Node.js and the browser).
  • With a bundler ("moduleResolution": "bundler"), the bundler finds files without extensions, and imports without one are allowed.

Imports from packages need no path at all. With Node.js's built-in modules, a default import such as import os from "node:os" works even in CommonJS output, because esModuleInterop is always on since TypeScript 7.0:

import os from "node:os";
import { EOL } from "node:os";

const lines = ["INC-30412 Portal login slow", "INC-30415 Meter API timeouts"];
console.log(lines.join(EOL));
console.log(typeof os.cpus);
INC-30412 Portal login slow
INC-30415 Meter API timeouts
function

Type-only imports

A type-only import is an import that brings in only types, written import type { Tariff } from "./tariffs.js", or with type in front of single names: import { DAY, type Tariff } from …. The compiler removes it completely from the output, so the module is not loaded for it at run time. This is what the compiler made of main.ts above:

import { DAY, cost } from "./tariffs.js";
const night = { code: "NIGHT", pricePerKWh: 0.25 };
console.log(cost(10, DAY).toFixed(2), cost(10, night).toFixed(2));

The type import is gone, and so is the annotation on night. The value import stays. A name imported with import type cannot be used as a value, because it will not be there at run time:

import type { Server } from "node:http";

const server = new Server();
example.ts(3,20): error TS1361: 'Server' cannot be used as a value because it was imported using 'import type'.

Both forms in one file, with the built-in node:path module: the function basename is a value, ParsedPath only a type:

import path from "node:path";
import { basename, type ParsedPath } from "node:path";

function describe(file: ParsedPath): string {
  return `${file.name} (${file.ext.slice(1)}) in ${file.dir}`;
}

const file = path.parse("/data/readings/E-1001-2026-10.csv");
console.log(describe(file));
console.log(basename("/data/readings/E-1001-2026-10.csv", ".csv"));
E-1001-2026-10 (csv) in /data/readings
E-1001-2026-10

Why to mark type imports

Without import type, the compiler works out by itself which imported names are only types and drops them. Tools that look at one file at a time, such as Node.js type stripping and fast transpilers like esbuild, cannot do that: they do not read the other file, so they do not know that Tariff is an interface. The option verbatimModuleSyntax makes the rule simple and explicit: imports without type stay in the output exactly as written, and importing a type without type is an error:

npx tsc --verbatimModuleSyntax
src/main.ts(1,21): error TS1484: 'Tariff' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

tsc --init switches it on in new projects. It replaces the older options importsNotUsedAsValues and preserveValueImports, which no longer exist; tutorials that still use them are out of date.

Module resolution

Module resolution is the process by which the compiler turns the text in an import, such as "./tariffs.js" or "legacy-meter-lib", into a file it can read types from. It should follow the same rules as the program that will load the code later, or the compiler checks against a different file than the one that runs. The moduleResolution option chooses the rules:

Setting Use it for Rules
nodenext (or node16) code that Node.js runs Node.js's own rules: extensions required in ES modules, the "exports" field of a package's package.json respected
bundler code that Vite, esbuild or webpack bundles like Node.js, but extensions may be left out, as bundlers allow

You rarely set it yourself: with "module": "nodenext" it is nodenext, with node16, node18 or node20 it is node16, and otherwise the default is bundler. For a package, the compiler looks for types in this order: a declaration file named in the package's package.json (the "types" field, or a "types" condition in "exports"), a .d.ts file next to the JavaScript file, and finally a matching @types package.

Declaration files

A declaration file is a file ending in .d.ts that contains only type information: the exported names of a JavaScript module with their types, but no implementation. The compiler reads it to check the code that uses the module; Node.js and browsers never load it. With the option declaration, tsc writes one next to every JavaScript file it emits. For tariffs.ts above:

export interface Tariff {
    code: string;
    pricePerKWh: number;
}
export declare const DAY: Tariff;
export declare function cost(kWh: number, t: Tariff): number;

The interface is copied as it is; the constant and the function keep their types but lose their bodies. Libraries publish these files together with their JavaScript, so that users of the library get type checking and autocompletion without the TypeScript source. Use .d.mts and .d.cts for .mjs and .cjs files.

A library package contains index.js, the code that runs, and index.d.ts, the types. When you compile your app.ts, tsc reads the declaration file to check your calls. When the program runs, Node.js or the browser loads the JavaScript file and your compiled app.js. The declaration file is never run. library package index.d.ts types only index.js code that runs tsc checks app.ts Node.js or browser runs app.js + index.js compile time run time
Two parallel paths: the declaration file serves the type checker, the JavaScript file serves the runtime. Nothing checks that the two agree.

That last point matters. A declaration file is a description, and descriptions can be wrong: hand-written ones in particular, or ones for an older version of the library. When types and behavior disagree, trust the behavior, and report or fix the types.

Ambient declarations with declare

An ambient declaration is a declaration with the declare keyword: it tells the compiler that a variable, function, class or module exists and what its type is, without creating it. It produces no JavaScript at all. Everything in a .d.ts file is ambient; in a normal .ts file you write declare yourself. The compiler then checks every use against the declaration:

declare function formatKWh(kWh: number): string;

formatKWh("4.2");
example.ts(3,11): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

The classic use is a global that something else provides: a <script> tag that sets configuration on the page, or a value a build tool inserts. In a module, globals are added with declare global; var (not let or const) makes the name a property of globalThis:

// Tell the compiler about a global that a <script> tag normally provides.
declare global {
  var kestrelConfig: { region: string; vatRate: number } | undefined;
}

// In the browser the page would set this; here the example sets it itself.
globalThis.kestrelConfig = { region: "NL", vatRate: 0.21 };

const net = 84.5;
const rate = globalThis.kestrelConfig?.vatRate ?? 0;
console.log(`Total incl. VAT: €${(net * (1 + rate)).toFixed(2)}`);

export {};
Total incl. VAT: €102.24

The type includes | undefined on purpose: if the page forgets the script, the code still works, with the fallback after ??. A declaration that promises more than is true compiles just as happily, and fails only when the program runs:

// A promise to the compiler: "BUILD_ID exists at run time."
declare const BUILD_ID: string;

console.log("Starting meter import");
console.log(`Build ${BUILD_ID.toLowerCase()}`);
Starting meter import
ReferenceError: BUILD_ID is not defined

Typing a JavaScript package yourself

Import a package that has no types, and the compiler cannot know what it exports. If the package is not installed at all, the error is TS2307:

import { readMeter } from "legacy-meter-lib";

console.log(readMeter("E-1001"));
example.ts(1,27): error TS2307: Cannot find module 'legacy-meter-lib' or its corresponding type declarations.

If it is installed but contains only JavaScript, the error is TS7016, and it says what to do:

src/legacy.ts(1,27): error TS7016: Could not find a declaration file for module 'legacy-meter-lib'. '/home/ravi/kestrel-billing/node_modules/legacy-meter-lib/index.js' implicitly has an 'any' type.
  Try `npm i --save-dev @types/legacy-meter-lib` if it exists or add a new declaration (.d.ts) file containing `declare module 'legacy-meter-lib';`

First look for an @types package (next section). If none exists, write a small declaration file in your project, for example types/legacy-meter-lib.d.ts, and describe only the parts you use:

declare module "legacy-meter-lib" {
  export interface MeterValue {
    id: string;
    kWh: number;
  }
  export function readMeter(id: string): MeterValue;
}

Make sure the folder is part of the project (the "include" list in tsconfig.json). The one-line form declare module "legacy-meter-lib"; also silences the error, but then every import from the package has type any, and nothing is checked. It is a stopgap, not a solution.

DefinitelyTyped and @types packages

DefinitelyTyped is a large community-maintained repository on GitHub with declaration files for JavaScript libraries that do not ship their own. As of October 2026 it holds declarations for about 8,700 packages. Each is published on npm as an @types package: a package in the @types scope, such as @types/node, that contains only declaration files for the library of the same name. You install it as a development dependency, next to the library:

npm install express
npm install --save-dev @types/express @types/node
  • Prefer libraries with built-in types. A library written in TypeScript publishes its own .d.ts files, which always match the code. npm marks such packages with a small "TS" icon; packages with types on DefinitelyTyped get a "DT" icon instead (as of 2026; the exact presentation may change).
  • Match the versions. The major version of an @types package follows the library: @types/node 24 describes Node.js 24. A newer version of the types than of the runtime lets you call functions that do not exist yet.
  • Never install both. If a library has built-in types, the @types package for it is usually outdated or deprecated.

The types option

Packages you import find their @types package automatically through module resolution. Some packages, however, add globals: @types/node declares process and Buffer, test frameworks declare describe and it. Up to TypeScript 5.9, the compiler loaded every package in node_modules/@types for that purpose. Since TypeScript 6.0, the types option defaults to an empty list, and you name the global packages you want:

{
  "compilerOptions": {
    "module": "nodenext",
    "types": ["node"]
  }
}

Without that entry, a project that uses process fails with an error that explains the fix:

src/p.ts(1,13): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.

Typing JavaScript with JSDoc

Not every project can or wants to switch to .ts files. As shown in JSDoc with @ts-check, the same checker reads types from JSDoc comments in plain JavaScript. With allowJs and declaration, tsc can even generate a declaration file from such a file, so a library written in JavaScript can still ship accurate types:

// @ts-check

/**
 * Adds VAT to a net amount in euros.
 * @param {number} net
 * @param {number} [rate=0.21]
 * @returns {number}
 */
export function withVat(net, rate = 0.21) {
  return Math.round(net * (1 + rate) * 100) / 100;
}
npx tsc vat.js --allowJs --declaration --emitDeclarationOnly --outDir types --module nodenext --ignoreConfig
/**
 * Adds VAT to a net amount in euros.
 * @param {number} net
 * @param {number} [rate=0.21]
 * @returns {number}
 */
export declare function withVat(net: number, rate?: number): number;

The optional parameter with a default, written [rate=0.21] in JSDoc, became rate?: number. TypeScript 7.0 made JSDoc checking stricter in a few places (for example, @enum is no longer recognized); those details, and how to move a JavaScript project over step by step, are on tooling, configuration and migrating from JavaScript.

Namespaces: an older way to organize code

Before ES modules existed, TypeScript had its own way of grouping code: namespace Billing { … }, which compiles to an object that holds the exported members. You will find namespaces in older code and in declaration files for libraries that create a global object. For new code, the TypeScript handbook recommends ES modules instead.

  • The old spelling with the module keyword, module Billing { … }, is an error since TypeScript 7.0 (TS1540: use namespace). This is not the same as declare module "name" with a string, which is still the normal way to describe a package.
  • A namespace with code in it generates JavaScript, so Node.js type stripping and the option erasableSyntaxOnly reject it; see TypeScript in Node.js and the browser.
  • Triple-slash references (/// <reference path="…" />) belong to the same era. In application code, use imports.