この記事について
9年ほどPHP/Laravelで実務をやってきた身ですが、今回からTypeScriptフルスタックの技術検証に挑戦します。最終的にはHono/ExpressでバックエンドAPIを作り、Next.jsのSPAから叩くTodoアプリを作るのが目標です。ボリュームが大きくなりそうなので、3本のシリーズに分けます。
- TypeScript基礎編(この記事) — PHPと対比しながら型システムに慣れる
- バックエンド実装編 — Hono vs Express、SQLiteで永続化、実測比較
- フロントエンド結合編 — Next.js SPAでAPIを叩くTodo UI
この記事では、TypeScriptの型システムの中でも「PHPの緩い型付けとは決定的に違う」と感じたポイントを、実際に手元で起こしたエラーと一緒にまとめます。
対象読者:
- PHPなど動的型付け寄りの言語の実務経験があり、TypeScriptに入門したい人
-
strict系のコンパイラオプションが実際何をしてくれるのか、具体例で知りたい人
1. 環境構築(Docker + Node.js 24)
Node.jsの現行Active LTSはNode.js 24です(2026年9月時点、Node.js 22はMaintenance LTS)。今後の記事でHono/Express/Next.jsと環境が増えていくので、最初からDocker前提で組みます。
ディレクトリ構成
hono-express-nextjs-todo/
├── Dockerfile
├── docker-compose.yml
└── src/
Dockerfile
FROM node:24-slim
WORKDIR /app
RUN corepack enable
CMD ["sleep", "infinity"]
docker-compose.yml
services:
ts-app:
build: .
volumes:
- .:/app
- node_modules:/app/node_modules
working_dir: /app
tty: true
volumes:
node_modules:
node_modulesを名前付きボリュームにしているのは、Windows上でバインドマウント越しにnode_modulesを扱うとI/Oが遅くなりがちなためです。
docker compose up -d --build
docker compose exec ts-app sh
TypeScriptのセットアップ
コンテナに入った状態で:
npm init -y
npm install -D typescript @types/node tsx
npx tsc --init
生成されたtsconfig.jsonは、最近のTypeScriptだと最初からnoUncheckedIndexedAccessやexactOptionalPropertyTypesが有効化された状態で出てきました。せっかくなので、これをそのまま活用します。
{
"compilerOptions": {
// File Layout
"rootDir": "./src",
"outDir": "./dist",
// Environment Settings
"module": "nodenext",
"target": "esnext",
"types": ["node"],
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Recommended Options
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true
}
}
2. noUncheckedIndexedAccess:配列アクセスの安全性
PHPで連想配列にアクセスする時、存在しないキーを指定しても実行は止まりません(Warning: Undefined array keyが出るだけです)。
$fruits = ['apple', 'banana', 'orange'];
echo $fruits[10]; // Warning: Undefined array key 10 (実行は継続)
TypeScriptでnoUncheckedIndexedAccessを有効にすると、配列の添字アクセスの結果にundefinedの可能性が型として現れます。
const fruits: string[] = ["apple", "banana", "orange"];
const maybeFruit = fruits[10]; // 型は string | undefined
const upperFruit: string = maybeFruit.toUpperCase(); // 型エラーになる
実際にコンパイルチェックすると:
src/index.ts:4:28 - error TS18048: 'maybeFruit' is possibly 'undefined'.
4 const upperFruit: string = maybeFruit.toUpperCase();
~~~~~~~~~~
maybeFruitがstring | undefined型なのにstring型の変数へ代入しようとしたので弾かれています。オプショナルチェーンとnull合体演算子で直します。
const upperFruit: string = maybeFruit?.toUpperCase() ?? "(該当なし)";
3. exactOptionalPropertyTypes:オプショナルプロパティの厳密化
オプショナルプロパティ(?)は「省略できる」という意味ですが、デフォルトでは「省略する」ことと「明示的にundefinedを代入する」ことが同じ扱いになります。exactOptionalPropertyTypesはこの2つを区別します。
interface TodoDraft {
title: string;
memo?: string;
}
const draft: TodoDraft = {
title: "牛乳を買う",
memo: undefined, // これがエラーになる
};
src/index.ts:11:7 - error TS2375: Type '{ title: string; memo: undefined; }' is not assignable to type 'TodoDraft' with 'exactOptionalPropertyTypes: true'.
Types of property 'memo' are incompatible.
Type 'undefined' is not assignable to type 'string'.
11 const draft: TodoDraft = {
~~~~~
直し方は、memoキー自体を省略するだけです。
const draft: TodoDraft = {
title: "牛乳を買う",
};
修正後、型チェックと実行を両方確認しました。
$ npx tsc --noEmit
(エラーなし)
$ npx tsx src/index.ts
(該当なし) { title: '牛乳を買う' }
memoキーがオブジェクトに現れていない(undefinedを明示代入していないので、キーとしても存在しない)ことが実行結果からも確認できます。
なぜこれが嬉しいかというと、"memo" in draftのようなキー存在チェックやObject.keys(draft)を使うコードで、「キーはあるのに値がundefined」という紛らわしいケースを型レベルで防げるからです。
4. interfaceのタイポ検知:PHPの連想配列との対比
PHPの連想配列は「なんでも入る箱」なので、キー名のタイポは実行時まで気づけません。
$todo = ['id' => 1, 'title' => '牛乳を買う', 'done' => false];
echo $todo['tittle']; // Warning: Undefined array key "tittle" (実行は止まらない)
TypeScriptのinterfaceは構造を固定するので、タイポはコンパイル時に弾かれます。
interface Todo {
id: number;
title: string;
done: boolean;
}
const todo: Todo = { id: 1, title: "牛乳を買う", done: false };
console.log(todo.tittle); // わざとタイポ
src/index.ts:14:18 - error TS2551: Property 'tittle' does not exist on type 'Todo'. Did you mean 'title'?
14 console.log(todo.tittle);
~~~~~~
src/index.ts:3:3 - 'title' is declared here.
3 title: string;
~~~~~
Found 1 error in src/index.ts:14
Did you mean 'title'?と修正候補まで提示してくれるのが、型付き構造ならではのDXです。これはPHPの連想配列では得られないフィードバックです。
| PHP(連想配列) | TypeScript(interface) |
|
|---|---|---|
| タイポの検知タイミング | 実行時(Warningのみ、処理は継続) | コンパイル時(エラーで停止) |
| 修正候補の提示 | なし | あり(Did you mean 'title'?) |
| 構造の保証 | なし(キーは自由に追加/削除できる) | あり(定義した形以外は代入不可) |
修正して実行すると、期待通りtitleが出力されました。
$ npx tsx src/index.ts
牛乳を買う
5. Generics:Repository<T>パターン
PHPには言語ネイティブのジェネリクスが存在しません。PHPDocで@param Collection<int, Todo> $todosのように書くことはありますが、これはPHPStanやPsalmといった静的解析ツールが読むコメントに過ぎず、言語自体やランタイムは一切チェックしません。TypeScriptはコンパイラが型として直接チェックします。
次回のバックエンド実装編で使う「リポジトリパターン」を先取りしてみます。
interface Repository<T> {
findAll(): T[];
findById(id: number): T | undefined;
save(item: T): void;
}
interface Todo {
id: number;
title: string;
done: boolean;
}
class InMemoryTodoRepository implements Repository<Todo> {
private items: Todo[] = [];
findAll(): Todo[] {
return this.items;
}
findById(id: number): Todo | undefined {
return this.items.find((t) => t.id === id);
}
save(item: Todo): void {
this.items.push(item);
}
}
const repo = new InMemoryTodoRepository();
repo.save({ id: 1, title: "牛乳を買う", done: false });
repo.save({ id: 2, title: "records", done: true });
console.log(repo.findAll());
console.log(repo.findById(1));
console.log(repo.findById(999));
実行結果:
[
{ id: 1, title: '牛乳を買う', done: false },
{ id: 2, title: 'records', done: true }
]
{ id: 1, title: '牛乳を買う', done: false }
undefined
Repository<T>が「Tの部分を後から埋める型のテンプレート」で、implements Repository<Todo>のところでTがTodoに確定しています。findById(999)がundefinedを返しても呼び出し側は型エラーになりません。Todo | undefinedという戻り値の型を正直に申告しているので、これを使う側は「見つからないかもしれない」ことを前提にコードを書かざるを得なくなります。noUncheckedIndexedAccessと同じ思想です。
6. any vs unknown
anyはPHPの緩い型付けに一番近い感覚の型ですが、それを敢えて使わせないunknownという型がTypeScriptには存在します。
function processAny(value: any) {
console.log(value.toUpperCase());
}
function processUnknown(value: unknown) {
console.log(value.toUpperCase());
}
processAny("hello");
processAny(123);
型チェックすると:
src/index.ts:6:15 - error TS18046: 'value' is of type 'unknown'.
6 console.log(value.toUpperCase());
~~~~~
Found 1 error in src/index.ts:6
processAnyの方は何のエラーも出ず、processUnknownの方だけvalueの型がunknownだと弾かれます。関数の中身が実際に呼ばれているかどうかに関係なく、コード上に存在するだけでチェック対象になる点もポイントです。
processUnknownをコメントアウトして実行すると、興味深い結果になりました。
$ npx tsc --noEmit
(エラーなし)
$ npx tsx src/index.ts
HELLO
/app/src/index.ts:2
console.log(value.toUpperCase());
^
TypeError: value.toUpperCase is not a function
at processAny (/app/src/index.ts:2:21)
at <anonymous> (/app/src/index.ts:10:1)
npx tsc --noEmitはエラーなしで通ったのに、npx tsxで実行するとprocessAny(123)のところで実行時クラッシュしました。これはanyの危険性そのものです。anyはコンパイル時のチェックを完全にすり抜けるので、実行時に初めて壊れます。PHPの型なし変数がFatal error: Call to undefined methodで実行時に落ちるのと同じ構図です。
もう1つの気づきとして、tscとtsxは別物という点があります。tsxはTypeScriptのコードをその場で実行するためのツールであり、型チェック自体は行いません。実務ではtsc --noEmitをCIなどで別途走らせないと、型エラーが実行時まで紛れ込んでしまいます。
まとめ
-
noUncheckedIndexedAccessは、配列の範囲外アクセスにundefinedの可能性を型で正直に反映させる。PHPの「未定義キー警告(実行は継続)」と違い、コンパイル時に止まる -
exactOptionalPropertyTypesは、「プロパティを省略する」ことと「undefinedを明示代入する」ことを区別する -
interfaceによるタイポ検知は、PHP連想配列に対する明確なアドバンテージ。Did you meanの提案も付く - Genericsは言語ネイティブ機能としてコンパイラがチェックする。PHPのPHPDocベースの疑似ジェネリクスとは保証の強さが違う
-
anyは型チェックを放棄する。unknownは「使う前に確認を強制する」型。tscとtsxが別物である点もあわせて実務上重要
次回はHonoとExpressで同じ仕様のTodo APIを実装し、SQLiteでの永続化とあわせて比較します。


