querySelector の型をどう扱うか - 実務で使われるパターン

document.querySelector や document.getElementById は、戻り値の型が実際の要素より広く返ってきます。TypeScript で DOM を扱うとき、この「型のギャップ」をどう埋めるかは避けて通れない問題です。

querySelector の戻り値型

querySelector の型定義はオーバーロードされており、引数に渡すセレクタ文字列によって戻り値の型が変わります。

// タグ名を渡すと対応する要素型が返る
const div = document.querySelector("div");
// 型: HTMLDivElement | null

// クラスや ID を渡すと Element | null になる
const el = document.querySelector(".my-class");
// 型: Element | null

タグ名セレクタの場合は TypeScript の組み込み型マッピング(HTMLElementTagNameMap)が効くため、正しい要素型に推論されます。一方、クラス名や ID、複合セレクタを使った瞬間に Element | null まで広がり、要素固有のプロパティ(value、checked、href など)にアクセスできなくなります。

4 つのパターン

実務では主に 4 つのアプローチが使われています。

パターン 1: 型アサーション

最もよく見かける書き方です。

const input = document.querySelector("#email") as HTMLInputElement;
console.log(input.value);

短く書ける反面、2 つのリスクがあります。1 つ目は、セレクタに該当する要素が存在しなかった場合に null が返るのに、型の上では HTMLInputElement として扱われる点です。input.value にアクセスした瞬間にランタイムエラーが起きます。2 つ目は、要素の種類が想定と違った場合(input ではなく textarea だった場合など)も、コンパイラは何も警告しない点です。

それでもこのパターンが広く使われている理由は単純で、HTML を自分たちで管理している以上、要素の存在と種類は把握しているという前提があるからです。

パターン 2: ジェネリクス引数

querySelector はジェネリクスを受け取れるように定義されています。

const input = document.querySelector<HTMLInputElement>("#email");
// 型: HTMLInputElement | null

as との決定的な違いは、null が型に残ることです。そのままプロパティにアクセスしようとするとコンパイルエラーになるため、null チェックを強制されます。

const input = document.querySelector<HTMLInputElement>("#email");

// コンパイルエラー: input は null かもしれない
console.log(input.value);

// null チェックが必要
if (input) {
  console.log(input.value);
}

型アサーションより安全で、記述量もそこまで増えません。チームで統一するならこのパターンが最もバランスがよく、多くの TypeScript プロジェクトで推奨されています。

パターン 3: instanceof による型ガード

ランタイムでも要素の型を検証する方法です。

const el = document.querySelector("#email");

if (el instanceof HTMLInputElement) {
  console.log(el.value);
}

コンパイル時と実行時の両方で型が保証されるため、安全性は最も高くなります。ただし、要素が見つからなかった場合や型が違った場合は if ブロックに入らず、何も起きずに処理がスキップされます。これはバグを隠す原因にもなるため、else 側でエラーを投げるかどうかは設計判断です。

const el = document.querySelector("#email");

if (el instanceof HTMLInputElement) {
  console.log(el.value);
} else {
  throw new Error("#email is not an HTMLInputElement");
}

パターン 4: ラッパー関数

DOM 取得と型チェックをまとめたユーティリティ関数を用意するパターンです。

function getElement<T extends Element>(
  selector: string,
  type: new (...args: any[]) => T
): T {
  const el = document.querySelector(selector);
  if (el instanceof type) {
    return el;
  }
  throw new Error(
    `Element "${selector}" not found or not an instance of ${type.name}`
  );
}

呼び出し側は簡潔になります。

const input = getElement("#email", HTMLInputElement);
// 型: HTMLInputElement(null ではない)
console.log(input.value);

const canvas = getElement("#main", HTMLCanvasElement);
const ctx = canvas.getContext("2d");

要素が存在しない場合やインスタンスの型が違う場合は即座に例外が飛ぶため、問題の発見が早くなります。大規模なプロジェクトや、DOM の構造が頻繁に変わる環境ではこの方法が採用されることがあります。

どれを選ぶか

パターンnull 安全性ランタイム検証
as アサーションなしなし
ジェネリクス引数ありなし
instanceofありあり
ラッパー関数ありあり

一般的に最も採用されているのはパターン 2 のジェネリクス引数です。null チェックが型レベルで強制されるため、アサーションの「要素がなかったら即クラッシュ」という問題を避けられます。パターン 1 の as は手軽さから個人プロジェクトや小規模なスクリプトでよく使われますが、チーム開発ではジェネリクス引数に統一しているケースが多いです。

パターン 3 と 4 は、外部から注入される HTML(CMS のテンプレート、サードパーティのウィジェットなど)を扱う場面で特に有効です。自分たちが HTML を完全に管理していない状況では、ランタイム検証なしに要素の型を信頼するのは危険だからです。

Python から TypeScript への移行での注意点

Python(Django や Flask)のテンプレートで生成されていた HTML を TypeScript で操作する場合、テンプレート側の変更が TypeScript のコードに波及するという問題が起きます。たとえば、テンプレートで id=“email” だった要素が id=“user-email” にリネームされても、TypeScript 側のセレクタ文字列はただの文字列なのでコンパイラは何も検知しません。

// テンプレート側で id が変わっても、ここはコンパイルが通る
const input = document.querySelector<HTMLInputElement>("#email");

この問題に対するアプローチとして、セレクタ文字列を定数として一箇所にまとめる方法があります。

const SELECTORS = {
  emailInput: "#user-email",
  submitButton: "#submit-btn",
  errorMessage: ".error-msg",
} as const;

const input = document.querySelector<HTMLInputElement>(
  SELECTORS.emailInput
);

セレクタが散在しなくなるため、テンプレート側の変更に追従しやすくなります。ただし、これはコンパイル時の検知ではなく運用上の工夫です。テンプレートと TypeScript の整合性をコンパイル時に保証したい場合は、React や Svelte のようにテンプレートと型が統合されたフレームワークへの移行が根本的な解決策になります。