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

💡 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 exported

The 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:

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)

Pro Tips

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

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

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.