TypeScript import type / export type

TypeScript lets you mark an import or export as type-only, meaning it exists purely for the type checker and leaves no trace in the compiled JavaScript. The import type and export type syntax tells the compiler (and other build tools like Babel or esbuild) exactly which bindings are types versus values, which matters more than it might first appear once your build pipeline stops running a full type checker on every file.

Overview: why type-only imports exist

When you write ordinary TypeScript, import and export statements can refer to two very different things: runtime values (functions, classes, variables) and compile-time-only types (interfaces, type aliases, and — confusingly — classes and enums used purely as types). TypeScript’s compiler always knows the difference, because it does full type analysis across your whole program. But TypeScript is often not the only tool touching your code. Fast transpilers like Babel, esbuild, or SWC compile files one at a time, with no cross-file type information at all. Given a line like import { User } from \"./models\";, a single-file transpiler cannot tell whether User is an interface (which should be erased) or a class (which should be kept as a real require/import). Guessing wrong either strips a needed runtime import or leaves a dangling import to a module that, after erasure, might not even export a value with that name.

import type and export type solve this ambiguity by making the type-only intent explicit in the syntax itself, so any tool — not just tsc — can safely erase it. This is also the mechanism behind the isolatedModules and verbatimModuleSyntax compiler flags, which force every file to be compilable in isolation, without needing to consult other files to know what is a type and what is a value.

What actually happens at compile time

Remember that all TypeScript types are erased at runtime — interfaces, type aliases, generic parameters, and type-only imports never exist in the emitted JavaScript. A plain import { Foo } from \"./foo\" that only ever uses Foo as a type is usually erased automatically by tsc, because the compiler can see, across the whole program, that Foo is never used as a value. But that automatic erasure is a courtesy from a full type checker; it disappears the moment a single-file transpiler is in charge. Writing import type { Foo } from \"./foo\" removes the guesswork: the statement is guaranteed to be type-only, so it is deleted from the output no matter which tool compiles the file.

Syntax

The general forms are:

import type { A, B } from \"module\";
import type Default from \"module\";

export type { A, B };
export type { A, B } from \"module\";

// inline per-specifier modifier (TS 4.5+)
import { type A, valueB } from \"module\";
export { type A, valueB };
  • import type { ... } — every named binding in the statement is a type; the whole statement is erased.
  • export type { ... } — re-exports (or exports) names purely as types.
  • import { type A, valueB } from \"module\" — the inline type modifier marks just one specifier as type-only inside an otherwise normal import, so you don’t need two separate import statements for the same module.
  • You cannot mix a default import type with named value imports in one statement, but you can mix inline type and value specifiers inside a single named-import list.

Examples

Example 1: a basic type-only import

Suppose a sibling module ./user exports a User interface. Since only its shape is needed, it is imported with import type:

declare module \"./user\" {
  export interface User {
    id: number;
    name: string;
  }
}

import type { User } from \"./user\";

function greet(user: User): string {
  return `Hello, ${user.name}! Your ID is ${user.id}.`;
}

const u: User = { id: 1, name: \"Ada Lovelace\" };
console.log(greet(u));

Output:

Hello, Ada Lovelace! Your ID is 1.

Because import type is used, the emitted JavaScript contains no require or import for ./user at all — the whole statement vanishes. User only ever describes the shape of u; it never exists as a runtime object, so there is nothing left to import once types are stripped.

Example 2: re-exporting a type from a barrel file

A common pattern is a barrel module that re-exports pieces of an internal module under a public path. If the re-exported name is a type, use export type ... from:

declare module \"./models\" {
  export interface Product {
    sku: string;
    price: number;
  }
}

// re-exporting only the type from a barrel file
export type { Product } from \"./models\";

import type { Product } from \"./models\";

function formatProduct(product: Product): string {
  return `${product.sku}: $${product.price.toFixed(2)}`;
}

const item: Product = { sku: \"SKU-100\", price: 29.99 };
console.log(formatProduct(item));

Output:

SKU-100: $29.99

The export type { Product } from \"./models\" statement re-publishes the interface through this file without pulling in any runtime code from ./models. Both the export and the later import are erased, leaving only formatProduct and the plain object literal in the compiled output.

Example 3: the inline type modifier

Sometimes you need both a type and a value from the same module in one statement. Rather than writing two import lines, prefix just the type specifier with type:

declare module \"./config\" {
  export interface FeatureFlags {
    darkMode: boolean;
    betaBanner: boolean;
  }
  export const APP_NAME: \"OrbitCRM\";
}

import { type FeatureFlags, APP_NAME } from \"./config\";

const flags: FeatureFlags = { darkMode: true, betaBanner: false };
console.log(`${APP_NAME} \u2014 darkMode: ${flags.darkMode}, betaBanner: ${flags.betaBanner}`);

Output:

OrbitCRM \u2014 darkMode: true, betaBanner: false

Here FeatureFlags is a type used only for the flags annotation, while APP_NAME is a real runtime constant. After compilation, the emitted import keeps only APP_NAME; the type FeatureFlags specifier is dropped, but the statement as a whole survives because it still imports a real value.

