🎄

Pick<T, K> / TypeScript一人カレンダー

2022/12/16に公開約6,800字

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の6日目です。昨日は『Parameters<T>, ConstructorParameters<T>』を紹介しました。

Pick<T, K>

本日紹介するのはPick<T, K>です。Utility TypesとしてTypeScript 2.1で追加されました。

https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys

その名の通り、Tに渡したオブジェクトの型から、Kに指定したプロパティのみを抽出して得ることができます。実際のコードでみてみましょう。

type Obj = {
  a: string;
  b: number;
  c: boolean;
};

type T11 = Pick<Obj, "a">;
//   ^? { a: string; }

type T12 = Pick<Obj, "b">;
//   ^? { b: number; }

type T13 = Pick<Obj, "a" | "c">;
//   ^? { a: string; c: boolean; }

type T14 = Pick<Obj, "z">;
// Error: Type '"z"' does not satisfy the constraint 'keyof Obj'.(2344)

type T15 = Pick<Obj, keyof Obj>;
//   ^? { a: string; b: number; c: boolean; }

T11ではObj型のaプロパティのみを持つオブジェクト型{ a: string; }を得られます。T12も同様で、bを指定したので{ b: number; }が得られます。

複数指定したい場合はUnion Typesを使います。T13のように"a" | "c"と指定すると、a, cを備えたオブジェクト型を得られることがわかります。T14では存在しないプロパティ"z"を指定しましたが、この場合、Obj型にはzプロパティが備わっていないとしてエラーになります。

keyof Type Operator

Objがどういうプロパティを備えているかはkeyof Objとして得られます。keyof Type Operatorのドキュメントも参照してください。ここでkeyof Obj"a" | "b" | "c"のことですので、Objから"a" | "b" | "c"を持ったオブジェクト型を得る、すなわち同じものになります。あえてPick<Obj, keyof Obj>のような書き方をすることは通常ありませんが、keyofの挙動として紹介しました。

どう使う?

業務でPick<T, K>をどう使うかですが、筆者は頻繁に使用しています。例を2つ紹介します。

例1

まずはNext.jsにて、バックエンドのAPIハンドラーを実装するときです。

import { type NextApiRequest, type NextApiResponse } from "next";

function handler(req: NextApiRequest, res: NextApiResponse): void {
  res.status(200);
}

一般的に、TypeScriptでNext.jsのhandler()関数を実装するときはこのような引数型アノテーションを使います。それが気になり始めるのはテストを実装するとき。テスト・フレームワークにはJestを使っています。

describe("handler()", () => {
  test("200 を返す", () => {
    const statusSpy = jest.fn();

    const req: any = {};
    const res: any = { status: statusSpy };

    handler(req, res);

    expect(statusSpy.mock.calls[0]?.[0]).toEqual(200);
  });
});

anyが出てきてしまいました。ちゃんと型をつけなければなりません。ということで引数と同じくNextApiRequest, NextApiResponseを指定しましょう。

const req: NextApiRequest = {};
const res: NextApiResponse = { status: statusSpy };

今度はreq変数に50種類以上のプロパティが足りないと言われてしまいました。テストにreqは関係ないのに…です。じゃあNextApiRequestと互換なモックを作るかというと、手間がかかりすぎてしまいます。

多くの開発者はこうするのではないでしょうか。

const req = {} as unknown as NextApiRequest;
const res = { status: statusSpy } as unknown as NextApiResponse;

たしかにas unknown asをつけるとコンパイラのエラーを出さないようにすることはできます。しかし本当にそれでいいのでしょうか?as unknown asはコンパイラを欺いているに過ぎません。これを使ったところで「本来の型とは異なる値である」事実は何も変わりません。そのためasを使うのはanyを使うことと同じくらい慎重になるべきです。

さてここでPick<T, K>の出番です。

モックを偽装するのではなく、引数側を「必要なものだけ」の指定にすればよいのです。つまり関数の宣言は次のようになります。

import { type NextApiRequest, type NextApiResponse } from "next";

function handler(
  req: Pick<NextApiRequest, "query" | "body">,
  res: Pick<NextApiResponse, "status">
): void {
  res.status(200);
}

例ではreqを全く使っていませんが、実際の業務では大半の状況でquerybody、あるいは両方を使うと思います。もちろんheadersを指定に加えても構いません。とにかく、使うものだけをPick<T, K>Kに指定するのです。resも同様です。ここではstatusを指定しています。

続いてテスト側のreqモックやresモックの変数アノテーションも工夫します。こちらにもPick<NextApiRequest, "query" | "body">と書いてしまうとこの箇所がDRYに反するため、Parameters<typeof handler>[0]を使って参照できるようにします。

type ResStatus = Parameters<typeof handler>[1]["status"];

