Pick<T, K> / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の6日目です。昨日は『Parameters<T>
, ConstructorParameters<T>
』を紹介しました。
Pick<T, K>
本日紹介するのはPick<T, K>
です。Utility TypesとしてTypeScript 2.1で追加されました。
その名の通り、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
を全く使っていませんが、実際の業務では大半の状況でquery
かbody
、あるいは両方を使うと思います。もちろん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
型として定義されているもっと大きな構造体からname
とavatarPath
だけがほしいということを示したいのであれば、個別に記述するのではなくPick<T, K>
を使ってUser
と関連があると示すことをおすすめします。id
やuserId
といった表記揺れを防ぐ観点でも、大元の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