cheat sheet

Type-Only Imports & Exports

import type and export type erase at compile time, preventing runtime side-effects and enabling better tree-shaking. Covers inline type qualifiers, verbatimModuleSyntax, and common gotchas with enums and namespaces.

Type-Only Imports & Exports

What it is

import type imports only type information — the import is fully erased from the JavaScript output, producing no runtime code. This prevents accidental runtime side-effects from modules that have initialization code, reduces bundle size, and enables better tree-shaking. Bundlers and TypeScript itself can statically determine that a type-only import carries no runtime value.

import type vs import

import type is erased entirely from the compiled JavaScript output, while a regular import keeps the module reference at runtime. Use import type when a symbol is only ever used in a type position — this prevents accidental side-effects from modules that execute code on load and makes the intent explicit to bundlers.

typescript
// Runtime import — the module is evaluated at runtime
// If Foo is only used as a type, this may cause side-effects or bloat
import { Foo } from "./foo";

// Type-only import — completely erased from the compiled output
import type { Foo } from "./foo";

// Both are fine for type annotations
const x: Foo = createFoo();

Compiled JavaScript output for import type:

javascript
// import type { Foo } from "./foo";
// ↑ erased — no trace in the .js output

const x = createFoo();

Compiled JavaScript output for a regular import used only as a type:

javascript
// With isolatedModules or verbatimModuleSyntax: this is an error
// Without them: TypeScript may or may not elide the import (implementation-defined)
const x = createFoo();

Inline type qualifier (TypeScript 4.5+)

The inline type qualifier lets you mix type-only and value imports in a single import statement:

typescript
// Before TS 4.5 — required two separate imports
import type { UserRole } from "./types";
import { createUser, DEFAULT_ROLE } from "./user";

// TS 4.5+ — single import with mixed qualifiers
import { type UserRole, createUser, DEFAULT_ROLE } from "./user";

The type qualifier applies per-binding: UserRole is erased; createUser and DEFAULT_ROLE are kept as runtime values.

typescript
// Real-world example: a service module
import {
  type RequestConfig,  // erased — only used in type position
  type ResponseShape,  // erased — only used in type position
  HttpClient,          // kept — class instantiated at runtime
  HTTP_TIMEOUT,        // kept — const used at runtime
} from "./http";

const client = new HttpClient(HTTP_TIMEOUT);

async function fetch(config: RequestConfig): Promise<ResponseShape> {
  return client.get(config.url);
}

export type

export type re-exports a name purely as type information — the re-export is erased at compile time and does not cause the source module to be evaluated. Use it in barrel files or public API surfaces where you want to expose types without adding runtime dependencies.

typescript
// types.ts
export type { User, UserRole } from "./models/user";
export type { ApiResponse } from "./models/api";
export type { Config } from "./config";

// Re-exporting a type defined in the same file
interface InternalHelper {
  compute(): number;
}
export type { InternalHelper };

Re-exporting all types from a module:

typescript
// TS 3.8+ — re-export all types from a module
export type * from "./models/user";
export type * from "./models/api";

When to use import type

Use import type when the imported symbol is only used in a type position:

  • Function parameter or return types
  • Generic type arguments
  • Interface or type alias definitions
  • Class field type annotations
typescript
import type { EventEmitter } from "events";
import type { IncomingMessage, ServerResponse } from "http";

// Used only as types — import type is correct
function createHandler(
  emitter: EventEmitter,
  req: IncomingMessage,
  res: ServerResponse
): void {
  // ...
}

Do not use import type when the symbol is used as a value:

typescript
import type { EventEmitter } from "events";

// Error — EventEmitter used as a value (class instantiation)
const emitter = new EventEmitter(); // TS error: 'EventEmitter' cannot be used as a value

verbatimModuleSyntax (TypeScript 5.0+)

verbatimModuleSyntax is a tsconfig.json option (TS 5.0+) that enforces consistent module import/export syntax and removes the ambiguity around when TypeScript elides imports.

json
{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}

With verbatimModuleSyntax: true, the rule is simple:

  • Imports without type are kept in the output verbatim
  • Imports with type are always erased
  • You must use import type for any import used only as a type, or TypeScript reports an error
typescript
// verbatimModuleSyntax: true

import { Foo } from "./foo";  // Error if Foo is only used as a type
import type { Foo } from "./foo"; // Correct — will be erased

import { type Bar, baz } from "./baz"; // Correct — Bar erased, baz kept

Enable verbatimModuleSyntax in new projects. It eliminates an entire class of import-related bugs and makes the intent explicit. It also works correctly with bundlers that do their own module analysis.

