Polyglot
Languages TypeScript scope
TypeScript § scope

Scope and modules

TypeScript’s scope and module model is principally JavaScript’s: lexical block-scoped let and const, function-scoped var (legacy), and the ES module system as the conventional contemporary form. Each .ts file is a module if it contains an import or export; otherwise it is treated as a script (running in the global scope). TypeScript adds namespaces (the older “internal modules” form, conventionally replaced by ES modules), declaration merging, ambient declarations in .d.ts files, and the conventional patterns for typing third-party JavaScript libraries through @types/... packages from DefinitelyTyped. The combination — lexical scoping, ES modules, declaration files, the type-only and value-only namespaces — is the substance of TypeScript’s organisational model.

Scope rules

JavaScript (and TypeScript) has two principal scoping disciplines:

let and const — block scope

function example() {
    if (true) {
        let x = 5;
        const y = 10;
        // x and y are visible here
    }
    // x and y are NOT visible here
}

for (let i = 0; i < 3; i++) {
    // i is scoped to this iteration
}
// i is NOT visible here

let admits reassignment; const does not (though the value’s properties may still mutate).

var — function scope (legacy)

function example() {
    if (true) {
        var x = 5;
    }
    console.log(x);                              // 5 — visible throughout the function
}

The conventional discipline avoids var; the function-scoping is surprising and admits the temporal dead zone and hoisting pitfalls that block-scoped let/const resolve.

Hoisting

Function declarations are hoisted to the top of their scope:

hello();                                          // OK; greet is hoisted
function hello() { console.log("hi"); }

let and const declarations are not hoisted in the same sense — they exist in the temporal dead zone until the declaration is reached:

console.log(x);                                  // ERROR: cannot access before declaration
let x = 5;

Function expressions (assigned to let/const) follow the variable’s hoisting:

hello();                                          // ERROR: hello not initialized
const hello = () => console.log("hi");

The conventional discipline declares variables at the top of their scope; hoisting is best avoided.

Closures

Functions admit accessing variables from the enclosing scope:

function makeCounter() {
    let count = 0;
    return () => {
        count++;
        return count;
    };
}

const counter = makeCounter();
console.log(counter());                          // 1
console.log(counter());                          // 2

The closure captures count by reference; subsequent calls share the same binding. The mechanism admits substantial state encapsulation without classes.

A common pre-2015 pitfall: capturing the loop variable with var:

const callbacks = [];
for (var i = 0; i < 3; i++) {
    callbacks.push(() => i);                     // all closures share var i
}
console.log(callbacks.map(f => f()));            // [3, 3, 3]

With let, each iteration has its own binding:

const callbacks = [];
for (let i = 0; i < 3; i++) {
    callbacks.push(() => i);                     // each closure has its own i
}
console.log(callbacks.map(f => f()));            // [0, 1, 2]

The conventional contemporary discipline always uses let in for loops.

ES modules

The conventional contemporary form is ES modules. Each .ts file with at least one import or export is a module:

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

export const PI = 3.14159;

export interface Point {
    x: number;
    y: number;
}

export default function multiply(a: number, b: number): number {
    return a * b;
}
// main.ts:
import multiply, { add, PI, Point } from "./math.js";

console.log(add(2, 3));                          // 5
const p: Point = { x: 1, y: 2 };
console.log(multiply(2, 3));

Note the .js extension — TypeScript imports refer to the emitted path (some configurations admit .ts, but .js is conventional).

Re-exports

// index.ts:
export { add, PI } from "./math.js";
export * from "./helpers.js";
export { default as multiply } from "./math.js";

The mechanism admits a clean public API even when implementation is split across files.

Type-only imports

import type { Point } from "./math.js";
import { add, type Point } from "./math.js";

The type modifier admits importing only the type — the import is erased during compilation. Conventional for clean tree-shaking and clarifying intent.

Dynamic imports

async function loadMath() {
    const math = await import("./math.js");
    return math.add(2, 3);
}

Dynamic imports admit code-splitting and conditional loading.

CommonJS interop

Older Node.js code uses CommonJS:

// math.js:
function add(a, b) { return a + b; }
module.exports = { add };
// In TypeScript:
import { add } from "./math";                    // with esModuleInterop
const math = require("./math");                  // with the @types/node types

The esModuleInterop compiler option admits the cleaner ES module-style imports for CommonJS modules.

Namespaces

The namespace keyword (older form, conventionally replaced by ES modules) admits internal organisation:

namespace Geometry {
    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);
    }
}

const p: Geometry.Point = { x: 1, y: 2 };
console.log(Geometry.distance(p, { x: 0, y: 0 }));

Namespaces are conventionally avoided in modern TypeScript; ES modules are the recommended form. The principal exception is in declaration files for global libraries (treated below).

Declaration merging

Several declaration types may be merged — multiple declarations of the same name combine into one:

Interface merging

interface Person {
    name: string;
}

interface Person {
    age: number;
}

// Merged: Person has both name and age
const p: Person = { name: "Alice", age: 30 };

The mechanism is conventional for augmenting third-party types — adding properties to existing interfaces:

declare global {
    interface Window {
        myCustomProperty: string;
    }
}

Namespace merging

A namespace can merge with a class, function, or interface to add members:

class Greeter {
    greet(name: string) { return `Hello, ${name}`; }
}

