Free Handbook · Runs in your browser

Modules & Declaration Files

Import/export syntax in TypeScript, what a .d.ts file is and why a JavaScript library ships one, declare and ambient modules, @types packages, and how a module specifier resolves to a file.

0 / 105 lessons🔥 0 day streak
ShareXLinkedIn

Module 09 · what you'll be able to do

  • Write ES module import/export syntax in TypeScript, including type-only imports
  • Explain what a .d.ts file is and why a compiled JavaScript library ships one
  • Declare a global or an ambient module for code TypeScript cannot see the source of
  • Install an @types package and know when a library ships its own types instead
  • Read what moduleResolution and paths do in tsconfig.json
01

ES module import/export in TypeScript

TypeScript's module syntax is exactly ECMAScript's import/export — nothing new to learn there. What TypeScript adds is checked at the boundary: the type checker verifies that what a file imports matches what the other file actually exports, and a separate form, import type, imports only a type with zero runtime trace.

typescriptmath.ts
export interface Point {
  x: number
  y: number
}

export function distance(a: Point, b: Point): number {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
}

export const ORIGIN: Point = { x: 0, y: 0 }

// A default export — one per file, imported without braces
export default function midpoint(a: Point, b: Point): Point {
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
}
typescriptapp.ts
import midpoint, { distance, ORIGIN, type Point } from "./math"
// ^ default        ^ named            ^ type-only named import

const here: Point = { x: 3, y: 4 }
console.log(distance(here, ORIGIN))
console.log(midpoint(here, ORIGIN))

// Import everything as one namespace object
import * as MathUtils from "./math"
MathUtils.distance(here, ORIGIN)

// Re-export: this file did not define Point, but other files can import it from here
export { type Point } from "./math"
FormSyntaxWhen to reach for it
Named export/importexport function f() / import { f } from "./m"The default. Most things a module exposes.
Default export/importexport default f / import f from "./m"One obvious "main thing" per file — a component, a single class.
Namespace importimport * as M from "./m"Importing many names from one module without listing each one.
Type-only importimport type { T } from "./m"You only need T for annotations — guarantees the import is erased, never a runtime require.
Re-exportexport { X } from "./m"Building a public "barrel" file that gathers several modules' exports into one.
import type is not a style preference
With import type, the compiler guarantees the import produces zero runtime code — the line disappears entirely from the emitted JavaScript. This matters when a bundler compiles files one at a time (isolatedModules, which esbuild, SWC and Next.js all require): the bundler cannot see across files to know an import was "only a type", so an ordinary import { Point } for a type-only value can crash the build. Write import type whenever the imported name is only ever used in a type position.
typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
5
5.0
Your turn
In real files this would be two modules: an interface and two functions exported from math.ts, imported by name into app.ts. Try writing that split on your own machine with tsc — the run button here only proves the arithmetic.
Quick check

Which import form is guaranteed to produce zero runtime code once compiled?

02

Declaration files: what a .d.ts is

A .d.ts file contains only type declarations — signatures, interfaces, shapes — never an implementation. It compiles to nothing; nothing is ever emitted from it. Its job is to describe code that already exists somewhere as plain JavaScript, so the compiler can check the calls you make into it without ever reading that JavaScript itself.

typescriptgreet.js
// The actual, runnable implementation — plain JavaScript, no types
export function greet(name, punctuation) {
  return "Hello, " + name + (punctuation || "!")
}
typescriptgreet.d.ts
// The type description that sits next to it — no function BODY, only its shape
export function greet(name: string, punctuation?: string): string
  • Hand-written — you write the .d.ts yourself next to a plain .js file you own or vendored.
  • Generatedtsc --declaration produces one automatically from your own .ts source; this is how most published TypeScript packages ship types.
  • Published separately — the community writes and maintains one on DefinitelyTyped, installed as an @types/ package (next lesson).
ExtensionContainsCompiler behaviour
.tsImplementation + typesType-checked, then compiled to .js
.d.tsTypes only, no implementationType-checked as a contract, never emits any JavaScript
.jsImplementation onlyNot type-checked at all, unless allowJs + checkJs are on
package.json points at it
A published package tells TypeScript where its .d.ts lives with a "types" (or older "typings") field in package.json, usually alongside "main". When you import that package, TypeScript follows "types", not "main", to find what to check against.
JuniorWhat is a .d.ts file, and why does a plain JavaScript library need one?

