A File Is a Module Only If It Says So
TypeScript follows one rule to decide what a file is: if it contains a top-level import or export, it is a module, and everything inside it is private unless exported. If it contains neither, it is a script, and everything declared in it is global — visible to every other script file in the project, whether you wanted that or not.
This rule explains an error that baffles beginners. Create two files, declare const config at the top level of each, and you get Cannot redeclare block-scoped variable 'config' — two files you never connected are apparently colliding. They are both scripts, so both declarations are in the same global scope. Add an export to either file and the error vanishes.
The one-line fix for a file that genuinely has nothing to export is export {} at the bottom. It exports nothing and makes the file a module. You will also need it in any file that uses declare global, since global augmentation is only allowed from inside a module.
// utils.ts — no import, no export: this is a SCRIPT
const config = { retries: 3 };
// app.ts — also a script
const config = { timeout: 5000 };
// ERROR: Cannot redeclare block-scoped variable 'config'.
// Fix: make them modules
// utils.ts
export const config = { retries: 3 };
// app.ts
import { config } from "./utils.js";
// For a file that exports nothing but must still be a module
export {}; - The same rule decides whether
declare globalis legal. In a script file the declarations are already global, so the wrapper is an error; in a module it is the only way to reach the global scope.
Named Exports, Default Exports, and Which to Prefer
TypeScript uses the standard ES module syntax you already know. A named export attaches a name to the thing you are exporting, and importers must use that exact name in braces. A default export has no name of its own, so importers invent one — which is convenient right up until three files invent three different names for the same thing.
Most style guides now prefer named exports, and the reasons are practical rather than aesthetic. Renaming a named export is a single refactor your editor can perform across the codebase; renaming a default export changes nothing at the call sites, so the old names linger. Auto-import works better with named exports because the editor knows what the symbol is called. And a typo in a default import is not an error, just a differently named variable.
There is one exception worth keeping. Some frameworks require a default export — a React page component in Next.js, an Astro layout — and there you follow the framework. Everywhere else, export by name. You can also re-export from another module to build a public surface, and rename on the way in or out with as when two modules export the same name.
// math.ts — named exports
export function add(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
export class Calculator {
add(a: number, b: number): number { return a + b; }
}
// app.ts
import { add, PI, Calculator } from "./math.js";
import { add as sum } from "./math.js"; // rename on import
console.log(add(2, 3), PI);
// A default export — the importer chooses the name
// logger.ts
export default function log(message: string): void {
console.log(message);
}
// app.ts — all three of these are legal, which is the problem
import log from "./logger.js";
import writeLine from "./logger.js";
import anythingAtAll from "./logger.js";
// Re-exporting to build a public surface
export { add, PI } from "./math.js"; - A module can have both: several named exports plus one default. Mixing them is legal but tends to produce inconsistent import lines across a codebase, so pick one style per project.
import type: Saying What Disappears
When you import an interface, TypeScript has to work out whether that import should survive into the compiled JavaScript. Types are erased, so an import used only in an annotation should vanish — but the compiler cannot always tell, particularly when a build tool is compiling one file at a time with no view of the others.
import type removes the guesswork. It states that the import is types only, and the whole statement is deleted from the output. Its counterpart export type does the same for re-exports. Beyond the compiled size, this prevents a real class of bug: an import kept only for a type annotation still executes the imported module at runtime, which means side effects run and, in a large project, circular imports that should not exist quietly do.
Two compiler options relate to this. isolatedModules restricts you to code that can be compiled file by file, which is what fast bundlers such as esbuild require; with it on, re-exporting a type without export type is an error. verbatimModuleSyntax goes further: every import without the type keyword is preserved exactly as written, so what you type is what you get. Both push you towards being explicit, and being explicit is the right habit regardless of your build setup.
You can also mark individual specifiers, which is handy when one line pulls in both a value and a type from the same module.
// types.ts
export interface Student {
id: number;
name: string;
}
export type Role = "admin" | "teacher" | "student";
// service.ts
import type { Student, Role } from "./types.js";
// This entire line is deleted from the compiled JavaScript.
export function label(s: Student, role: Role): string {
return `${s.name} (${role})`;
}
// Mixing a value and a type from one module
import { createStudent, type Student } from "./student.js";
// Re-exporting a type — required by isolatedModules
export type { Student } from "./types.js"; - A good default for a new project: turn on
isolatedModulesand useimport typeeverywhere types are imported. It costs one keyword and makes your code portable across every build tool.
Module Resolution: the .js Extension Surprise
Module resolution is how TypeScript turns the string in your import into a file on disk, and it is the source of more confusion than any other part of the language. The rules depend on the module and moduleResolution settings in your tsconfig.json, which is why the same import line works in one project and fails in another.
The rule that surprises everybody: when a project targets real ES modules under Node — "module": "NodeNext" — relative imports must include a file extension, and the extension you write is .js even though the file on disk is .ts. This is not a bug. The import string is preserved untouched into the output, where the file really will be .js, so you are writing the path as it will exist at runtime.
Projects built with a bundler usually set "moduleResolution": "bundler" instead, where extensions are optional because the bundler resolves them. Both are correct; they describe different runtimes. What causes pain is copying an import style from one kind of project into the other.
Path aliases are the other common trap. Setting paths in tsconfig.json lets you write @/lib/api instead of a long relative path, and it makes the compiler happy — but tsc does not rewrite those strings in the output. Unless your bundler or runtime is configured with the same aliases, the compiled code fails to resolve them at runtime.
// tsconfig.json for Node with ES modules
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
// The file on disk is ./utils/format.ts
import { formatMarks } from "./utils/format.js"; // correct
import { formatMarks } from "./utils/format"; // ERROR: needs an extension
import { formatMarks } from "./utils/format.ts"; // ERROR: do not write .ts
// Path aliases: the compiler understands them...
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
}
import { api } from "@/lib/api";
// ...but tsc emits that string unchanged. Your bundler or runtime
// must be configured with the same alias, or it fails at run time. - If an import works in your editor but fails when you run the code, the cause is almost always one of these two: a missing extension under NodeNext, or a path alias the runtime does not know about.
Declaration Files and Typing Libraries
A declaration file, ending .d.ts, contains types and no implementation. It is how TypeScript learns the shape of JavaScript it cannot see: a library written in plain JavaScript, a global your bundler injects, a file format your loader handles.
Most popular packages ship their own declarations inside the package, and you get types automatically on install. When a package does not, the community maintains them in the DefinitelyTyped project, published as @types/ followed by the package name. That is what npm install --save-dev @types/node and @types/express are doing — installing the descriptions, not the code.
For a library with no types anywhere, you write your own. Declaring the handful of functions you actually use takes ten minutes and gives you real checking on the part you touch. The other everyday use is telling TypeScript about globals that exist because of your environment: a script tag that defines window.dataLayer, or non-code imports such as .svg files handled by a bundler.
// Types that come from DefinitelyTyped
// npm install --save-dev @types/node @types/express
// src/types/globals.d.ts — your own declarations
// 1. A library with no types at all
declare module "legacy-chart" {
export function render(el: HTMLElement, data: number[]): void;
export const version: string;
}
// 2. A global injected by a script tag
declare global {
interface Window {
dataLayer: unknown[];
}
}
// 3. Non-code imports your bundler handles
declare module "*.svg" {
const src: string;
export default src;
}
export {}; // makes this file a module, required for declare global - Declaration files are only descriptions. Writing
declare function save(x: string): voiddoes not create asavefunction — it promises one already exists at runtime. If the promise is wrong, you get a clean compile and a not defined error in the browser.
Namespaces, and Organising a Real Project
Before JavaScript had modules, TypeScript provided namespace — a way to group declarations under one global name and avoid collisions when everything shared a single scope. You will still meet it in older codebases and in declaration files for libraries designed around script tags.
In new code, do not use it. ES modules give you the same isolation with real file boundaries, tooling support, and tree-shaking that bundlers understand. The TypeScript documentation itself recommends modules over namespaces for modern projects. The one place namespaces still appear legitimately is inside .d.ts files describing a library that genuinely does attach everything to a global object.
For organising your own project, keep it plain. Group by feature rather than by technical kind, so a folder holds a feature's types, service and component together instead of scattering them across types/, services/ and components/. Shared types that several features genuinely need can live in a common module.
Barrel files — an index.ts that re-exports a folder's contents — are worth a word of caution. They make imports tidy, but importing one thing pulls in the whole barrel, which can slow builds and create circular imports that are hard to trace. Use them at the edge of a package with a real public surface, not inside every folder.
// Legacy style — you will read this, but do not write it
namespace Validation {
export function isEmail(value: string): boolean {
return /.+@.+\..+/.test(value);
}
}
Validation.isEmail("a@b.com");
// Modern equivalent: a module
// validation.ts
export function isEmail(value: string): boolean {
return /.+@.+\..+/.test(value);
}
// app.ts
import { isEmail } from "./validation.js";
import * as Validation from "./validation.js"; // same grouping, if you want it
// A barrel file: convenient, but not free
// src/students/index.ts
export { StudentService } from "./service.js";
export type { Student } from "./types.js"; - If you see
module Foo { }in very old TypeScript, that is the original spelling ofnamespaceand has nothing to do with ES modules. It is one of the more unfortunate naming collisions in the language's history.