verbatimModuleSyntax is incompatible with module: "CommonJS" when you're writing import/export ES module syntax, because CommonJS uses require(). Use it with module: "NodeNext", "ESNext", or "Preserve".

Gotchas

Enums cannot be type-only imported

Enums compile to real JavaScript objects. Importing them with import type will erase them, causing a runtime error.

typescript
// foo.ts
export enum Direction { Up, Down, Left, Right }

// bar.ts
import type { Direction } from "./foo";

const d = Direction.Up; // Error — 'Direction' cannot be used as a value
                        // because it was imported with 'import type'

Use a regular import for enums:

typescript
import { Direction } from "./foo"; // Correct
const d = Direction.Up;            // Works

Namespaces cannot be type-only imported

Like enums, namespaces are runtime values (objects) in the compiled output. They cannot be imported with import type.

typescript
import type { MyNamespace } from "./ns"; // Error — namespaces are values
MyNamespace.doSomething();               // Would fail anyway — import is erased

Class constructor is a value

A class is both a type (the instance shape) and a value (the constructor function). Using import type allows using the class as a type annotation but not instantiating it or using instanceof.

typescript
import type { MyClass } from "./MyClass";

const x: MyClass = getSomething(); // OK — type position
const y = new MyClass();           // Error — MyClass is erased
x instanceof MyClass;              // Error — MyClass is erased

import type with isolatedModules

When isolatedModules: true is set (required by Babel, esbuild, and most bundlers), TypeScript cannot determine whether a regular import is type-only without reading all files. Using import type explicitly is required for any symbol used only in a type position.

typescript
// tsconfig.json: isolatedModules: true

import { Foo } from "./foo"; // Error if Foo is only used as a type
                              // "Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'"

import type { Foo } from "./foo"; // Correct

Before and after: real module refactor

Before (potential side-effect from utils module initialization):

typescript
import { formatDate, DateFormat, parseDate } from "./date-utils";
import { ApiClient, ApiConfig, ApiError } from "./api-client";

const config: ApiConfig = { baseUrl: "/api", timeout: 5000 };
const client = new ApiClient(config);

function formatResponse(err: ApiError): string {
  return `Error at ${formatDate(new Date(), DateFormat.ISO)}`;
}

After (all type-only imports made explicit):

typescript
import { formatDate, parseDate } from "./date-utils";
import type { DateFormat } from "./date-utils";
import { ApiClient } from "./api-client";
import type { ApiConfig, ApiError } from "./api-client";

const config: ApiConfig = { baseUrl: "/api", timeout: 5000 };
const client = new ApiClient(config);

function formatResponse(err: ApiError): string {
  return `Error at ${formatDate(new Date(), "iso")}`;
}

Why import type exists — the elision problem

Before import type, TypeScript decided whether to keep or drop an import by analysing how its bindings were used. If you only ever wrote const x: Foo = …, the compiler elided the import { Foo } statement. If you also wrote new Foo(), the import survived. This implicit elision worked for tsc but failed catastrophically under any tool that compiled files in isolation (Babel, esbuild, swc) — those tools cannot see across files to know whether Foo is used as a type or a value elsewhere.

The result was a class of bugs unique to "single-file transpilers":

typescript
// Babel sees this file in isolation
import { sideEffectfulModule } from "./init"; // module body runs side-effects
import { Foo } from "./foo";                  // Foo only used as type

const x: Foo = createX();

Babel/esbuild had no way to know Foo was type-only. They kept the import, the module ran, side-effects happened. tsc elided the import, side-effects didn't happen. Same source code, two different runtime behaviours.

import type makes the intent explicit. The compiler — any compiler — sees import type and knows the line must be erased. verbatimModuleSyntax (covered below) finishes the job by erasing the ambiguity entirely.

The history: importsNotUsedAsValues and preserveValueImports

Two TypeScript options preceded verbatimModuleSyntax and shaped what we use today. They are deprecated in TS 5.0+ but still appear in legacy configs.

OptionTS versionBehaviour
importsNotUsedAsValues3.8–4.xremove (default, elide) / preserve (keep) / error (forbid type-only imports without import type)
preserveValueImports4.5–4.xtrue keeps value imports even if unused; pairs with importsNotUsedAsValues: preserve
verbatimModuleSyntax5.0+Single replacement for both. import kept, import type erased, no implicit elision.

The migration in 2026 is one-line:

json
{
  "compilerOptions": {
    // Old (TS 4.x):
    // "importsNotUsedAsValues": "preserve",
    // "preserveValueImports": true,

    // New (TS 5.0+):
    "verbatimModuleSyntax": true
  }
}

