Every TypeScript file is two programs stacked on top of each other: the JavaScript that runs and the types that the compiler checks and then throws away. Imports sit right on the seam. Some of them bring in values the program needs at runtime, some bring in only types, and some bring in both. Getting this wrong produces one of the more confusing production failures in TypeScript, an error about a missing export that the source file clearly imports. That’s why module questions turn up at the senior end of TypeScript interviews.
Types vanish, so their imports must too
Here’s a small module with one interface and one class, plus a log line so you can see when it’s evaluated:
// models.ts
console.log("models.ts evaluated");
export interface User { id: string; name: string }
export class ApiClient {
get(path: string): Promise<unknown> { return Promise.resolve(path); }
}When models.ts compiles, the interface produces no JavaScript at all. User simply isn’t exported from models.js. ApiClient is a class, so it exists in both worlds: as a value (the constructor) and as a type (the instance shape).
Now a consumer that only uses User as a type:
// greet.ts
import { User } from "./models.js";
export function greet(u: User): string { return `hi ${u.name}`; }Compiled with ordinary settings, TypeScript notices that User is only used in type positions and deletes the whole import. The output is just the function, and models.js is never loaded. That process is called import elision, and for years it was invisible and automatic.
How elision breaks under other tools
Elision depends on knowing whether each imported name is a type or a value. tsc can work that out because it reads models.ts. Tools that transpile one file at a time, such as esbuild, SWC, Babel and Vite’s dev server, look at greet.ts alone and can’t tell whether User is an interface or a class. If they keep the import, the browser or Node asks models.js for an export that doesn’t exist.
You can reproduce the failure with tsc itself. With verbatimModuleSyntax on, the compiler emits imports exactly as written (and reports an error, covered next), so running the output shows what a single-file transpiler would produce:
import { User } from "./models.js";
SyntaxError: The requested module './models.js' does not provide an export named 'User'That’s a real run with Node 22. It’s an ES module linking error, raised before any of your code runs, and the stack trace points at an import that looks perfectly reasonable in the source. If you’ve ever seen this after switching a project to Vite or esbuild, this is the cause.
Writing the intent down
The fix is to say which imports are type-only, so no tool has to guess. TypeScript gives you two spellings, and they don’t emit the same JavaScript:
// greet-inline.ts: inline type modifier
import { type User } from "./models.js";
export function greet(u: User): string { return `hi ${u.name}`; }
// greet-type.ts: type-only import declaration
import type { User } from "./models.js";
export function greet(u: User): string { return `hi ${u.name}`; }The emitted files, with verbatimModuleSyntax on:
// greet-inline.js
import {} from "./models.js";
export function greet(u) { return `hi ${u.name}`; }
// greet-type.js
export function greet(u) { return `hi ${u.name}`; }import type removes the whole statement. The inline type modifier removes only that name, and keeps the statement, which becomes import {} from "./models.js". That empty import still loads and evaluates the module. Running greet-inline.js prints models.ts evaluated; running greet-type.js prints nothing.
Usually you want the import type form: it’s clearer and avoids loading modules you don’t need. The inline form is right when the same statement also imports values, as in import { type User, ApiClient } from "./models.js", or when you deliberately rely on the module’s side effects, such as a file that registers custom elements or polyfills when imported. If you need side effects and nothing else, import "./polyfills.js" says so most directly.
verbatimModuleSyntax: no more guessing
Since TypeScript 5.0, verbatimModuleSyntax makes the rule strict and simple. Any import or export without a type modifier is kept in the output, and any with one is dropped. To make that safe, the compiler errors when you import a type without marking it:
greet.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.It replaces the importsNotUsedAsValues and preserveValueImports flags, which TypeScript 5.0 deprecated, and it’s the recommended setting for any project whose JavaScript is produced by something other than tsc, which today is most front-end and many Node projects. It also applies to re-exports: export type { User } from "./models.js" for types, plain export { ApiClient } for values.
One consequence surprises people moving to it: because ESM syntax is emitted as written, a file that compiles to CommonJS can’t use import and export at all. The compiler reports TS1295, “ECMAScript imports and exports cannot be written in a CommonJS file under ‘verbatimModuleSyntax’”, and you either switch the package to "type": "module" or write import x = require("..."). Most new projects use ESM output and never notice.
Classes are both
The opposite mistake is importing a value with import type:
// client.ts
import type { ApiClient } from "./models.js";
export const client = new ApiClient();client.ts(2,27): error TS1361: 'ApiClient' cannot be used as a value because it was imported using 'import type'.A type-only import gives you the class’s instance type for annotations (let c: ApiClient), but not the constructor, because the constructor is runtime code. This is occasionally exactly what you want: a module that only accepts an ApiClient as a parameter can use import type and avoid creating a runtime dependency on the module that defines it. That’s a legitimate way to break an import cycle between two modules that only need each other’s types.
The same split applies to enums, which are real objects at runtime, and to namespaces that contain values. When a name is both a type and a value, ask how this file uses it.
Interview practice
A team migrates a React app from webpack with ts-loader to Vite. In development, the page is blank and the console shows “The requested module ‘/src/types.ts’ does not provide an export named ‘Props’”. The production build with tsc worked last week. What’s happening, and how would you fix it so it can’t come back?
Answer
ts-loader ran the full TypeScript compiler, which knew Props was an interface and elided the import. Vite transpiles each file on its own with esbuild, which can’t see that Props is a type, so it preserved import { Props } from "./types". The browser then failed at module linking, because types.js exports nothing called Props.
The immediate fix is to change those imports to import type { Props } (or import { type Props, OtherValue } when the statement also imports values). To stop it recurring, I’d turn on verbatimModuleSyntax in tsconfig.json, so every unmarked type import becomes a TS1484 compile error in the editor and in CI. I’d also add tsc --noEmit to the CI pipeline if it isn’t there, because Vite doesn’t type-check at all. typescript-eslint’s consistent-type-imports rule can auto-fix the existing imports in one pass.