Tech Learning Daily

2026-08-20 (Thu) — 第 35 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Lang 🌿 基礎 ⏱ 約 7 分

`as HTMLInputElement` と書いたのに、なぜ実行時に `null` で落ちるのか?

あなたも昨日、document.getElementById(...) の戻り値を as HTMLInputElement と書いてエディタの赤い波線を消したはずだ。tsc は何も文句を言わずコンパイルを通した。それなのに本番で el.value を読んだ瞬間、Cannot read properties of null で落ちる。型を書いたのに、なぜ型は自分を守ってくれなかったのか。

🎯 3 行まとめ
  • 型注釈は tsc が型検査を終えた時点ですべて消去され、コンパイル後の JS には型情報が一切残らない。実行時に型を確認する仕組みはそもそも存在しない
  • as は「この値はこの型だと信じてくれ」というコンパイラへの申告であって検査ではない。申告が外れていても tsc はエラーを出さず、ズレは実行時に初めて表面化する
  • TypeScript は名前ではなく形(プロパティの有無と型)で互換性を判断する構造的型付けを採用している。これは実行時にクラス名のようなタグが残らない型消去と表裏一体の設計だ

型注釈は tsc の中だけに存在する

TypeScript のコンパイラ tsc は型注釈を読んで整合性を検査したあと、その注釈をそぎ落として出力する。公式ドキュメントは「型注釈は実行時の挙動を一切変えない」と明言しており、生成される JS のコードには : string のような記法はどこにも残らない。interfacetype の宣言自体も出力対象外で、ファイルの中から丸ごと消える。

この仕組みは 型消去(type erasure) と呼ばれる。tsc は「型検査に通った」という事実を型検査のその瞬間にだけ使い、実行時にその事実を参照する手段を残さない。型検査は一度きりの静的なチェックであって、実行中に繰り返し確認される保証ではないということだ。

TSソース
el as HTMLInputElement
        │
        │ tsc が型検査
        ▼
  型エラーなし
(黙ってコンパイル通過)
        │ 型情報を消去して出力
        ▼
plain JS
el.value
        │
        │ ブラウザで実行
        ▼
el が null なら
ここで初めて例外

as は検査ではなく申告

el as HTMLInputElement という記法は、el が本当に HTMLInputElement かどうかを tsc に確かめさせているわけではない。「これは HTMLInputElement だと私が保証する」という一方的な申告であり、tsc はそれを鵜呑みにするだけだ。公式ドキュメントも「型アサーションはコンパイル時に消去されるため、実行時に対応するチェックは一切無い。アサーションが間違っていても例外や null は生成されない」と明記している。

間違っていても例外が「生成されない」というのが要点だ。as 自体はエラーを起こす仕組みを持たない。落ちるのは、その後に書いたコード(el.value の参照など)が、実際には存在しない値へアクセスしようとした結果にすぎない。!(non-null アサーション演算子)も同じ立場にある。これも型注釈と同様に消去される記法の一つであり、値が本当に非 null かどうかをその場で検査するものではない。

🍱 たとえるなら

型注釈は建築現場の検査済みシールのようなものだ。検査官(tsc)は設計図(ソースコード)を見て建材の基準を確認するが、検査済みシールそのものは完成した建物(コンパイル後の JS)には貼られない。だから現場に運ばれた実際の資材が本当に検査を通ったものかは、建物が建ったあとに見ても分からない。現場監督が「これは検査済みのはずだ」と申告(as)しても、検査官はその場に立ち会っていないので申告の真偽を確認しようがない。

なぜ「形」で互換性を判断するのか

Java や C# のような名前的型付け(nominal typing)の言語では、あるクラスが特定のインターフェースを実装すると明示的に宣言しない限り、そのインターフェースの代わりとして使うことはできない。TypeScript も同じように振る舞うと思って書き始めると、しかし実際にはそうではないことにすぐ気づく。TypeScript は 構造的型付け(structural typing)を採用しており、公式ドキュメントは「xy と同じメンバーを少なくとも持っていれば、xy と互換性がある」という一文で基本ルールを説明する。interface Pet に対して implements Pet と書いていない class Dog のインスタンスでも、同じ形の name プロパティさえ持っていれば代入できてしまう。

