TypeScriptで作る!チェックリストアプリ|ローカル保存&編集機能


TypeScriptでチェックリストアプリを作るという作業の中身は、型が付いていない2つの入口に、自分で境界線を引くことです。document.getElementById()HTMLElement | null を返し、JSON.parse()any を返します。この2か所をどう受け止めるかで、コードが strict を通るかどうかも、リロード後にデータが壊れるかどうかも決まります。

この記事では、ローカル保存つきのチェックリストアプリを HTML → TypeScript本体 → tsconfig.json → npx tsc → ブラウザで保存されているかを目視確認 の順に、途中を飛ばさず作ります。掲載しているTypeScriptは実際に tsc --strict(TypeScript 5.9.3)にかけてエラー0を確認したものです。

あわせて、型なしのJavaScriptをそのまま .ts に置き換えると strict で30件超のエラーが出る、その内訳と直し方も扱います。JavaScriptだけで小さなアプリを組み立てる流れは JavaScriptミニアプリの作り方 にまとめてあるので、型なし版と読み比べると差分がはっきりします。


作るもの:ローカル保存つきチェックリスト

完成品は チェックリスト作成ツール でそのまま動かせます。タスクを追加したあとブラウザを閉じ、開き直しても内容が残る——それが今回のゴールです。

  • テキストを入力してタスクを追加(Enterキー対応、Shift+Enterで改行)
  • チェックすると取り消し線が付き、完了タスクはリストの下へ移動
  • editボタンでその場編集、delボタンで削除
  • 操作のたびにlocalStorageへ保存し、リロードしても復元される
使うものこの記事での役割
TypeScript 5系アプリ本体のロジック。interface と型ガードで、扱うデータの形を固定する
tsc(TypeScriptコンパイラ).ts.js に変換する。出力するJavaScriptの文法水準は tsconfig.jsontarget で決まる
HTML5 / CSS入力欄・リスト・見た目。TypeScript側は3つの id だけを手がかりにする
Web Storage API(localStorage)タスクの永続化。文字列しか保存できないので JSON.stringify / JSON.parse を挟む
Node.js + npmtsc を動かすための実行環境

ブラウザだけで完結させたい場合の選択肢は ブラウザで完結するコーディング環境 にまとめていますが、tsc を回すこの記事の手順ではNode.jsをインストールしたローカル環境を前提にします。


HTMLとCSSの土台をつくる

TypeScript側は taskInput / addBtn / taskList という3つの id を手がかりにDOMを取得します。ロジックより先に、この3つを持つHTMLを用意します。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Check List</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="container">
      <h1>Check List</h1>
      <div class="input-area">
        <textarea id="taskInput" placeholder="タスクを入力" rows="1"></textarea>
        <button id="addBtn">add</button>
      </div>
      <ul id="taskList"></ul>
    </div>

    <!-- 読み込むのは src/app.ts ではなく、コンパイル後の dist/app.js -->
    <script type="module" src="./dist/app.js"></script>
  </body>
</html>

重要なのは最終行です。<script type="module" src="./dist/app.js"> が読み込むのはコンパイル後のJavaScriptで、これから書く src/app.ts ではありません。.ts をそのまま <script> で指定してもブラウザは解釈できず、何も起きません。

ファイル役割
index.html画面。3つの iddist/app.js の読み込みを持つ
style.css見た目。done クラスの表示だけTypeScript側と対応している
tsconfig.jsonコンパイル設定
src/app.ts自分が書くTypeScript
dist/app.jstsc が生成するJavaScript。手で編集しない

style.css(最小限)

見た目は好みで構いませんが、完了タスクの表現だけはTypeScript側と対応しています。lispandone クラスを付ける処理を書くので、そのスタイルを用意しておきます。

body {
  margin: 0;
  padding: 24px 16px;
  background: #1e1f24;
  color: #f2f2f2;
  font-family: system-ui, sans-serif;
  font-size: 16px;
}
.container { max-width: 600px; margin: 0 auto; }
.input-area { display: flex; gap: 8px; }
#taskInput { flex: 1; padding: 12px; font-size: 16px; resize: none; }
#taskList { list-style: none; padding: 0; margin-top: 24px; }
#taskList li { display: flex; align-items: center; gap: 8px; padding: 12px 0; border-bottom: 1px solid #3a3b42; }
#taskList li.done { opacity: 0.5; }
#taskList span.done { text-decoration: line-through; }
#taskList span { flex: 1; }
button { padding: 8px 12px; font-size: 16px; cursor: pointer; }