A .d.ts file holds only type declarations — no runnable code, no function bodies — describing the shape of something that exists as JavaScript elsewhere. A JavaScript library was never compiled by tsc, so it has no types the compiler could check against; the .d.ts is the missing contract, written by hand, generated from a TypeScript source, or supplied separately by DefinitelyTyped, that lets TypeScript check calls into that library.

03

declare: describing what already exists

declare tells the compiler "trust me, this exists at runtime" without generating any code for it — no variable is created, no function is defined. It is for exactly one situation: something is available when your code runs, but TypeScript has no way to see where it came from, because it was not created by an import in this project — a <script> tag, a bundler-injected constant, a test runner global.

typescriptanalytics.ts
// index.html has:  <script src="https://cdn.example.com/ga.js"></script>
// which defines window.gtag before this file ever runs. TypeScript never
// sees that <script> tag, so without the line below, "gtag" is a
// ReferenceError-in-waiting the compiler cannot warn you about — worse,
// it is a hard TS2304 "Cannot find name" until you tell it gtag exists.
declare const gtag: (...args: unknown[]) => void

gtag("event", "purchase", { value: 49 })
typescriptwindow.d.ts
// Augmenting an existing global type instead of a bare const — this adds a
// property to the ambient Window interface everyone already has
declare global {
  interface Window {
    gtag: (...args: unknown[]) => void
  }
}

export {}   // required: this line is what makes the file a MODULE, so
            // "declare global" augments the shared global scope rather
            // than silently declaring one more separate global scope
typescriptEdit it. ⌘/Ctrl + Enter runs.
You should see
gtag call: event, purchase, value=49

The declare const gtag: ... line itself never runs — it only convinces the checker. At runtime, something else (the script tag) has to have actually created gtag, or this is a ReferenceError no type ever prevents.

A file with declare global needs export {}
Without at least one top-level import or export, TypeScript treats a .ts file as a legacy global script, not a module — and declare global {} inside a script-mode file is redundant with the whole file already being global, which usually is not what you meant. Add export {} (exports nothing, but makes the file a module) whenever a file's only content is a global augmentation.
declare
A type-only statement: describes a binding for the checker without creating it. Erased completely on compile.
Ambient declaration
Any declaration made with declare — "ambient" because it describes something already present in the ambient (surrounding) environment.
Global augmentation
declare global { ... } — adds to the shared global type scope (like Window) from inside a module file.
04

Ambient modules for untyped imports

The same idea, applied to a whole module specifier instead of one global: declare module "some-specifier" tells TypeScript "when something imports this string, here is what it gets" — without TypeScript ever reading that module's actual source. This covers two cases: an npm package with no types anywhere, and a non-JavaScript import your bundler handles (an .svg, a .css module) that has no types by definition.

typescriptmy-lib.d.ts
// This package ships no types, and no @types/my-lib exists on npm
declare module "my-lib" {
  export function process(input: string): string
  export const VERSION: string
}

// Anywhere else in the project, this now compiles — with real autocomplete
import { process, VERSION } from "my-lib"
typescriptassets.d.ts
// A wildcard ambient module — matches ANY specifier ending in .svg.
// Bundlers (webpack, Vite) turn an .svg import into a URL string at build
// time; TypeScript has no idea unless you tell it, once, project-wide.
declare module "*.svg" {
  const src: string
  export default src
}

// now this compiles everywhere in the project
import logo from "./logo.svg"
SituationWhat you reach for
Package ships its own types (has a "types" field)Nothing — import and it is already typed
Package has no types, but DefinitelyTyped has onenpm i -D @types/<package> (next lesson)
Package has no types anywhere on npmWrite your own declare module "pkg" with just the shapes you actually call
Non-JS import a bundler handles (svg, css, json)A wildcard ambient module, once, for the whole project
Where these files live
Convention, not a rule: a small src/types/ (or types/) folder holding files like globals.d.ts and my-lib.d.ts, picked up because they sit inside the include tsconfig already covers — no import needed for ambient files to take effect, they just have to be part of the compiled program.
Mid-levelYou installed a package with no bundled types and no matching @types package on npm. What are your options?

