Result)を組み合わせられる売上データの JSON を読み込み、商品ごとの売上合計を降順で出力するスクリプトを作ります。
$ mkdir handson && cd handson
$ npm init -y
$ npm i -D typescript tsx @types/node
sales.json を用意します。
[
{ "date": "2026-01-15", "product": "ノート", "amount": 1200, "qty": 3 },
{ "date": "2026-01-16", "product": "ペン", "amount": 300, "qty": 10 },
{ "date": "2026-02-01", "product": "ノート", "amount": 800, "qty": 2 },
{ "date": "2026-02-03", "product": "消しゴム", "amount": 150, "qty": 5 },
{ "date": "2026-02-20", "product": "ペン", "amount": 450, "qty": 15 }
]
手を動かさずに答えだけ見たい場合
この課題の解答は docs/samples/handson/ に置いてあります(step1.ts と step4.ts、sales.json)。02. 実行環境で触れたとおり、npm i のあと npx tsx step4.ts で動きます。
ただし、まず自分で書いてみてください。この章の目的は完成品を読むことではなく、any を潰していく過程を体験することです。
$ npx tsx step4.ts
ノート: 2000 円
ペン: 750 円
消しゴム: 150 円
いきなり完成形を書こうとしないでください。
実務で TypeScript を導入する場面は、既存の JavaScript に型を足す形が大半です。この課題も同じ順序で進めます。まず動かし、次に型を付け、最後に検証とエラー処理を足します。
型を気にせず、JavaScript を書く要領で動かします。
// step1.ts
import { readFileSync } from "node:fs";
const text = readFileSync("sales.json", "utf8");
const sales = JSON.parse(text);
const totals: any = {};
for (const s of sales) {
totals[s.product] = (totals[s.product] ?? 0) + s.amount;
}
console.log(totals);
$ npx tsx step1.ts
{ 'ノート': 2000, 'ペン': 750, '消しゴム': 150 }
動きますが、検査は何も働いていません。
JSON.parse の戻り値は any なので、s.product も s.amount も any です(06)。s.prodcut と打ち間違えても、実行するまで気づけません。
s.product を s.prodcut に変えて実行し、エラーにならないことを確かめるnpx tsc --noEmit step1.ts を実行し、それでも通ることを確かめるデータの形を type で書き、集計結果を Map に変えます。
// step2.ts
import { readFileSync } from "node:fs";
type Sale = { date: string; product: string; amount: number; qty: number };
const text = readFileSync("sales.json", "utf8");
const sales = JSON.parse(text) as Sale[]; // ← まだ嘘をついている
function sumByProduct(sales: Sale[]): Map<string, number> {
const out = new Map<string, number>();
for (const s of sales) {
out.set(s.product, (out.get(s.product) ?? 0) + s.amount);
}
return out;
}
for (const [product, total] of sumByProduct(sales)) {
console.log(`${product}: ${total} 円`);
}
打ち間違いは検出できるようになりましたが、as Sale[] が残っています。
06 で見たとおり、as は何も検証していません。JSON の中身が違っていても通ってしまいます。段階3で外します。
sales.json の amount を "1200"(文字列)に書き換えて実行するas をやめ、unknown で受けて型ガードで確かめます(08)。
// step3.ts(抜粋)
function isSale(v: unknown): v is Sale {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.date === "string" &&
typeof o.product === "string" &&
typeof o.amount === "number" &&
typeof o.qty === "number"
);
}
const parsed: unknown = JSON.parse(text);
if (!Array.isArray(parsed)) {
throw new Error("配列ではありません");
}
const sales: Sale[] = [];
for (let i = 0; i < parsed.length; i++) {
const row: unknown = parsed[i];
if (!isSale(row)) {
throw new Error(`${i} 件目の形式が不正です`);
}
sales.push(row);
}
amount を文字列に変えて実行し、件数付きのエラーで止まることを確かめる例外を投げる代わりに Result を返し、呼ぶ側に対処を強制します(11)。
| 段階 | 状態 | 検出できるもの |
|---|---|---|
| 1 | any のまま | 何も検出できない |
| 2 | 型を付けたが as で嘘 | 打ち間違いだけ |
| 3 | 検証を入れた | データの不備も検出 |
| 4 | Result で返す | 呼ぶ側に対処を強制 |
// step4.ts
import { readFileSync } from "node:fs";
type Sale = { date: string; product: string; amount: number; qty: number };
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function isSale(v: unknown): v is Sale {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.date === "string" &&
typeof o.product === "string" &&
typeof o.amount === "number" &&
typeof o.qty === "number"
);
}
function loadSales(path: string): Result<Sale[]> {
let text: string;
try {
text = readFileSync(path, "utf8");
} catch {
return { ok: false, error: new Error(`ファイルを読めません: ${path}`) };
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: new Error(`JSON として読めません: ${path}`) };
}
if (!Array.isArray(parsed)) {
return { ok: false, error: new Error("配列ではありません") };
}
const sales: Sale[] = [];
for (let i = 0; i < parsed.length; i++) {
const row: unknown = parsed[i];
if (!isSale(row)) {
return { ok: false, error: new Error(`${i} 件目の形式が不正です`) };
}
sales.push(row);
}
return { ok: true, value: sales };
}
function sumByProduct(sales: Sale[]): Map<string, number> {
const out = new Map<string, number>();
for (const s of sales) {
out.set(s.product, (out.get(s.product) ?? 0) + s.amount);
}
return out;
}
const r = loadSales("sales.json");
if (!r.ok) {
console.error(`エラー: ${r.error.message}`);
process.exit(1);
}
for (const [product, total] of [...sumByProduct(r.value)].sort((a, b) => b[1] - a[1])) {
console.log(`${product}: ${total} 円`);
}
const missing = loadSales("nothing.json");
console.log(missing.ok ? "読めた" : `想定どおり失敗: ${missing.error.message}`);
$ npx tsx step4.ts
ノート: 2000 円
ペン: 750 円
消しゴム: 150 円
想定どおり失敗: ファイルを読めません: nothing.json
配列でない JSON を渡すと、次のように止まります。
$ npx tsx step4.ts # sales.json が { "not": "an array" } の場合
エラー: 配列ではありません
| 症状 | 原因 | 対処 |
|---|---|---|
TS2591: Cannot find name 'node:fs' | TS7 types の指定漏れ | tsconfig.json に "types": ["node"](02) |
parsed[i] が any になる | Array.isArray の後は any[] になる | const row: unknown = parsed[i]; と明示する |
out.get(...) が undefined かもしれない | strict Map.get は T | undefined を返す | ?? 0 で既定値を与える(04) |
process.exit の後もエラーになる | 型の上では処理が続く扱い | process.exit は never を返すので通常は不要。出なければ return を足す |
| ソートの結果が変わらない | Map を直接 sort できない | [...map] で配列にしてから sort する |
date の先頭7文字("2026-01")でまとめるamount / qty。qty が 0 のときの扱いを決めるdate が YYYY-MM-DD の形式か、amount が 0 以上かを確かめるISODate 型にする(05)isSale を zod などで書き直し、手書きとの差を比べるここまでで本編は終わりです。
実務で必要になったときは、付録から引いてください。エラーの意味が分からないときは A1. 逆引き、書きたい型が決まっているときは A2. 型レシピ集、処理ごとまとめて欲しいときは A3. 実務パターン集です。