TypeScript本体を書く

src/app.ts を4つのパーツに分けて見ていきます。順に「タスクの型」「DOM取得」「保存と読み込み」「キー操作」で、最後に全体を通しで載せます。

タスク1件の型を決める

// タスク1件の形。この記事の型の起点になる
interface Task {
  id: number;
  text: string;
  done: boolean;
}

ここが型の起点です。以降に出てくる「タスクの配列」「保存する値」「localStorageから読み戻した値」は、すべてこの Task に照らして扱います。逆に言えば、この形を決めずに let tasks = []; と書き始めると、配列の中身が any のまま最後まで押し切られて型の恩恵が消えます。

DOM要素を「nullでない具体型」に絞る

// getElementById は HTMLElement | null を返す。
// ここで null を落として、同時に具体的な要素型へ絞り込む
function requireElement<T extends HTMLElement>(id: string): T {
  const el = document.getElementById(id);
  if (el === null) {
    throw new Error(`要素が見つかりません: #${id}`);
  }
  return el as T;
}

const taskInput = requireElement<HTMLTextAreaElement>("taskInput");
const addBtn = requireElement<HTMLButtonElement>("addBtn");
const taskList = requireElement<HTMLUListElement>("taskList");

document.getElementById() の戻り値は HTMLElement | null です。TypeScriptはHTMLファイルを読まないので、そのidの要素が本当に存在するかを知る手段がなく、常にnullの可能性を残します。さらに戻り値は基底型の HTMLElement なので、valueselectionStartsetSelectionRange といった textarea 固有のメンバーには触れません。

そこで、起動時に一度だけnullを落とし、同時に具体型へ絞る関数を通します。as HTMLTextAreaElement は「この要素はtextareaだとコンパイラに申告する」だけの操作で、実行時の検査はしません。HTML側のタグを書き換えると申告が嘘になるので、idが見つからない時点で throw して早い段階で気づけるようにしています。DOM操作そのものに慣れていない場合は JavaScript DOM操作の練習問題 で手を動かしてから戻ってくると読みやすくなります。

保存と読み込み:JSON.parseはanyを返す

// unknown を Task と見なしてよいかを実行時に検査する型ガード
function isTask(v: unknown): v is Task {
  return (
    typeof v === "object" &&
    v !== null &&
    typeof (v as Task).id === "number" &&
    typeof (v as Task).text === "string" &&
    typeof (v as Task).done === "boolean"
  );
}

function saveTasks(): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

function loadTasks(): void {
  const data = localStorage.getItem(STORAGE_KEY);
  if (data === null) return;
  try {
    const parsed: unknown = JSON.parse(data);
    tasks = Array.isArray(parsed) ? parsed.filter(isTask) : [];
  } catch {
    tasks = [];
  }
  nextId = Math.max(...tasks.map((t) => t.id), 0) + 1;
}

localStorageは文字列しか保存できないため、書き込みは JSON.stringify()、読み戻しは JSON.parse() を挟みます。このうち危ないのは読み戻す側です。JSON.parse() の戻り値は any で、tasks: Task[] と宣言していても中身が Task でなくてもコンパイルは通ってしまいます。そこで unknown で受け、型ガード isTask() を通ったものだけを配列に入れます。unknown は型を確定させるまで何もできない型なので、検査を書き忘れるとコンパイラの側が止めてくれます。型ガードの構文(v is Task という型述語)はTypeScript公式ハンドブックのNarrowingで解説されています。

なお localStorage.getItem() の戻り値は string | null ですが、if (data === null) return; を通った後は string に絞り込まれるため、strict でもここでエラーは出ません。落とし穴は戻り値のnullではなく、その先の JSON.parse() にあります。保存先としてlocalStorageを選ぶ判断そのものについては localStorage・sessionStorage・Cookie・IndexedDBの使い分け で整理しています。

Enterで追加、Shift+Enterで改行

// Enter で追加 / Shift+Enter で改行
taskInput.addEventListener("keydown", (e: KeyboardEvent) => {
  if (e.key !== "Enter") return;
  e.preventDefault();

  if (e.shiftKey) {
    const start = taskInput.selectionStart;
    const end = taskInput.selectionEnd;
    const value = taskInput.value;
    taskInput.value = value.slice(0, start) + "\n" + value.slice(end);
    taskInput.setSelectionRange(start + 1, start + 1);
  } else {
    addTask();
  }
});

