【第16回】第2部の買い物メモを型で守る|TypeScriptへの書き換え(嫁子が教えるTS講座)

今回のゴール

第3部の最後です。第10回で作った買い物メモを、そのまま型で守ります。全体の流れは講座の目次へ。


嫁子: 今日は新しい文法はほとんど出てこないよ。既にあるコードに型を当てるだけ。

でもこれ、実務でいちばん多い作業なの。ゼロから書き始めることより、すでに動いているものを直す・足すほうが圧倒的に多いからね。

しかも今日は、エラーが14件出るところから始めるよ。全部消すからね。


① 拡張子を変えるところから

まず app.jsapp.ts に変えます。それだけで、第15回で入れた strict が全部のチェックを始めます。

npx tsc --noEmit

第10回のコードでは、ここで14件のエラーが出ます。数を見て身構えるかもしれませんが、中身は3種類しかありません

種類 エラーの言い分 件数
items の中身が不明 any[] と推論されます」 6
② 要素が null かもしれない listnull の可能性があります」 6
value を持っていない valueHTMLElement に存在しません」 2

同じ種類のものは、同じ直し方で片付きます。14件と思うから多く見えるだけで、実際は3回の作業です。

嫁子: エラー一覧を見た瞬間に閉じたくなる気持ち、分かるよ。私も最初はそうだった。

でも種類ごとに数えると、だいたい3〜4種類に収まるの。だからまず分類してから手を動かす。ここだけ覚えておいてね。


items の中身を宣言する

出ているのはこのエラーです。

error TS7034: Variable 'items' implicitly has type 'any[]' in some locations

原因は第12回で触れた通り、空の配列から始めているからです。中に何が入る予定なのか、TypeScriptには判断できません。

そこで第13回でやった type を使います。

type Item = {
  name: string;
  done: boolean;
};

let items: Item[] = [];

3行足すだけで、①の6件が消えます。しかも、この時点から item.name の打ち間違いが全部エラーになります。


③ 要素が null かもしれない問題

いちばん件数が多いのがこれです。

error TS18047: 'list' is possibly 'null'.

document.getElementById() は、該当する要素が無ければ null を返します。TypeScriptはその可能性を無視させてくれません。

1か所ずつ if で確認してもいいのですが、4回書くことになります。そこで確認する関数を1つ作ります。

function must(id: string): HTMLElement {
  const el = document.getElementById(id);
  if (el === null) {
    throw new Error(`#${id} が見つかりません`);
  }
  return el;
}

const list = must("list");
const count = must("count");
const addButton = must("add");

must は「無ければその場で止める」関数です。戻り値の型が HTMLElement なので、受け取った側はもう null を気にせずに済みます。

嫁子: ここ、型のためだけに書いてるわけじゃないよ。

HTMLの id を書き換えて、JavaScript側を直し忘れる事故ってよくあるでしょ。今までは画面が静かに動かなくなるだけだった。これからは「#list が見つかりません」と名指しで教えてくれるの。

型に付き合わされてるように見えて、実は自分が助かってるんだよね。


input.value が使えない問題

3つ目です。

error TS2339: Property 'value' does not exist on type 'HTMLElement'

getElementById が返すのは「HTMLの要素」であって、それが入力欄かどうかまでは分かりません。value は入力欄だけが持つものなので、拒否されます。

よく見るのは as を使う書き方です。

const input = must("input") as HTMLInputElement;

as は「これは入力欄だと分かっているから、そう扱って」という指定です。ただし確認はしていません。もし違うものを指していたら、実行時に壊れます。

確認までするなら、こう書きます。

function mustInput(id: string): HTMLInputElement {
  const el = must(id);
  if (!(el instanceof HTMLInputElement)) {
    throw new Error(`#${id} は入力欄ではありません`);
  }
  return el;
}

const input = mustInput("input");

instanceof は「本当にそれか」を実行時に確かめる書き方です。ここでは、こちらを使います。

嫁子: as は、型チェックに対する「大丈夫だから通して」なの。第12回の any と親戚だね。

AIは as をよく使うよ。手っ取り早いからね。それが間違っていたときに教えてくれるのは、もう誰もいないという点だけ覚えておいて。


⑤ 書き換え後の全体

3種類を片付けると、こうなります。これで npx tsc --noEmit がエラー0件で通ります。

type Item = {
  name: string;
  done: boolean;
};

let items: Item[] = [];

