型を付けて事故を防げ
中段 / 目安 30分
凛さんが、いま本番で動いているコードを開きました。エラーは1つも出ていません。テストも緑です。それでも凛さんは険しい顔をしています。
「このコード、今日は動いています。明日は落ちます。売上の集計に 文字列の価格が混ざり得るからです」
見せられたのは、注文データを合計する関数でした。データの一部で価格が "1200" と文字列で入っています。JavaScript は 0 + "1200" を平気で計算し、答えは "01200" という文字列になります。エラーは出ません。間違った答えが、静かに出力されるだけです。これがいちばん怖い壊れ方です。
凛さんは続けます。「型注釈を付けてください。すると コンパイルの段階で赤が出ます。その赤は、いま画面に見えていない未来のバグです。そして——赤を見たら、直すところまでが仕事です。警報を鳴らして、鳴らしっぱなしで帰る人はいません」
型は事故を早く鳴らす警報です。警報が鳴る場所には、必ず原因があります。この問題では、まず型を付けて警報を鳴らし、次に データ側と処理側の両方を直して、最後に静かな状態でコンパイルを通してください。片方だけ直しても通りません。
直し方は2つ考えられます。データの型を「数値も文字列も来る」と正直に書いて、使う直前に数値へ変換する道。もう1つは、入口で正規化してしまい、以降は数値だけが流れる世界にする道です。この問題では後者を選んでください。凛さんの言葉では「汚れは入口で落とす。奥まで持ち込まない」です。
完成条件
注文データを受け取り、合計金額を返す関数を作ります。
受け取る1件は次の3つを持ちます。
name— 商品名 (string)price— 価格。外から来るデータなので、数値のことも文字列のこともありますcount— 個数。こちらも数値のことも文字列のこともあります
この「外から来た生のデータ」の形を RawOrder という interface として定義してください。price と count は number | string になります。
そのうえで次の2つを作ります。
toNumber(value: number | string): number— 入口の掃除係。数値ならそのまま、文字列なら数値に変換して返す。変換できない文字列 (""や"unknown"など) は 0 を返すtotalAmount(orders: RawOrder[]): number—toNumberを通してから価格 × 個数を足し上げ、合計を返す
合計は必ず number で返してください。any と as は使わないでください。
書けたら実験です。totalAmount の中で toNumber を通さず order.price * order.count と直接書いてみてください。number | string どうしは掛け算できないというエラーが出ます。それが凛さんの言う「未来のバグ」です。確かめたら戻してください。
進め方
白紙から書いてください。手が止まったらヒントを開けます。ヒントは3段階で、①方針、②使う構文、③部分解の順に出ます。1つも開けずに通せたら、その問題は自分のものです。
開かずに10分粘って進まないなら、①だけ開けてください。粘る時間そのものには価値がありません。価値があるのは、自分で書いた行が動いた瞬間です。
エラーが出たら、行番号を見てください。コンパイラは犯人の場所を正確に指しています。赤は敵ではありません。本番で落ちる前に教えてくれる、いちばん早い警報です。
要件
- interface RawOrder を定義する。name は string、price と count は number | string
- toNumber(value: number | string): number を定義する
- toNumber は数値ならそのまま返し、文字列なら数値に変換して返す
- 数値に変換できない文字列は 0 を返す
- totalAmount(orders: RawOrder[]): number を定義する
- totalAmount は toNumber を通してから 価格 x 個数 を足し上げること
- any と as を使わないこと
入出力例
totalAmount([{"count":2,"name":"深煎りブレンド","price":480},{"count":1,"name":"抹茶ラテ","price":580}]) → 1540
totalAmount([{"count":"2","name":"深煎りブレンド","price":"480"},{"count":"3","name":"抹茶ラテ","price":580}]) → 2700
totalAmount([{"count":4,"name":"カフェオレ","price":"unknown"},{"count":"","name":"チーズケーキ","price":480}]) → 0
totalAmount([{"count":2,"name":"自家製レモネード","price":"560"},{"count":"unknown","name":"季節のフルーツティー","price":620},{"count":"1","name":"深煎りブレンド","price":"480"}]) → 1600
totalAmount([]) → 0ヒント
前のヒントを開くと次が開きます。開かずに解けると未開封クリアです