Modules, Declaration Files and Typing JavaScript Libraries
Modules in TypeScript, import type, declaration files (.d.ts), types from DefinitelyTyped (@types), typing JavaScript with JSDoc, and declaring your own globals.
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).
-
.tscompiles to.js,.mtsto.mjs(always an ES module) and.ctsto.cjs(always CommonJS). Whether a.jsoutput is an ES module or CommonJS follows the"type"field in the nearestpackage.json, exactly as Node.js decides it. -
Since TypeScript 5.7 the option
rewriteRelativeImportExtensionslets 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.
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.tsfiles, 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
@typespackage follows the library:@types/node24 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
@typespackage 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
modulekeyword,module Billing { … }, is an error since TypeScript 7.0 (TS1540: usenamespace). This is not the same asdeclare 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
erasableSyntaxOnlyreject it; see TypeScript in Node.js and the browser. -
Triple-slash references (
/// <reference path="…" />) belong to the same era. In application code, use imports.