For a long time I used TypeScript's import type with a sloppy mental model: "the thing that imports only types." It turns out this is not merely a difference in syntax. It decides whether the import survives into the compiled JavaScript at all. The difference starts to bite once instanceof checks or circular dependencies between modules enter the picture. Nor can you simply fall back on checking the class name via constructor.name. Minification may change class names. My grasp of all this was a little hazy, so I'm going to sort out, in order, how import type, runtime dependencies, circular dependencies, and minification relate to one another.

Types That Vanish, Imports That Stay

To begin with, TypeScript types basically do not survive into the compiled JavaScript. Suppose you write the following TypeScript.

type User = {
  name: string;
};

function greet(user: User) {
  console.log(user.name);
}

Compile it to JavaScript and the type information is wiped clean.

function greet(user) {
  console.log(user.name);
}
The type User exists nowhere at runtime. Types are there for checking during development, and the JavaScript that browsers and Node.js execute basically carries none of them. A type, in other words, is a ghost that vanishes the moment the program runs. The same goes for types from another file. Define a type in user.ts and load it into greet.ts with import type.
// user.ts
export type User = {
  name: string;
};
// greet.ts
import type { User } from './user';

export function greet(user: User) {
  console.log(user.name);
}
This User is only ever used as a type, so the import vanishes from the compiled JavaScript entirely.
// greet.js
export function greet(user) {
  console.log(user.name);
}
So import type { User } from './user' is an import purely for type checking. The running program has no use for it, and it leaves no trace. A regular import, by contrast, can survive compilation. This time, define User as a class and use it on the right-hand side of instanceof.
// user.ts
export class User {
  constructor(public name: string) {}
}
// check.ts
import { User } from './user';

export function isUser(value: unknown) {
  return value instanceof User;
}
instanceof is an operator evaluated at JavaScript runtime. So in this case, the import remains in the compiled JavaScript.
import { User } from './user';

export function isUser(value) {
  return value instanceof User;
}
This is the crux. If you only use User as a type, import type will do. But if you use it at runtime, as in value instanceof User, it won't.

What instanceof Demands

instanceof determines whether an object was created from a particular class.
class User {}

const user = new User();

console.log(user instanceof User);
// true
Roughly speaking, it asks, "Is user an instance created from User?" To answer that, the actual User class has to be on hand at runtime. Bring a ghost to the comparison and you get nowhere. That's why you cannot write the following.
import type { User } from './user';

export function isUser(value: unknown) {
  return value instanceof User;
}
import type disappears after compilation, so User doesn't exist at runtime. To use it with instanceof, you need a regular import: import { User } from './user'.

When a Circular Dependency Is Born

This property shows up in its most annoying form as a circular dependency. Suppose we have the following two files.

// request.ts
import { parseBody } from './body';

export class AppRequest {
  constructor(public raw: Request) {}

  async json() {
    return parseBody(this);
  }
}
// body.ts
import { AppRequest } from './request';

export async function parseBody(request: AppRequest | Request) {
  const headers = request instanceof AppRequest
    ? request.raw.headers
    : request.headers;

  return {
    contentType: headers.get('Content-Type'),
  };
}
request.ts uses parseBody from body.ts, and body.ts uses AppRequest from request.ts. The dependency runs from request.ts to body.ts and then right back to request.ts. It goes all the way around, and that is a circular dependency. The cause is that body.ts imports AppRequest at runtime. Why does it need a runtime import? Because it uses request instanceof AppRequest. Running that requires the actual AppRequest class, so the import from body.ts to request.ts stays put after compilation. So, should we just switch body.ts to import type?
import type { AppRequest } from './request';

export async function parseBody(request: AppRequest | Request) {
  const headers = request instanceof AppRequest
    ? request.raw.headers
    : request.headers;

  return {
    contentType: headers.get('Content-Type'),
  };
}
That's no solution. In fact, this code doesn't even work. An AppRequest brought in with import type doesn't exist at runtime, yet instanceof AppRequest demands the real thing at runtime. For the same target, import type and instanceof simply cannot coexist. If you want the circular dependency gone, switching to import type isn't enough. You have to change the runtime check itself. Stop summoning the ghost, and find some other way to tell the two apart.