textarea は既定でEnterが改行なので、「Enterで送信」にするには改行を止める必要があります。ここではEnter以外を早期returnで抜けたあと、e.preventDefault() を一度だけ呼び、そのうえで e.shiftKey によって「自分でカーソル位置に改行を差し込む」か「タスクを追加する」かを分けています。taskInputHTMLTextAreaElement に絞ってあるので、selectionStartsetSelectionRange() にそのまま触れます。

全体のソース(src/app.ts)

// タスク1件の形。この記事の型の起点になる
interface Task {
  id: number;
  text: string;
  done: boolean;
}

const STORAGE_KEY = "checklist-tasks";

// getElementById は HTMLElement | null を返す。
// ここで null を落として、同時に具体的な要素型へ絞り込む
function requireElement<T extends HTMLElement>(id: string): T {
  const el = document.getElementById(id);
  if (el === null) {
    throw new Error(`要素が見つかりません: #${id}`);
  }
  return el as T;
}

const taskInput = requireElement<HTMLTextAreaElement>("taskInput");
const addBtn = requireElement<HTMLButtonElement>("addBtn");
const taskList = requireElement<HTMLUListElement>("taskList");

let tasks: Task[] = [];
let nextId = 1;

// unknown を Task と見なしてよいかを実行時に検査する型ガード
function isTask(v: unknown): v is Task {
  return (
    typeof v === "object" &&
    v !== null &&
    typeof (v as Task).id === "number" &&
    typeof (v as Task).text === "string" &&
    typeof (v as Task).done === "boolean"
  );
}

function saveTasks(): void {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

function loadTasks(): void {
  const data = localStorage.getItem(STORAGE_KEY);
  if (data === null) return;
  try {
    const parsed: unknown = JSON.parse(data);
    tasks = Array.isArray(parsed) ? parsed.filter(isTask) : [];
  } catch {
    tasks = [];
  }
  nextId = Math.max(...tasks.map((t) => t.id), 0) + 1;
}

function addTask(): void {
  const text = taskInput.value.trim();
  if (text === "") return;
  tasks = [...tasks, { id: nextId++, text, done: false }];
  saveTasks();
  renderTasks();
  taskInput.value = "";
}

function renderTasks(): void {
  taskList.innerHTML = "";
  const sorted: Task[] = [
    ...tasks.filter((t) => !t.done).reverse(),
    ...tasks.filter((t) => t.done).reverse(),
  ];

  sorted.forEach((task) => {
    const li = document.createElement("li");

    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = task.done;
    checkbox.addEventListener("change", () => {
      tasks = tasks.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t));
      saveTasks();
      renderTasks();
    });

    const span = document.createElement("span");
    span.textContent = task.text;
    if (task.done) {
      span.classList.add("done");
      li.classList.add("done");
    }

    const editBtn = document.createElement("button");
    editBtn.textContent = "edit";
    editBtn.addEventListener("click", () => {
      const input = document.createElement("input");
      input.type = "text";
      input.value = task.text;
      input.classList.add("edit-input");
      input.addEventListener("keydown", (e: KeyboardEvent) => {
        if (e.key === "Enter") {
          const next = input.value.trim() === "" ? task.text : input.value.trim();
          tasks = tasks.map((t) => (t.id === task.id ? { ...t, text: next } : t));
          saveTasks();
          renderTasks();
        } else if (e.key === "Escape") {
          renderTasks();
        }
      });
      li.replaceChild(input, span);
      input.focus();
    });

    const deleteBtn = document.createElement("button");
    deleteBtn.textContent = "del";
    deleteBtn.addEventListener("click", () => {
      tasks = tasks.filter((t) => t.id !== task.id);
      saveTasks();
      renderTasks();
    });

    li.append(checkbox, span, editBtn, deleteBtn);
    taskList.appendChild(li);
  });
}

addBtn.addEventListener("click", () => {
  addTask();
});

// Enter で追加 / Shift+Enter で改行
taskInput.addEventListener("keydown", (e: KeyboardEvent) => {
  if (e.key !== "Enter") return;
  e.preventDefault();

  if (e.shiftKey) {
    const start = taskInput.selectionStart;
    const end = taskInput.selectionEnd;
    const value = taskInput.value;
    taskInput.value = value.slice(0, start) + "\n" + value.slice(end);
    taskInput.setSelectionRange(start + 1, start + 1);
  } else {
    addTask();
  }
});

