プログラマのための IT 教科書
第4部 プログラミング言語

TypeScript の型

読了目安 45

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

TypeScript は「JavaScript に型を付けたもの」です。 ただ、新人にとっては型エラーと戦う時間が最初の壁になります。

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

なぜ型があるのか

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

// 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.jsonstrict: 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 で消すのは問題を先送りしているだけです。 本当にその型なのかを、実行時に確認する仕組みを入れてください。

同じ壊れたデータを anyunknown の関数に渡すと何が起きるか、動かして確かめてください。 greetWithAny の引数を unknown に変えると、コンパイルが通らなくなることも試せます。

TypeScript の型 — 実行できるサンプルexamples/typescript/src/01-unknown.ts

ブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。

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

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

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 に通し、どのフィールドがなぜ弾かれるかを見てください。 スキーマの positive() を外すと、マイナスの年齢が素通りするようになります。

TypeScript の型 — 実行できるサンプルexamples/typescript/src/02-validation.ts

ブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。

null 安全

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

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 を渡したときに ||?? で結果がどう分かれるか、出力で見比べてください。

TypeScript の型 — 実行できるサンプルexamples/typescript/src/04-nullish.ts

ブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。

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

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

よくない例

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

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

  • isLoading: true なのに data もある
  • dataerror が両方ある
  • 全部 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 を防ぐ」だけなら、型の価値は限定的です。

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

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

TypeScript の型 — 実行できるサンプルexamples/typescript/src/03-discriminated-union.ts

ブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。

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 でまとめる。 これはレビューでよく指摘される改善点です。

直列と並列で実行時間がどれだけ違うか、Promise.allPromise.allSettled が 失敗時にどう分かれるかを、実際の出力で見てください。

TypeScript の型 — 実行できるサンプルexamples/typescript/src/05-async.ts

ブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。

型エラーの読み方

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

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 で並列に

章末問題

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

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

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

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

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