Module configuration and missing types are the most common sources of confusing errors in real projects.
Any file with a top-level import or export is a module; TypeScript uses standard ES module syntax. Types can be exported and imported just like values:
// user.ts
export interface User { id: number }
// app.ts
import type { User } from "./user.js";`import type` (or import { type User }) marks an import as type-only, guaranteeing it is erased. With verbatimModuleSyntax enabled — recommended for new projects — the compiler requires this marking and otherwise leaves your imports exactly as written, which keeps behaviour predictable under bundlers and type-stripping runtimes.
Resolution depends on module/moduleResolution. Under nodenext, relative imports in ESM need the runtime file extension: you write ./user.js even though the source is user.ts, because that's the path Node will load. With "bundler" resolution, extensionless imports are fine. Package imports honour package.json "exports" and "types" fields.
Where types come from for a dependency:
- Packages written in TypeScript or shipping their own `.d.ts` files (declared via
"types"/"exports"in package.json) just work. - Otherwise, community types often exist on DefinitelyTyped:
npm i -D @types/lodash. - If nothing exists, declare a minimal module yourself in a
.d.tsfile:declare module "legacy-lib" { export function run(): void; }, or a baredeclare module "legacy-lib";that types everything asany.
A declaration file (.d.ts) contains only types — no implementations. tsc can generate them from your code with declaration: true. Files under node_modules/@types are included automatically (configurable with types), and skipLibCheck: true skips checking all .d.ts files, which speeds builds considerably.
Example
// --- src/models.ts ---
export interface Product { id: string; price: number }
export const currency = "EUR";
// --- src/cart.ts ---
// import { currency, type Product } from "./models.js"; // .js under nodenext
// --- src/types/legacy-lib.d.ts --- (types for an untyped package)
// declare module "legacy-lib" {
// export function slugify(input: string): string;
// }
// --- usage ---
// import { slugify } from "legacy-lib";
// const total = (items: Product[]) =>
// `${items.reduce((s, p) => s + p.price, 0)} ${currency}`;
// Same idea in one file, so it runs:
interface Product2 { id: string; price: number }
const total = (items: Product2[], cur = "EUR") =>
`${items.reduce((sum, p) => sum + p.price, 0)} ${cur}`;
console.log(total([{ id: "a", price: 5 }, { id: "b", price: 7 }])); // "12 EUR"Remember
- Use
import typefor types; turn onverbatimModuleSyntax - Under
nodenext, write./file.jsin imports of.tsfiles - Types come from the package,
@types/*, or your owndeclare module .d.tsfiles contain declarations only
Watch out for
- Writing
./user.tsor./userin imports under nodenext resolution, then hitting runtime or resolution errors.