import type with only a vague understanding: "the thing that imports only types."
In reality, though, import type is not merely a difference in syntax; it determines whether the import remains in the compiled JavaScript.
It becomes especially important when type checks using instanceof or circular dependencies between modules are involved.
Also, you might think, "Can't I just look at the class name with constructor.name?" But minification can change class names, so that isn't a stable check either.
Since this area was a bit fuzzy for me, I'm sorting out the relationship between import type, runtime dependencies, circular dependencies, and minification.
TypeScript types disappear after compilation
First, the basic premise: TypeScript types basically do not remain in the compiled JavaScript.
For example, suppose you write the following TypeScript.
type User = {
name: string;
};
function greet(user: User) {
console.log(user.name);
}
When compiled to JavaScript, the type information disappears.
function greet(user) {
console.log(user.name);
}
The type User does not exist at runtime.
TypeScript types exist only for type checking during development.
The JavaScript that browsers and Node.js execute basically has no types.
import type disappears after compilation
Consider using a type from another file.
// user.ts
export type User = {
name: string;
};
Use it in another file.
// greet.ts
import type { User } from './user';
export function greet(user: User) {
console.log(user.name);
}
This User is used only as a type.
So in the compiled JavaScript, the import disappears.
// greet.js
export function greet(user) {
console.log(user.name);
}
In other words, this is an import purely for type checking.
import type { User } from './user';
It isn't needed when the JavaScript runs, so it disappears after compilation.
A regular import can remain after compilation
On the other hand, what happens with the following code?
// user.ts
export class User {
constructor(public name: string) {}
}
// check.ts
import { User } from './user';
export function isUser(value: unknown) {
return value instanceof User;
}
Here, User is used on the right-hand side of instanceof.
instanceof is an operator evaluated at JavaScript runtime, so the import remains in the compiled JavaScript.
import { User } from './user';
export function isUser(value) {
return value instanceof User;
}
This is the key point.
If you useUser only as a type, you can use import type.
But if you use it at runtime as follows, you can't use import type.
value instanceof User;
What does instanceof do?
instanceof is used to determine 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 this.
Is user an instance created from User?
This check requires the class User itself at runtime.
So 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 does not exist at runtime.
If you want to use it with instanceof, you need a regular import.
import { User } from './user';
An example where a circular dependency arises
For example, 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'),
};
}
Looking at the dependencies, we have this.
request.ts
→ uses parseBody from body.ts
But there is also this dependency.
body.ts
→ uses AppRequest from request.ts
Together, the situation is this.
request.ts
↓
body.ts
↓
request.ts
This is a circular dependency.
The cause is thatbody.ts imports AppRequest at runtime.
The reason a runtime import is needed is that it uses request instanceof AppRequest.
To execute instanceof AppRequest, the actual class AppRequest is required.
So the import from body.ts to request.ts remains in the compiled JavaScript.
Just switching to import type doesn't solve it
So, should we just change body.ts to use 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'),
};
}
This is not a solution.
In fact, this code doesn't even work.
That's because anAppRequest imported with import type does not exist at runtime.
To execute instanceof AppRequest, the class AppRequest is needed at JavaScript runtime.
But import type disappears after compilation.
In other words, these two cannot coexist.
import type { AppRequest } from './request';
request instanceof AppRequest;
If you want to eliminate the circular dependency, switching to import type isn't enough. You need to change the runtime check itself.
How to avoid the runtime import
In the earlier example, 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) {}
}
Request, on the other hand, is the web-standard Request.
The web-standard Request has headers directly.
const req = new Request('https://example.com');
console.log(req.headers);
// Headers {}
Meanwhile, suppose AppRequest holds the original Request via raw.
appReq.raw.headers;
Using this structural difference, we can make the check 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'),
};
}
Here we use the check 'headers' in request.
This 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
In this case, we check whether request directly has a property called headers.
The web-standard Request has headers directly.
AppRequest accesses it via raw.headers.
We use that difference to make the determination.
The dependencies after the fix
The fixedbody.ts looks like this.
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'),
};
}
In this code, AppRequest is used only as a type.
So in the compiled JavaScript, the import of AppRequest disappears.
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 dependencies become this.
request.ts
→ body.ts
But this one disappears.
body.ts
→ request.ts
That avoids the circular dependency.
Why isn't constructor.name good enough?
At this point, you might come up with another idea: a check like the following.
request.constructor.name === 'AppRequest';
This seems to let you check by class name without importing AppRequest.
For example, during development it works like this.
class AppRequest {}
const request = new AppRequest();
console.log(request.constructor.name);
// "AppRequest"
So this is true.
request.constructor.name === 'AppRequest';
At first glance it looks fine.
But you should avoid this approach.
The reason is that it is fragile against minification.
What is minification?
Minification is a process that makes JavaScript code smaller.
In production builds, code is often compressed to reduce the size of the JavaScript that gets delivered.
For example, 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);
When minified, whitespace and newlines disappear, and variable and class names get shortened.
Conceptually, it becomes 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.
What was originally
class AppRequest
can become the following after minification.
class t
In that case, request.constructor.name is "t", not "AppRequest".
In other words, this check, which passed during development, may fail after the production build.
request.constructor.name === 'AppRequest';
That is what "constructor.name is fragile against minification" means.
Where does minification happen?
Minification happens behind the scenes of the build tools and frameworks you normally use.
For example, there are tools like these.
Vite
Rollup
esbuild
Terser
webpack
Next.js
Bun build
Even if you never call a minifier explicitly, the code may be minified automatically by your production build settings.
For example, when you run the following command, a process that reduces code size may run behind the scenes.
npm run build
In that process, class names and function names may get shortened.
What's scary about depending on constructor.name?
The problem is that constructor.name is just a string.
request.constructor.name === 'AppRequest';
This checks whether the class name is the string "AppRequest".
But after minification, the class name may change.
What was
class AppRequest {}
might become
class t {}
Then request.constructor.name becomes "t".
As a result, the check breaks.
In other words, a check based onconstructor.name depends on the following assumption.
Names do not change after code transformation
This deserves care in application code as well, but I think it is safer to avoid it especially in libraries and shared modules.
Is instanceof safe against minification?
From the minification standpoint alone, instanceof is safer than constructor.name.
request instanceof AppRequest;
It isn't looking at the class name string.
Roughly speaking, it looks at this.
Does request's prototype chain include AppRequest.prototype?
Even if the class name changes from AppRequest to t, it still works as long as it uses the same class reference.
However, when you want to avoid a circular dependency as in this case, instanceof has a different problem.
That is that you must import AppRequest at runtime.
import { AppRequest } from './request';
request instanceof AppRequest;
As long as this import exists, the runtime dependency remains.
So if you want to eliminate the circular dependency, you need to think of a check other thaninstanceof.
Property existence checks have caveats too
So, is a property existence check always safe?
Of course not.
A check like
'headers' in request;
depends on the structure of the object.
In this example, it depends on the following assumptions.
Request has headers directly
AppRequest does not have headers directly
If you later add a headers property to AppRequest as well, this check breaks.
class AppRequest {
constructor(public raw: Request) {}
get headers() {
return this.raw.headers;
}
}
With an implementation like this, 'headers' in request becomes true for AppRequest too.
So when using a property existence check, it is a good idea to leave a comment on why that property can be used 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 you read the code later, it's easy to recall the intent.
Comparing the checking approaches
Summarizing the approaches that have come up so far:
| Approach | Example | Pros | Cons |
|---|---|---|---|
instanceof | request instanceof AppRequest | Natural as a class check | Needs a runtime import, so a circular dependency can remain |
constructor.name | request.constructor.name === 'AppRequest' | No import needed and easy to understand | Breaks if minification changes the class name |
| Property existence check | 'headers' in request | No runtime import needed; doesn't depend on the class name | Fragile against changes to the object's structure |
| Give it an explicit tag | request.kind === 'app' | Clear intent | Requires adding a property just for the check |
There is no approach that is always right.
What matters is what you treat as a stable premise.
Giving objects an explicit tag
In some cases, there is also a design that gives objects an explicit tag.
class AppRequest {
readonly kind = 'app-request';
constructor(public raw: Request) {}
}
And on the checking side:
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 is easy to understand.
However, you end up adding a property purely for the check.
For application code I think it is perfectly fine, but in libraries that value being lightweight, you may want to avoid such additions.
Which approach should you choose?
If circular dependencies aren't a problem in your application code, I think it's fine to useinstanceof.
if (value instanceof User) {
// ...
}
This is the natural way to write it.
However, you need to be careful in cases like the following.
You only want it as a type but are using a regular import
Runtime imports have increased because of instanceof
A circular dependency has arisen between modules
You are writing code to be distributed as a library
It has to keep working reliably after minification
In such cases, it seems better to consider structural checks or tag-based checks.
On the other hand, checks based onconstructor.name are basically safer avoided.
value.constructor.name === 'User';
This may work during development, but it can break after minification.
Summary
In TypeScript, you need to think separately about dependencies needed as types and dependencies needed at JavaScript runtime.
If you use something only as a type, you can useimport type.
import type { User } from './user';
This import disappears from the compiled JavaScript.
On the other hand, if you use it withinstanceof, you need a regular import.
import { User } from './user';
value instanceof User;
This import remains in the compiled JavaScript.
So it affects the dependencies between modules.
If that runtime import is what causes a circular dependency, just switching toimport type doesn't solve it.
You need to switch to a check that doesn't use instanceof.
Also, a check like the following is dangerous because minification may change the class name.
value.constructor.name === 'User';
Instead, consider approaches like the following.
'propertyName' in value;
Or give objects an explicit tag.
value.kind === 'some-kind';
However, property existence checks also depend on structure, so it's a good idea to leave a comment explaining why that check is acceptable.
In the end, the important perspective is this.
Is that import needed only for types?
Or is it also needed at runtime?
Once you become conscious of this difference, it gets easier to design TypeScript code with the compiled JavaScript in mind as well.
import type is not merely a difference in syntax.
I think it is an important tool for reducing runtime dependencies, avoiding circular dependencies, and stabilizing the bundled code.