TypeScript – Modules and Type-Only Imports

August 24, 20268 min readUpdated 9/5/2026

Modules are JavaScript's, and TypeScript adds one idea to them: an import can be for a type, which means it must disappear at build time. That sounds like a detail. It is the source of most module-related confusion in a modern TypeScript project, and of a compiler flag both halves of the pizza app turn on.

The ordinary part

// money.ts
export const TAX_RATE = 0.085;
export function formatMoney(amount: number): string { /* … */ }

// elsewhere
import { formatMoney, TAX_RATE } from '../lib/money';

Named exports, as in any ES module. Types are exported the same way:

export type UUID = string;
export interface Product { /* … */ }

A file with any top-level import or export is a module, and its top-level names are private to it. A file with neither is a script, and its declarations go into the global scope — which is occasionally what you want for an ambient declaration file, and never what you want by accident.

import type

Here is the thing that is specific to TypeScript:

import type { ApiErrorBody } from '../types';

That is the first line of the app's HTTP module, and the type keyword says the import exists only for the type checker. The compiler removes the whole statement, so no require and no import of ../types appears in the output.

Why say it explicitly, when the compiler could work it out? Because in a modern build, nothing has the whole picture.

Vite, esbuild and SWC transform one file at a time. Looking at import { ApiErrorBody } from '../types', such a tool cannot tell whether ApiErrorBody is a type to be erased or a value to be kept — that answer is in a different file it has not read. Guess "keep" and you emit an import of a module that has no runtime exports at all. Guess "drop" and you delete a real one.

import type removes the guess. It is why the pattern is everywhere in the pizza codebase, in both frameworks:

import { HttpErrorResponse } from '@angular/common/http';
import type { ApiErrorBody } from './models';

Two imports, two intentions, stated. HttpErrorResponse is used with instanceof, so it must survive to runtime; ApiErrorBody is a shape and must not.

The three flags behind it

isolatedModules tells the compiler to reject anything a single-file transform could not handle, so tsc catches the problem rather than the bundler shipping something broken. Both pizza configs are effectively under it; the Angular one sets it explicitly.

verbatimModuleSyntax goes further, and is what the React app uses:

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",

The rule it enforces is simple: imports without type are emitted exactly as written, and imports with it are dropped entirely. No cleverness, no elision of things that turned out to be unused. If you import a type without saying type, you get an error telling you to say it.

The consequence people notice first is that a value import with no remaining runtime use is kept — which is correct, because that import may exist for its side effects.

moduleDetection: "force" treats every file as a module, so a file that happens to have no imports does not silently become a global script.

You can also mark individual names inside a shared import, which is handy when a module exports both:

import { ApiError, type ApiErrorBody } from './api';

Module resolution

How a specifier becomes a file. Two settings matter now, and the rest are legacy.

"bundler" — resolve the way Vite, webpack or esbuild will. Extensions may be omitted, package.json exports is respected, and the bundler does the real work. This is what a frontend project wants, and what the React app sets.

"nodenext" — resolve the way Node does, which means honouring the ESM rules strictly. The consequence that surprises everyone: in an ESM Node project you must write the file extension, and it must be .js even though the file on disk is .ts:

import { toUserDto } from './serializers.js';   // the file is serializers.ts

That looks wrong and is correct. The import specifier describes the output, and TypeScript does not rewrite specifiers. Node will be loading serializers.js, so that is what the import must say. Any TypeScript backend on ESM is full of these.

The React app sets allowImportingTsExtensions, which permits the opposite — writing ./money.ts — and is legal only because noEmit is on. There would be no way to produce valid JavaScript from it otherwise.

Default exports

They work, and most style guides now discourage them:

export default function formatMoney(amount: number) { /* … */ }
import anyNameAtAll from '../lib/money';   // the name is not checked

A default import can be called anything, so a typo becomes a rename rather than an error, automated refactoring cannot follow it, and two files can import the same thing under two names. Named exports have none of those problems, and the pizza app uses them throughout — the one common exception being frameworks that require a default, such as a page component in some routers.

Declaration files

A .d.ts file contains types and no implementation. You will meet them in three ways.

Shipped by a package. Most modern libraries include their own, and there is nothing to do.