describe("handler()", () => {
  test("200 を返す", () => {
    const statusSpy = jest.fn<ReturnType<ResStatus>, Parameters<ResStatus>>();

    const req: Parameters<typeof handler>[0] = { query: {}, body: {} };
    const res: Parameters<typeof handler>[1] = { status: statusSpy };

    handler(req, res);

    expect(statusSpy.mock.calls[0]?.[0]).toEqual(200);
  });
});

Parameters<typeof handler>[1]["status"]は長いため、type ResStatusとしてType Aliasを宣言するのもよいです。

とまぁ、これはちょっと過剰な例ではありますがas unknown asを使わずに徹底的に型をつけるということは可能なのです。実際の案件では、参加メンバーのスキルに応じてasを局所的に許容する、例えばテストコードでのみ許容するなどの柔軟さはあってもよいかもしれませんが、まずはasを使わずにも書けるということを覚えておくとよいでしょう。

Pick<T, K>は特にテストを書く際にテスト対象の依存を限定する効果があるため、筆者は積極的に使っています。

例2

もうひとつはモデリングでの例です。筆者の過去に関わった案件では、名前のよく似たUserがいくつもあったものです。

type User = {
  id: string;
  name: string;
  // いくつかのプロパティがある
};

type UserDetail = {
  id: string;
  name: string;
  // いくつかのプロパティがある
  // なんかたくさんのプロパティがある
  // いっぱいぶら下がってる…
};

type SimpledUser = {
  id: string;
  name: string;
};

サンプルコードのための具体的なプロパティ名を思い出すこともできないくらい当時複雑だったので、ここでは省略していますが、Userなのかそうではないのかといった怪しい型がたくさんあったりしました。

type User = {
  id: string;
  name: string;
  // いくつかのプロパティがある
};

// 別のファイルにて
type User = {
  userId: string;
  name: string;
  // 別のよくわからないプロパティがある
};

このように「idなのかuserIdなのか、どっち?」のような状況があったりすると、非常に怪しい空気になってしまいます。こういった状況は5年ほど前筆者が実際に体験してきたものです。

だいたいこういった状況は、TypeScriptにあまり理解のない開発者がまずUserを作り、その後機能追加でどうにもいかなくなりUserDetailを作り、別のコンポーネント用にSimpledUserを作り…、といった負の連鎖によって生まれがちです。新たに追加されるプロパティがひたすら?:でオプショナル扱いになっていたり、あるいは大量の| nullが足されていたり…。経験ある読者もおられるのではないでしょうか。

TypeScriptも誕生から時が経ち、今はこういった型定義を書く開発者も減ってきた…とは思いたいものですが、長年蓄積されたTypeScriptコードなどではまだまだ怪しい型定義に溢れていることと思います。

こういう状況ではまず何よりもモデリングをちゃんと考える、そして必要ないプロパティはむやみに生やさないということが挙げられます。もし、とあるAPI /users GET から得られるUserオブジェクトにたくさんプロパティが生えていたとしても、コンポーネントに全量持ち運ぶ必要はないのです。

例えば、名前と顔写真を表示する小さなコンポーネントがあったとしたら次のように定義します。

type Props = {
  user: Pick<User, "name" | "avatarPath">;
};

もちろん{ user: { name: string; avatarPath: string; } }のようにネストしたオブジェクト型で書いても間違いではないのですが、User型として定義されているもっと大きな構造体からnameavatarPathだけがほしいということを示したいのであれば、個別に記述するのではなくPick<T, K>を使ってUserと関連があると示すことをおすすめします。iduserIdといった表記揺れを防ぐ観点でも、大元のUserからPickするほうがより安全です。

そして、次のように書くことは全く問題ありません。User型との依存すら絶って更に抽象化を求めた場合こうなるはずです。

// 問題なし
type Props = {
  name: string;
  avatarPath: string;
};

// さらに追求するとこうなる
type Props = {
  label: string;
  path: string;
};

これは状況や好みによるところも大きいですが、すでに大勢の開発者によって開拓されきったレガシー案件などでは、手元に残された環境から「少しでもましな方向」に進む判断をすべくPick<T, K>を使うという選択をすることもあります。Pick<T, K>は何もレガシー環境のために用意された型ではありませんが、依存の芋づるを断ち切り依存をシンプルにしていくという用途からみて、レガシー環境改善との相性はよいです。

明日は『Omit<T, K>

プロパティもimport文も多ければ多いほど依存が複雑でありテストしにくいものです。Pick<T, K>を使って余分なものは扱わないようにしてシンプルな依存関係、シンプルなテストコードを心がけるとよいでしょう。明日はPick<T, K>とは逆の効果を持つOmit<T, K>を紹介します。それではまた。

Discussion

ログインするとコメントできます