Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #11

Modules in TypeScript: import type, verbatimModuleSyntax and File Extensions

Mert Özen Nov 3, 2026 8 min 2 views
Modules in TypeScript: import type, verbatimModuleSyntax and File Extensions

We look at modules versus scripts in TypeScript, import type and export type, why verbatimModuleSyntax matters, .ts extensions in imports, and bundler versus nodenext resolution.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

In the previous post we typed async code and wrote functions that validate a fetch response. In a real project those functions don't live in one file; they are spread across files that import each other. Look at these two files. The project's tsconfig has verbatimModuleSyntax turned on:

// user.ts
export interface User {
  id: number;
  name: string;
}

export function formatUser(user: User): string {
  return `#${user.id} ${user.name}`;
}

// main.ts
import { User, formatUser } from "./user.ts";

const ada: User = { id: 1, name: "Ada" };
console.log(formatUser(ada));

The logic is flawless: User is exported, and so is formatUser. Do you think npx tsc will object to that import line? If it does, why? The answer is hidden in how TypeScript erases types when it turns modules into JavaScript.

Every File Is Either a Module or a Script

We covered the import/export syntax of ES modules in the JavaScript series, and TypeScript uses the same syntax. TypeScript adds one distinction: a file with at least one import or export is a module and has its own scope. A file with no import/export at all is treated as a script, and its top-level declarations land in a shared, global scope.

// first.ts
const count = 1;
console.log(count);

// second.ts
// Error: Cannot redeclare block-scoped variable 'count'.
const count = 2;
console.log(count);

These two files know nothing about each other, but TypeScript sees both as scripts and finds two counts in the same global scope. Adding an export to the file fixes it, and so does using module: "nodenext" in tsconfig with "type": "module" in package.json; in the second case TypeScript already treats every .ts file as an ES module. Keep this distinction in mind, because it comes back when we talk about declaration files.

What Happens to an Import When Types Are Erased?

