TypeScriptType-Only Imports & Exports

Type-Only Imports & Exports

TypeScript's import type and export type syntax (introduced in TypeScript 3.8) let you be explicit about whether an import is used only as a type or also as a runtime value. The compiler erases type-only imports entirely — they produce zero JavaScript output.

This distinction matters for build performance, avoiding circular dependency issues, and ensuring that bundlers can tree-shake correctly.

The Problem: Phantom Imports

Before import type, TypeScript was smart enough to elide imports that were only used as types. But this implicit elision could confuse bundlers, linters, and developers reading the code — it wasn't clear from looking at an import whether it was a value import or a type-only import.

TS
// Before import type — ambiguous
import { User, UserService } from './user';
// Is User used as a value (class) or just a type?
// A reader can't tell without scanning the whole file.

function greet(user: User): string {   // only used as a type
  return `Hello ${user.name}`;
}

const svc = new UserService(); // used as a value
      

TS
// After import type — explicit and clear
import type { User } from './user';           // type-only: erased at compile time
import { UserService } from './user';          // value: kept in JS output

function greet(user: User): string {
  return `Hello ${user.name}`;
}

const svc = new UserService();
      
import type Syntax

TS
// Import a single type
import type { User } from './user';

// Import multiple types
import type { User, UserRole, UserPreferences } from './user';

// Import a type with a rename
import type { User as UserModel } from './user';

// Import all types as a namespace
import type * as UserTypes from './user';
// UserTypes.User, UserTypes.UserRole, etc.

// Import a default type export
import type Logger from './logger';
      
export type Syntax

TS
// Export a type at the declaration site
export type { User };
export type { UserRole, UserPreferences };

// Export with rename
export type { User as UserModel };

// Inline export type
export type UserId = string;
export type UserRecord = {
  id: UserId;
  name: string;
  createdAt: Date;
};

// Re-export a type from another module
export type { User } from './user';
export type * from './user-types';
      
Note
export type on a re-export ensures the re-exported binding is treated as a type even by bundlers that do not understand TypeScript. This is especially important for tools like esbuild and swc that strip types without full TypeScript analysis.
Inline import type Specifiers (TS 4.5+)

TypeScript 4.5 added the ability to mark individual specifiers as type-only within a regular import statement. This lets you mix value and type imports from the same module in a single line:

TS
// Mix value and type imports in one statement (TypeScript 4.5+)
import { UserService, type User, type UserRole } from './user';

// Equivalent to:
import { UserService } from './user';
import type { User, UserRole } from './user';

// Inline type specifiers in re-exports too
export { UserService, type User, type UserRole } from './user';
      
Tip
Inline type specifiers are useful when a module exports a mix of values and types and you want to import both in a single statement while still being explicit about which are types.
Why It Matters: verbatimModuleSyntax

TypeScript 5.0 introduced the verbatimModuleSyntax compiler option. When enabled, TypeScript enforces that you always use import type for type-only imports. Any import that is only used as a type must use the type keyword — the compiler errors if it would need to silently elide an import.

This option is recommended for projects using bundlers (Vite, esbuild, swc) that transpile TypeScript without running the full type checker.

JSON
// tsconfig.json
{
  "compilerOptions": {
    "verbatimModuleSyntax": true
  }
}
      

TS
// With verbatimModuleSyntax: true

// ✗ Error: 'User' is a type and must use 'import type'
import { User } from './user';
function greet(user: User) {}

// ✓ Correct
import type { User } from './user';
function greet(user: User) {}
      
Warning
verbatimModuleSyntax is incompatible with the module: commonjs setting because CommonJS output requires runtime imports. Use it with module: esnext or module: preserve.
Avoiding Circular Import Issues

Circular dependencies between modules can cause runtime errors when values are accessed before they are initialized. Types are erased at runtime, so import type breaks circular runtime dependency chains:

TS
// order.ts
import type { User } from './user';  // type-only — no runtime circular dep

export interface Order {
  id: string;
  user: User;  // only used in types
  total: number;
}

