Modules & declaration files
What makes a file a module, type-only imports, how `.d.ts` files describe untyped JavaScript, and why module resolution goes wrong.
A file is a module if it has an import or export
Without one, it is a script: everything in it lands in the global scope and collides with everything else.
const message = "I am a module, because this file exports something" export const version = "1.0" console.log(message, version)
An empty export {} is the standard way to make a file a module when it has nothing to share.
Exporting and importing
// Named exports โ several per file, imported by name.
export const PI = 3.14159
export function area(r: number): number {
return PI * r ** 2
}
export type Shape = { r: number }
// A default export โ one per file.
export default function circle(r: number): Shape {
return { r }
}
console.log(area(circle(2).r).toFixed(2))The import forms, for reference:
import circle from "./circle.js" // default
import { area, PI } from "./circle.js" // named
import { area as circleArea } from "./circle.js"
import * as shapes from "./circle.js" // namespace
import type { Shape } from "./circle.js" // types only
import "./side-effects.js" // run it, import nothing
export { area } from "./circle.js" // re-export
export * from "./circle.js"โ ๏ธ Under NodeNext resolution, relative imports need the .js extension โ even from a .ts file, and even though the file on disk is .ts. You are naming the file that will exist after compilation, not the source. allowImportingTsExtensions relaxes this for bundler setups.
Type-only imports and exports
import type is erased entirely. It tells the compiler "this is only a type" and guarantees no run-time import is emitted.
export type User = { id: number; name: string }
export const VERSION = "1.0"
// In a consuming file these would be:
// import type { User } from "./mod.js" โ erased
// import { VERSION } from "./mod.js" โ a real import
// import { type User, VERSION } from "./mod.js" โ mixed, User erased
const u: User = { id: 1, name: "Ada" }
console.log(u.name, VERSION)Declaration files
A .d.ts file contains types and no implementation. It is how TypeScript describes JavaScript it cannot see.
// legacy-maths.d.ts โ describing an untyped JavaScript library
declare module "legacy-maths" {
export function add(a: number, b: number): number
export const VERSION: string
export default function calculate(expr: string): number
}// globals.d.ts โ describing things that exist at run time but not in any module
declare global {
interface Window {
myApp: { version: string }
}
var __DEV__: boolean
}
export {}declare means "this exists; take my word for it". Nothing is emitted, and nothing is verified.
declare const INJECTED_VERSION: string // a build tool will define this // TypeScript believes it. At run time it may not exist at all: console.log(typeof INJECTED_VERSION)
๐งญ Scenario: A team declares declare const API_URL: string for a value their bundler injects. Someone runs the code in a test harness without the bundler, API_URL is genuinely undefined, and the error is a bare ReferenceError a long way from the declaration. declare describes the world; it does not create it.
Where types come from
For a package foo, the compiler looks, in order:
types/typingsinfoo'spackage.json, orexportswith atypescondition- an
index.d.tsnext to the JavaScript node_modules/@types/fooโ the community-maintained DefinitelyTyped package- nothing, and
foois implicitlyany(an error undernoImplicitAny)
// Modern packages ship their own types, so this needs no @types package:
const parsed: unknown = JSON.parse('{"a":1}')
console.log(parsed)Namespaces: the thing you probably do not want
Before ES modules, TypeScript had namespace for grouping. It still works and still appears in .d.ts files describing global libraries, but for application code modules replaced it entirely.
namespace Geometry {
export const PI = 3.14159
export function circleArea(r: number): number {
return PI * r ** 2
}
}
console.log(Geometry.circleArea(2).toFixed(2))๐ก Tip: If you are writing namespace in a .ts file in 2026, you almost certainly want a module instead. Modules give you real isolation, tree shaking and tooling support; namespaces give you a shared global object.
Module augmentation
Adding to someone else's types without forking them โ the main legitimate use of declaration merging.
// Adding a property to Express's Request type
import "express"
declare module "express-serve-static-core" {
interface Request {
user?: { id: number; name: string }
}
}The interface must already exist and be declared with interface; you cannot augment a type.
import() types
You can reference a type from another module inline, without a top-level import โ useful in .d.ts files and in JSDoc.
export type User = { id: number; name: string }
// Elsewhere, inline:
type Handler = (u: import("./main.js").User) => void
const h: Handler = (u) => console.log(u.name)
h({ id: 1, name: "Ada" })Reference: Modules and Declaration Files.