import type を、僕は長らく「型だけimportするやつ」と雑に理解していた。ところが、これは単なる書き方の違いではない。コンパイル後のJavaScriptに import が残るかどうかを左右する代物なのだ。
この違いは、instanceof による型判定や、モジュール間の循環依存が絡むとにわかに効いてくる。かといって constructor.name でクラス名を見ればいいかというと、そうもいかない。minifyでクラス名が変わりうるからだ。このあたりの理解がいささか曖昧だったので、import type、実行時依存、循環依存、minifyの関係を順に整理しておく。
消える型、残るimport
そもそもTypeScriptの型は、コンパイル後のJavaScriptには基本的に残らない。たとえば次のようなTypeScriptを書いたとする。
type User = {
name: string;
};
function greet(user: User) {
console.log(user.name);
}
これをJavaScriptにコンパイルすると、型情報はきれいさっぱり消える。
function greet(user) {
console.log(user.name);
}
User という型は、実行時にはどこにも存在しない。型はあくまで開発中のチェックのためにあり、ブラウザやNode.jsが実行するJavaScriptには基本的に残らないのだ。言ってみれば、型は実行時には姿を消す幽霊である。
別ファイルの型を使う場合も事情は同じだ。user.ts で型を定義し、それを greet.ts から 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 type { User } from './user' は、型チェックのためだけのimportである。実行時には用がないので、跡形もなく消えるというわけだ。
対して、通常の import はコンパイル後にも残ることがある。今度は User をクラスとして定義し、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 はJavaScriptの実行時に評価される演算子だ。そのため、この場合はコンパイル後のJavaScriptにも import が残る。
import { User } from './user';
export function isUser(value) {
return value instanceof User;
}
ここが肝である。User を型として使うだけなら 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 { User } from './user' という通常のimportが要る。
循環依存が生まれるとき
この性質がもっとも厄介な形で表に出るのが、循環依存だ。たとえば次の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 を使っているからだ。これを実行するには AppRequest の実体が必要なので、body.ts から request.ts へのimportがコンパイル後にも残ってしまう。
では、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 は実行時にその実体を要求する。同じ相手に対して、import type と instanceof は両立しないのだ。
循環依存を消したいなら、import type に変えるだけでは足りない。実行時の判定方法そのものを変える必要がある。幽霊を呼び出すのをやめて、別の手がかりで見分けるしかないわけだ。
構造で見分ける
改めて整理すると、扱いたいリクエストはAppRequest | Request の2種類だった。AppRequest は自分たちのアプリケーション用のラッパークラスである。
export class AppRequest {
constructor(public raw: Request) {}
}
もう一方の 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 なら直接持っているし、AppRequest なら raw.headers 経由でしか触れない。その差を判定に使っているのである。
修正後の body.ts では、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 への依存は残るものの、逆向きの依存は消える。かくして循環は断ち切られた。
constructor.name という誘惑
ここで、別の案を思いつく人もいるかもしれない。
request.constructor.name === 'AppRequest';
これなら AppRequest をimportせずとも、クラス名で判定できそうに見える。実際、開発時には次のように動く。
class AppRequest {}
const request = new AppRequest();
console.log(request.constructor.name);
// "AppRequest"
一見よさそうだが、この方法は避けたほうがよい。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" を返す。開発時に通っていた判定が、本番ビルド後にしれっと失敗するのだ。
厄介なのは、minifyが普段のビルドツールやフレームワークの裏側でひっそり行われることだ。Vite、Rollup、esbuild、Terser、webpack、Next.js、Bun build あたりがその担い手である。自分で明示的にminifierを呼んでいなくても、本番ビルドの設定で自動的にminifyされることがある。僕たちが打つのは、せいぜい次の一行だ。
npm run build
これだけで、裏でコードサイズを縮める処理が走る。その過程で、クラス名や関数名が短くなるかもしれない。本人の与り知らぬところで改名が済んでいるわけだ。
結局のところ、constructor.name はただの文字列に過ぎない。request.constructor.name === 'AppRequest' は、クラス名が "AppRequest" という文字列かどうかを見ているだけである。この判定は「コード変換後も名前が変わらない」という前提にまるごと寄りかかっている。アプリケーションコードでも注意は要るが、ライブラリや共通モジュールでは殊に避けておくのが無難だろう。
instanceof とプロパティチェックの弱点
翻って、minifyという観点だけで見れば、instanceof は constructor.name よりずっと安全だ。request instanceof AppRequest はクラス名の文字列を見ているわけではない。ざっくり言えば、「request の prototype chain に AppRequest.prototype が含まれているか」を見ている。だからクラス名が t に変わっても、同じクラス参照を使っている限りちゃんと動く。
もっとも、今回のように循環依存を避けたい場面では、instanceof には別の問題がある。AppRequest を実行時importしなければならないことだ。
import { AppRequest } from './request';
request instanceof AppRequest;
このimportがある限り、実行時の依存は残る。循環依存を消したいなら、instanceof 以外の判定方法を考えるしかない。
では、プロパティの存在チェックなら常に安全なのか。むろん、そんなうまい話はない。'headers' in request のような判定は、オブジェクトの構造に依存している。この例でいえば、「Request は headers を直接持ち、AppRequest は持たない」という前提の上に成り立っている。将来、気の利いた誰か(たぶん半年後の僕だ)が 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' | 意図が明確 | 判定用のプロパティを追加する必要がある |
どれかが常に正解というわけではない。大事なのは、なにを安定した前提として扱うかである。
表の最後に挙げた、明示的なタグを持たせる方法についても触れておく。クラスに判定用のkind を持たせておくのだ。
class AppRequest {
readonly kind = 'app-request';
constructor(public raw: Request) {}
}
判定する側では、その kind を見る。
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後も安定して動く必要がある。こうした場合は、構造的な判定やタグによる判定を検討したほうがよい。
一方、value.constructor.name === 'User' のような判定は、基本的に避けるのが安全だ。開発時には動いても、minify後に壊れる可能性を常に抱えている。正直なところ、この爆弾を抱えて本番に出るのは御免蒙りたい。
要するに、型として必要な依存と、実行時に必要な依存は分けて考えなければならない。型としてだけ使うなら import type でよく、そのimportはコンパイル後に消える。instanceof で使うなら通常のimportが必要で、それは残ってモジュール間の依存関係に影響する。
その実行時importが循環依存を生んでいるなら、import type に書き換えるだけでは解決しない。'propertyName' in value のような構造的な判定か、value.kind === 'some-kind' のようなタグに切り替える必要がある。構造に依存する判定には、なぜそれでよいのかをコメントで添えておく。
以上、import type と実行時依存の関係を、循環依存とminifyを絡めて整理した。そのimportは型のためだけに要るのか、それとも実行時にも要るのか。幽霊で足りるのか、実体が要るのか、と言い換えてもいい。この問いを持てるようになると、コンパイル後のJavaScriptまで見据えて設計しやすくなる。import type は書き方の違いなどではなく、実行時依存を減らし、循環を避け、バンドル後のコードを安定させるための道具なのだ。