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.
// 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
// 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
// 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
// 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';
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:
// 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';
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.
// tsconfig.json
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
// 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) {}
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:
// 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
// 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:
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.
// 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';
Enforcing import type with ESLint
// .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.