Modules & Declaration Files
Reviewed & published by Brayan K
A module is a single file that uses export to share specific values and types and import to pull them into other files, while a declaration file (.d.ts) supplies type information for code that already exists without emitting any runtime JavaScript.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Share code with named and default exports
- Import with named, default, and aliased (as) syntax
- Use type-only imports (import type) that erase at compile time
- Combine modules into one entry point with re-exports (barrels)
- Describe existing code with ambient .d.ts declarations and declare
- Understand the basics of how module specifiers are resolved
💡 Real-World Analogy
A module is a shop with a storefront and a stockroom. The items on display (your exports) are what customers can buy; everything in the back (un-exported helpers) is private and stays out of sight. An import is a customer walking in and taking a specific labelled item off the shelf. A barrel file is a shopping mall directory that lists where to find products from several shops in one place. A .d.ts declaration file is the price-and-spec sheet for goods made in another factory: it tells you exactly what each item is, even though it wasn't manufactured here.
1. Exports, Imports & the Public Surface
Each file is its own scope. Whatever you mark with export becomes visible to other files; everything else stays private to that file. You then bring names in with import. Here is real TypeScript module syntax across two files:
// mathUtils.ts ----------------------------------------
function square(n: number): number { // private — no 'export'
return n * n;
}
export function sumOfSquares(a: number, b: number): number {
return square(a) + square(b); // can use the private helper
}
export const PI = 3.14159;
export interface Point { x: number; y: number } // types export too
// main.ts ---------------------------------------------
import { sumOfSquares, PI, type Point } from "./mathUtils";
const p: Point = { x: 1, y: 2 };
console.log(sumOfSquares(3, 4), PI); // 25 3.14159
// square is NOT importable — it was never exportedThe runnable box uses the module pattern to show the same public/private split inside one file.
// === The module pattern in plain JS (what import/export compiles toward) ===
// A "module" is a file that exposes some things and keeps the rest private.
// Before ES modules, we faked it with a function that returns a public object.
// (TS named exports map onto exactly this idea: some names are visible, some not.)
const MathUtils = (function () {
// private helper — NOT exported
function square(n) { return n * n; }
// public surface — these are the "named exports"
function sumOfSquares(a, b) { return square(a) + square(b); }
const PI = 3.14159;
return { sumOfSquares, PI }; // export { sumOfSquares, PI }
})();
console.log("PI:", MathUtils.PI); // PI: 3.14159
console.log("sumOfSquares(3,4):", MathUtils.sumOfSquares(3, 4)); // sumOfSquares(3,4): 25
console.log("square is private:", MathUtils.square); // square is private: undefined
// In TypeScript the same module would be written as:
// // mathUtils.ts
// function square(n: number) { return n * n; } // private (not exported)
// export function sumOfSquares(a: number, b: number) { ... }
// export const PI = 3.14159;
// and used as:
// import { sumOfSquares, PI } from "./mathUtils";2. Default vs Named Exports & Re-exports
A module can have at most one export default plus any number of named exports. The default is imported under a name you choose; named exports keep their names (or are aliased with as). A barrel file re-exports several modules so consumers import from one path:
// user.ts ---------------------------------------------
export default function createUser(name: string) { // the default
return { id: 1, name };
}
export const ADMIN_ROLE = "admin"; // named
export const GUEST_ROLE = "guest"; // named
// consumer.ts -----------------------------------------
import makeUser, { ADMIN_ROLE, GUEST_ROLE as GUEST } from "./user";
// ^default ^named ^named, aliased
// index.ts (a "barrel") -------------------------------
export * from "./user"; // re-export everything
export { formatDate } from "./date"; // re-export one name
export { default as User } from "./user"; // re-export a default as a name// === Default vs named exports, and a "barrel" re-export ===
// A file may have ONE default export plus any number of NAMED exports.
// export default function ... // the default
// export const x = ... // a named export
// Module A — simulated as an object with a default + named members
const userModule = {
default: function createUser(name) { return { id: 1, name }; }, // export default
ADMIN_ROLE: "admin", // export const ADMIN_ROLE
GUEST_ROLE: "guest", // export const GUEST_ROLE
};
// Importing: the default has whatever name YOU choose; named ones keep their names.
// import makeUser, { ADMIN_ROLE } from "./user";
const makeUser = userModule.default; // you pick "makeUser"
const ADMIN_ROLE = userModule.ADMIN_ROLE; // name is fixed
console.log(makeUser("Alice")); // { id: 1, name: 'Alice' }
console.log("role:", ADMIN_ROLE); // role: admin
// A "barrel" (index.ts) re-exports several modules from one place:
// export * from "./user";
// export { formatDate } from "./date";
const barrel = { ...userModule, formatDate: (d) => "2026-01-01" };
console.log("barrel keys:", Object.keys(barrel));
// barrel keys: [ 'default', 'ADMIN_ROLE', 'GUEST_ROLE', 'formatDate' ]3. Type-Only Imports & Declaration Files
import type brings in something used purely as a type and is erased completely from the output — zero runtime cost, no circular-import surprises. A .d.ts file uses declare to describe code that already exists at runtime (a global, a plain-JS library) so the compiler can type-check against it:
// types.ts --------------------------------------------
export interface User { id: number; name: string }
// service.ts ------------------------------------------
import type { User } from "./types"; // erased at compile time
export function greet(user: User) {
return "Hello, " + user.name;
}
// globals.d.ts (an ambient declaration file) ----------
declare const APP_VERSION: string; // a global provided by the page
declare function track(event: string): void;
// Declaring a module with no types of its own (e.g. an untyped library):
declare module "legacy-lib" {
export function doThing(n: number): string;
}// === type-only imports and ambient declarations (.d.ts) — the runtime view ===
// "import type { User } from './types'" imports ONLY a type. It is ERASED at
// compile time and produces NO runtime code. Proof: types never appear in JS.
// Here we model the runtime side — the actual data the type would describe.
// Pretend ./types.ts has: export interface User { id: number; name: string }
// The "import type" line vanishes; only real values remain at runtime:
function greet(user) {
return "Hello, " + user.name; // 'user' was typed as User, but it's just data here
}
console.log(greet({ id: 1, name: "Alice" })); // Hello, Alice
// Ambient declarations (.d.ts) describe code that ALREADY exists (e.g. a global
// loaded by a <script> tag) so TypeScript knows its types without compiling it.
// // globals.d.ts
// declare const APP_VERSION: string; // "this global exists, it's a string"
// declare function track(event: string): void;
// At runtime, those globals are provided by the environment. We simulate one:
globalThis.APP_VERSION = "1.4.2"; // provided elsewhere at runtime
console.log("version:", globalThis.APP_VERSION); // version: 1.4.2
// "declare" adds NO runtime code — it only tells the type-checker something
// exists. If the runtime value is missing, you get a runtime error, not a TS one.
console.log("declare emits nothing at runtime — it's a promise to the compiler");🔎 Deep Dive: Module Resolution Basics
When you write import x from "...", TypeScript has to find the file. The specifier tells it how:
- Relative ("./utils", "../lib/date") — resolved against the current file's folder. These are your files.
- Bare / package ("react", "lodash") — looked up in node_modules, using the package's types/exports fields (or a matching @types/* package).
- Path aliases ("@/components/Button") — custom prefixes mapped in tsconfig.json under paths (this project uses @/ for src/).
The moduleResolution compiler option (commonly "bundler" or "node16") controls the exact lookup rules — you'll meet it in the next lesson on tsconfig.
🎯 Your Turn
Build a small string-utilities module with the module pattern: return the functions you want to "export" and list the public surface. Fill in the blanks and match the expected output.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Build a string-utilities "module" with the module pattern. The returned
// object is the set of NAMED exports; anything not returned stays private.
const stringUtils = (function () {
function reverse(s) { return s.split("").reverse().join(""); }
function shout(s) { return s.toUpperCase() + "!"; }
// 1) Return BOTH functions as named exports
return { ___, ___ }; // 👉 reverse, shout
})();
console.log(stringUtils.reverse("hello")); // olleh
console.log(stringUtils.shout("hi")); // HI!
// 2) Log the public "exports" (the returned object's keys)
console.log("exports:", ___); // 👉 Object.keys(stringUtils)
// ✅ Expected output:
// olleh
// HI!
// exports: [ 'reverse', 'shout' ]Common Errors (and the fix)
- ❌ "Module has no exported member 'X'": you forgot export on the declaration, or imported the wrong name. ✅ Add export, or check spelling/casing of the imported name.
- ❌ Default vs named mix-up: import { createUser } when the file used export default fails. ✅ Import a default without braces: import createUser from "./user".
- ❌ "Cannot find module 'foo' or its type declarations": the package ships no types. ✅ Install @types/foo, or add a declare module "foo"; stub in a .d.ts.
- ❌ Accidental global script: a file with no imports/exports leaks names globally and may clash. ✅ Add an export {} to force module scope.
- ❌ Type imported as a value: importing an interface and trying to use it at runtime fails — it doesn't exist after compilation. ✅ Use import type and only reference it in type positions.
Pro Tips
- 💡 Default to named exports. They refactor and tree-shake better; reserve export default for a file's single main export.
- 💡 Mark type-only imports with import type (or inline { type X }) to keep them out of the runtime bundle.
- 💡 Keep barrels small. A giant index.ts re-exporting everything can hurt tree-shaking and invite circular imports.
- 💡 Reach for @types/* first when a JS library lacks types; write your own .d.ts only when none exists.
Mini-Challenge: Counter Module
Build a counter "module" that keeps its count private and exposes only increment, decrement, and value — exactly the encapsulation a real module file provides. Follow the outline, run it, and match the example output.
// 🎯 MINI-CHALLENGE: a counter module with PRIVATE state
// In TS this would be a module file:
// // counter.ts
// let count = 0; // private (not exported)
// export function increment() { count += 1; return count; }
// export function decrement() { count -= 1; return count; }
// export function value() { return count; }
//
// Build createCounter() using the module pattern so that:
// 1. "count" is PRIVATE (not reachable from outside).
// 2. increment(), decrement(), and value() are the public exports.
//
// Test:
// const counter = createCounter();
// counter.increment(); counter.increment(); counter.decrement();
// counter.value(); // should be 1
// counter.count; // should be undefined (private!)
//
// ✅ Example output:
// 1
// 2
// 1
// value: 1
// count leaked? undefined
// your code here🎉 Lesson Complete
- ✅ export defines a file's public surface; everything else stays private
- ✅ One export default per file plus any number of named exports
- ✅ import type brings in types and is fully erased at compile time
- ✅ Barrel files (index.ts) re-export modules from one path
- ✅ .d.ts files use declare to type existing code with no runtime output
- ✅ Specifiers resolve relatively, from node_modules, or via tsconfig path aliases
Practice quiz
In a module, what makes a value visible to other files?
- Declaring it with var
- Putting it at the top of the file
- Marking it with export
- Naming it in capitals
Answer: Marking it with export. Whatever you mark with export becomes the file's public surface; everything else stays private.
How many default exports may a module have?
- At most one
- Unlimited
- Exactly two
- None allowed
Answer: At most one. A module can have at most one export default, plus any number of named exports.
How do you import a default export?
- import { thing } from './mod'
- import default thing from './mod'
- require('./mod')
- import thing from './mod'
Answer: import thing from './mod'. A default is imported without braces and you choose the name: import thing from './mod'.
When should you use import type?
- For every import
- When importing something used only in type positions
- Only for default exports
- Never in modern code
Answer: When importing something used only in type positions. import type is for type-only imports; it is fully erased and pulls in no runtime code.
What is a barrel file?
- An index.ts that re-exports the public API of a folder
- A file that stores binary data
- A compiled output file
- A test fixture
Answer: An index.ts that re-exports the public API of a folder. A barrel re-exports several modules from one place so consumers import from a single path.
What does a .d.ts declaration file contain?
- Runtime code and types
- Compiled JavaScript
- Only type declarations, no runtime code
- CSS definitions
Answer: Only type declarations, no runtime code. A .d.ts file holds only type declarations describing code that exists elsewhere.
What does the declare keyword emit at runtime?
- A global variable
- Nothing; it only informs the type-checker
- A function stub
- An import statement
Answer: Nothing; it only informs the type-checker. declare adds no runtime code; it tells the compiler something already exists.
A file with no top-level import or export is treated as what?
- A module with its own scope
- An error
- A barrel
- A script whose declarations leak into global scope
Answer: A script whose declarations leak into global scope. Without an import/export it is a script; add export {} to make it a proper module.
How is a relative specifier like './utils' resolved?
- From node_modules
- Against the current file's folder
- Via tsconfig paths only
- From the project root always
Answer: Against the current file's folder. Relative specifiers are resolved against the importing file's directory.
Why prefer named exports over a default for most code?
- They run faster
- They allow more than one per file only as default
- They autocomplete, rename safely, and tree-shake well
- They are required by strict mode
Answer: They autocomplete, rename safely, and tree-shake well. Named exports refactor, auto-import, and tree-shake better; reserve default for a file's main thing.
Continue this course
- Previous: Discriminated Unions
- Next: tsconfig & Compiler Options — strict mode, target/module, and the options that matter most
- Quick reference: TypeScript cheat sheet
Frequently asked questions
What's the difference between a default export and a named export?
A module can have at most ONE default export and any number of named exports. A default export is imported without braces and the importer chooses the name: import anything from './mod'. Named exports are imported in braces and must match the exported name (unless aliased with 'as'): import { foo, bar as baz } from './mod'. Use named exports for most things — they're easier to refactor, auto-import, and tree-shake. Reserve a default for the single 'main' thing a file provides, such as a React component.
When should I use 'import type' instead of a normal import?
Use import type (or inline import { type X }) when you're importing something used ONLY in type positions — interfaces, type aliases, or a class used purely as a type annotation. The 'type' keyword guarantees the import is fully erased at compile time, so it never accidentally pulls runtime code into your bundle and can't cause a circular-dependency runtime crash. With the isolatedModules / verbatimModuleSyntax settings, marking type-only imports explicitly is often required.
What is a .d.ts declaration file and when do I need one?
A .d.ts file contains ONLY type declarations — no runtime code. It describes the shape of JavaScript that exists elsewhere so TypeScript can type-check against it: the types for a plain-JS library, global variables injected by a <script> tag or build tool, or the public API of your own compiled package. You write or install .d.ts files (e.g. the @types/* packages) so the compiler knows about code it isn't compiling itself. The 'declare' keyword inside them means 'this already exists at runtime; just trust these types'.
What is a 'barrel' file and is it a good idea?
A barrel is an index.ts that re-exports the public API of a folder in one place — export * from './user'; export { formatDate } from './date'; — so consumers can write import { createUser, formatDate } from './lib' instead of reaching into individual files. Barrels tidy up import paths and define a clear public surface. The caveat: large barrels can hurt tree-shaking and create circular imports, so keep them small and avoid re-exporting everything from a deeply interconnected module graph.