Under the hood: what the compiler does

When tsc processes an import, it walks every usage of each imported name in the file. If a name is used only in type positions — annotations, generic arguments, extends clauses — it is classified as type-only and erased from the emitted JavaScript, whether or not you wrote the type keyword yourself. The explicit import type / inline type syntax simply makes that classification mandatory and visible in the source, instead of inferred.

This distinction becomes load-bearing under two compiler settings:

  • isolatedModules forces every file to be transpilable on its own, as if by a tool with no cross-file knowledge. Under this flag, re-exporting a type without export type is a hard error, because a single-file transpiler cannot otherwise tell that the re-export is type-only.
  • verbatimModuleSyntax (TypeScript 5.0+) goes further: it disables all automatic erasure inference entirely. Under this flag, you must write type on every type-only import/export, and any import that mixes types and values without the inline modifier is left completely untouched in the output — nothing is silently elided.

Either way, the underlying truth does not change: types never exist at runtime. import type and export type don’t change what code does; they change what the compiler is allowed to assume about your intent when deciding what to erase.

Common Mistakes

Mistake 1: using an imported type as a runtime value

Interfaces are pure compile-time constructs. Referencing one as if it were a value is always an error, regardless of any compiler flag:

declare module \"./models\" {
  export interface User {
    id: number;
    name: string;
  }
}

import { User } from \"./models\";

console.log(User);

tsc reports: 'User' only refers to a type, but is being used as a value here. An interface has no runtime representation to log — there is nothing for console.log(User) to print. The fix is to only ever use User in type positions, and to mark the import as type-only so the mistake is caught even earlier and the import itself is understood to be erasable:

declare module \"./models\" {
  export interface User {
    id: number;
    name: string;
  }
}

import type { User } from \"./models\";

const u: User = { id: 1, name: \"Grace Hopper\" };
console.log(u.name);

Mistake 2: forgetting export type for a re-exported type under isolatedModules

Barrel files that funnel types through several layers of re-exports are the most common place this bites:

declare module \"./models\" {
  export interface Product {
    sku: string;
    price: number;
  }
}

export { Product } from \"./models\";

With isolatedModules enabled, tsc reports: Re-exporting a type when the 'isolatedModules' flag is provided requires using 'export type'. Without full-program knowledge, a single-file transpiler cannot tell that Product is a type and would either emit a broken runtime import or silently produce undefined. The fix is exactly the keyword the error names:

declare module \"./models\" {
  export interface Product {
    sku: string;
    price: number;
  }
}

export type { Product } from \"./models\";

Best Practices

  • Enable isolatedModules (and, on TypeScript 5.0+, consider verbatimModuleSyntax) in every project that uses Babel, esbuild, SWC, or ts-jest, since these tools transpile file-by-file and rely on explicit type-only syntax to erase imports safely.
  • Use the inline type modifier (import { type Foo, bar }) instead of two separate import statements when you need both a type and a value from the same module — it keeps related imports together and is exactly as safe.
  • Reach for a whole-statement import type when every named import from a module is a type; it signals intent to readers immediately, even before checking usages.
  • In barrel files, always re-export types with export type { ... } from \"...\" rather than a plain export { ... } from \"...\", even without isolatedModules — it documents intent and future-proofs the file.
  • Don’t mark a class or enum import as import type if you also construct instances of it (new Foo()) or read its runtime members (enum values) — those are real runtime usages and the import must stay a normal, non-type-only import.
  • Remember that import type has zero effect on behavior at runtime; it only affects what appears in the emitted JavaScript and what the compiler will let you get away with when erasing imports.

Practice Exercises

Exercise 1: Given a module that exports an interface Order { id: string; total: number } and a value function formatOrder(order: Order): string, write a single import statement using the inline type modifier that imports both, then use them to log a formatted order.

Exercise 2: Take a barrel file that does export { Address } from \"./models\" where Address is an interface. Rewrite it so it would pass under isolatedModules, and explain in one sentence why the original version fails.

Exercise 3: Write a short interface Config and a constant version: string in the same (imaginary) module. Import Config as type-only and version as a normal value in one statement, then predict exactly what the compiled JavaScript import line would look like after erasure.

Summary

  • import type / export type mark an import or export as existing purely for the type checker; the statement is fully erased from compiled JavaScript.
  • The inline type modifier (import { type A, b }) marks a single specifier as type-only inside an otherwise normal import or export.
  • All types are erased at runtime regardless of this syntax — the keyword only changes what the compiler is allowed to assume when deciding what to erase.
  • This syntax exists mainly to support single-file transpilers (Babel, esbuild, SWC) via the isolatedModules and verbatimModuleSyntax compiler flags, which cannot infer type-only status across files.
  • Forgetting export type on a re-exported type under isolatedModules is a common, easily fixed compiler error.
  • Using a type identifier as a runtime value (e.g. console.log(SomeInterface)) is always an error, with or without these flags.