From DefinitelyTyped. npm i -D @types/react and similar, for packages that do not.

Written by you, for the gaps. Two common cases — declaring a module that has no types at all, and telling TypeScript about a non-code import:

// src/globals.d.ts
declare module 'legacy-widget';          // everything from it is `any`

declare module '*.svg' {
  const src: string;
  export default src;
}

The third case is the declaration merging from lesson 7 — adding your environment variables to Vite's ImportMetaEnv, or a field to Express's Request.

Note that declare module 'x' with no body is a blunt instrument: it silences the error and gives you any, with the spreading behaviour from lesson 4. Declaring the handful of functions you actually call is usually twenty minutes better spent.

Barrel files

An index.ts that re-exports a directory:

export * from './money';
export * from './api';

Convenient for imports, and worth being wary of in application code. A barrel makes every importer depend on the whole directory, which hurts tree-shaking, slows the type checker, and is a reliable source of circular imports — the classic being two modules that both import from the barrel that exports them both.

The pizza app's src/types/index.ts is a barrel of a safe kind: it contains the declarations rather than re-exporting them, and it is types only, so nothing survives to runtime. That is the case where the pattern costs nothing.

Circular imports

Two modules importing each other is legal in ES modules and legal in TypeScript, and it works fine for types — the checker resolves the whole program, so a cycle of pure type references is harmless.

Values are another matter. A cycle in runtime imports means one of the two modules runs before the other has finished initialising, and whatever it reads is undefined:

// money.ts
import { TAX_RATE } from './config';
export const rate = TAX_RATE;   // undefined if config imported money first

The type system does not warn you. TAX_RATE is declared number and it is, eventually — the type is right and the value is not there yet.

Two things make cycles much less likely. Put shared types in a leaf module that imports nothing, which is what src/types/index.ts is. And use import type wherever the import is only a type, because such an import is erased and cannot participate in a runtime cycle at all. That second one is a genuine benefit of the explicit syntax beyond keeping the bundler happy.

Side-effect imports

An import with no bindings runs the module for what it does rather than what it exports:

import 'bootstrap/dist/css/bootstrap.min.css';
import 'reflect-metadata';

These must never be written as import type, and they are the reason verbatimModuleSyntax keeps an import whose bindings are all unused — the compiler cannot know whether the module was imported for its side effects.

Re-exporting

Three forms, and the middle one is easy to get wrong:

export { formatMoney } from './money';        // a value
export type { Product } from './types';       // a type — note where `type` goes
export * from './money';                       // everything

Under isolatedModules, re-exporting a type without export type is an error, for exactly the reason import type exists: a single-file transform cannot tell whether Product is a value that must be forwarded at runtime.

export * as ns from './money' gives you a namespace object, which is occasionally tidier than a barrel and has the same tree-shaking caveats.

Path aliases

Deep relative imports are the usual reason people reach for aliases:

import { formatMoney } from '../../../lib/money';   // where am I?

TypeScript's half is paths, relative to baseUrl:

    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }

The trap is that this configures the type checker only. TypeScript will resolve @/lib/money and report no error; your bundler will then fail to find it at build time, or worse, at runtime.

So the alias has to be declared twice — once for tsc and once for whatever actually builds. In Vite that is resolve.alias; in Jest, moduleNameMapper; in Node, the imports field in package.json. Keeping the two in step is the cost of the feature, and it is why plenty of projects, including this one, simply do not use aliases.

One more thing worth knowing when you inherit an older codebase: namespace and import x = require('y') are pre-modules TypeScript, from before ES modules existed. They still work, erasableSyntaxOnly rejects namespaces with runtime members, and there is no reason to write either in new code. If you meet them, they are doing what a module does, less well.

The short version of this whole lesson: modules behave as they do in JavaScript, and the one thing you have to add is saying which imports are types. Do that, and almost every module-related error you will hit — a bundler emitting an import of nothing, a re-export failing under isolatedModules, a circular import resolving to undefined — either disappears or becomes a compile error with a clear message.

Everything else is JavaScript, and behaves the way it does everywhere else.

Next

tsconfig, the Flags That Matter — the strict family one flag at a time, and the two real configurations in this app that contradict each other on purpose.