TypeScriptで深いJSON構造から要素を取り出すときに型をちゃんと取るTIPS

このブログは個人ブログと同じ内容です

kotamat.com

下記のような多段のマスターデータが存在しているときに、ちゃんと型安全に値を取り出したいとなったときの型定義を考える

large.json

{
  "1": "foo",
  "2": "baz"
}

detail.json

{
  "1": {
    "1001": "foo1",
    "1002": "foo2"
  },
  "2": {
    "2001": "baz1",
    "2002": "baz2"
  }
}

これに対して、下記のような参照をしたら、ちゃんと値を取りたい。

import large from "~/large.json";
import detail from "~/detail.json";

// anyもちゃんとした型に変更する
function getValue(largeKey: any, detailKey: any) {
  // hogeを型がちゃんとついてる状態で取得する
  const hoge = detail[largeKey][detailKey];
}

※ちなみに上記で import している large, detail は as const したような厳密な型定義になっていることを前提とする。

declare module "~/detail.json" {
  type Detail = {
    "1": {
      "1001": "foo1";
      "1002": "foo2";
    };
    "2": {
      "2001": "baz1";
      "2002": "baz2";
    };
  };
  const data: Detail;
  export default data;
}

Step1: 深さ指定して key の型を取れるようにする

https://qiita.com/KuwaK/items/587205867d333b705a41 を参考にさせていただき、下記のような型を作成する(Property2 を NthDepthProperty に変えているが、必要であればもとに戻します)

type NumMap = {
  3: 2;
  2: 1;
  1: 1;
};

type ValueOf<T> = T[keyof T];

export type NthDepthProperty<T, P extends keyof NumMap> = P extends 1
  ? keyof T
  : ValueOf<
      {
        [K in keyof T]: T[K] extends object
          ? NthDepthProperty<T[K], NumMap[P]>
          : never;
      }
    >;

簡単に説明すると、最大 3 段ネストするオブジェクトに対して、型引数 2 つ目に指定した階層に存在する key を Union Type にして返却するというもの。NumMap を増やせば当然いくらでもネストは可能だが、今回は 3 段のままで大丈夫なのでそのままにする

その上で元々のファイルを下記のように変えると、引数として望まれる型が適切に指定できる

import large from "~/large.json";
import detail from "~/detail.json";
import { NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>, // largeKey: "1" | "2"
  detailKey: NthDepthProperty<typeof detail, 2> // detailKey: ValueOf<{"1": "1001" | "1002"; "2": "2001" | "2002"}> = "1001" | "1002" | "2001" | "2002"
) {
  const hoge = detail[largeKey][detailKey];
}

largeKey は keyof でもいいんだけど、見た目的に揃えた

Step2: Union Type を Intersection に変える型を用意する

上記のままでも行けそうな感じはするが、下記のようなエラーが出てしまう

(parameter) detailKey: ValueOf<{
    1: "1001" | "1002";
    2: "2001" | "2002";
}>
型 'ValueOf<{ 1: "1001" | "1002"; 2: "2001" | "2002"; }>' の式を使用して型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。
  プロパティ '1001' は型 '{ "1001": "foo1"; "1002": "foo2"; } | { "2001": "baz1"; "2002": "baz2"; }' に存在しません。

これはdetail[largeKey] で取得した値の型が

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

となっており、例えばこのオブジェクトに対して"1001"という key を指定した場合、largeKey=2 で参照すると出てくる後方の型にマッチしない可能性があるためエラーになるかもしれないから。

回避策としては、下記のように丁寧に TypeGuard をして行くことも考えられるが、key が増えるたびに処理が増えてしまい、なんのための型定義をしているのかよくわからなくなってしまう。

if (largeKey === "1" && detailKey === "1001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
} else if (largeKey === "2" && detailKey === "2001") {
  const l = detail[largeKey];
  const hoge = l[detailKey];
}

ここで開発者は事前に largeKey と detailKey の組み合わせがほぼ確実に正しい形で渡って生きていることを知っている(そうでない場合は一旦想定しないで OK)とした場合、Union 型を Intersection 型に変える事によって解決する。 つまり

{
    "1001": "foo1";
    "1002": "foo2";
} | {
    "2001": "baz1";
    "2002": "baz2";
}

{
    "1001": "foo1";
    "1002": "foo2";
} & {
    "2001": "baz1";
    "2002": "baz2";
}

に変えてしまえば、"1001"でアクセスしたとしても確実に値を得られるということになる。

ここで、Intersection に変換する方法はこちらを参考に下記のような型で解決できる

export type UnionToIntersection<U> = (
  U extends any ? (k: U) => void : never
) extends (k: infer I) => void
  ? I
  : never;

あとはこんな感じでコードを変更すれば hoge がいい感じの型で取得できるようになる

import large from "~/large.json";
import detail from "~/detail.json";
import { UnionToIntersection, NthDepthProperty } from "./types";

function getValue(
  largeKey: NthDepthProperty<typeof large, 1>,
  detailKey: NthDepthProperty<typeof detail, 2>
) {
  const l = detail[largeKey];
  const hoge = (l as UnionToIntersection<typeof l>)[detailKey]; // hoge: "foo1" | "foo2" | "baz1" | "baz2"
}

おしまい