Types travel with imports
import type — or the inline type modifier — says the import exists only for the checker. The line is erased, which means it can never cause a circular import at runtime or keep a module alive in the bundle.
// door.ts
export interface Door { id: string; name: string; price: number }
export type Finish = "painted" | "oiled";
export function describe(door: Door): string { … }
// order.ts
import { describe, type Door } from "./door.js";
import type { Finish } from "./door.js";
verbatimModuleSyntax
// with verbatimModuleSyntax: true
import { Door } from "./door.js"; // Error if Door is only a type
import type { Door } from "./door.js"; // correct
It removes all the guessing about which imports survive compilation — and with a bundler that transpiles file by file, that guessing is where the confusing build errors come from.
A file with no import or export is not a module
// utils.ts — no import, no export
interface Door { … } // now a GLOBAL type, colliding with every other Door
export {}; // one line, and the file is a module again
"Duplicate identifier" or "Cannot redeclare block-scoped variable" in a file that looks fine almost always means it is a script rather than a module. Global names also collide with the DOM: Range, Event, Text and Location are all taken.
Default or named
Named exports autocomplete, rename safely and can be found by a text search. A default export is right when a file has exactly one thing in it and a tool expects it — a route module, a component file in some conventions.
export default function describe(door: Door) { … } // renamed freely on import
export function describe(door: Door) { … } // the name is the contract
Declaration files
A .d.ts says what a shape is without producing any JavaScript. You write them by hand for untyped code, and generate them for a package you ship.
// door.d.ts — describes something that already exists
export interface Door { id: string; name: string }
export declare function describe(door: Door): string;
// generated from your source with "declaration": true, for a library you publish
A package with no types
npm i -D @types/the-package // 1. someone has already written them
// 2. src/types/the-package.d.ts — write the part you use
declare module "the-package" {
export function slugify(input: string, options?: { lower?: boolean }): string;
}
// 3. the escape hatch, when you are in a hurry
declare module "the-package"; // everything from it is any
It compiles, and it turns the checker off for everything that package touches. Write the three functions you actually call instead — it takes five minutes.
Global declarations
// env.d.ts
declare global {
interface Window { __analytics?: (event: string) => void; }
}
export {}; // required: without it this file is not a module and "global" is an error
Paths and resolution
That last line surprises everyone: with native ESM in Node you import the output path, so a TypeScript file imports its neighbour as .js. With a bundler and moduleResolution: "bundler", you do not.
{
"compilerOptions": {
"moduleResolution": "bundler",
"paths": { "@/*": ["./src/*"] }
}
}
import { Door } from "@/model/door"; // with a bundler
import { Door } from "./model/door.js"; // with Node: the .js extension, even from .ts