😊

TypeScriptでネストしたオブジェクトをPickするユーティリティ型を実装する

2023/10/07に公開2

モチベーション

はじめまして。TypeSciptが大好きなTecSocと言います。
名前のTとcとSはTypeSciptから来ています。

TypeScirptのユーティリティ型であるPickはご存じでしょうか?
オブジェクトの型から任意のプロパティだけを抽出してくれるもので
APIレスポンスの型などで
一部のプロパティしか使わない型を定義する場合に2重管理しなくて良くなるので非常に便利です。
しかし、このPickはシャローなオブジェクトでしか使うことができません。
例えば、

type Post = {
  id: number;
  content: string;
  user: {
    id: number;
    name: string;
    iconUrl: string;
  }
};

Postから以下のようなuser.idだけをPickした型を作ることはできません。

type PickedUserId = {
  user: {
    id: number;
  }
}

GraphQLのAPIなどで、このような「特定オブジェクトの特定プロパティ」だけ取得できる場合があり
DeepPick Utility Typesがあると重宝しそうな気がしたので実装してみました。

GraphQLの例:

クエリ

query {
  posts {
    user {
      name
    }
  }
}

レスポンス

{
  "data": {
    "posts": [
      {
        "user": {
	  "name": "TecSoc"
	}
      }
    ]
  }
}

とりあえず実装してみる

まずは愚直に実装してみます。
ユーティリティ型の引数として元となるオブジェクトの型と、そのオブジェクトにアクセスするプロパティのstringを引数に取ります。
そして、受け取ったプロパティの文字列型をドット(.)区切りでAとBに分割します。

type DeepPick<T extends Record<string, any>, U extends string> = U extends keyof T ?
  Pick<T, U>
: U extends `${infer A}.${infer B}` ?
  A extends keyof T ?
    B extends keyof T[A] ?
      { [K in A]: DeepPick<T[A], B> }
    : never
  : never
: never;

では実際に使ってみましょう。

type UserId = DeepPick<Post, "user.id">;
const pickedPost1: UserId = "tecsoc";
const pickedPost2: UserId = { user: { id: 1 } };
const userId = pickedPost2.user.id;

pickedPost1は型と違うのでエラーになっています。

userIdに代入したところ、number型になっています

一見完成したように見えます。上のコードを書きおわった時は私もそう思っていました。
では、複数のプロパティでPickしようとするとどうなるでしょうか?

type PickedPost = DeepPick<Post, 'user.id' | 'content'>;
const pickedPost3: PickedPost = {
  user: {
    id: "tecsoc"
  },
  content: "文字列"
}
const userId2 = pickedPost3.user.id;

エラーとなってしまいました。なぜでしょうか?
pickedPost3をホバーしてみると

{
    user: Pick<{
        id: number;
        name: string;
        iconUrl: string;
    }, "id">;
} | Pick<Post, "content">

という出力になっていました。
ユニオン型になっているのが原因のようです。
A | Bのユニオン型の場合AかBの型である必要がありAとBのプロパティをつまみ食いすることはできません

次の章で修正していきます。

ユニオン型に対応する(完成系)

ユニオン型に対応するため、繰り返し処理のようなことを行う必要があります。
その際に使ったテクニックを一部紹介・解説します。

  1. Conditional Types
    詳しくは解説しませんが型の世界のifみたいなもので、三項演算子で記述します。
    今回のコードでは何度も登場します。
  2. Inferring Within Conditional Types
    string型をリテラル型に推論しています。
  3. Mapped Typesを使い、ユニオン型を繰り返し処理する
  4. ユニオン型(& & keyof T)を使ってTのプロパティに存在しないものを除外する
  5. K extends readonly unknown[]を使いユニオン型であるかの判定をする
    Kに対して繰り返し処理をしたく、T[K][number]のようにすれば良いかと思いましたが
    Kがユニオン型でない場合にうまくいかなかったので
    ユニオン型であるかを判定し、DeepPickに再帰させることで繰り返し処理を実現しました。
    参考リンク
  6. その他
    細かいテクニックはソースコメントに書いてみました。
    以下、完成系のソースコードはGistに投稿しておいたのでご覧ください。

使ってみる

type UserNameAndId = DeepPick<Post, "user.id" | "user.name">;
const userNameAndId1: UserNameAndId = {
  user: {
    id: 1,
    name: "tecsoc"
  }
};

const userNameAndId2: UserNameAndId = {
  user: {
    id: 1,
    name: 2
  }
};

const userNameAndId3: UserNameAndId = {
  user: {
    id: 1,
    name: "tecsoc"
  },
  hoge: "hoge"
};

想定通り、1はエラーにならず。2と3はエラーになりました。

おわりに

頭とGoogle検索とChat GPT(ちなみに3.5です)を駆使したところ
無事にDeepPick型を実装することができました。
命名に対する指摘でも、処理に対する指摘でも、感想でも
なんでも良いので、何かしらの反応をお待ちしております。

GitHubで編集を提案

Discussion

ootideaootidea

タプル型を使って実装すると、もしかしたらこういう書き方ができるかもしれませんね。

type UserNameAndId = DeepPick<Post, ["user", "id" | "name"]>;
TecSocTecSoc

コメントありがとうございます!!参考になります