Write your own ambient module declaration — declare module "package-name" with just the functions and shapes you actually use, even typed loosely as any at first — and keep it in a project types file. Longer term, you can contribute a definition upstream to DefinitelyTyped so the whole ecosystem benefits. The one thing to avoid is scattering @ts-ignore across every call site; one ambient declaration file centralises the gap instead of hiding it repeatedly.

05

@types packages and DefinitelyTyped

Most popular JavaScript libraries were never written in TypeScript, so their types live in a separate, community-maintained project: DefinitelyTyped. Its packages are published to npm under the @types/ scope — @types/lodash, @types/react, @types/node — and TypeScript automatically discovers anything installed under node_modules/@types, with no import or config needed to wire it up.

Package situationWhat to do
Ships its own .d.ts and a "types" fieldNothing — types come bundled, installing the package is enough
No bundled types, but @types/<name> exists on npmnpm i -D @types/<name> — a dev dependency, since it is compile-time only
No bundled types and no @types/ packageWrite an ambient module declaration yourself (previous lesson)
jsonpackage.json
{
  "name": "slugify-lite",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "devDependencies": {
    "@types/node": "^22.0.0"
  }
}
@types/node is the one nearly every project needs
Node itself is not written in TypeScript, so process, Buffer, __dirname and the rest of the Node standard library have no types without @types/node installed. Your code runs fine in Node either way — this gap is compile-time only, which is exactly what makes it confusing the first time: the program works, and tsc still refuses to compile it.
Mid-levelA script runs fine with node app.js, but tsc reports "Cannot find name 'process'". What is going on?

The Node runtime provides process regardless of TypeScript — that is why running it with node works. But TypeScript has no idea what process is unless something tells it, and the Node standard library is not written in TypeScript, so that description has to come from @types/node. Installing @types/node as a dev dependency (compile-time only, never shipped) resolves it; nothing about the running program changes.

06

Module resolution: node vs bundler

Type-checking an import "./thing" requires TypeScript to first turn that string into a real file, the same way Node or a bundler eventually will at runtime. moduleResolution in tsconfig picks which of those algorithms the checker mimics — get it wrong and you get errors that only exist in the editor, or worse, code that compiles clean and then fails to actually run.

moduleResolutionMimicsUse it when
node10Legacy CommonJS require() resolutionAn old Node project on CommonJS — rarely the right choice for something new
node16 / nodenextModern Node ESM+CJS interopPublishing an npm package, or a Node app running "type": "module" — requires explicit .js extensions on relative imports, matching real ESM
bundlerWhat Vite, webpack, esbuild and Next.js actually doA frontend or full-stack app built by a bundler, which allows extension-less imports and honours a package's "exports" map more loosely than Node itself does
jsontsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

paths lets you write import { Task } from "@/types" instead of "../../../types". It only ever remaps the specifier for the compiler and your editortsc does not rewrite @/* into a real relative path in the JavaScript it emits. Whatever actually runs the output — Node directly, or a bundler — has to be told the same mapping separately.

Works in the editor, "Cannot find module" at runtime
This is the classic paths gotcha: VS Code and tsc --noEmit both resolve @/* happily because they read tsconfig.json directly, then node dist/app.js fails because plain Node has never heard of paths. Bundlers and frameworks that read your tsconfig themselves (Next.js, Vite) resolve it for you automatically; a plain tsc-then-node pipeline needs a runtime resolver package, or the aliases rewritten to relative paths during the build.
Mid-levelYou added a "@/*" path alias, it autocompletes fine in your editor, but node dist/app.js says "Cannot find module '@/types'". Why?

tsconfig paths is a compile-time and editor-time convenience only — tsc leaves the import specifier exactly as written in the emitted JavaScript, it does not rewrite @/types into a relative path. Plain Node resolves modules with its own algorithm and knows nothing about tsconfig, so the alias fails at runtime unless something else translates it: a bundler that reads tsconfig and rewrites imports during the build, or a runtime resolver package wired into how the app starts.

Quick check

Which moduleResolution setting most closely matches what Vite, webpack and Next.js actually do when resolving an import?

Finish the TypeScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.