Telling Them Apart by Shape

To restate, there were two kinds of requests to handle: AppRequest | Request. AppRequest is a wrapper class for our own application.
export class AppRequest {
  constructor(public raw: Request) {}
}
The other, Request, is the web-standard Request, and it has headers directly.
const req = new Request('https://example.com');

console.log(req.headers);
// Headers {}
AppRequest, however, holds the original Request via raw. To reach its headers you write appReq.raw.headers. Exploit that structural difference, and you can tell the two apart without importing the AppRequest class at runtime.
import type { AppRequest } from './request';

const isRawRequest = (request: AppRequest | Request): request is Request =>
  'headers' in request;

export async function parseBody(request: AppRequest | Request) {
  const headers = isRawRequest(request)
    ? request.headers
    : request.raw.headers;

  return {
    contentType: headers.get('Content-Type'),
  };
}
The 'headers' in request used here is JavaScript's in operator. It checks whether an object has a particular property.
const user = {
  name: 'Taro',
};

console.log('name' in user);
// true

console.log('email' in user);
// false
Here we're checking whether request has a headers property directly. The web-standard Request does, while AppRequest only offers it via raw.headers. That gap is what the check relies on. In the fixed body.ts, AppRequest is used only as a type. So the import of AppRequest disappears from the compiled JavaScript.
const isRawRequest = (request) => 'headers' in request;

export async function parseBody(request) {
  const headers = isRawRequest(request)
    ? request.headers
    : request.raw.headers;

  return {
    contentType: headers.get('Content-Type'),
  };
}
Now the runtime dependency from body.js to request.js is gone. The dependency from request.ts to body.ts remains, but the reverse one disappears. And with that, the cycle is broken.

The Temptation of constructor.name

At this point some of you may come up with another idea.

request.constructor.name === 'AppRequest';
This looks like it lets you check by class name without importing AppRequest. And indeed, during development it works like this.
class AppRequest {}

const request = new AppRequest();

console.log(request.constructor.name);
// "AppRequest"

It looks fine at first glance, but you should steer clear of it. It is terribly fragile against minification.

Minification is the process of making JavaScript code smaller. In production builds, compressing code to lighten the JavaScript you ship is entirely routine. Suppose we have the following code.

class AppRequest {
  constructor(raw) {
    this.raw = raw;
  }
}

const request = new AppRequest(new Request('https://example.com'));

console.log(request.constructor.name);

Minify it, and not only do whitespace and newlines disappear, variable and class names get shortened too. Conceptually, it ends up looking like this.

class t{constructor(e){this.raw=e}}const r=new t(new Request("https://example.com"));console.log(r.constructor.name);
Look at the class name. The perfectly respectable class AppRequest can be cut down to a single letter, class t. Then request.constructor.name returns "t", not "AppRequest". A check that passed during development quietly fails after the production build.

The nasty part is that minification happens quietly behind the build tools and frameworks you use every day. Vite, Rollup, esbuild, Terser, webpack, Next.js, Bun build and the like all do it. Even if you never call a minifier explicitly, your production build settings may minify the code automatically. All we type, at most, is this one line.

npm run build

That alone kicks off a size-reducing process behind the scenes. Along the way, class and function names may get shortened. The renaming is done before you ever hear about it.

Ultimately, constructor.name is nothing more than a string. request.constructor.name === 'AppRequest' merely checks whether the class name is the string "AppRequest". The whole check leans on the assumption that names don't change after code transformation. It deserves care in application code too, but in libraries and shared modules it's especially wise to avoid it.

The Weak Spots of instanceof and Property Checks