If you see importsNotUsedAsValues or preserveValueImports in any tsconfig, replace them with verbatimModuleSyntax. The behaviour is clearer and the deprecation warning goes away.

isolatedModules in depth

isolatedModules: true is a correctness assertion: it tells TypeScript "do not emit anything that depends on cross-file information". It does not change emit by itself — it adds errors for code patterns that single-file transpilers (Babel, esbuild, swc) cannot handle.

json
{
  "compilerOptions": {
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

The forbidden patterns:

typescript
// Forbidden: const enum (requires cross-file inlining)
export const enum Color { Red, Green, Blue }
// Error: Cannot access ambient const enums when 'isolatedModules' is enabled.

// Forbidden: re-exporting a type without the 'type' qualifier
export { SomeType } from "./types";
// Error: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.

// Forbidden: namespace with merged value + type that depend on cross-file analysis
namespace Foo { /* ... */ }

The fixes:

typescript
// Use a regular enum or a const object
export enum Color { Red, Green, Blue }

// Use export type for type-only re-exports
export type { SomeType } from "./types";

Bun, esbuild, swc, Vite, and any other parallel transpiler require isolatedModules: true. The official tsc does not require it but emits identically when it's enabled.

verbatimModuleSyntax — the modern way

verbatimModuleSyntax collapses the entire elision story into a single rule: keep import verbatim, erase import type. No magic, no implicit choices, no surprises.

json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "isolatedModules": true
  }
}

What it changes:

typescript
// Source
import { Foo } from "./foo";           // kept verbatim
import type { Bar } from "./bar";      // erased
import { type Baz, qux } from "./qux"; // Baz erased per-binding, qux kept

const x: Foo = createFoo();
const y: Bar = createBar();
const z = qux();
javascript
// Compiled output (target: ES2022, module: ESNext)
import { Foo } from "./foo";
import { qux } from "./qux";

const x = createFoo();
const y = createBar();
const z = qux();

The Bar import is gone. Foo is kept even though it's only used in a type position — because you wrote import without type, the compiler obeys verbatim. This is a feature: it makes the intent unambiguous.

Compiler errors you'll see if you forget the type qualifier:

text
src/app.ts:1:10 - error TS1484: 'Bar' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

1 import { Bar } from "./bar";
          ~~~

The fix is mechanical: add type to every type-only binding. ESLint's @typescript-eslint/consistent-type-imports rule can autofix the whole codebase.

Compatibility with module formats

verbatimModuleSyntax is incompatible with CommonJS emit when you author ESM syntax (import / export). The reason: CJS doesn't have a verbatim ESM equivalent — import { x } from "y" becomes const { x } = require("y"). The rewrite is not verbatim.

module settingverbatimModuleSyntax allowed?Reason
CommonJSNo (with ESM syntax)CJS rewrites are inherently non-verbatim
ESNext / ES2022 / ES2020YesPure ESM emit
NodeNext / Node16YesPer-file ESM/CJS based on package.json
PreserveYesKeep exact source syntax

If you're stuck on module: CommonJS (legacy Node, certain library targets), you cannot enable verbatimModuleSyntax. Migrate to NodeNext or ESNext first.

bash
npx tsc --showConfig | grep -E '(module|verbatim)'

Output:

text
"module": "NodeNext",
"verbatimModuleSyntax": true,

Side-effect imports

A side-effect import imports a module purely for its side effects — code that runs on load — and binds no names. They look like:

typescript
import "./polyfills";
import "reflect-metadata";
import "./styles/global.css";

These imports are never candidates for import type because they have no bindings to erase. verbatimModuleSyntax keeps them verbatim, which is exactly what you want — polyfills, metadata setup, and CSS injection must run.

typescript
// All of these survive verbatim
import "./register-globals";   // sets up window.__INITIAL_STATE__
import "reflect-metadata";     // adds Reflect.defineMetadata, etc.
import "../styles/main.css";   // Vite/webpack pick this up

You can mix side-effect imports with type imports in the same file freely:

typescript
import "./polyfills";                    // side-effect — runs on load
import type { Foo } from "./types";       // type-only — erased
import { bar } from "./util";             // value — runs on load and binds bar

Side-effect imports are also the place to install a runtime patch before loading code that depends on it. The order matters:

typescript
import "core-js/proposals/promise-with-resolvers"; // patch globalThis.Promise
import { useResolvers } from "./util";              // now safe to import

Tree-shaking can drop side-effect imports if package.json's "sideEffects": false is set. Either mark the offending module's source files in "sideEffects": ["./polyfills.js"], or use the explicit /* @vite-ignore */ / webpack /* webpackChunkName */ comments.

