0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PHPerがTypeScriptに挑戦する〜基礎編(hono-express-nextjs-todoシリーズ①)〜

0
Posted at

この記事について

9年ほどPHP/Laravelで実務をやってきた身ですが、今回からTypeScriptフルスタックの技術検証に挑戦します。最終的にはHono/ExpressでバックエンドAPIを作り、Next.jsのSPAから叩くTodoアプリを作るのが目標です。ボリュームが大きくなりそうなので、3本のシリーズに分けます。

  1. TypeScript基礎編(この記事) — PHPと対比しながら型システムに慣れる
  2. バックエンド実装編 — Hono vs Express、SQLiteで永続化、実測比較
  3. フロントエンド結合編 — 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だと最初からnoUncheckedIndexedAccessexactOptionalPropertyTypesが有効化された状態で出てきました。せっかくなので、これをそのまま活用します。

{
  "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();
                             ~~~~~~~~~~

maybeFruitstring | 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'?)
構造の保証 なし(キーは自由に追加/削除できる) あり(定義した形以外は代入不可)

スクリーンショット 2026-09-12 162226.png

修正して実行すると、期待通り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

スクリーンショット 2026-09-12 162237.png

Repository<T>が「Tの部分を後から埋める型のテンプレート」で、implements Repository<Todo>のところでTTodoに確定しています。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つの気づきとして、tsctsxは別物という点があります。tsxはTypeScriptのコードをその場で実行するためのツールであり、型チェック自体は行いません。実務ではtsc --noEmitをCIなどで別途走らせないと、型エラーが実行時まで紛れ込んでしまいます。

スクリーンショット 2026-09-12 162417.png

まとめ

  • noUncheckedIndexedAccessは、配列の範囲外アクセスにundefinedの可能性を型で正直に反映させる。PHPの「未定義キー警告(実行は継続)」と違い、コンパイル時に止まる
  • exactOptionalPropertyTypesは、「プロパティを省略する」ことと「undefinedを明示代入する」ことを区別する
  • interfaceによるタイポ検知は、PHP連想配列に対する明確なアドバンテージ。Did you meanの提案も付く
  • Genericsは言語ネイティブ機能としてコンパイラがチェックする。PHPのPHPDocベースの疑似ジェネリクスとは保証の強さが違う
  • anyは型チェックを放棄する。unknownは「使う前に確認を強制する」型。tsctsxが別物である点もあわせて実務上重要

次回はHonoとExpressで同じ仕様のTodo APIを実装し、SQLiteでの永続化とあわせて比較します。


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?