function must(id: string): HTMLElement {
  const el = document.getElementById(id);
  if (el === null) {
    throw new Error(`#${id} が見つかりません`);
  }
  return el;
}

function mustInput(id: string): HTMLInputElement {
  const el = must(id);
  if (!(el instanceof HTMLInputElement)) {
    throw new Error(`#${id} は入力欄ではありません`);
  }
  return el;
}

const list = must("list");
const count = must("count");
const input = mustInput("input");
const addButton = must("add");

function render(): void {
  list.textContent = "";

  items.forEach((item: Item, index: number) => {
    const li = document.createElement("li");

    const span = document.createElement("span");
    span.textContent = item.name;
    if (item.done) {
      span.classList.add("done");
    }
    span.addEventListener("click", () => {
      item.done = !item.done;
      render();
    });

    const del = document.createElement("button");
    del.textContent = "削除";
    del.addEventListener("click", () => {
      items.splice(index, 1);
      render();
    });

    li.appendChild(span);
    li.appendChild(del);
    list.appendChild(li);
  });

  const remain = items.filter((item) => item.done === false).length;
  count.textContent = `残り ${remain} 件`;
}

addButton.addEventListener("click", () => {
  const name = input.value.trim();
  if (name === "") {
    return;
  }
  items.push({ name: name, done: false });
  input.value = "";
  render();
});

render();

変換して読み込むところまでは第15回と同じです。

npx tsc

HTML側は <script src="app.js"> のままで構いません。動くものは今まで通りのJavaScriptだからです。


⑥ 何が変わったのか

見た目も動きも同じですが、書ける間違いが減っています。

書いた間違い 前(JavaScript) 後(TypeScript)
item.nane と打ち間違える 画面に何も出ない その場でエラー
done"yes" を入れる そのまま動く その場でエラー
HTMLの id を変えて直し忘れる 静かに動かなくなる 「見つかりません」と止まる
items.push({ name: "牛乳" }) 動くが done が無い 項目が足りないとエラー

最後の行が地味に効きます。あとから型に項目を1つ足すと、足し忘れている場所が全部エラーで出てきます。手で探す必要がありません。

嫁子: これが「後から直せるコード」の正体なの。第1回で「動くけどまずいコード」の話をしたでしょ。あそこで言ってた3つ目がこれだよ。

変更したときに、直すべき場所を機械が教えてくれるかどうか。それだけの違いなんだけど、半年後の自分にとっては天と地なんだよね。


⑦ AIに書き換えさせるなら

同じ作業をAIに頼むこともできます。そのときの頼み方です。

この app.js を TypeScript に書き換えてください。 - strict が有効な前提で、エラーが0件になるまで - anyas は使わない - document.getElementByIdnull は、確認用の関数を1つ作ってまとめる - データの型は type Item として先に定義する

条件を書かないと、anyas で埋めた「エラーは無いが守られていない」コードが返ってくることがあります。今日やった3種類の分類は、そのまま指示に書く材料になります。

書いておく条件 書かないとどうなるか
any を使わない 型チェックが止まる
as を使わない 確認せずに通される
null の扱いを指定 同じ if が何度も書かれる

嫁子: 自分で1回やったから、この条件が書けるようになったんだよ。

第10回でも同じことを言ったけど、AIに任せる前に一度自分でやる価値は、まさにここなの。やってない人は「エラーを消して」としか言えないからね。


つまずきポイント

items[index] のような番号での取り出しは、設定によっては「undefined かもしれない」と言われます。上のコードで forEach が渡してくれる item をそのまま使っているのは、そのためでもあります。

.js を編集しないでください。編集するのは .ts のほうです。.js は変換のたびに上書きされます。


今回のまとめ


次にやること

第3部(TypeScript)はこれで終わりです。次回から第4部・フロントエンドに入ります。

第9回の最後に書いた「手で全部書き換える方式の限界」を、ここから解決していきます。今日のコードには render() を呼び忘れると画面が古いまま残る問題がまだありますが、それを構造として解決するのがReactです

今回の宿題:完成したコードの type Itemprice: number; を1行足して、npx tsc --noEmit を実行してください。このアプリでは1か所(items.push のところ)が「price が足りない」と名指しで出ます。規模が大きくなるほど、この一覧の価値が上がります。

嫁子: 型を1行足しただけで、直すべき場所の一覧が出てくるの。これ、JavaScriptのままだったら自分で全部探すしかなかったんだよ。

第3部を通してやってきたことの意味が、たぶんこの瞬間にいちばんよく分かると思う。やってみてね。