Modules and the seam
ESM/CJS, emitted TypeScript constructs, ambient declarations, merging, module syntax, and the unsoundness catalogue.
The idea
Every file so far has been one of two clean things: real JavaScript, or type syntax that vanishes without a trace. This one is neither, and that is the point.
A seam here means a place where two systems meet and the join is visible. There are three on this page, and they turn out to be the same problem wearing different clothes:
- constructs that generate JavaScript rather than
describing it —
enum,namespace, parameter properties, decorators — which is why this is the one census file Node cannot run by stripping types; - declarations about code the checker will never see, where TypeScript believes you without evidence;
- modules, where a file’s names cross into another file, and two different module systems disagree about what crossing means.
The file closes with the unsoundness catalogue — the honest list of places a well-typed program can still fail. Read it as the natural end of this page rather than an appendix: every entry is a seam where a checked world touches an unchecked one.
Modules: the rule everything else follows
A file is a module if it has a top-level import
or export, and a script otherwise — and a script’s
top-level names are globals. That is why you see a bare
export {} in files that export nothing: it exists purely to
say “this is a module, do not leak my variables”.
Two properties of ESM imports surprise people, and both come from the same design: the module graph is resolved before anything runs.
- Imports are hoisted. Every dependency is fully evaluated before the first line of the importing file, no matter where you wrote the statement.
- Imports are live bindings. An import is a link to
the exporter’s variable, not a copy of its value, so you observe later
changes to it.
conston your side never made it a snapshot.
The import and export forms are worth reading as a list — default,
named, renamed, namespace, side-effect-only, type-only, inline
type, dynamic import(), and the re-export
forms. The one to notice is import type:
always erased, and the only form that promises to be.
import x = require() and export = are
TypeScript’s own pre-ESM forms. They survive for one case ESM genuinely
cannot express: a CommonJS module whose entire export is a single value
rather than a set of named ones.
A .d.ts file is nothing but
declarations and emits no code at all — a whole file that is implicitly
declare, describing something that already exists.
isolatedModules requires each file to
be compilable on its own, with no knowledge of the others, because that
is how modern bundlers work. It therefore bans anything needing
cross-file type information — const enum across files, and
re-exporting a type without export type.
CJS and ESM are two different models
ESM is static, hoisted, and asynchronous. CJS is dynamic, synchronous, and copies values rather than linking them. Most interop pain traces to that last difference.
In a .ts module, module,
exports, require, __dirname and
__filename do not exist. The ESM equivalents are
createRequire(import.meta.url),
fileURLToPath(import.meta.url), and dirname of
that. Going the other way, a CJS module’s whole
module.exports object arrives as the
default export, and named imports work only when Node
can spot them by reading the source — which it cannot always do.
esModuleInterop is the flag that decides whether
import x from "cjs" means the module.exports
object or its .default property, and it inserts a helper to
do it.
The constructs that emit code
This is the category that makes the file uncompilable by stripping. For each one, the lesson is the difference between what you wrote and what comes out.
enum emits an object. A
numeric enum emits the mapping both ways, so a
value can be turned back into a name — meaning it has twice the keys you
declared. A string enum has no reverse mapping, since a string
value could collide with a name. A
const enum emits nothing and pastes a
literal at each use site: fast, and incompatible with separate
compilation, so it should never appear in a library’s public API. Enums
are also nominal-ish — two enums with identical members will not
substitute for each other. If nominal-ish was all you wanted, an
as const object plus a typeof-derived union
gives you the same thing and erases completely, which is what most
modern code does.
namespace is TypeScript’s own pre-ESM
module system. It compiles to an IIFE assigned to a var, which is why
export inside a namespace is meaningful — it decides what
becomes a property of the resulting object. Do not write new ones, but
expect them in older code and in .d.ts files. The one place
they still earn their keep is merging with a function of the same name,
which is how you type a callable thing that also has properties.
Parameter properties —
constructor(public readonly x: number) — declare
and assign a field from a constructor parameter. The assignment
they generate appears in no line of your source, which is exactly why
type-stripping cannot handle them.
Decorators are an expression prefixed with
@, called while the class is being defined and handed a
context object describing what is being decorated. That
call has to happen at run time, so a decorator can never be erased. Note
that the standard decorators and the older
experimentalDecorators model have different calling
conventions and are not interchangeable.
accessor belongs here too: it generates a
getter/setter pair over a private slot.
Ambient declarations: believing without evidence
declare means “this exists at run time,
take my word for it”. Nothing is emitted and nothing is checked — which
makes this the one place TypeScript is unsound by
design. An incorrect ambient declaration is a lie with no
detector.
declare covers consts, functions, classes, and
namespaces. Two forms are worth recognising specifically:
declare global— a module’s declarations are local to it, so reaching the global scope needs this explicit escape hatch.declare module "name"— adds to someone else’s module types, which is how plugin ecosystems extend a library without forking it, and how importing a.cssor.svggets a type at all.
Declaration merging, and the two namespaces
Interfaces with the same name merge; type aliases do not. A class merges with an interface of the same name, letting the interface add members with no implementation — useful when something is patched on at run time.
Underneath that is the rule from file 08, now load-bearing: a
name can be a type and a value at once, because those are
separate namespaces. A class declares both — an instance
shape in type space and a constructor in value space — and
typeof C is how you cross from one to the other.
The type modifier on imports is the same idea at file
scope. Without it the compiler must guess whether an
import is used only as a type and elide it, and that guess breaks
side-effectful modules and single-file transpilers.
verbatimModuleSyntax bans the guess: anything without
type is emitted verbatim, anything with it is dropped.
The unsoundness catalogue
Places where a well-typed program can still fail at run time. Knowing this list by name is most of what separates reading TypeScript from trusting it:
any— disables checking and propagates silently.- Type assertions — you overruling the checker, with nothing emitted.
- Array covariance (file 09) — writable containers treated as covariant.
- Index signatures promising a value that may not exist —
noUncheckedIndexedAccessfixes this one. Object.keysreturningstring[]rather than(keyof T)[]— deliberate, because an object may have more keys than its type declares.- Optional properties versus explicitly-
undefinedvalues —exactOptionalPropertyTypes. - The non-null assertion
!— an unchecked promise. - Mutation through an aliased reference invalidating narrowing.
- Parameter bivariance in method syntax.
as constbeing readonly at compile time only —Object.freezeis the runtime half, and they are not the same guarantee.
The strict family is worth reading as the other side of
that list. Each flag can be toggled individually, and each one changes
what the same source text means — which is the last and largest
seam: there is no single language called TypeScript, only a language
plus the flags you compiled it with.
Where this leaves you
That is the census. Files 01–07 are one runtime language, 08–09 are a second language describing it, and this file is every place the two touch. If you can say, of any line, which of those three it belongs to and what it leaves behind after compilation, pass I has done its job.
The census cannot tell you whether you can read — whether
you would have predicted any of it without the comment beside it. That
is what the marked // ? lines are for. Revisit those lines
and keep asking the split file 01 introduced: what does this text parse
as, what survives at runtime, and what does the checker conclude?
While you read
- For each construct, ask whether it emits code or vanishes.
- For each
declare, ask who is responsible for the thing actually existing. - For each import, remember the dependency ran before this file’s first statement.
- For merged declarations, list what each side contributed.
- For each item in the unsoundness list, write down the runtime failure it permits.
The file
This is the only file that will not run under node.
Compile it instead, and read the emitted JavaScript for the
enum — that difference is the whole lesson. Mark anything
you cannot explain with // ? in the source.
// 10 — MODULES, DECLARATIONS, AND THE TS/JS SEAM
//
// This is the seam. Files 01–09 were either real JavaScript or TypeScript that
// vanishes cleanly. Everything here is one of two other things: module syntax, or
// TypeScript that GENERATES code rather than describing it. That second category
// is why this is the one file Node cannot run by stripping types.
//
// A reading file, with three markers:
//
// // => what the expression evaluates to, once compiled and run
// // emits: the JavaScript tsc actually generates for the line above
// // type: what the checker infers
//
// The `// emits:` lines are the point of this file. Compile it and read the
// output — the distance between what you wrote and what you get IS the lesson.
//
// npm run check tsc over the whole folder (a single-file tsc call would
// ignore tsconfig.json and every flag in it)
//
// Mark anything you can't explain out loud with // ?
// ─── 1. MODULE SYNTAX ────────────────────────────────────────────────────────
// The rule that decides everything else: a file is a MODULE if it has a
// top-level import or export, and a SCRIPT otherwise — in which case its
// top-level names are globals. That is why you see a bare `export {}` in files
// that export nothing: it exists purely to say "this is a module, don't leak my
// variables".
export {};
// Import forms (commented — this repo has no other files to import from):
//
// import def from "./mod.ts"; default import
// import { named, other as alias } from "…"; named + rename
// import * as ns from "./mod.ts"; namespace import
// import def, { named } from "./mod.ts"; both
// import "./mod.ts"; side effects only
// import type { T } from "./mod.ts"; type-only — always erased
// import { type T, value } from "./mod.ts"; inline type modifier
// const mod = await import("./mod.ts"); dynamic import — returns a promise
// import.meta.url module metadata
//
// Export forms:
//
// export const named = 1; inline
// export { local as public }; clause, with rename
// export default expr; one per module
// export type { T }; type-only
// export * from "./mod.ts"; re-export all
// export * as ns from "./mod.ts"; re-export namespaced
// export { x } from "./mod.ts"; re-export selected
export const named = 1;
export default function defaultExport() {}
const local = 2;
export { local as publicName };
// Two things about imports that surprise people. They are HOISTED — every
// dependency is fully evaluated before the first line of this file runs, no
// matter where you wrote the import. And they are LIVE: an import is a link to
// the exporter's variable, not a copy of its value, so importers see later
// changes (34.1).
export let liveBinding = 0;
export function mutate() {
liveBinding++; // an importer reading `liveBinding` observes this
}
mutate();
const afterMutation = liveBinding; // => 1 — and an importer would see 1 too, not the 0
// it was at import time
// TypeScript's own forms, from before ESM existed. Still the right answer for the
// one case ESM genuinely cannot express: a CommonJS module whose ENTIRE export is
// a single value rather than a set of named ones.
//
// import fs = require("node:fs"); // TS import-equals
// export = someValue; // TS export-assignment (whole-module export)
// /// <reference types="node" /> // triple-slash directive, pulls in types
//
// A .d.ts is nothing but declarations and emits no code at all. Think of it as a
// whole file that is implicitly `declare`, describing something that already
// exists.
//
// isolatedModules means each file must be compilable on its own, knowing nothing
// about the others. Modern bundlers work that way, so it bans anything that needs
// cross-file type information: `const enum` across files, and re-exporting a type
// without `export type`.
// ─── 2. CJS / ESM INTEROP ────────────────────────────────────────────────────
//
// Two module systems with genuinely different models. ESM is static, hoisted and
// asynchronous; CJS is dynamic, synchronous, and copies values rather than
// linking them. Most interop pain comes from that last difference.
// In a .ts file compiled as ESM, `module`, `exports`, `require` and `__dirname`
// do not exist. These are the equivalents:
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const require_ = createRequire(import.meta.url); // a working `require` inside ESM
const __filename_ = fileURLToPath(import.meta.url); // ESM has no __filename
const __dirname_ = dirname(__filename_); // ...nor __dirname
const requireKind = typeof require_; // => "function"
const filenameKind = typeof __filename_; // => "string"
const hasMetaUrl = import.meta.url.length > 0; // => true
// Going ESM -> CJS: the whole module.exports object arrives as the DEFAULT
// export. Named imports work only when Node can spot them by reading the source,
// which it cannot always do.
// import pkg from "cjs-module"; const { named } = pkg;
//
// esModuleInterop makes `import x from "cjs"` mean "the module.exports object"
// rather than "its .default property", and inserts the helper that does it.
// ─── 3. ENUMS — NOT ERASABLE ─────────────────────────────────────────────────
//
// The first construct that emits code.
// A NUMERIC ENUM emits an object with the mapping going BOTH ways, so a value can
// be turned back into a name. Which means it has twice the keys you declared.
enum Direction {
Up, // 0 — auto-numbered from zero
Down, // 1
Left = 10, // explicit
Right, // 11 — numbering continues from the last explicit value
}
// emits: var Direction; (function (Direction) {
// Direction[Direction["Up"] = 0] = "Up"; ...
// })(Direction || (Direction = {}));
// That doubled assignment is the reverse mapping being built: the inner one sets
// name -> value and returns the value, and the outer sets value -> name.
const forward = Direction.Up; // => 0
const backward = Direction[10]; // => "Left" — REVERSE MAPPING, numeric enums only
const continued = Direction.Right; // => 11
const doubledKeys = Object.keys(Direction).length; // => 8 — four members, eight keys
// A STRING ENUM has no reverse mapping, because a string value could collide with
// a member name.
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
}
const stringValue = Status.Active; // => "ACTIVE"
const stringKeys = Object.keys(Status).length; // => 2 — one key per member, no reverse
// A `const enum` emits NOTHING and pastes a literal at each use site. Fast, and
// incompatible with separate compilation — never put one in a library's API.
const enum Inlined {
A = 1,
}
const inlined = Inlined.A; // => 1
// emits: const inlined = 1 /* Inlined.A */; — no Inlined object exists at run time,
// only that comment where the member name used to be
// Enums are also nominal-ish: two enums with identical members will not stand in
// for each other. If nominality was all you wanted, here is the version that
// erases completely — and it is what most modern code uses instead.
const DirectionConst = { Up: 0, Down: 1 } as const;
type DirectionConst = (typeof DirectionConst)[keyof typeof DirectionConst]; // type: 0 | 1
const constObjectValue = DirectionConst.Up; // => 0
// emits: const DirectionConst = { Up: 0, Down: 1 }; — the `as const` and the type
// both vanish, leaving an ordinary object
// ─── 4. NAMESPACES — NOT ERASABLE ────────────────────────────────────────────
//
// TypeScript's own module system, from before the language had one. You will meet
// it in older code and in .d.ts files. Do not write new ones.
// It compiles to an IIFE assigned to a var, which is why `export` inside a
// namespace is meaningful: it decides what becomes a property of that object.
namespace Geometry {
export const PI = 3.14159;
export function area(r: number) {
return PI * r ** 2;
}
export namespace Nested { // namespaces nest
export const deep = true;
}
const notExported = "invisible outside";
void notExported;
}
// emits: var Geometry; (function (Geometry) { Geometry.PI = 3.14159; ... })(Geometry || (Geometry = {}));
// `notExported` becomes a plain local inside that IIFE, unreachable from outside —
// which is exactly the private-scope trick from file 02, generated for you.
const namespacedCall = Geometry.area(1); // => 3.14159
const nestedValue = Geometry.Nested.deep; // => true
// The one place namespaces still earn their keep: MERGING with a function of the
// same name, which is how you type a callable thing that also has properties.
function decorated() {}
namespace decorated {
export const version = "1.0";
}
const mergedProperty = decorated.version; // => "1.0"
const stillCallable = typeof decorated; // => "function" — one binding, both meanings
// ─── 5. PARAMETER PROPERTIES — NOT ERASABLE ──────────────────────────────────
//
// Declare and assign a field straight from a constructor parameter. Convenient —
// and it generates an assignment that appears in no line of your source, which is
// exactly why type-stripping refuses it.
class Point {
constructor(
public readonly x: number,
private y: number = 0,
protected label?: string,
) {}
// emits: class Point {
// x; y; label;
// constructor(x, y = 0, label) { this.x = x; this.y = y; this.label = label; }
// }
// Three field declarations AND three assignments, none of which you wrote.
// Node's stripper cannot produce them, because stripping only removes
// characters — it never adds any.
describe() {
return `${this.x},${this.y},${this.label ?? ""}`;
}
}
const described = new Point(1, 2, "p").describe(); // => "1,2,p"
const fieldsExist = Object.keys(new Point(1, 2, "p")); // => ["x", "y", "label"]
// All three are ordinary properties, whatever modifier you wrote.
// Worth putting side by side one more time. TypeScript's `private` is a promise
// the checker keeps: the property is right there at run time and obj["soft"]
// reads it. `#private` is enforced by the engine and invisible to everything (40.4).
class Privacy {
private soft = "readable via bracket access at runtime";
#hard = "genuinely inaccessible";
reveal() {
return [this.soft, this.#hard];
}
}
const visibleKeys = Object.keys(new Privacy()); // => ["soft"] — `#hard` is not a property
const softlyPrivate = (new Privacy() as any)["soft"];
// => "readable via bracket access at runtime"
const bothFromInside = new Privacy().reveal();
// => ["readable via bracket access at runtime", "genuinely inaccessible"]
// ─── 6. DECORATORS AND accessor ──────────────────────────────────────────────
//
// A decorator is an expression prefixed with `@`, called while the class is being
// defined and handed a context object describing what it decorates. That call has
// to happen at run time, so a decorator can never be erased.
// These are the STANDARD decorators. The older `experimentalDecorators` model has
// a different calling convention, and the two are not interchangeable.
const decoratorLog: string[] = [];
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
return function (this: This, ...args: Args): Return {
decoratorLog.push(`calling ${String(context.name)}`);
return target.call(this, ...args);
};
}
class Service {
@logged
fetch(id: number) {
return id;
}
accessor tracked = 1; // `accessor` generates a getter/setter pair over a private slot
}
const fetched = new Service().fetch(1); // => 1 — through the wrapper the decorator returned
const wasLogged = decoratorLog; // => ["calling fetch"]
const accessorValue = new Service().tracked; // => 1
const accessorIsOnPrototype = Object.getOwnPropertyNames(Service.prototype);
// => ["constructor", "fetch", "tracked"] — `accessor` put a real getter/setter pair on
// the prototype, backed by a private field on each instance
// ─── 7. AMBIENT DECLARATIONS ─────────────────────────────────────────────────
//
// `declare` means "this exists at run time, take my word for it". Nothing is
// emitted and nothing is checked, which makes this the one place TypeScript is
// unsound BY DESIGN. An incorrect ambient declaration is a lie with no detector.
declare const INJECTED_AT_BUILD: string;
declare function externalFn(a: string): number;
declare class ExternalClass {
method(): void;
}
declare namespace ExternalNamespace {
const version: string;
}
// emits: nothing whatsoever, for any of the four.
const promisedString = typeof INJECTED_AT_BUILD; // => "undefined"
// The declaration says `string`. At run time there is no such binding at all, and
// nothing anywhere reported a problem. That is the design, not a bug — and it is
// why a wrong .d.ts is the hardest kind of TypeScript error to find.
// A module's declarations are local to it, so reaching the global scope needs an
// explicit escape hatch.
declare global {
// eslint-disable-next-line no-var
var myGlobal: string | undefined;
interface Window {
customProperty?: number;
}
}
globalThis.myGlobal = "set";
const globalRead = globalThis.myGlobal; // => "set"
// Adding to someone else's module types — how plugin ecosystems extend a
// library's interfaces without forking it:
// declare module "some-library" { interface Options { extra?: boolean } }
//
// ...and how importing a .css or .svg gets a type at all:
// declare module "*.css" { const content: string; export default content }
// ─── 8. DECLARATION MERGING ──────────────────────────────────────────────────
// Interfaces of the same name MERGE. Type aliases do not — a duplicate alias is
// TS2300, "Duplicate identifier".
interface Merged { a: string }
interface Merged { b: number }
const merged: Merged = { a: "a", b: 1 }; // type: Merged, with both members required
// A class merges with an interface: the interface adds members with no
// implementation, which is how you type something patched on at run time.
class Mergeable {}
interface Mergeable { addedLater(): string }
Mergeable.prototype.addedLater = () => "patched on";
const patched = new Mergeable().addedLater(); // => "patched on"
// The checker believed the interface. Nothing verified that the prototype
// assignment ever happened — delete that line and this still compiles.
// A name can be a TYPE and a VALUE at once, because they live in separate
// declaration spaces.
class DualMeaning {} // both a type (the instance shape) and a value (the constructor)
type Alias = DualMeaning; // type position
const instance: Alias = new DualMeaning(); // value position
type TypeofClass = typeof DualMeaning; // the CONSTRUCTOR's type, not the instance's
const isInstance = instance instanceof DualMeaning; // => true
// ─── 9. WHAT `import type` AND verbatimModuleSyntax DO ───────────────────────
//
// Without a `type` modifier the compiler must GUESS whether an import is used
// only as a type, and elide it if so. That guess breaks side-effectful modules
// and single-file transpilers. `verbatimModuleSyntax` bans the guess: anything
// without `type` is emitted verbatim, anything with it is dropped.
//
// import { Thing } from "./m.ts"; // emitted, even if only used as a type
// import type { Thing } from "./m.ts"; // always dropped
// import { type Thing, value } from "…"; // Thing dropped, value kept
// ─── 10. THE UNSOUNDNESS CATALOGUE ───────────────────────────────────────────
//
// Places where a well-typed program can still fail at run time. Knowing this list
// by name is most of what separates reading TypeScript from trusting it.
// 1. `any` — disables checking, and spreads silently through everything it touches.
const anyValue: any = "string";
const notReallyNumber: number = anyValue; // type: number
const actualType = typeof notReallyNumber; // => "string" — the type says number, the
// value is a string, and no line of this file is an error
// 2. TYPE ASSERTIONS — you overruling the checker.
const asserted = {} as { required: string };
const requiredIsMissing = asserted.required; // => undefined, typed `string`
// 3. ARRAY COVARIANCE (file 09, section 7).
// 4. INDEX SIGNATURES promise a value that may not be there.
const record: Record<string, string> = {};
const missing = record.nothing; // => undefined
// type: string | undefined here, because noUncheckedIndexedAccess is on. Turn that
// flag off and the same line is typed plain `string`, still holding undefined.
// 5. Object.keys returns string[], not (keyof T)[] — deliberately, since an object
// may carry more keys than its type declares.
const keysAreStrings = Object.keys({ a: 1 }); // => ["a"], typed string[]
// 6. Optional properties versus undefined values (exactOptionalPropertyTypes).
// 7. The non-null assertion `!` — an unchecked promise.
// 8. Mutation through an aliased reference invalidating a narrowing.
// 9. Function parameter bivariance in method syntax.
// 10. `as const` objects are readonly at COMPILE time only. Object.freeze is the
// runtime half, and the two are not the same guarantee.
// The `strict` family, individually. Each can be toggled on its own, and each
// changes what the SAME source means:
// strictNullChecks null/undefined stop being members of every type
// strictFunctionTypes parameter positions checked contravariantly
// strictBindCallApply bind/call/apply are type-checked
// strictPropertyInitialization class fields must be assigned (`!` opts out)
// noImplicitAny untyped parameters become errors
// noImplicitThis `this` of unknown type becomes an error
// useUnknownInCatchVariables catch bindings are `unknown`, not `any`
// alwaysStrict emit "use strict" and parse in strict mode
// Beyond strict: noUncheckedIndexedAccess, exactOptionalPropertyTypes,
// noImplicitOverride, noFallthroughCasesInSwitch, noPropertyAccessFromIndexSignature.