// user.ts
import type { Order } from './order';  // type-only

export interface User {
  id: string;
  name: string;
  orders: Order[];  // only used in types
}

// No circular runtime dependency — both imports are erased at compile time ✓
      
Impact on Bundle Size and Build Performance

Tools like esbuild and swc strip TypeScript types without running the full type checker (they transpile file-by-file). They need to know which imports are safe to erase. If you write:

import { SomeClass } from './module';

...but only use SomeClass as a type, esbuild still has to check whether SomeClass is actually used at runtime to decide whether to erase the import. With import type, the decision is immediate and unambiguous.

Import style

Bundler can erase immediately

Safe for isolated transpilation

import { T } from ...

No — must analyze usage

No

import type { T } from ...

Yes — always erased

Yes

import { type T } from ...

Yes — always erased

Yes

Practical Pattern: Separating Types from Values

TS
// types.ts — pure type definitions
export interface UserDTO {
  id: string;
  name: string;
  email: string;
}

export type CreateUserInput = Omit<UserDTO, 'id'>;
export type UpdateUserInput = Partial<CreateUserInput>;

// user.service.ts — runtime values
import type { UserDTO, CreateUserInput, UpdateUserInput } from './types';

export class UserService {
  private users: UserDTO[] = [];

  create(input: CreateUserInput): UserDTO {
    const user: UserDTO = { id: crypto.randomUUID(), ...input };
    this.users.push(user);
    return user;
  }

  update(id: string, input: UpdateUserInput): UserDTO | undefined {
    const user = this.users.find(u => u.id === id);
    if (user) Object.assign(user, input);
    return user;
  }
}
      
import type with @types Packages

When you use types from @types/* packages (e.g. @types/node, @types/react), those are already type-only by nature. Using import type from them is good practice and signals intent clearly:

TS
import type { IncomingMessage, ServerResponse } from 'http';
import type { ReactNode, FC, MouseEvent } from 'react';
import type { NextApiRequest, NextApiResponse } from 'next';

// These are all types — no runtime value is imported
function handler(req: IncomingMessage, res: ServerResponse) {
  res.end('OK');
}

const Button: FC<{ onClick: (e: MouseEvent) => void; children: ReactNode }> = ({
  onClick,
  children,
}) => {
  // JSX here
  return null as any;
};
      
export type in Library Authoring

When you publish a TypeScript library, using export type in your public API barrel clearly signals which exports are type-only. Consumers using bundlers that strip types file-by-file will handle them correctly.

TS
// index.ts — library public API barrel
// Value exports (kept in JS output)
export { UserService } from './services/user.service';
export { ApiClient } from './services/api.client';
export { createApp } from './app';

// Type exports (erased from JS output)
export type { User, UserDTO, CreateUserInput } from './types/user';
export type { ApiConfig, ApiResponse } from './types/api';
export type { AppOptions } from './types/app';
      
Success
Separating value exports from type exports in your barrel file makes the public API contract explicit and ensures your library works correctly with all transpilation tools.
Enforcing import type with ESLint

JSON
// .eslintrc.json — enforce import type for type-only imports
{
  "rules": {
    "@typescript-eslint/consistent-type-imports": [
      "error",
      {
        "prefer": "type-imports",
        "disallowTypeAnnotations": true,
        "fixStyle": "inline-type-imports"
      }
    ],
    "@typescript-eslint/consistent-type-exports": [
      "error",
      { "fixMixedExportsWithInlineTypeSpecifier": true }
    ]
  }
}
      
Summary
  • import type { T } erases the import entirely — no JavaScript output.

  • export type { T } marks a re-export as type-only so bundlers can safely drop it.

  • Inline type specifiers (import { type T }) let you mix value and type imports in one statement.

  • verbatimModuleSyntax enforces explicit import type usage — recommended for bundler projects.

  • import type breaks circular runtime dependency chains because types are erased.

  • Fast transpilers (esbuild, swc) benefit from import type — they can erase without analysis.

  • Use ESLint consistent-type-imports rule to enforce import type automatically.