この設計は場当たり的な妥協ではない。公式ドキュメントは、JavaScript のコードが関数式やオブジェクトリテラルのような無名の構造を多用する言語であるため、名前による関係宣言よりも形による互換性判定のほうが自然に馴染むと説明している。名前的型付けが求める「明示的な実装宣言」は、実行時にはそもそも参照できる情報が残っていない型消去の世界と相性が悪い。名前という記録が残らない以上、コンパイラが確かめられるのは「形が一致しているか」だけになる。

型システムが届かない「外の境界」

冒頭の document.getElementById(...) は、まさにこの限界が表面化する典型例だ。DOM から取ってきた要素、fetch(...).then(r => r.json()) の戻り値、JSON.parse(...) の結果——これらは TypeScript の型検査が生まれる前、つまりプログラムの外側から来る値だ。コンパイラはソースコードの中で完結する整合性しか確認できないので、外部から来た値についてはプログラマが与えた型注釈をそのまま信じるしかない。getElementById の戻り値の型がそもそも要素が無い可能性を含む HTMLElement | null であるにもかかわらず、それを as HTMLInputElement で上書きしてしまえば、コンパイラの目からは null の可能性は完全に消える。

この境界を実行時にも守りたいなら、型注釈とは別に、値の形を実際に調べるコードが要る。それは構造的型付けがコンパイル時にやっていることを、実行時にもう一度自分の手でやり直すという意味だ。

💼 実務でどう出会うか

最も頻繁に踏むのは DOM 操作だ。querySelector 系の戻り値は要素が見つからない可能性を型に含むが、as! で握りつぶすとその可能性ごと消え、要素が無いページ構成に変わった瞬間に本番で落ちる。次に多いのが API レスポンスで、fetch(...).then(r => r.json()) の戻り値は多くのランタイム型定義で any 扱いになり、const user: User = await res.json() と書いても中身が本当に User の形をしている保証は無い。バックエンドのレスポンス仕様が変わってフィールドが消えても、フロントの型定義だけが古いまま気づかれずに残ることがある。

⌨️ 手を動かす(5 分)

型注釈と as が本当にコンパイル後の JS から消えるかを、実際に tsc の出力を見て確かめる。

mkdir -p /tmp/ts-erasure && cd /tmp/ts-erasure
cat > sample.ts <<'EOF'
interface Pet { name: string }
class Dog { name = "pochi"; }

// 構造的型付け: Dog は Pet を implements していないが代入できる
const p: Pet = new Dog();

const el = document.getElementById("x") as HTMLInputElement;
console.log(el.value); // el が実際には null でも tsc は何も言わない
EOF
npx -y [email protected] tsc sample.ts --target es2020 --skipLibCheck
cat sample.js

sample.js を開くと interface Pet の宣言、: Pet という注釈、as HTMLInputElement の記述がすべて消え、document.getElementById("x").value だけが素の JS として残っているはずだ。id="x" の要素が存在しないページでこの JS を動かせば、型エラーなしで通過したコードがそのまま TypeError を投げる。

🙅 よくある誤解
  • `as Foo` と書けば、それ以降は実行時にも Foo 型であることが保証される — 実際はコンパイル時にしか効かない申告であり、外れていても実行時にエラーが起きるまで気づけない。
  • 型チェックさえ通れば、その値は必ずその形を持っている — DOM や API レスポンス、`JSON.parse` の結果のようにプログラムの外から来る値は、型注釈があってもコンパイラが実際に検査したわけではなく、開発者の申告をそのまま受け入れているだけだ。
  • クラス名や interface 名が違えば別の型として扱われる — TypeScript は構造的型付けなので、名前が違ってもプロパティの形が一致すれば互換とみなされる。`implements` を書いていないクラスのインスタンスでも代入できる。
📖 用語ミニ辞典
型消去(type erasure)
コンパイル時の型情報が、出力される JS のコードには一切残らない TypeScript の仕組み。
型アサーション(as)
値の型をコンパイラに申告する記法。実行時の検査は行わず、間違っていても例外は生成されない。
構造的型付け(structural typing)
型の互換性を、名前ではなくプロパティの形(メンバーの一致)で判断する型システム。
名前的型付け(nominal typing)
Java や C# などが採用する、明示的な実装・継承の宣言がなければ型が互換にならない型システム。
ランタイムバリデーション
実行時に値の形を実際に調べて検証する処理。型消去によって失われる実行時の保証を補う。
🔗 もっと深く