Notifications

No notifications

/Phase 3

Modules & Declarations

Every .ts file with an import or export is a module. TypeScript layers a type-aware import system on top of standard ES modules: import type, declaration files (.d.ts), ambient modules, and the @types/* ecosystem. Knowing how the pieces fit makes integrating any JS library painless.

On this page

Detailed Theory

Files are modules

Any file containing top-level import or export is a module; everything else is a *script* (its declarations leak into the global scope — usually not what you want).

// math.ts
export function add(a: number, b: number) { return a + b; }
export const PI = 3.14;

// app.ts import { add, PI } from "./math";

Default vs named:

// logger.ts
export default function log(msg: string) { console.log(msg); }
export const LEVEL = "info";

// app.ts import log, { LEVEL } from "./logger";

import type and export type

If you only need a value's *type* (no runtime use), use import type so the bundler can drop the import entirely.

import type { User } from "./user";   // erased at compile time
import { saveUser } from "./user";    // kept (runtime use)

You can mix them inline:

import { saveUser, type User } from "./user";

This matters a lot for isolatedModules + ESM bundlers (Vite, esbuild, swc) — they don't see across files, so they need explicit type-only imports.

tsconfig essentials for modules

OptionWhat it does
moduleOutput module syntax (ESNext, CommonJS, NodeNext)
moduleResolutionHow imports are resolved — use Bundler for Vite/Next, NodeNext for Node
esModuleInteropLets you do import express from "express" for CJS modules
isolatedModulesTreat every file independently — required by Vite/esbuild
pathsPath aliases (the @/ you use in this project)
baseUrlRequired for paths

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Now import { x } from "@/lib/x" works from anywhere.

Declaration files — .d.ts

A .d.ts file contains types only, no runtime code. Three places they come from:

1. Auto-generated from your code

{ "compilerOptions": { "declaration": true } }

tsc emits a .d.ts next to every .js. Essential when publishing a library.

2. Bundled with the package

Most modern libraries (e.g. zod, react, next) ship .d.ts inside their npm package. tsc picks them up automatically — you do nothing.

3. Community types — @types/*

For libraries written in plain JS (e.g. lodash, express, node), types live on DefinitelyTyped:

npm i lodash
npm i -D @types/lodash

If you've ever wondered why @types/node shows up in basically every TS project — that's why.

Ambient declarations — declare

Tell TS that something exists at runtime even though there's no import:

// global.d.ts
declare const __APP_VERSION__: string;     // injected by Vite define
declare module "*.svg" {                    // import logo from "./logo.svg"
  const content: string;
  export default content;
}

Common patterns:

  • Importing non-TS files (.svg, .css, .md).
  • Globals injected by your build tool.
  • Augmenting window or Express Request.
// types/express.d.ts
import "express";
declare module "express" {
  interface Request {
    userId?: string;
  }
}

This is what middleware libraries do to add fields to Express's request.

Re-exports — barrels

// lib/index.ts
export * from "./math";
export * from "./logger";
export { default as Button } from "./Button";

Now consumers do import { add, log, Button } from "@/lib". Watch out: in some bundlers, naive barrels can hurt tree-shaking — keep them small or skip them.

Dynamic import()

Asynchronous + code-split. Returns a Promise of the module's namespace object:

const { default: heavy } = await import("./heavyModule");

In Next.js / React this is how you lazy-load components.

Summary checklist

  • ✅ Every file you write should have an import/export.
  • ✅ Use import type for type-only imports.
  • ✅ Use paths for clean aliases (@/*).
  • ✅ Install @types/ for any JS-only library.
  • ✅ Use .d.ts files to declare globals / non-TS imports / module augmentation.