window.addEventListener("load", () => {
  loadTasks();
  renderTasks();
});

チェック・編集・削除はいずれも tasks を作り直してから saveTasks()renderTasks() を呼ぶ形にそろえています。元の配列を書き換えずに新しい配列を作るので、「保存したつもりで保存されていない」状態が起きにくくなります。


tsconfig.jsonを書いてコンパイルする

.ts はブラウザで直接動きません。プロジェクト直下に tsconfig.json を置き、tsc にどう変換するかを指示します。

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true
  },
  "include": ["src/**/*.ts"]
}
設定意味
strict厳格な型チェックをまとめて有効にする。strictNullChecksnoImplicitAny はこれに含まれる
target出力するJavaScriptの文法水準。ES2022 なら ???. がそのままの形で出力される
lib型定義として読み込む標準ライブラリ。DOM を入れないと documentlocalStorage が「存在しない」と言われる
outDir / rootDir出力先と入力元。src/app.tsdist/app.js になる
sourceMapDevToolsで元の .ts の行を見られるようにする
# TypeScript をインストール(プロジェクトごとに入れる)
npm init -y
npm install --save-dev typescript

# tsconfig.json の設定でコンパイル(dist/app.js が生成される)
npx tsc

# 保存するたびに自動でコンパイルしたいとき
npx tsc --watch

npx tsc が何も出力せずに終われば成功です(エラーがあれば行番号付きで表示されます)。dist/app.js ができたら index.html をブラウザで開きます。type="module" のスクリプトは file:// で開くと読めないブラウザがあるため、npx serve のような簡易サーバー経由で開くほうが確実です。

target の指定は出力の見た目を大きく変えます。ES2020 より前を指定すると、??(Null合体演算子)などの新しい構文はコンパイラが古い書き方に展開します。この記事のデモとして公開している dist/app.js も、??!== null && !== void 0 ? という形に展開された状態でビルドされており、ES2020 より前の target で作られたものです。動作に問題はありませんが、出力を読んだときに「自分が書いた覚えのないコードが出てくる」原因になります。手元では上記の tsconfig.json で作り直すのが分かりやすい形です。設定項目の正確な定義はTypeScript公式のtsconfigリファレンスにあります。


strictで実際に出る3系統のエラーと直し方

このアプリのコンパイル済みJavaScriptを、そのまま app.ts にリネームしてコンパイルすると、この規模でも33件のエラーが出ます(TypeScript 5.9.3、tsc --strict --target ES2022 --lib ES2022,DOM で実測)。数は多く見えますが、正体は3系統しかありません。

エラーコード出るメッセージ原因対処
TS18047'taskInput' is possibly 'null'.getElementById() の戻り値は HTMLElement | null起動時に一括でnullを落として早期return、またはthrowする
TS2339 / TS2551Property 'value' does not exist on type 'HTMLElement'.valueselectionStartsetSelectionRangeHTMLTextAreaElement 固有のメンバーas HTMLTextAreaElement で具体型に絞る
TS7034 / TS7005 / TS7006implicitly has an 'any[]' type.let tasks = [] に型がないinterface Task を定義して let tasks: Task[] = [] にする

TS18047とTS2339:nullの可能性と、基底型のまま触っている問題

const taskInput = document.getElementById("taskInput");
// error TS18047: 'taskInput' is possibly 'null'.
// error TS2339: Property 'value' does not exist on type 'HTMLElement'.
const text = taskInput.value.trim();

1行に2件のエラーが同時に出ます。「nullかもしれない」と「そのプロパティは基底型に無い」は別々の問題で、片方だけ直しても残ります。taskInput!.value のように !(Non-null assertion)で黙らせることもできますが、それはnullチェックを消すだけで value のエラーは消えません。前掲の requireElement() のように「nullを落とす」と「具体型に絞る」を1か所でまとめて済ませるのが、結果的に書く量が少なくなります。

TS7034 / TS7005 / TS7006:暗黙のany[]

const STORAGE_KEY = "checklist-tasks";
// error TS7034: Variable 'tasks' implicitly has type 'any[]' in some
//               locations where its type cannot be determined.
let tasks = [];
let nextId = 1;

