プログラマのための IT 教科書

TypeScript の型

この部の 2 / 9 章 ・ 全体で 35 / 76 章 ・ 読了目安 45 分

この章を読むとできるようになること
  • any を使わずに外部データを安全に扱える
  • 型で状態を表現できる
  • 型エラーを読んで原因を特定できる

前章で JavaScript の挙動を見ました。 そこで気づいたはずです——実行するまで、何が起きるか分からない。

user.profile.name    // user が null なら、実行時に落ちる
res.json()           // 何が返ってくるか、書いた本人も分からない
'10' - 5             // 5(暗黙の型変換)

これらはすべて実行時に初めて発覚します。 本番で、利用者の操作によって。

「実行する前に気づけるようにしよう」 ——これが型を付ける目的です。

ただし新人にとっては、型エラーと戦う時間が最初の壁になります。

この章のゴールは、型エラーを「邪魔なもの」ではなく 「実行前に教えてくれる警告」 として読めるようになることです。

この章のサンプルは手元で動かせます

以下の出力は、すべて実際に動かした結果です。自分でも動かして、書き換えてください。

git clone https://github.com/makoto-developer/learning-it-skills-examples
cd learning-it-skills-examples/typescript
pnpm install
pnpm start        # 5本まとめて実行される

src/ の各ファイルが、この章の節に対応しています。 壊して確かめるのが目的なので、遠慮なく書き換えてください。

なぜ型があるのか

型が防ぐのは、次のようなバグです。

// JavaScript: 実行するまで気づかない
const user = await fetchUser(id);
console.log(user.nmae);       // undefined(typo)
console.log(user.age + 1);    // NaN(age が文字列だった)
// TypeScript: 書いた瞬間にエラーになる
console.log(user.nmae);
//               ~~~~ Property 'nmae' does not exist. Did you mean 'name'?

型は、動かす前に間違いを見つける仕組みです。 テストを書かなくても、typo と型の取り違えは全部見つかります。

型は消える

TypeScript の型は、コンパイル時にすべて消えます。 実行時には普通の JavaScript になり、型のチェックは一切行われません。

だから外部から来たデータ(API レスポンスなど)は、 型を書いただけでは安全になりません。後述する実行時の検証が必要です。

strict は必ず有効にする

tsconfig.json の strict: true は、TypeScript の価値の大半を占めます。

これを切ると、null チェックも any の検出も効かなくなり、 「型を書いているのに JavaScript と同じ事故が起きる」状態になります。

{ "compilerOptions": { "strict": true } }

新規プロジェクトなら必ず有効にしてください。

any を使わない

any は「型チェックをやめる」という宣言です。1つ書くと、そこから伝染します。

const data: any = await response.json();
const name = data.user.profile.name;  // 何もチェックされない
name.toUpperCase();                   // 実行時に落ちる可能性

代わりに unknown を使う

unknown は「何か分からない」という型です。使う前に確認を強制されます。

const data: unknown = await response.json();
 
// そのままでは使えない(コンパイルエラー)
data.user;  // Error: 'data' is of type 'unknown'
 
// 確認してから使う
if (typeof data === 'object' && data !== null && 'user' in data) {
  // ここでは安全
}

any は「チェックを黙らせる」、unknown は「チェックを強制する」。 似て非なるものです。

as で型エラーを消さない
const user = data as User;   // 「これは User だと思ってくれ」

as(型アサーション)はコンパイラを黙らせるだけで、実際の値は何も変わりません。 中身が違えば実行時に落ちます。

型エラーが出た時、as で消すのは問題を先送りしているだけです。 本当にその型なのかを、実行時に確認する仕組みを入れてください。

同じ壊れたデータを any と unknown の関数に渡すと、こうなります(src/01-unknown.ts)。

■ 正常なレスポンス
any     -> ALICE
unknown -> ALICE
 
■ サーバーの仕様変更で user が null になった
any     -> 実行時に落ちた: Cannot read properties of null (reading 'name')
unknown -> (想定外のレスポンス。既定値で続行)

any はここで初めて壊れます。unknown は壊れる前に気づけます。 手元では、greetWithAny の引数を unknown に変えると コンパイルが通らなくなることも試せます。

外部から来るデータは実行時に検証する

実務で最も重要な節です。

API のレスポンスに型を書いても、それは「そうであってほしい」という願望にすぎません。 サーバーの仕様変更、ネットワークエラー、想定外の値——実際には何が来るか分かりません。

// 危険: 型は書いてあるが、実行時には何も確認していない
const user: User = await response.json();

そこで zod のようなライブラリで、実行時に検証します。

import { z } from 'zod';
 
const userSchema = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number().int().positive(),
  email: z.string().email(),
});
 
