Technology  /  TypeScript

๐Ÿ”ท TypeScript 16 guides ยท updated 2026

Type erasure, structural typing, generics, and the type-level programming toolkit โ€” TypeScript taught the way it actually behaves at compile time and at runtime.

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

math.ts
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; }
}
app.ts
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 collisions
import { add as addNumbers } from "./math";
// Importing everything as a namespace object
import * as MathUtils from "./math";
MathUtils.add(2, 3);

Type-Only Imports and Exports

user.ts
export interface User {
id: string;
name: string;
}
export function createUser(name: string): User {
return { id: crypto.randomUUID(), name };
}
app.ts
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 module
import { 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

yes

no

yes

no

yes

no

import { foo } from './utils'

Does ./utils.ts exist?

Use it

Does ./utils/index.ts exist?

Use it

Is 'utils' a package in node_modules?

Resolve via its package.json

Cannot find module

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 all
export 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.

Terminal window
# 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 --declaration
math.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 all
function 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 shape
declare module "legacy-library" {
export function formatCurrency(amount: number, currencyCode: string): string;
}
app.ts
import { formatCurrency } from "legacy-library";
formatCurrency(99.99, "USD"); // fully type-checked now, despite the library itself having no types
formatCurrency("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

Terminal window
npm install --save-dev @types/lodash
import _ from "lodash";
_.chunk([1, 2, 3, 4], 2); // fully typed, even though lodash itself is plain JavaScript

For 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

globals.d.ts
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 name
import "reflect-metadata"; // common for libraries needing to register global behavior before use

An 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.