Modules, type imports and .d.ts files

TypeScript · Practical · 14 min

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.ts file: declare module "legacy-lib" { export function run(): void; }, or a bare declare module "legacy-lib"; that types everything as any.

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 type for types; turn on verbatimModuleSyntax
  • Under nodenext, write ./file.js in imports of .ts files
  • Types come from the package, @types/*, or your own declare module
  • .d.ts files contain declarations only

Watch out for

  • Writing ./user.ts or ./user in imports under nodenext resolution, then hitting runtime or resolution errors.

Check yourself

  1. Under module/moduleResolution nodenext, how should src/app.ts import src/util.ts?
  2. A JavaScript package has no bundled types. What's the usual first thing to try?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/modules/introduction.html