type User = z.infer<typeof userSchema>;   // 型はスキーマから自動生成
 
const user = userSchema.parse(await response.json());
// ここを通過した時点で、user は確実に User の形をしている
  • 形が違えばその場でエラーになる(後続の処理で謎の undefined が出ない)
  • 型定義とバリデーションが1つで済む(二重管理しなくてよい)
境界で検証する

すべての場所で検証する必要はありません。外部との境界で1回やれば十分です。

  • API レスポンスを受け取った時
  • フォーム入力を受け取った時
  • 環境変数を読んだ時
  • localStorage から読んだ時(壊れた値が入っている可能性)

境界を通過したあとは、型を信じてよくなります。

壊れた4件のレスポンスを zod に通すと、どのフィールドがなぜ弾かれるかが分かります (src/02-validation.ts)。

■ 1 件目: {"id":"u1","name":"alice","age":24,"email":"alice@example.com"}
  OK alice (24歳)
■ 2 件目: {"id":"u2","name":"bob","age":-3,"email":"bob@example.com"}
  NG age: Number must be greater than 0
■ 3 件目: {"id":"u3","name":"carol","age":30,"email":"not-an-email"}
  NG email: Invalid email
■ 4 件目: {"id":999,"name":"dave","age":41}
  NG id: Expected string, received number
  NG email: Required

型注釈だけなら 2〜4 件目も素通りし、後続の処理で謎の値として現れます。 スキーマの positive() を外すと、マイナスの年齢が通るようになります。

null 安全

strict を有効にすると、null や undefined の可能性がある値をそのまま使えなくなります。

function getUser(id: string): User | undefined { ... }
 
const user = getUser('42');
console.log(user.name);
//          ~~~~ 'user' is possibly 'undefined'

対処は3つです。

// 1. 確認してから使う(推奨)
if (user === undefined) return;
console.log(user.name);
 
// 2. オプショナルチェーン(無ければ undefined になる)
console.log(user?.name);
 
// 3. デフォルト値(?? は null/undefined のときだけ右辺)
const name = user?.name ?? '名無し';
|| と ?? は違う
const count = input || 10;   // input が 0 でも 10 になる
const count = input ?? 10;   // input が 0 なら 0 のまま

|| は 0 '' false も「偽」として扱います。 数値や文字列のデフォルト値には ?? を使ってください。 「0 が指定できないバグ」の典型的な原因です。

0 '' false を渡すと、|| と ?? で結果が分かれます(src/04-nullish.ts)。

■ 入力: {"pageSize":0,"nickname":"","notify":false}
  pageSize  || 10 -> 10     ← 0 が消えた
  pageSize  ?? 10 -> 0
  nickname  || '名無し' -> "名無し"   ← 空文字が消えた
  nickname  ?? '名無し' -> ""
  notify    || true -> true          ← false が消えた
  notify    ?? true -> false

「0 を指定できない」「通知をオフにしたのに戻る」 はこれが原因です。

判別可能ユニオンで状態を表す

新人と経験者で最も差が出るのが、型で状態を表現する技術です。

よくない例

type Request = {
  isLoading: boolean;
  data?: User;
  error?: Error;
};

この型は、あり得ない状態を表現できてしまいます。

  • isLoading: true なのに data もある
  • data と error が両方ある
  • 全部 undefined

結果、コードのあちこちで「本当に data はあるのか」を確認することになります。

判別可能ユニオン

type Request =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: Error };

あり得ない状態が、そもそも書けなくなります。 そして status で分岐すると、その中では型が確定します。

function render(request: Request): string {
  switch (request.status) {
    case 'loading':
      return '読み込み中';
    case 'success':
      return request.data.name;    // data があることが保証される
    case 'error':
      return request.error.message;
  }
}

網羅性チェック

新しい状態を追加した時に、対応漏れをコンパイラに見つけさせられます。

function render(request: Request): string {
  switch (request.status) {
    case 'loading': return '読み込み中';
    case 'success': return request.data.name;
    case 'error': return request.error.message;
    default: {
      // ここに来る型が never でなければコンパイルエラーになる
      const exhaustive: never = request;
      throw new Error(`未対応の状態: ${JSON.stringify(exhaustive)}`);
    }
  }
}

Request に { status: 'cancelled' } を追加すると、この関数がコンパイルエラーになります。 対応を忘れた箇所を、実行前に全部教えてくれます。

これが型の本当の価値

「typo を防ぐ」だけなら、型の価値は限定的です。

あり得ない状態を書けなくする、変更漏れを検出する—— ここまで来ると、型は設計の道具になります。

動かすと、状態ごとに表示が切り替わります(src/03-discriminated-union.ts)。

loading  -> 読み込み中...
success  -> 成功: ユーザー3件
error    -> エラー: タイムアウト

