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.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 は「チェックを強制する」。
似て非なるものです。
const user = data as User; // 「これは User だと思ってくれ」as(型アサーション)はコンパイラを黙らせるだけで、実際の値は何も変わりません。
中身が違えば実行時に落ちます。
型エラーが出た時、as で消すのは問題を先送りしているだけです。
本当にその型なのかを、実行時に確認する仕組みを入れてください。
同じ壊れたデータを any と unknown の関数に渡すと何が起きるか、動かして確かめてください。
greetWithAny の引数を unknown に変えると、コンパイルが通らなくなることも試せます。
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() を外すと、マイナスの年齢が素通りするようになります。
examples/typescript/src/02-validation.tsブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。
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 を渡したときに || と ?? で結果がどう分かれるか、出力で見比べてください。
examples/typescript/src/04-nullish.tsブラウザ内で動きます。コードを書き換えるとすぐ結果が変わります。
判別可能ユニオンで状態を表す
新人と経験者で最も差が出るのが、型で状態を表現する技術です。
よくない例
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 を防ぐ」だけなら、型の価値は限定的です。
あり得ない状態を書けなくする、変更漏れを検出する—— ここまで来ると、型は設計の道具になります。
FetchState に { status: 'empty' } を1行足してみてください。
render の default が赤くなり、対応漏れが実行前に見つかります。
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'); // Userasync 関数は必ず Promise<T> を返します。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.all と Promise.allSettled が
失敗時にどう分かれるかを、実際の出力で見てください。
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'.
- 最後の行から読む — 具体的な原因が書いてある(
emailが足りない) - 最初の行で全体を把握 — 何を何に代入しようとしたか
- 途中は入れ子の説明なので、必要になったら読む
型が入れ子になっていると、エラーも入れ子になります。
その場合は、一度変数に切り出して、どこで型が食い違っているかを絞り込むと早いです。
実務の落とし穴まとめ
anyを使う — 型チェックが伝染的に無効になるasで型エラーを消す — 実行時には何も変わらない- 外部データを検証しない — 型注釈は願望にすぎない
||でデフォルト値 —0や空文字が潰れる- await の付け忘れ — 型があれば検出できる
Promise.allを使わない — 無駄に直列で待つ
まとめ
- 型は実行前に間違いを見つける仕組み。実行時には消える
strict: trueは必須anyではなくunknown+ 型ガード。asで黙らせない- 外部データは境界で実行時検証(zod)。型注釈は保証ではない
??と||は違う。数値・文字列のデフォルトには??- 判別可能ユニオンであり得ない状態を書けなくする。
neverで網羅性チェック - 変更しないものは
readonly - 依存しない非同期は
Promise.allで並列に
章末問題
const timeout = input || 3000 と書きました。どんな問題がありますか。
読み込み状態を表す型として、より良いのはどちらですか。
コンパイルエラーを消すために as User と書きました。何が起きますか。
次の章では、バックエンドで使う Go を扱います。 TypeScript とはかなり違う考え方の言語です。