Modules and Declaration Files in TypeScript: Imports, Exports & .d.ts
Every non-trivial TypeScript project is split across multiple files, and every one of those files eventually imports a third-party library โ some written in TypeScript, plenty written in plain JavaScript with no type information at all. This guide covers how TypeScriptโs module system actually works, the type-only import syntax that exists specifically because of type erasure, and .d.ts declaration files โ the mechanism that lets TypeScript understand untyped JavaScript without the library author ever writing a line of TypeScript.
Basic Import/Export Syntax
export function add(a: number, b: number): number { return a + b;}
export const PI = 3.14159;
export interface Point { x: number; y: number;}
export default class Calculator { add(a: number, b: number) { return a + b; }}import Calculator, { add, PI, Point } from "./math";
const calc = new Calculator();const point: Point = { x: 1, y: 2 };console.log(add(2, 3), PI);Named exports (export function, export const, export interface) can be multiple per file and must be imported by their exact name (or renamed with as). Default exports (export default) are limited to one per file and can be imported under any name the importing file chooses โ this is exactly why import Calculator from "./math" works even though the class itself has no name requirement matching on the import side.
// Renaming on import โ useful for avoiding naming collisionsimport { add as addNumbers } from "./math";
// Importing everything as a namespace objectimport * as MathUtils from "./math";MathUtils.add(2, 3);Type-Only Imports and Exports
export interface User { id: string; name: string;}
export function createUser(name: string): User { return { id: crypto.randomUUID(), name };}import { createUser } from "./user";import type { User } from "./user"; // type-only import
const user: User = createUser("Alice");import type tells the compiler this import is used only for type checking and should be completely removed from the compiled JavaScript output โ it never generates a runtime require/import statement at all. This matters for a specific, real problem: if User were imported with a regular import and only ever used in type positions, some bundler configurations would still include a runtime import of a module that, at runtime, contributes nothing (since interfaces have zero runtime representation, per how TypeScript works) โ import type makes the โthis is compile-time onlyโ intent explicit and guarantees the import is stripped.
// Combined syntax โ mixing a value import and a type-only import from the same moduleimport { createUser, type User } from "./user";// tsconfig.json โ enforcing this as a lint-level rule project-wide{ "compilerOptions": { "verbatimModuleSyntax": true }}verbatimModuleSyntax (the modern replacement for the older isolatedModules + importsNotUsedAsValues combination) forces every type-only import to be explicitly marked as such โ without it, TypeScript sometimes has to guess whether an import is type-only, which can behave inconsistently across different build tools that donโt share tscโs full type information.
Module Resolution: How TypeScript Finds Your Imports
The moduleResolution setting in tsconfig.json (covered in the project setup guide) controls the exact algorithm used here โ NodeNext follows Node.jsโs actual resolution rules (including respecting package.jsonโs exports field for packages that define one), while older resolution strategies use a looser, more permissive algorithm that can resolve imports Node itself would actually fail on at runtime. Mismatches between what tsc accepts and what your runtime actually resolves are a common source of โit type-checked fine but crashed at runtimeโ bugs.
Declaration Files (.d.ts) โ Types Without Implementation
// shapes.d.ts โ describes types only, contains no runtime code at allexport interface Shape { area(): number;}
export function createCircle(radius: number): Shape;A .d.ts file contains only type information โ no function bodies, no runtime logic, nothing that could be executed. It exists purely to tell TypeScript โhereโs the shape of something that exists elsewhere,โ which is exactly the mechanism that lets TypeScript type-check code that calls into plain JavaScript it never actually compiled from source.
# When you build a TypeScript library for others to consume, "declaration": true in tsconfig.json# generates a matching .d.ts file for every .ts file automatically:tsc --declarationmath.ts โ math.js (compiled implementation) โ math.d.ts (generated type declarations, shipped alongside math.js)This is exactly how published npm packages written in TypeScript ship type information to consumers โ the compiled .js runs at runtime, and the accompanying .d.ts lets TypeScript-using consumers get full type checking and autocomplete, without needing the original .ts source or forcing consumers to compile anything themselves.
Typing an Untyped JavaScript Library
// legacy-library.js โ a plain JS library with no types at allfunction formatCurrency(amount, currencyCode) { return new Intl.NumberFormat("en-US", { style: "currency", currency: currencyCode }).format(amount);}
module.exports = { formatCurrency };// legacy-library.d.ts โ hand-written declaration file describing the library's shapedeclare module "legacy-library" { export function formatCurrency(amount: number, currencyCode: string): string;}import { formatCurrency } from "legacy-library";formatCurrency(99.99, "USD"); // fully type-checked now, despite the library itself having no typesformatCurrency("99.99", "USD"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'declare module "legacy-library" tells TypeScript โtrust me, a module with this exact name exists, and hereโs its shapeโ โ this is a real, common pattern for adding types to a small internal library or a third-party package that doesnโt ship its own types and doesnโt have community-maintained types available either.
DefinitelyTyped โ the community solution at scale
npm install --save-dev @types/lodashimport _ from "lodash";_.chunk([1, 2, 3, 4], 2); // fully typed, even though lodash itself is plain JavaScriptFor the thousands of popular untyped JavaScript packages, hand-writing your own .d.ts is rarely necessary โ DefinitelyTyped, a massive community-maintained repository, publishes type declarations as separate @types/<package-name> packages. TypeScript automatically checks for a matching @types package in node_modules and uses it if present, with zero configuration needed beyond installing it. This is the same mechanism briefly introduced with @types/node in the project setup guide โ itโs a general pattern, not something specific to Nodeโs built-in modules.
Ambient Declarations for Global Values
declare global { interface Window { analytics: { track(event: string, properties?: Record<string, unknown>): void; }; }}
export {}; // this empty export makes the file a module, required for `declare global` to work correctly// Anywhere in the app, with no import needed:window.analytics.track("page_view", { path: "/checkout" });declare global augments a global type (here, adding a custom analytics property to the built-in Window interface) using the declaration merging mechanism covered in the interfaces guide โ this is the standard way to describe third-party scripts injected into window by analytics tags, ad SDKs, or other code that isnโt part of your module graph at all.
Namespaces โ Mostly a Legacy Concept Now
namespace Validation { export interface Validator { isValid(value: string): boolean; }
export class EmailValidator implements Validator { isValid(value: string): boolean { return value.includes("@"); } }}
const validator = new Validation.EmailValidator();Namespaces predate ES modules in TypeScriptโs history โ they were the original way to organize code into logical groups before JavaScript itself had a standard module system. With ES modules now universal and well-supported by every modern tool, namespaces are largely legacy: they still appear in older codebases and in some declaration files describing legacy global libraries (like the Express.Request augmentation shown in the interfaces guide, which does use a namespace), but new code should default to ES module import/export rather than reaching for namespace.
CommonJS vs ES Modules โ Why This Still Matters
// CommonJS style (Node's original module system)const { readFile } = require("fs");module.exports = { myFunction };
// ES Module style (the modern standard, used across browsers and modern Node)import { readFile } from "fs";export { myFunction };TypeScript has to compile down to whichever module system your runtime actually expects โ this is exactly what the module option in tsconfig.json controls. Writing import/export syntax in your .ts source doesnโt guarantee the compiled .js uses ES modules; with module: "CommonJS", TypeScript rewrites your import/export statements into require/module.exports calls automatically, letting you write modern syntax while still targeting an older module system underneath.
// package.json determines how Node interprets plain .js files{ "type": "module" } // .js files are treated as ES modules{ "type": "commonjs" } // .js files are treated as CommonJS (the default if omitted)The practical failure mode: a tsconfig.json set to module: "CommonJS" but a package.json declaring "type": "module" (or vice versa) produces working tsc output that fails at runtime with a module-format error โ because compilation and the runtimeโs actual interpretation of the compiled files are governed by two separate configuration files that have to agree.
Side-Effect Imports
import "./polyfills"; // runs the module's top-level code, imports nothing by nameimport "reflect-metadata"; // common for libraries needing to register global behavior before useAn import with no bindings still executes the target moduleโs top-level code โ useful for polyfills, global CSS imports in bundler-based frontend setups, or libraries (like reflect-metadata, relevant to the decorators guide next) that need to run setup logic as a side effect before the rest of your code depends on it.
Frequently Asked Questions
Whatโs the actual difference between import type and a regular import used only for types? Functionally, both usually compile identically once TypeScriptโs compiler is smart enough to detect the import is type-only and elide it automatically. import type makes that intent explicit and guaranteed, which matters specifically for tools (some bundlers, isolatedModules mode) that transpile files independently without full cross-file type information and therefore canโt always make that determination themselves.
Do I need to write my own .d.ts files often? Rarely, in practice โ most popular packages either ship their own types or have a @types/* package on DefinitelyTyped. Writing your own becomes necessary for internal/private packages, very obscure or unmaintained libraries, or augmenting a global object (like the Window example) that no @types package would cover for your specific use case.
Why does my editor show type errors for a JavaScript package I just installed? Either it has no shipped types and no @types/* package exists, or the installed @types/* version is out of sync with the actual package version. Check the packageโs README for TypeScript support notes, or search for @types/<package-name> on npm directly.
Is namespace deprecated? Not formally removed from the language, but effectively superseded by ES modules for new code. It remains relevant mainly for reading/maintaining older codebases and specific declaration-file patterns for legacy global libraries.
Can a .d.ts file and a .ts file with the same name coexist? No โ if youโre generating .d.ts files via tsc --declaration from .ts source, they go into a separate output directory (outDir), not alongside the source, precisely to avoid this collision.
Whatโs Next
You now understand how TypeScript code is organized across files and how it interoperates with the vast ecosystem of plain JavaScript that was never written with types in mind. Next: Decorators in TypeScript, the final guide in this series, covering class and method decorators, metadata, and the real difference between the older experimentalDecorators flag and the newer Stage 3 decorator standard.