手元で FetchState に { status: 'empty' } を1行足してみてください。 render の default が赤くなり、対応漏れが実行前に見つかります。

API のレスポンスを受け取る時、最も安全なのは?

readonly とイミュータブル

意図しない変更を型で防げます。

type Order = {
  readonly id: string;
  readonly items: readonly Item[];
};
 
order.id = 'x';        // Error
order.items.push(...); // Error

変更する必要がないものは readonly にするのが基本です。 「どこかで書き換えられているかも」という不安がなくなり、読む時のコストが下がります。

ジェネリクス

型を「引数」として受け取る仕組みです。同じ処理を型ごとに書かずに済みます。

// any を使うと戻り値の型が失われる
function firstAny(items: any[]): any { return items[0]; }
 
// ジェネリクスなら型が保たれる
function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}
 
const name = first(['a', 'b']);   // string | undefined
const count = first([1, 2]);      // number | undefined

最初から書けなくて構いません。any を使いたくなった時に思い出すぐらいで十分です。

非同期と型

async function fetchUser(id: string): Promise<User> { ... }
 
const user = await fetchUser('42');   // User

async 関数は必ず Promise<T> を返します。await で中身を取り出します。

await の付け忘れ
const user = fetchUser('42');    // Promise<User>(await を忘れている)
console.log(user.name);          // Error: Property 'name' does not exist on type 'Promise<User>'

型があるおかげでコンパイルエラーになります。 JavaScript なら undefined が出るだけで、原因を探すことになっていました。

これが型のありがたみを最も感じる瞬間かもしれません。

並列に実行する

// 直列: 合計 200ms
const user = await fetchUser(id);
const orders = await fetchOrders(id);
 
// 並列: 合計 100ms
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);

互いに依存しない非同期処理は Promise.all でまとめる。 これはレビューでよく指摘される改善点です。

直列と並列で実行時間がどれだけ違うかは、動かすとはっきりします(src/05-async.ts)。

■ 待つ必要のないものを直列で待つと、その分だけ遅くなる
  直列 (for await): 901ms
  並列 (Promise.all): 301ms
 
■ 1件失敗したときの違い
  Promise.all          -> 全体が失敗する: ユーザー ng の取得に失敗
  allSettled fulfilled -> user:a
  allSettled rejected  -> Error: ユーザー ng の取得に失敗
  allSettled fulfilled -> user:c

全部揃わないと意味がないなら all、1件ずつ結果を扱いたいなら allSettled です。

型エラーの読み方

長いエラーに圧倒されがちですが、読む順番があります。

Type '{ id: string; name: string; }' is not assignable to type 'User'.
  Property 'email' is missing in type '{ id: string; name: string; }'
  but required in type 'User'.
  1. 最後の行から読む — 具体的な原因が書いてある(email が足りない)
  2. 最初の行で全体を把握 — 何を何に代入しようとしたか
  3. 途中は入れ子の説明なので、必要になったら読む
エラーが長い時

型が入れ子になっていると、エラーも入れ子になります。

その場合は、一度変数に切り出して、どこで型が食い違っているかを絞り込むと早いです。

実務の落とし穴まとめ

  1. any を使う — 型チェックが伝染的に無効になる
  2. as で型エラーを消す — 実行時には何も変わらない
  3. 外部データを検証しない — 型注釈は願望にすぎない
  4. || でデフォルト値 — 0 や空文字が潰れる
  5. await の付け忘れ — 型があれば検出できる
  6. Promise.all を使わない — 無駄に直列で待つ

まとめ

  • 型は実行前に間違いを見つける仕組み。実行時には消える
  • strict: true は必須
  • any ではなく unknown + 型ガード。as で黙らせない
  • 外部データは境界で実行時検証(zod)。型注釈は保証ではない
  • ?? と || は違う。数値・文字列のデフォルトには ??
  • 判別可能ユニオンであり得ない状態を書けなくする。never で網羅性チェック
  • 変更しないものは readonly
  • 依存しない非同期は Promise.all で並列に

公式ドキュメント

迷ったら一次情報に戻ってください。

対象リンク
TypeScript Handbookhttps://www.typescriptlang.org/docs/handbook/intro.html
TypeScript 日本語ドキュメントhttps://www.typescriptlang.org/ja/docs/
type-challenges(型の練習)https://github.com/type-challenges/type-challenges

章末問題

const timeout = input || 3000 と書きました。どんな問題がありますか。

読み込み状態を表す型として、より良いのはどちらですか。

コンパイルエラーを消すために as User と書きました。何が起きますか。

次の章では、バックエンドで使う Go を扱います。 TypeScript とはかなり違う考え方の言語です。

読み終わったら記録しておくと、目次で進み具合が分かります。