TypeScriptで注文ステータスを型で守ってみよう(総合演習)
ここまでの TypeScript レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。interface・型エイリアス・ユニオン型・ジェネリクス・クラス——すでに練習したものだけで組み立てます。
作るのは注文管理です。注文の一覧を表示し、状態ごとに数え、売上を集計して、支払い方法まで表示します。
ここで確かめてほしいのは「型に守ってもらう感覚」です。TypeScriptの値打ちは、書くのが楽になることではありません。間違ったコードが、そもそも書けなくなることです。この演習では、その場面をわざと何度も作ってあります。
このページではコードを実行していません。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。
表示している実行結果は本物です。このページを作るときに、実際に走らせて得た出力をそのまま載せています。
完成イメージ
最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。
===== 注文一覧 ===== A-1001 田中 12800円 支払済 A-1002 佐藤 4500円 入金待ち (ギフト包装) A-1003 鈴木 39800円 発送済 A-1004 高橋 2980円 キャンセル A-1005 伊藤 15600円 支払済 --- 状態別 --- 入金待ち: 1件 支払済: 2件 発送済: 1件 キャンセル: 1件 売上計: 68,200円 支払方法: VISA ****4242
STEP 1注文をinterfaceで表す
🎯 このステップのゴール: readonlyと省略可能なプロパティを使い分ける
まずデータの形を型として書きます。TypeScriptでは、ここが出発点です。
interface Orderで、注文1件が持つものを並べます。ここで2つの印を使い分けます。
readonly id: string——注文番号は一度決めたら変わりません。readonlyを付けておくと、うっかりorder.id = "..."と書いた瞬間にエラーになります。「変わらないもの」を型で宣言しておくと、変える気がなかったコードを守ってくれます。
note?: string——備考は無い注文のほうが多いので、?を付けて省略可能にします。
この?には副作用があります。型がstring | undefinedになるので、そのまま文字列として使おうとするとエラーになります。使う前に「あるかどうか」を確かめさせられる——面倒に見えて、これがundefinedによる事故を防いでいます。
const orders: Order[] = [...]と型を書いておくと、書き間違えた瞬間に赤線が出ます。totlと打ち間違えても、statusに無い値を入れても、実行する前に分かります。
注文件数: 5件 1件目: A-1001 田中 備考つき: 1件
STEP 2状態をリテラルのユニオン型で表す
🎯 このステップのゴール: 取りうる値を全部書き出し、switchで漏れなく分岐する
注文の状態をstringにしてはいけません。stringだと、何でも入ってしまうからです。
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled"——これがリテラルのユニオン型です。この4つ以外は代入できません。
効果は劇的です。status: "payed"と打ち間違えても、その場で赤線が出ます。stringのままだったら、この綴り間違いは本番で「なぜか支払済にならない」という形で現れます——原因を探すのに半日かかる種類の不具合です。
statusLabelのswitchにも仕掛けがあります。戻り値の型を: stringと書いておくと、4つのうち1つでもcaseを書き忘れたときに「戻り値が undefined になる経路がある」とエラーになります。
これがユニオン型のいちばんの価値です。あとで"refunded"という状態を足したとき、コンパイラが「ここも直せ」と全部教えてくれます。人間が探し回る必要がありません。
なおenumで書く方法もあります。ただし状態を表す文字列なら、リテラルのユニオンのほうが素直です——値がそのまま文字列なのでJSONに入れてもそのまま読め、実行時のコードも増えません。
A-1001 支払済 A-1002 入金待ち A-1003 発送済 A-1004 キャンセル A-1005 支払済
STEP 3売上に数える注文だけを絞り込む
🎯 このステップのゴール: 関数の型を意識して、filterに渡す条件を書く
売上として数えるのは支払済と発送済だけです。入金待ちはまだ入っていないし、キャンセルは入りません。
orders.filter((o) => o.status === "paid" || o.status === "shipped")——filterに渡しているのが関数です。
ここでoの型を書いていないのに注目してください。ordersがOrder[]だと分かっているので、TypeScriptがoはOrderだと推論します。だからo.と打った瞬間に、候補が全部出てきます。
型を書く場所を減らせるのがTypeScriptの設計です。入り口(ordersの型)さえきちんと書けば、そこから先は勝手に伝わっていきます。全部に型を書くのは、むしろ初心者にありがちな書きすぎです。
ここでo.status === "payed"と打ってみてください。ユニオン型にない値との比較は、エラーになります——「この比較は常にfalseです」と教えてくれます。ステップ2で型を絞っておいた効果です。
合計はfor...ofで足していきます。reduceを使う手もありますが、読みやすさで言えばfor...ofのほうが親切なことが多いです。
売上に数える注文: 3件 売上計: 68200円
STEP 4支払い方法を「判別可能なユニオン」で扱う
🎯 このステップのゴール: kindで分岐すると、そのcaseの中だけ型が絞られることを確かめる
支払い方法は種類によって持っている情報が違います。カードならブランドと下4桁、振込なら銀行名、代金引換なら何も要りません。
これを1つの型で表すのが判別可能なユニオンです。
要点は、全部にkindという同じ名前のリテラル型を持たせることです。{ kind: "card"; ... }と{ kind: "bank"; ... }——このkindが見分けるための札になります。
そしてswitch (payment.kind)で分岐すると、魔法のようなことが起きます。case "card":の中では、TypeScriptが「これはカードの形だ」と理解し、payment.brandとpayment.last4が使えるようになります。
これを「型の絞り込み」と呼びます。case "cash":の中でpayment.brandと書いてみてください——エラーになります。代金引換にブランドは無いのだから、当然です。
この仕組みがどれだけ有り難いか。もし全部を1つのinterfaceにまとめて、使わないフィールドを?で省略可能にしていたら——どの組み合わせが正しいのか、型からは何も分かりません。「カードなのにlast4が無い」というあり得ない値が作れてしまいます。
判別可能なユニオンは、あり得ない組み合わせを最初から作れなくします。TypeScriptを使う理由が、いちばんはっきり出る場面です。
VISA ****4242 みずほ銀行 振込 代金引換
STEP 5ジェネリクスで集計を汎用にする
🎯 このステップのゴール: 「何の配列でも合計できる」関数を、型を保ったまま書く
ステップ3で書いた合計処理を、注文以外にも使える形にします。
function sumBy<T>(items: T[], pick: (item: T) => number): number——この<T>がジェネリクスです。
読み方は「Tという型を後から決める」。itemsはTの配列で、pickはTを受け取って数値を返す関数。何の配列かは、呼ぶときに決まります。
sumBy(orders, (o) => o.total)と呼べば、TはOrderだと自動で決まります。だからo.と打つと注文のプロパティが候補に出ます。型を渡し忘れる心配はありません——推論されるからです。
ジェネリクスを使わない場合と比べてみてください。items: any[]と書けば同じように動きます。ただしo.totalが正しいかどうか、誰も確かめてくれません。o.totlと打っても通ってしまい、結果がNaNになります。
anyは「型検査をやめる」という意味です。使った瞬間、その先はTypeScriptを使っていないのと同じになります。ジェネリクスは、汎用性を捨てずに型を保つための道具です。
全注文の合計: 75680円 重さの合計: 5kg
STEP 6クラスにまとめて仕上げる
🎯 このステップのゴール: データと操作を1つにまとめ、外から触れる範囲を絞る
最後の仕上げです。ここまでの部品をOrderBookというクラスにまとめます。
private readonly orders: Order[]——2つの印を重ねています。privateはクラスの外から触れない、readonlyは中でも入れ替えない。
なぜここまで閉じるのか。外からbook.orders.push(...)ができてしまうと、このクラスが約束していることが崩れます。触れる入口をbillable()やrevenue()に絞れば、おかしな状態になりようがありません。
countByStatusではMap<OrderStatus, number>を使います。ただのオブジェクトでも数えられますが、Mapならキーの型まで縛れます——存在しない状態を数えようとすればエラーです。
counts.get(o.status) ?? 0の??はnull合体演算子です。getはキーが無いとundefinedを返すので、そのときだけ0にします。
||ではなく??を使う理由があります。||は0や空文字も「偽」と見なして右側を採ります。件数を扱うここでは、0を0のまま通したい——だから??です。この違いは数を扱うときに必ず効いてきます。
toLocaleString("ja-JP")で3桁ごとにカンマが入ります。padEndとpadStartで桁を揃えれば、表の形になります。
完成です。ordersに注文を足したり、statusを書き換えたりしてみてください。件数も売上も、全部連動して変わります。
そのうえでOrderStatusに"refunded"を足してみてください。コンパイラがstatusLabelと表示順の配列を指して「ここが漏れている」と教えてくれます。これがTypeScriptを使う手応えです。
===== 注文一覧 ===== A-1001 田中 12800円 支払済 A-1002 佐藤 4500円 入金待ち (ギフト包装) A-1003 鈴木 39800円 発送済 A-1004 高橋 2980円 キャンセル A-1005 伊藤 15600円 支払済 --- 状態別 --- 入金待ち: 1件 支払済: 2件 発送済: 1件 キャンセル: 1件 売上計: 68,200円 支払方法: VISA ****4242
よくある質問
このページではコードを実行しないのですか?
していません。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。
ただし表示している実行結果は本物です。このページを作るときに実際に走らせて得た出力を、そのまま載せています。
ユニオン型と enum は、どちらを使えばいいですか
状態を表す文字列なら、リテラルのユニオンが素直です。値がそのまま文字列なので、JSONに入れてもそのまま読めますし、実行時のコードが1行も増えません(enumは変換後にオブジェクトを生成します)。
enumが向くのは、連番の数値に名前を付けたいときや、値と名前を相互に引きたいときです。どちらでもよい場面では、ユニオンから始めて困ったら移る、で十分です。
?? と || は何が違いますか
左側を「無い」と見なす範囲が違います。
||は偽と見なせる値すべて——0・空文字""・falseも含めて——右側に切り替えます。
??はnullとundefinedのときだけ右側を採ります。
だから件数や金額のように0が正しい値になりうる場面では??です。counts.get(status) || 0と書いても今回はたまたま同じ結果ですが、0を意味のある値として扱う場面で必ず食い違います。
型を書くのが面倒です。どこまで書けばいいですか
入り口と出口だけで足りることが多いです。変数の宣言、関数の引数、関数の戻り値——ここに書いておけば、中の変数はほとんど推論されます。
実際この演習でも、filterやfor...ofの中で型を書いている箇所はありません。orders: Order[]と一度書いてあるだけで、そこから先へ伝わっていきます。
逆に関数の戻り値の型は書く価値があります。ステップ2で見たように、書き漏らしをコンパイラが見つけてくれるのは戻り値の型を書いているからです。
🚩 次はこのカテゴリへ
TypeScriptは最後にJavaScriptへ変換されて動きます。型はすべて消え、残るのはJavaScriptだけです。だから土台のJavaScriptを知っているほど、TypeScriptは扱いやすくなります。JavaScriptカテゴリの最終演習では、ブラウザで動くToDoアプリを作ります。
JavaScriptのレッスンへ進む