namespace Greeter {
    export const standardGreeting = "Hello, world";
}

console.log(Greeter.standardGreeting);

The pattern is rare in idiomatic code; module-level constants are conventional.

Declaration files

.d.ts files contain type-only declarations — no runtime code. They describe the shape of JavaScript code for the type system:

// my-lib.d.ts:
declare module "my-lib" {
    export function process(data: string): string;
    export interface Config {
        verbose: boolean;
    }
}

The conventional uses:

  • Typing JavaScript libraries — the @types/... packages from DefinitelyTyped provide types for thousands of npm packages.
  • Ambient declarations — describing globals provided by the runtime (browser DOM, Node.js APIs).
  • Module declarations — typing modules without TypeScript source.

Ambient modules

// my-lib.d.ts:
declare module "my-lib" {
    export function helper(): void;
}

The form admits typing modules whose source is JavaScript or external. Once declared, import { helper } from "my-lib" is type-checked.

Global declarations

// globals.d.ts:
declare const VERSION: string;
declare const PROCESS_ENV: "development" | "production";

declare function customLog(message: string): void;

The form admits declaring globals injected by the build system (e.g., Webpack’s DefinePlugin).

DefinitelyTyped and @types/...

The DefinitelyTyped repository hosts type declarations for thousands of JavaScript libraries:

npm install --save-dev @types/node
npm install --save-dev @types/react
npm install --save-dev @types/lodash

The @types/... packages contain only .d.ts files; the implementation comes from the corresponding npm package:

import * as fs from "node:fs";                    // types from @types/node
import _ from "lodash";                           // types from @types/lodash

For libraries that ship their own types (most modern TypeScript libraries), no @types/... package is needed.

node_modules and types

The compiler’s moduleResolution setting determines how imports are resolved:

{
    "compilerOptions": {
        "moduleResolution": "Bundler"            // for projects using bundlers
    }
}

The principal options:

  • Node10 — the original Node.js algorithm.
  • Node16 / NodeNext — modern Node.js with package.json exports support.
  • Bundler — for projects using bundlers (Vite, esbuild, webpack).

The conventional contemporary settings vary:

  • LibraryNodeNext.
  • Application with bundlerBundler.
  • Application without bundlerNodeNext.

Common patterns

Module organisation

src/
├── index.ts                                      (public API; re-exports)
├── core/
│   ├── parser.ts
│   ├── lexer.ts
│   └── ast.ts
├── utils/
│   └── strings.ts
└── types.ts                                      (shared type declarations)
// index.ts:
export { parse } from "./core/parser.js";
export { tokenize } from "./core/lexer.js";
export type { Token, ParseResult } from "./types.js";

Augmenting third-party types

// extras.d.ts:
import "express";

declare module "express" {
    interface Request {
        user?: { id: string; name: string };
    }
}

The form admits adding fields to existing types from third-party libraries.

Barrel exports

// components/index.ts:
export { Button } from "./Button.js";
export { Card } from "./Card.js";
export { Modal } from "./Modal.js";
export type { ButtonProps, CardProps, ModalProps } from "./types.js";

// Consumer:
import { Button, Card, type ButtonProps } from "./components";

The pattern admits consolidated import paths.

Type-only re-exports

// types.ts:
export type { User } from "./user.js";
export type { Post } from "./post.js";
export type { Comment } from "./comment.js";

// Consumer:
import type { User, Post } from "./types.js";

Module side effects

import "./globals.js";                           // side-effect-only import
import "@/styles/main.css";                      // CSS via bundler

The form admits importing modules for their side effects (registering global handlers, applying polyfills, loading CSS).

Path aliases

{
    "compilerOptions": {
        "baseUrl": "./src",
        "paths": {
            "@/*": ["*"],
            "@components/*": ["components/*"],
            "@utils/*": ["utils/*"]
        }
    }
}
import { Button } from "@components/Button";
import { format } from "@utils/strings";

The pattern admits cleaner imports without long relative paths. Bundler configuration must be aligned for runtime resolution.

Conditional declarations

// env.d.ts:
declare const __DEV__: boolean;

// Used like a runtime constant; the value is supplied by the build:
if (__DEV__) {
    console.log("development mode");
}

A note on import.meta

Modern modules expose metadata via import.meta:

console.log(import.meta.url);                    // module's URL
console.log(import.meta.dirname);                // directory (Node.js)
console.log(import.meta.filename);               // filename (Node.js)

// Top-level await (admitted in modules):
const data = await fetch("/api");

The import.meta is unique to ES modules; it does not exist in CommonJS.

A note on the conventional discipline

The contemporary TypeScript scope/modules advice:

  • Use const by default; let for reassignment; never var.
  • Use ES modulesexport/import, not namespaces or CommonJS.
  • Use import type for type-only imports.
  • Use barrel index.ts files for clean public APIs.
  • Use @types/... for typing JavaScript libraries.
  • Use declaration merging for augmenting third-party types.
  • Use path aliases for cleaner imports in larger projects.
  • Use import.meta.url for module paths in modern code.

The combination — block-scoped lexical bindings, ES modules with import/export, declaration files for typing JavaScript, namespaces for legacy and global typing, the @types/... ecosystem — is the substance of TypeScript’s organisational model. The discipline produces clear, modular, type-safe code that interoperates with the broader JavaScript ecosystem.