TypeScript だけで安全に JSON 文字列内の値を読み取る
TypeScript で JSON 文字列をパースする記事は多々あれど、Narrowing で値を読み取る記事がなかったので…。
JavaScript で JSON 文字列をパースする
この記事における1「JavaScript で JSON 文字列をパースする」とは、JavaScript Object Notation (JSON) 形式で書かれた文字列をJavaScript の標準組み込みオブジェクト(JSON
)が持つ静的メソッド(JSON.parse()
)で構文解析して JavaScript オブジェクトを取得することです。
const jsonText = `
{
"name": "Minami Kotori"
}
`;
const jsObject = JSON.parse(jsonText);
console.log(jsObject.name); // Minami Kotori
TypeScript における JSON.parse()
TypeScript が提供している型宣言ファイル上は、JSON.parse()
の戻り値の型は any
です。
つまり、パースされた JavaScript オブジェクトは JSON 文字列に書かれていないデータも含めて任意のデータへアクセス出来ます。
ただし、TypeScript 上での型エラーが出ないだけであり、JavaScript 上での実行時エラーが発生する可能性はあります。危ない。
const jsonText = `
{
"name": "Minami Kotori"
}
`;
const jsObject = JSON.parse(jsonText);
// ⚠️ favoriteFood プロパティは存在しないが、any 型なので TypeScript はエラーを出さない
// JavaScript 上もエラーが出ない
console.log(jsObject.favoriteFood); // undefined
// 🔥 school プロパティが存在しないが、any 型なので TypeScript はエラーを出さない
// undefined の name プロパティにアクセスしようとするので、JavaScript 上で実行時エラーが発生する ☠️
console.log(jsObject.school.name); // Uncaught TypeError: Cannot read properties of undefined (reading 'name')
安全に値を読み取るためには、JSON.parse()
の戻り値をany
型以外にする必要があります。
JSON.parse()
が本当に返すもの
JSON.parse()
の戻り値の型は仕様で定義されています。
Object, Array, 文字列, 数値, 論理値, null 値のいずれかで、指定された JSON の text に対応する値です。
ref: MDN - JSON.parse()
console.log(JSON.parse("{}")); // {}
console.log(JSON.parse("[]")); // []
console.log(JSON.parse('"hoge"')); // "hoge"
console.log(JSON.parse("1")); // 1
console.log(JSON.parse("true")); // true
console.log(JSON.parse("null")); // null
これに従った変数宣言時に型アノテーションを書くとしたら…👇
const jsObject:
| object // オブジェクトか
| unknown[] // なにかの配列か (配列はオブジェクトの一種だけれど)
| string // 文字列か
| number // 数値か
| boolean // 論理値か
| null /* または、null */ = JSON.parse(jsonText);
こんなん、実質 👇 じゃん。
const jsObject: unknown /* 型分からん */ = JSON.parse(jsonText);
型が分からない値は uknown
型にします。
型が分からないまま特定の型を期待した使用 (プロパティにアクセスしたり、関数の引数に渡したり) をさせない状態が、安全です。
Narrowing する
unknown
な値に安全にアクセスするために、より具体的な型に絞り込んだ上で (Narrowing) 期待するプロパティにアクセスします。
const jsonText = `
{
"name": "Minami Kotori"
}
`;
// どんな型かまだ分からない
const jsObject: unknown = JSON.parse(jsonText);
if (
// jsObject が null 以外の object で
typeof jsObject === "object" &&
jsObject !== null &&
// かつ、jsObject が null 以外の object の school プロパティを持っていて
"school" in jsObject &&
typeof jsObject.school === "object" &&
jsObject.school !== null &&
// jsObject.school が string 型の name プロパティを持っている
"name" in jsObject.school &&
typeof jsObject.school.name === "string"
) {
// ...ということが分かって初めて安全に jsObject.school.name を参照することが出来る
console.log(jsObject.school.name);
}
そもそも JSON 文字列かどうかも怪しい
構文解析に失敗すると、JSON.parse()
はSyntaxError
を throw します。
そのため構文解析が成功する前提の実装 (const json: unknown = JSON.parse(jsonText);
) だと、まだ安全ではありません。
// JavaScript のオブジェクトとしては正しいけれど、JSON 文字列としては不正
const jsonText = `
{
name: "Minami Kotori"
}
`;
// 🔥 構文解析に失敗してエラーが throw される
const json: unknown = JSON.parse(jsonText);
JSON.parse()
の引数は string 型なので、構文解析が失敗する値が渡される可能性も十分考えられます。そこで、 JSON.parse()
を try/catch して必ず return する関数を実装してみます2。
パース時点では必ず unknown
な値を取得出来るので、
- JSON 文字列をパース出来たか
- JSON 文字列内に期待のデータが入っているか
を Narrowing で検証すれば、安全です。
const parseJson = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
return;
}
};
const jsonText = `
{
name: "Minami Kotori"
}
`;
// `jsonText` が何であれ、 unknown な値を受け取る
const json = parseJson(jsonText);
結論
「TypeScript だけで安全に JSON 文字列内の値を読み取る」ためには、
- try/catch で構文解析失敗を考慮しつつ
- 戻り値の型を
any
からunknown
に変更した上で - Narrowing する
必要があります。
(余談) その他の方法
型アノテーション/アサーションで安全にはならない
期待する型を予め定義して型アノテーション/アサーションで any
を上書きする方法を紹介する記事が Web 上にはありますが、ちょっと便利になるだけで安全にはならないです。
むしろ嘘の可能性も考慮するとより危険になっているので、不便になっているとも言える3。
下記を見る限り、ちょっと便利になっている気がしなくもない。
// 👇 の構造の JSON 文字列を期待している
interface SchoolIdol {
name: string;
school: {
name: string;
};
}
const jsonText = `{}`;
const jsObject: SchoolIdol = JSON.parse(jsonText);
// const jsObject = JSON.parse(jsonText) as SchoolIdol;
// ✨ SchoolIdol 型に favoriteFood プロパティはないので、TypeSciprt 上でエラーが発生する
console.log(jsObject.favoriteFood); // Property 'favoriteFood' does not exist on type 'SchoolIdol'.
// ✨ SchoolIdol 型に従って、school.name にアクセス出来る
console.log(json.school.name);
しかし上記 jsonText
の値は記事の都合で省略されているのではなく、本当に {}
になっているとしたらどうでしょう?
const jsonText = `{}`;
const jsObject: SchoolIdol = JSON.parse(jsonText);
// 🔥 school プロパティが存在しないが、any 型なので TypeScript はエラーを出さない
// undefined の name プロパティにアクセスしようとするので、JavaScript 上で実行時エラーが発生する ☠️
console.log(jsObject.school.name); // Uncaught TypeError: Cannot read properties of undefined (reading 'name')
JSON
型にキャストする
JSON.parse()
の戻り値の型は全く分からない訳ではなく、正確には JSON の型ではあります。そこで JSON 型を TypeScript で作って(参考: type-fest) 戻り値の型を上書きする方法もあります。
interface JSON {} // 省略
const parseJson = (text: string): JSON => {
return JSON.parse(text);
};
だだしプロパティにアクセスするために Narrowing することに変わりはないので、unknown
を使うパターンと違いがない…。
ライブラリを使う
https://zod.dev とか、https://valibot.dev とかあるけれど、実装規模を考慮した上で依存関係の追加を検討したいですね。