ishikawa_pro's memorandum

若手webエンジニアの備忘録です.

Swift Node.js Docker AWS etc...色々やります。

TypeScriptで指定したfieldの値をキーとしたオブジェクトを作る

こんにちは。
珍しく1週間ただずの更新です。
最近はTypeScriptを結構書いていて、ちょっと知見もたまったのと、仕事が一段落してちょっと時間ができたので、今日はTypeScriptについて書きます。

指定したfieldの値をキーとしたオブジェクトを作る

普段の業務ではMongoDBを使っていたりする関係で、2つのコレクションから取ってきた複数のドキュメントをコードで、特定のfiledをキーにしてジョインしたいということが、たまにあります。

const books = [
    {author_id: 1, title: 'Coloring for beginners'},
    {author_id: 1, title: 'Advanced coloring'},
    {author_id: 2, title: '50 Hikes in New England'},
    {author_id: 2, title: '50 Hikes in Illinois'},
    {author_id: 3, title: 'String Theory for Dummies'},
    {author_id: 5, title: 'Map-Reduce for Fun and Profit'}    
];
const authors = [
    {id: 1, name: 'adam'},
    {id: 2, name: 'bob'},
    {id: 3, name: 'charlie'},
    {id: 4, name: 'diane'}
];

// ジョインしたい
cosnt joined = [
  {author_id: 1, title: "Coloring for beginners", name: "adam"},
  {author_id: 1, title: "Advanced coloring", name: "adam"},
  {author_id: 2, title: "50 Hikes in New England", name: "bob"},
  {author_id: 2, title: "50 Hikes in Illinois", name: "bob"},
  {author_id: 3, title: "String Theory for Dummies", name: "charlie"},
  {author_id: 5, title: "Map-Reduce for Fun and Profit", name: undefined},
]

ジョインするアプローチも色々あると思いますが、普段Node.jsでは、ジョインしたいドキュメントの配列から特定のフィールドの値をキーとしたオブジェクトを作成して、もう一方のドキュメントの配列をmapで回してジョインしていく、という方法を使ってました。

// { "1": {id: 1, name: 'adam'}, "2":  {id: 2, name: 'bob'}, ...} のようなオブジェクトを作る
const mapedObject = authors.reduce((ret, v) => {
  ret[v.id] = v;
  return ret;
}, {});

const res = books.map(b => {
  // bookのauthor_idを使って、紐づいているauthorを取得
  const a = mapedObject[b['author_id']];
  return {
    ...b,
    name: a ? a.name : undefined // left join的な感じ
  }
})

特定のキーでmapedObjectを作るのは、色々なところで使えるので

const toMap = (arr, key) => {
  return arr.reduce((ret, v) => {
    ret[v[key]] = v;
    return ret;
  }, {});
};

という感じで関数化してます。
今日はこれをTypeScript化するときに考えたことのまとめです。

さっきのNode.jsのコードをそのままTypeScript化しようとすると

const toMap = <T>(arr: T[], key: string):{[k: string]: T} => {
  return arr.reduce<{ [k: string]: T }>((ret, v) => {
    ret[v[key]] = v;
    return ret;
  }, {});
};

こんな感じにできそうですが、以下のようなエラーがでてコンパルが通りません。

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
  No index signature with a parameter of type 'string' was found on type 'unknown'.

v[key]T から値を取り出そうとしますが、 Tのindex signatureが宣言されていないので unknown型 になっており、v[key] のように直接的に値をとることができないためです。
この問題は、Tのindex signatureを宣言することで解決できます。

const toMap = <T extends {[k: string]: any}>(list: T[], key: string):{[k: string]: T} => {
  return list.reduce<{ [k: string]: T }>((ret, v) => {
    const a = v[key];
    ret[a] = v;
    return ret;
  }, {});
};

T extends {[k: string]: any} とすることでTのindex signatureをstringと宣言したのでコンパイルは通るようになりました。
しかし、keyがただのstringだとTが持っていないキーを指定できてしまうのがイケてないので、keyof を使ってもうちょっと工夫してみます。

const toMap = <T extends {[k: string]: any}>(arr: T[], key: keyof T):{[k: string]: T} => {
  return arr.reduce<{ [k: string]: T }>((ret, v) => {
    ret[v[key]] = v;
    return ret;
  }, {});
};

第2引数の型を keyof T とすることで、Tが持つものしか渡せなくなります。
また、こうすることでエディタもサジェストしてくれるようになります。

f:id:ishikawa_pro:20200628163656p:plain

Map Objectを使う

上記の方法でもいいと思いますが、Map Object版を考えて見ました。

const toMap = <T, K extends keyof T>(
  arr: T[],
  key: K
): Map<T[K], T> => {
    return arr.map<[T[K], T]>(e => [e[key], e]) // ['キーにしたい値', T] のtupleを作る
        .reduce<Map<T[K], T>>((acc, [k, v]) => { // Map Objectを作成 & set して returnする。
            acc.set(k, v);
            return acc;
        }, new Map<T[K], T>());
};

Map Objectを使うと、index signatureを気にしなくて済みます。
また、Map Objectの方が entries() , keys(), values() などのメソッドがあり、個人的にはobjectよりも色々扱いやすいのでMap Objectを使うのにハマってます。

まとめ

もうちょっと、色々なtipsを書こうと考えていましたが、書いてみると分量が多かったので今日はこれだけにします。
また時間があれば、ブログにまとめてみます。
最後までご拝読ありがとうございました。