Looking at it purely from the minification angle, instanceof is far safer than constructor.name. request instanceof AppRequest isn't looking at a class name string. Roughly speaking, it asks, "Does request's prototype chain include AppRequest.prototype?" So even if the class name becomes t, it keeps working as long as it uses the same class reference. That said, when you're trying to avoid a circular dependency as here, instanceof has a different problem: you must import AppRequest at runtime.
import { AppRequest } from './request';

request instanceof AppRequest;
As long as this import exists, the runtime dependency remains. If you want the cycle gone, there's no option but to find a check other than instanceof. Is a property existence check always safe, then? Of course not; there's no such free lunch. A check like 'headers' in request depends on the shape of the object. In this example, it rests on the assumption that Request has headers directly while AppRequest does not. If some thoughtful soul (most likely me, six months from now) gives AppRequest a headers of its own, the check breaks without ceremony.
class AppRequest {
  constructor(public raw: Request) {}

  get headers() {
    return this.raw.headers;
  }
}
With the implementation changed like this, 'headers' in request returns true for AppRequest as well. So when you use a property existence check, it's a good idea to leave a comment explaining why that property works for the check.
// Avoid importing AppRequest at runtime to prevent a circular dependency.
// Native Request has `headers` directly, while AppRequest exposes headers via `raw`.
const isRawRequest = (request: AppRequest | Request): request is Request =>
  'headers' in request;

That way, when future you opens this code six months from now, there's a clue to the intent.

Comparing the Checks

Lining up the checks that have come up so far gives the following.

ApproachExampleProsCons
instanceofrequest instanceof AppRequestNatural as a class checkNeeds a runtime import, so a circular dependency can remain
constructor.namerequest.constructor.name === 'AppRequest'No import needed and easy to understandBreaks if minification changes the class name
Property existence check'headers' in requestNo runtime import needed; doesn't depend on the class nameFragile against changes to the object's structure
Give it an explicit tagrequest.kind === 'app'Clear intentRequires adding a property just for the check

None of them is always right. What matters is what you treat as a stable premise.

A word on the last one in the table, giving objects an explicit tag. The idea is to give the class a kind purely for identification.
class AppRequest {
  readonly kind = 'app-request';

  constructor(public raw: Request) {}
}
The checking side then looks at that kind.
type AppRequestLike = {
  kind: 'app-request';
  raw: Request;
};

function isAppRequest(
  request: AppRequestLike | Request,
): request is AppRequestLike {
  return 'kind' in request && request.kind === 'app-request';
}

The intent couldn't be clearer. But you end up adding a property solely for the check. In application code that's perfectly fine, but libraries that prize being lightweight may want to avoid that kind of addition.

Which One to Choose

If circular dependencies aren't a problem in your application code, go ahead and use instanceof.
if (value instanceof User) {
  // ...
}
That's an entirely natural way to write it. But things change if any of the following apply. You're using a regular import for something you only need as a type. Runtime imports have been piling up because of instanceof. A circular dependency has appeared between modules. You're writing code to be distributed as a library, or it has to keep working reliably after minification. In those cases, it's better to consider structural checks or tag-based checks. Checks like value.constructor.name === 'User', meanwhile, are basically safer avoided. They may work during development, but they always carry the risk of breaking after minification. Frankly, going to production with that time bomb in my pocket is something I'd rather pass on. In short, dependencies needed as types and dependencies needed at runtime have to be thought about separately. If you use something only as a type, import type is enough, and that import disappears after compilation. If you use it with instanceof, you need a regular import, which survives and affects the dependencies between modules. If that runtime import is what creates a circular dependency, rewriting it as import type won't fix anything. You need to switch to a structural check like 'propertyName' in value or a tag like value.kind === 'some-kind'. And any check that depends on structure should come with a comment on why it's acceptable. That's where I've landed on import type and runtime dependencies, with circular dependencies and minification thrown in. Is this import needed only for types, or at runtime too? Put another way: will a ghost do, or do you need the real thing? Once you get in the habit of asking that, it becomes easier to design with the compiled JavaScript in view. import type is not a matter of syntax; it is a tool for cutting runtime dependencies, avoiding cycles, and keeping the bundled code stable.