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 inlinetypemodifier 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 typewith named value imports in one statement, but you can mix inlinetypeand 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:
isolatedModulesforces 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 withoutexport typeis 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 writetypeon 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+, considerverbatimModuleSyntax) 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
typemodifier (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 typewhen 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 plainexport { ... } from \"...\", even withoutisolatedModules— it documents intent and future-proofs the file. - Don’t mark a class or enum import as
import typeif you also construct instances of it (new Foo()) or read its runtime members (enumvalues) — those are real runtime usages and the import must stay a normal, non-type-only import. - Remember that
import typehas 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 typemark an import or export as existing purely for the type checker; the statement is fully erased from compiled JavaScript.- The inline
typemodifier (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
isolatedModulesandverbatimModuleSyntaxcompiler flags, which cannot infer type-only status across files. - Forgetting
export typeon a re-exported type underisolatedModulesis 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.