Dual ESM/CJS authoring

Libraries that publish both ESM and CJS bundles face an extra wrinkle: type-only imports must work in both outputs. verbatimModuleSyntax plus module: NodeNext is the modern way — let the file extension (.mts / .cts) decide the emit shape per file, and let import type erase consistently.

typescript
// src/index.mts — ESM entry
import type { CompactOptions } from "./types.mts";
import { compact } from "./compact.mts";

export function run(opts: CompactOptions) {
  return compact(opts.input);
}

export type { CompactOptions };
typescript
// src/index.cts — CJS shim
import type { CompactOptions } from "./types.cts"; // erased; types are static
const { compact } = require("./compact.cjs");

function run(opts: CompactOptions) {
  return compact(opts.input);
}

module.exports = { run };

The .cts file uses import type (which erases) for the type, and require() for the runtime value. The .mts file uses import type and import symmetrically. Both compile to the right format for their respective consumers.

The package.json ties them together:

json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.mts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
bash
tsc

Output: (none — exits 0 on success)

The dist/ directory now contains .mjs, .cjs, .d.mts, and .d.cts — each shaped correctly for its consumer.

Comparison with --importsNotUsedAsValues (legacy)

The deprecated importsNotUsedAsValues option had three modes; mapping each to today's settings clarifies the migration path.

importsNotUsedAsValuesWhat it didModern equivalent
remove (default)Elide unused-as-value imports silentlyverbatimModuleSyntax: false (default)
preserveKeep all imports verbatim; rely on import type for erasureverbatimModuleSyntax: true
errorError if a value import is used only as a typeverbatimModuleSyntax: true + @typescript-eslint/consistent-type-imports

preserveValueImports: true (also deprecated) augmented preserve mode by keeping every value import even if it appeared unused — same effect as verbatimModuleSyntax: true.

verbatimModuleSyntax simply made the rules trivial: keep what you wrote, erase what you typed. No three-way option, no second flag.

ESLint rule: consistent-type-imports

@typescript-eslint/consistent-type-imports enforces one style across your codebase. Combined with --fix, it autofixes every import { X } that should be import type { X }.

json
// eslint.config.js
{
  "rules": {
    "@typescript-eslint/consistent-type-imports": [
      "error",
      {
        "prefer": "type-imports",
        "fixStyle": "inline-type-imports",
        "disallowTypeAnnotations": true
      }
    ]
  }
}

Options:

  • prefer: "type-imports" — require import type for type-only imports (default).
  • fixStyle: "inline-type-imports" — fix with import { type X, y } instead of two statements.
  • fixStyle: "separate-type-imports" — fix with two separate statements.
  • disallowTypeAnnotations: true — forbid import("./foo").Bar type annotations in favour of regular imports.

Autofix the whole project:

bash
npx eslint --fix 'src/**/*.{ts,tsx}'

Output:

text
✓ Fixed 23 files

The companion rule @typescript-eslint/no-import-type-side-effects (TS 5.0+) errors if you write import type "./foo" — type-only side-effect imports are nonsensical because the import is erased.

Common pitfalls

  1. Using import type for a class you instantiate — error TS1361 'X' cannot be used as a value because it was imported using 'import type'. Switch to regular import or accept the constraint that the class is type-only here.
  2. Forgetting export type in a re-export under isolatedModulesexport { SomeType } from './types' errors. Use export type { SomeType } or export { type SomeType }.
  3. Mixing import type with import for the same module — works but duplicates the line. Use the inline qualifier: import { type Foo, bar }.
  4. Type-only importing a const enum — erased at runtime but const enum is supposed to be inlined. Under isolatedModules, const enum is forbidden anyway. Use a regular enum or a const object.
  5. verbatimModuleSyntax with module: CommonJS — TS error TS1287 "A type-only import can specify a default import or named bindings, but not both." Switch to module: NodeNext or ESNext.
  6. Editor lints differently from CI — your editor uses the project's ESLint config but CI runs tsc --noEmit only. Either turn on the same TS option (verbatimModuleSyntax) so tsc enforces it, or run ESLint in CI.
  7. Auto-import inserts the wrong form — VS Code, by default, inserts import { Foo }. Configure typescript.preferences.importModuleSpecifier: "shortest" and typescript.preferences.preferTypeOnlyAutoImports: true in .vscode/settings.json.
  8. Babel doesn't understand inline type — older @babel/preset-typescript versions strip the type qualifier incorrectly. Update to 7.16+ which supports it natively.
  9. export type * works but export type * as ns does not in older TypeScript. The namespaced form requires TS 5.0+.
  10. Side-effect import dropped by tree-shaking — if "sideEffects": false in package.json, bundlers may drop import "./register". Mark the file in "sideEffects": ["./register.js"].