In the beginner series we saw that types are erased during compilation. The same happens when you import both a type and a value from a module. With verbatimModuleSyntax turned off, this is the JavaScript tsc produces for main.ts (we'll get to why the extension became .js in a moment):

import { formatUser } from "./user.js";
const ada = { id: 1, name: "Ada" };
console.log(formatUser(ada));

User has disappeared from the import. tsc can do this because it sees the whole project: it opens user.ts and knows that User is an interface with no runtime counterpart. This is called import elision.

Here's the problem: tsc isn't always the tool running your code. When you type node main.ts, Node 26 reads the file on its own, strips the types and runs it; it doesn't open user.ts to figure out that User is a type. Most bundlers also transform files one at a time. If these tools leave the import { User } line as it is, the code asks for an export that doesn't exist at runtime. The fix is to say which imports are types in the file itself.

import type and export type

For that, TypeScript adds a type marker to imports and exports:

// report.ts
import type { User } from "./user.ts";
import { formatUser } from "./user.ts";

export type { User };

export function report(users: readonly User[]): string {
  return users.map(formatUser).join("\n");
}

Everything brought in with import type lives only in the type world, and the line is removed from the output entirely. If you take both a type and a value from the same module you don't have to split them into two lines; you can put the marker on a single name: import { type User, formatUser } from "./user.ts". The same applies to re-exports: export type { User } hands out only the type.

If you try to use something you imported as a type as a value, TypeScript stops you:

import type { formatUser } from "./user.ts";

// Error: 'formatUser' cannot be used as a value because it was imported using 'import type'.
formatUser({ id: 1, name: "Ada" });

// typeof stays in the type world, no problem
type Formatter = typeof formatUser;

This holds for classes too: a class imported with import type can be used as a type in an annotation, but you can't create an instance of it with new.

verbatimModuleSyntax: What You Write Is What You Get

The verbatimModuleSyntax flag, which arrived with TS 5.0, simplifies the rule: every import and export without type stays in the output as written, and the ones marked with type are removed. tsc no longer looks at other files and makes guesses. And because it doesn't guess, it reports an error when you import something that is clearly a type without the marker. That is the answer to the opening question:

main.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

To fix it, write import { type User, formatUser } from "./user.ts". Now Node, your bundler and tsc all produce the same output. Since TS 5.9, tsc --init turns this flag on, so it's the default habit in new projects.

One more subtle difference: if you write import { type User } from "./user.ts", the output keeps import {} from "./user.js", so the module is still loaded and its side effects, if any, still run. import type { User } removes the line completely. Re-exports get the same strictness:

// index.ts
// Error: Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.
export { User } from "./user.ts";
export { formatUser } from "./user.ts";

Change the first line to export type { User } from "./user.ts" and the error goes away.

File Extensions in Imports

In the browser and in Node, ES module imports need the full file extension; you can't write "./user" and expect the file to be found. When Node 26 runs .ts files directly, it wants the real file name in the import as well, which means "./user.ts". By default, though, tsc doesn't allow an import ending in .ts:

// Error: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
import { formatUser } from "./user.ts";

Two settings open this door. allowImportingTsExtensions can only be used when tsc emits no JavaScript at all (noEmit or emitDeclarationOnly); if tsc did emit, a .js file importing "./user.ts" would break at runtime. rewriteRelativeImportExtensions, which came with TS 5.7, rewrites .ts to .js in relative imports while emitting. That's why you saw "./user.js" in the compiled main.js above. With this flag on, imports ending in .ts are accepted without any extra permission.

moduleResolution: bundler or nodenext?

moduleResolution decides which file TypeScript resolves an import path to. Since TS 6.0 the default module is esnext, and in that case resolution switches to bundler mode; you can see this in the tsc --traceResolution output as the line "Module resolution kind is not specified, using 'Bundler'." bundler mode mimics what a bundler does: extensionless paths like import { formatUser } from "./user" are valid, because the bundler will be the one finding the file later.

module: "nodenext", on the other hand, applies Node's rules exactly. If package.json has "type": "module", files are ES modules and an extensionless relative import is rejected:

// Error: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './user.js'?
import { formatUser } from "./user";

A practical rule: if a bundler like Vite or esbuild will package your code, use bundler; if Node will run the code directly or you are publishing a library, use nodenext. The finer differences (CommonJS interop, the exports field) belong to the Node series; here it's enough to know which one to pick and when.

Node's own documentation recommends these settings for projects where you run .ts files directly. The examples in this series follow the same rules:

{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "noEmit": true,
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

noEmit turns tsc into a checker only; Node runs the code. verbatimModuleSyntax makes type-only imports visible in the file, so Node can strip types safely. erasableSyntaxOnly forbids TypeScript syntax that can't simply be erased; we'll cover that syntax in its own post later on. Add "type": "module" to package.json and the workflow comes down to two commands:

# type check
npx tsc
# run (does not type check)
node main.ts

Common Pitfalls

The first is forgetting "type": "module". With this tsconfig, if package.json has no type field, files count as CommonJS and tsc objects to every import with "ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'." (TS1295). The message is long, but the fix is one line.

The second is running code that hasn't passed type checking in Node and assuming it "works". Node doesn't read tsconfig; it's tsc, not Node, that enforces the verbatimModuleSyntax rule. If you forget to mark a type-only import, you find out either as an error in npx tsc or as a missing-export error at runtime. The first is much cheaper.

The third is relying on aliases like paths in tsconfig. tsc understands them, but Node doesn't read tsconfig, so it can't resolve them at runtime. For code that runs in Node, relative paths or the imports field in package.json are the sturdier option.

The fourth is script files. A small helper file with no imports or exports can collide with a variable of the same name in another file. If you see an error like that, first check whether the file is a module.

A Small Experiment

★☆☆ Put user.ts and main.ts from this post in a folder, together with the recommended tsconfig and a package.json that contains "type": "module". Run npx tsc and see TS1484. Then fix the error two different ways: once with import type on a separate line, and once with an inline type.

★★☆ Predict, then try: is the JavaScript output of import { type User } from "./user.ts" the same as that of import type { User } from "./user.ts"? Temporarily remove noEmit, set an outDir and look at the output yourself.

★★★ Delete "type": "module" from package.json and predict which error code tsc will report. Then set tsconfig to module: "esnext" and drop the extension from the import entirely; use the --traceResolution output to explain why you get no error this time.

Your functions and types now travel cleanly between files. But not every function you import returns successfully; some throw, and some run into invalid input. In the next post we move on to error handling: why the value you catch in catch is unknown, how to narrow it, and how to make errors part of the type system.