import type について、なんとなく「型だけimportするやつ」くらいの理解で使っていた。
ただ、実際には import type は単なる書き方の違いではなく、コンパイル後のJavaScriptに import が残るかどうかに関わる。
特に、instanceof を使った型判定や、モジュール間の循環依存が絡むとかなり重要になる。
さらに、constructor.name でクラス名を見ればいいのでは?と思っても、minifyでクラス名が変わる可能性があるので、これも安定した判定とは言いづらい。
このあたりが少し曖昧だったので、import type、実行時依存、循環依存、minifyの関係を整理しておく。
TypeScriptの型はコンパイル後に消える
まず大前提として、TypeScriptの型はコンパイル後のJavaScriptには基本的に残らない。
例えば、次のようなTypeScriptを書く。
type User = {
name: string;
};
function greet(user: User) {
console.log(user.name);
}
これをJavaScriptにコンパイルすると、型情報は消える。
function greet(user) {
console.log(user.name);
}
User という型は、実行時には存在しない。
TypeScriptの型は、あくまで開発中に型チェックするためのもの。
ブラウザやNode.jsが実行するJavaScriptには、基本的に型はない。
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);
}
この User は型としてしか使っていない。
そのため、コンパイル後のJavaScriptでは import が消える。
// greet.js
export function greet(user) {
console.log(user.name);
}
つまり、これは型チェックのためだけのimportだ。
import type { User } from './user';
JavaScript実行時には不要なので、コンパイル後には消える。
通常の import はコンパイル後に残ることがある
一方で、次のようなコードはどうなるか。
// user.ts
export class User {
constructor(public name: string) {}
}
// check.ts
import { User } from './user';
export function isUser(value: unknown) {
return value instanceof User;
}
この場合、User は instanceof の右辺で使われている。
instanceof はJavaScriptの実行時に評価される演算子なので、コンパイル後のJavaScriptにも import が残る。
import { User } from './user';
export function isUser(value) {
return value instanceof User;
}
ここが重要だ。
User を型として使っているだけなら、import type にできる。
しかし、次のように実行時で使うなら、import type にはできない。
value instanceof User;
instanceof は何をしているのか
instanceof は、あるオブジェクトが特定のクラスから作られたものかどうかを判定するために使う。
class User {}
const user = new User();
console.log(user instanceof User);
// true
これはざっくり言うと、こう聞いている。
user は User から作られたインスタンスですか?
この判定には、実行時に User というクラスそのものが必要になる。
だから、次のようには書けない。
import type { User } from './user';
export function isUser(value: unknown) {
return value instanceof User;
}
import type はコンパイル後に消えるので、実行時には User が存在しない。
instanceof で使いたいなら、通常の import が必要になる。
import { User } from './user';
循環依存が起きる例
例えば、次の2つのファイルがあるとする。
// 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
→ body.ts の parseBody を使う
一方で、こういう依存もある。
body.ts
→ request.ts の AppRequest を使う
つまり、こういう状態だ。
request.ts
↓
body.ts
↓
request.ts
これが循環依存。
原因は、body.ts が AppRequest を実行時importしていることだ。
なぜ実行時importが必要なのかというと、request instanceof AppRequest を使っているから。
instanceof AppRequest を実行するには、AppRequest というクラスの実体が必要になる。
そのため、body.ts から request.ts へのimportがコンパイル後のJavaScriptにも残る。
import type に変えるだけでは解決しない
では、body.ts 側を 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'),
};
}
これは解決にならない。
むしろ、このコードは成立しない。
なぜなら、import type した AppRequest は実行時には存在しないから。
instanceof AppRequest を実行するには、JavaScriptの実行時に AppRequest というクラスが必要になる。
でも import type はコンパイル後に消える。
つまり、これは両立できない。
import type { AppRequest } from './request';
request instanceof AppRequest;
循環依存を消したいなら、import type に変えるだけではなく、実行時の判定方法そのものを変える必要がある。
実行時importを避けるにはどうするか
先ほどの例では、扱いたいリクエストは2種類あった。
AppRequest | Request;
AppRequest は自分たちのアプリケーション用のラッパークラス。
export class AppRequest {
constructor(public raw: Request) {}
}
一方、Request はWeb標準のRequest。
Web標準の Request は headers を直接持っている。
const req = new Request('https://example.com');
console.log(req.headers);
// Headers {}
一方、AppRequest では元のRequestを raw 経由で持っているとする。
appReq.raw.headers;
この構造の違いを使えば、AppRequest クラスを実行時importしなくても判定できる。
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'),
};
}
ここでは、'headers' in request という判定を使っている。
これはJavaScriptの in 演算子。
オブジェクトが特定のプロパティを持っているかどうかを調べる。
const user = {
name: 'Taro',
};
console.log('name' in user);
// true
console.log('email' in user);
// false
今回の場合は、request に headers というプロパティが直接あるかを見ている。
Web標準の Request なら headers を直接持っている。
AppRequest なら raw.headers 経由でアクセスする。
その違いを使って判定している。
修正後の依存関係
修正後のbody.ts はこうなる。
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'),
};
}
このコードでは、AppRequest を型としてしか使っていない。
そのため、コンパイル後のJavaScriptでは AppRequest のimportが消える。
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'),
};
}
つまり、body.js から request.js への実行時依存がなくなる。
依存関係はこうなる。
request.ts
→ body.ts
しかし、これは消える。
body.ts
→ request.ts
そのため、循環依存を避けられる。
なぜ constructor.name ではダメなのか
ここで、別の案として次のような判定を思いつくかもしれない。
request.constructor.name === 'AppRequest';
これなら AppRequest をimportしなくても、クラス名で判定できそうに見える。
例えば、開発時にはこう動く。
class AppRequest {}
const request = new AppRequest();
console.log(request.constructor.name);
// "AppRequest"
なので、これは true になる。
request.constructor.name === 'AppRequest';
一見よさそう。
しかし、この方法は避けた方がよい。
理由は、minifyに弱いから。
minifyとは何か
minifyとは、JavaScriptのコードを小さくする処理だ。
本番ビルドでは、配信するJavaScriptのサイズを減らすために、コードを圧縮することがよくある。
例えば、次のようなコードがあるとする。
class AppRequest {
constructor(raw) {
this.raw = raw;
}
}
const request = new AppRequest(new Request('https://example.com'));
console.log(request.constructor.name);
minifyすると、空白や改行が消えたり、変数名やクラス名が短くなったりする。
概念的には、次のようになる。
class t{constructor(e){this.raw=e}}const r=new t(new Request("https://example.com"));console.log(r.constructor.name);
ここで注目したいのは、クラス名だ。
元のコードではこうだったものが、
class AppRequest
minify後にはこうなることがある。
class t
この場合、request.constructor.name は "AppRequest" ではなく、"t" になる。
つまり、開発時には通っていたこの判定が、本番ビルド後には失敗する可能性がある。
request.constructor.name === 'AppRequest';
これが「constructor.name はminifyに弱い」という意味だ。
どこでminifyされるのか
minifyは、普段使っているビルドツールやフレームワークの裏側で行われる。
例えば、次のようなツールがある。
Vite
Rollup
esbuild
Terser
webpack
Next.js
Bun build
自分で明示的にminifierを呼んでいなくても、本番ビルドの設定で自動的にminifyされることがある。
例えば、次のコマンドを実行したときに、裏側でコードサイズを小さくする処理が走ることがある。
npm run build
その過程で、クラス名や関数名が短くなる可能性がある。
constructor.name に依存すると何が怖いのか
問題は、constructor.name がただの文字列だということ。
request.constructor.name === 'AppRequest';
これは、クラス名が "AppRequest" という文字列かを見ている。
しかし、minify後にはクラス名が変わるかもしれない。
class AppRequest {}
が、こうなるかもしれない。
class t {}
そうなると、request.constructor.name は "AppRequest" ではなく "t" になる。
その結果、判定が壊れる。
つまり、constructor.name による判定は、次の前提に依存している。
コード変換後も名前が変わらない
アプリケーションコードでも注意が必要だが、特にライブラリや共通モジュールでは避けた方が安全だと思う。
instanceof ならminifyに強いのか
minifyという観点だけで見ると、instanceof は constructor.name より安全だ。
request instanceof AppRequest;
これは、クラス名の文字列を見ているわけではない。
ざっくり言うと、こう見ている。
request の prototype chain に AppRequest.prototype が含まれているか?
つまり、クラス名が AppRequest から t に変わっても、同じクラス参照を使っていれば動く。
ただし、今回のように循環依存を避けたい場合、instanceof には別の問題がある。
それは、AppRequest を実行時importしなければならないこと。
import { AppRequest } from './request';
request instanceof AppRequest;
このimportがある限り、実行時の依存は残る。
だから、循環依存を消したい場合には、instanceof 以外の判定方法を考える必要がある。
プロパティ存在チェックにも注意点はある
では、プロパティ存在チェックなら常に安全なのか。
もちろん、そうではない。
'headers' in request;
のような判定は、オブジェクトの構造に依存している。
この例では、次の前提に依存している。
Request は headers を直接持つ
AppRequest は headers を直接持たない
もし将来 AppRequest にも headers プロパティを追加したら、この判定は壊れる。
class AppRequest {
constructor(public raw: Request) {}
get headers() {
return this.raw.headers;
}
}
このような実装に変わると、'headers' in request が AppRequest に対しても true になってしまう。
そのため、プロパティ存在チェックを使う場合は、なぜそのプロパティで判定できるのかをコメントで残しておくとよい。
// 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;
こうしておくと、将来コードを読んだときに意図を思い出しやすい。
判定方法の比較
ここまで出てきた判定方法を整理すると、こうなる。
| 判定方法 | 例 | メリット | デメリット |
|---|---|---|---|
instanceof | request instanceof AppRequest | クラス判定として自然 | 実行時importが必要で、循環依存が残ることがある |
constructor.name | request.constructor.name === 'AppRequest' | import不要でわかりやすい | minifyでクラス名が変わると壊れる |
| プロパティ存在チェック | 'headers' in request | 実行時import不要。クラス名にも依存しない | オブジェクトの構造変更に弱い |
| 明示的なタグを持たせる | request.kind === 'app' | 意図が明確 | 判定用のプロパティを追加する必要がある |
どれが常に正解というわけではない。
大事なのは、何を安定した前提として扱うかだ。
明示的なタグを持たせる方法
場合によっては、明示的なタグを持たせる設計もある。
class AppRequest {
readonly kind = 'app-request';
constructor(public raw: Request) {}
}
そして、判定側でこうする。
type AppRequestLike = {
kind: 'app-request';
raw: Request;
};
function isAppRequest(
request: AppRequestLike | Request,
): request is AppRequestLike {
return 'kind' in request && request.kind === 'app-request';
}
これは意図がわかりやすい。
ただし、判定のためだけにプロパティを追加することになる。
アプリケーションコードなら十分ありだと思うが、軽量性を重視するライブラリでは、このような追加を避けたい場合もある。
どの方法を選ぶべきか
アプリケーションコードで、循環依存が問題になっていないなら、instanceof を使ってもよいと思う。
if (value instanceof User) {
// ...
}
これは自然な書き方だ。
ただし、次のような場合は注意が必要になる。
型として使いたいだけなのに通常 import している
instanceof のために実行時importが増えている
モジュール間で循環依存が起きている
ライブラリとして配布するコードを書いている
minify後も安定して動く必要がある
このような場合は、instanceof ではなく、構造的な判定やタグによる判定を検討した方がよさそうだ。
一方で、constructor.name による判定は、基本的には避けた方が安全。
value.constructor.name === 'User';
これは開発時には動いても、minify後に壊れる可能性がある。
まとめ
TypeScriptでは、型として必要な依存と、JavaScript実行時に必要な依存を分けて考える必要がある。
型としてだけ使うなら、import type を使える。
import type { User } from './user';
このimportは、コンパイル後のJavaScriptから消える。
一方で、instanceof で使う場合は、通常のimportが必要。
import { User } from './user';
value instanceof User;
このimportは、コンパイル後のJavaScriptにも残る。
そのため、モジュール間の依存関係に影響する。
もしその実行時importによって循環依存が起きているなら、import type に変えるだけでは解決しない。
instanceof を使わない判定方法に変える必要がある。
また、次のような判定は、minifyでクラス名が変わる可能性があるため危ない。
value.constructor.name === 'User';
代わりに、次のような方法を検討する。
'propertyName' in value;
あるいは、明示的なタグを持たせる。
value.kind === 'some-kind';
ただし、プロパティ存在チェックも構造に依存するため、なぜその判定でよいのかをコメントで残しておくとよい。
最終的に大事なのは、次の視点だ。
そのimportは型のためだけに必要なのか?
それとも実行時にも必要なのか?
この違いを意識できるようになると、TypeScriptのコードをコンパイル後のJavaScriptまで含めて設計しやすくなる。
import type は単なる書き方の違いではない。
実行時依存を減らし、循環依存を避け、バンドル後のコードを安定させるための重要な道具だと思う。