Real-world recipes

Migrate a codebase to verbatimModuleSyntax

Step-by-step on a real project — enable the option, let tsc enumerate violations, autofix with ESLint, commit.

bash
# 1. Enable verbatimModuleSyntax in tsconfig
git diff tsconfig.json

Output:

text
+    "verbatimModuleSyntax": true,
+    "isolatedModules": true
bash
# 2. Let tsc enumerate the violations
npx tsc --noEmit 2>&1 | head -10

Output:

text
src/api/client.ts:3:10 - error TS1484: 'UserRole' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

3 import { UserRole, createUser } from "./user";
          ~~~~~~~~
Found 47 errors in 23 files.
bash
# 3. Autofix with ESLint
npx eslint --fix --rule '@typescript-eslint/consistent-type-imports: ["error", {"prefer": "type-imports", "fixStyle": "inline-type-imports"}]' src

Output:

text
✓ Fixed 47 issues across 23 files
bash
# 4. Verify clean
npx tsc --noEmit && echo "clean"

Output:

text
clean

The result — every type-only binding now wears its type qualifier, the build is deterministic, and the codebase no longer relies on implicit elision.

Barrel file using export type *

A barrel file (index.ts that re-exports everything from sibling modules) is the canonical use of export type *. It exposes types without dragging the runtime modules into the consumer's bundle.

typescript
// src/models/index.ts
export type * from "./user";
export type * from "./post";
export type * from "./comment";

// Values that are part of the public API
export { createUser, updateUser } from "./user";
export { createPost, updatePost } from "./post";

Consumer:

typescript
import type { User, Post, Comment } from "@app/models";
import { createUser, createPost } from "@app/models";

const u: User = createUser({ name: "Alice Dev" });
bash
tsc && du -sh dist/models/

Output:

text
24K     dist/models/

The compiled dist/models/index.js contains only the createUser and createPost re-exports — the type re-exports are erased entirely.

Avoiding side-effects from heavyweight type modules

Some modules pull in expensive code at import time even though you only need the types. zod, for example, exports both runtime validators and types — if you only want the types, use import type.

typescript
// BAD — pulls in zod's full validator code, ~50KB
import { z, type ZodSchema } from "zod";

function describe(schema: ZodSchema) { /* ... */ }
typescript
// GOOD — only the types, zero runtime cost
import type { ZodSchema } from "zod";

function describe(schema: ZodSchema) { /* ... */ }

In a Cloudflare Worker, every kB of bundle costs cold-start time. Strict import type usage on type-only consumers can shave 100kB off a bundle.

bash
bun build src/worker.ts --target=node --minify --outfile=dist/worker.js
ls -lah dist/worker.js

Output:

text
-rw-r--r--  1 alice  staff   42K Apr 27 14:00 dist/worker.js

Compare with the same build using import { ZodSchema } (value import):

text
-rw-r--r--  1 alice  staff  136K Apr 27 14:00 dist/worker.js

A 3× difference comes from a single import type.

Define a public type-only API

A library that wants to expose a typed contract without forcing runtime adoption can ship a types-only entry point. The types condition in package.json exports lets consumers import type cleanly.

json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./types": {
      "types": "./dist/types.d.ts"
    }
  }
}

Consumer using runtime + types:

typescript
import { createClient } from "my-lib";
import type { Config } from "my-lib";

const client = createClient({ url: "/api" });

Consumer using types only (e.g. a downstream library that re-declares the API):

typescript
import type { Config, ApiResponse } from "my-lib/types";

tsc resolves my-lib/types to the .d.ts only — no runtime cost, no import of the library at all.

Use type-only imports in test files

Tests often import functions from the source plus types from the same module. Splitting the imports clarifies the contract: types are erased, functions are called.

typescript
// src/user.test.ts
import { describe, it, expect } from "vitest";
import { createUser, updateUser } from "./user";
import type { User, UserRole } from "./user";

describe("createUser", () => {
  it("creates with default role", () => {
    const u: User = createUser({ name: "Alice Dev" });
    const role: UserRole = u.role;
    expect(role).toBe("member");
  });
});
bash
npx vitest run src/user.test.ts

Output:

text
✓ src/user.test.ts (1 test)
  ✓ createUser > creates with default role

Test Files  1 passed (1)
     Tests  1 passed (1)
  Duration  234ms

The test file's emitted JS contains imports for vitest, createUser, and updateUser — no User or UserRole survive past the import type line.