function saveTasks() {
  // error TS7005: Variable 'tasks' implicitly has an 'any[]' type.
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

function loadTasks() {
  const data = localStorage.getItem(STORAGE_KEY);
  if (data) {
    tasks = JSON.parse(data);
    // error TS7006: Parameter 't' implicitly has an 'any' type.
    const maxId = Math.max(...tasks.map((t) => t.id), 0);
    nextId = maxId + 1;
  }
}

let tasks = [] は空配列なので、コンパイラには要素の型を推論する手がかりがありません。この状態で別の場所から参照するとTS7034とTS7005が出て、さらに tasks.map((t) => t.id) のコールバック引数まで型が決まらずTS7006が出ます。let tasks: Task[] = []; と1行直すだけで、この系統は連鎖的に消えます。

localStorage.getItem()のnullはstrictエラーにならない

意外に思われるところですが、localStorage.getItem() がnullを返す可能性は strict のエラーになりません。const data = localStorage.getItem(KEY); の戻り値は string | null でも、if (data)if (data === null) return; を書いた時点でコンパイラの制御フロー解析が string に絞り込むためです。つまり「nullチェック漏れでコンパイルが落ちる」ことは、この書き方をしている限り起きません。

問題はその先です。JSON.parse() の戻り値は any なので、ここから先はコンパイラが何も守ってくれません。これは型の書き方では検出できず、実際に壊れたデータを入れて動かしてみないと見えないので、次の章で確かめます。


デモ・ソースコード(CodePen)

下のCodePenは動く状態のデモです。ただし埋め込んでいるのはコンパイル後のJavaScriptで、型注釈はコンパイル時に消えるため残っていません。TypeScriptとしての正本は前の章のコードです。

差分チェックツールで効率UPお手本コードと自分のコードを比較して、違いを一目で確認できます。練習前にブックマークしておくと便利です。
Diff Checkerを開く →

See the Pen check-list by masakazuimai (@masakazuimai) on CodePen.


動作確認:本当に「保存できている」かを自分の目で確かめる

「リロードしても残る」は画面を見れば分かりますが、それだけではどういう形で保存されているかが分かりません。DevToolsで実データを見ておくと、後でバグが出たときの切り分けが一気に速くなります。

  1. アプリでタスクを2件追加する
  2. F12(Macは Cmd + Option + I)でDevToolsを開く
  3. Application タブ → Storage > Local Storage → 自分のオリジンを選ぶ
  4. checklist-tasks というキーのValueを見る
確認項目合格の状態不合格なら疑うところ
キーの有無checklist-tasks が存在するsaveTasks() が呼ばれていない。追加・チェック・編集・削除の各操作の直後に呼ぶ
値の形[{"id":1,"text":"買い物","done":false},...] のように id text done の3キーを持つオブジェクトの配列になっているJSON.stringify(tasks) に渡しているオブジェクトの形が Task と食い違っている
リロード復元再読み込みしても2件がそのまま表示されるloadloadTasks()renderTasks() の順に呼べていない

ハマりどころ:型が効くのはコンパイル時だけ

ここがこのアプリでいちばん壊れやすい場所です。localStorageの中身は誰でも書き換えられますが、その値は型検査を一切通っていません。DevToolsのConsoleで次を実行してリロードすると、何が起きるかがはっきりします。

// DevTools の Console で実行してリロードする
localStorage.setItem("checklist-tasks", "{oops");

// JSON としては正しいが Task ではないパターン
localStorage.setItem("checklist-tasks", '[{"text":"x"}]');

1つ目("{oops")を入れてリロードすると、画面には何も表示されず、エラーメッセージも画面には出ません。Consoleにだけ SyntaxError: Expected property name or '}' in JSON at position 1loadTasks から投げられ、そこで処理が止まるため描画まで到達しないからです。しかもこの状態で新しいタスクを追加すると、壊れた値の上に新しい配列が上書き保存され、元のデータは復旧できません。

2つ目([{"text":"x"}])はもっと厄介です。JSONとしては正しいので例外は出ませんが、Task ではないので Math.max(...tasks.map((t) => t.id), 0)t.idundefined になり、nextIdNaN になります。エラーは出ないまま、以後追加するタスクのidがすべて壊れる——静かな破損です。

// 危ない書き方:JSON.parse の戻り値は any。何が入っていても素通りする
tasks = JSON.parse(data);

// 安全な書き方:unknown で受け、型ガードを通ったものだけ採用する
const parsed: unknown = JSON.parse(data);
tasks = Array.isArray(parsed) ? parsed.filter(isTask) : [];

any で受けると型検査そのものが無効化されますが、unknown なら型ガードを通すまで中身に触れないので、検査の書き忘れをコンパイラが教えてくれます。合格ラインは、前掲の loadTasks() の形にしたうえで "{oops" を入れ直してリロードし、エラーで止まらず空のリストが表示されることです。ここまで確認できたら、この章は終わりにして構いません(IndexedDBへの移行やサーバー同期は、必要になってから考える話です)。


まとめと次の一手

このアプリでTypeScriptが効いていたのは、結局のところ次の3か所でした。DOMの取得(HTMLElement | null を落として具体型に絞る)、状態の宣言(let tasks: Task[])、そして外から来るデータの検査(unknown と型ガード)です。strict で出る33件のエラーも、この3系統に分解すれば直す手は決まります。

次に手を入れるなら、まず TaskdueDatepriority を足してみてください。このとき isTask() の更新を忘れると、追加したプロパティを持つ新しいデータが型ガードで弾かれて消える、という現象が起きます。型定義と検査関数は必ずセットで直す、という感覚はここで身に付きます。

同じ題材をライブラリ側から見たい場合は React製ToDoアプリにドラッグ&ドロップを実装する が対になります。状態を自前の配列で持つ今回の書き方が、Reactでは useState にどう置き換わるかを見比べると理解が進みます。TypeScript以前のJavaScriptの土台を固め直したいときは JavaScript学習ガイド を、localStorageに何を置いてよいかの線引きは localStorageにJWTを置くリスク を参照してください。


よくある質問(FAQ)

Q. getElementByIdの戻り値がnullになる可能性があると言われるのはなぜですか?

TypeScriptはHTMLファイルを読まないため、指定したidの要素が実際に存在するかを判断できないからです。document.getElementById() の戻り値の型は常に HTMLElement | null で、strict(正確には strictNullChecks)が有効な場合、nullの可能性を潰すまで .value などに触れません。起動時に一度だけnullを落として具体型へ絞る関数を通せば、以降のコードで毎回チェックする必要はなくなります。

Q. JSON.parseの結果に型を付けても安全ではないのですか?

安全にはなりません。JSON.parse() の戻り値は any なので、const tasks: Task[] = JSON.parse(data); と書いてもコンパイラは中身を検査せずそのまま通します。型が効くのはコンパイル時だけで、localStorageに実際に入っている文字列は検査の対象外です。unknown で受けて型ガードを通す形にすると、検査を書くまでコンパイラが値に触らせてくれないため、書き忘れを防げます。

Q. コンパイルしたdist/app.jsが読み込まれません

3点を順に確認します。まず npx tsc が実際に dist/app.js を生成しているか(outDirrootDir の指定によって出力先が想定と違うことがよくあります)。次に index.htmlsrc のパスが実ファイルと一致しているか。最後に type="module" のスクリプトは file:// で開くと読めないブラウザがあるため、簡易サーバー経由で開いているか。DevToolsのConsoleに404が出ていれば前2つ、モジュール読み込み系のメッセージなら最後の項目が原因です。

Q. strictは最初からオンにすべきですか?

新規に書き始めるコードならオンで始めるほうが扱いやすいです。後から有効化すると、この記事のように既存コードから一度に数十件のエラーが出て、機能開発とは別の独立したタスクになってしまいます。既存プロジェクトに導入する場合は strict を一括で入れるのではなく、strictNullChecksnoImplicitAny のように個別のオプションを1つずつ有効化していくと、どの変更がどのエラーを消したのかを追いやすくなります。

Q. localStorageに保存できる容量はどれくらいですか?

MDNのWeb Storage APIの解説によると、sessionStorageの上限は最大5MBで、localStorageの上限はそれ以上とされています(Firefoxではブラウザのクラッシュや再起動の際にオリジンあたり10MBに制限されるという注記があります)。ブラウザとユーザー設定によって変わる値なので、チェックリスト程度のテキストなら実用上の問題にはなりませんが、画像やファイルを詰め込む用途には向きません。

Q. Enterでの追加とShift+Enterでの改行が両方効いてしまいます

keydown ハンドラで e.preventDefault() を呼ぶ位置を確認してください。textarea は既定でEnterが改行なので、タスクを追加する分岐でも、自分で改行を差し込む分岐でも、既定の動作を止める必要があります。この記事のコードでは if (e.key !== "Enter") return; の直後に一度だけ preventDefault() を呼び、そのあとで e.shiftKey によって処理を分けているため、二重に改行が入ることがありません。

出典:TypeScript Handbook – NarrowingTypeScript – TSConfig ReferenceMDN Web Docs – Web Storage API