🎄

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

2022/12/19に公開約6,500字

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

Omit<T, K>

本日紹介するOmit<T, K>はTypeScript 3.5で追加されたUtility Typesです。

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

説明は簡単で昨日紹介したPick<T, K>とは逆の効果というものなのですが、誕生時期がPick<T, K>が2.1なのに対してこちらは3.5と、時期がけっこう離れています。このように似たような作用を持つUtility Typesだとしても長い時間をかけて少しずつ揃っていったというのがTypeScriptの面白さでもあります。

では具体的な挙動について確認しましょう。参考にPick<T, K>も並べてあります。

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

type T11 = Omit<Obj, "a">;
//   ^? { b: number; c: boolean; d: string; }

type T12 = Omit<Obj, "b" | "c">;
//   ^? { a: string; d: string; }

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

type T22 = Pick<Obj, "b" | "c">;
//   ^? { b: number; c: boolean; }

このように、Pick<T, K>とは真逆でKに指定したプロパティを含めず残りのプロパティだけを備えたオブジェクト型を得られます。

Omit<Obj, keyof Obj>のようにすべてのプロパティを指定したら{}を得ることになります。また、Omit<Obj, "z">のようにObjが備えていないプロパティを指定してもPick<T, K>とは異なりエラー扱いにはなりません。

どう使う?

Pick<T, K>ともよく似たOmit<T, K>ですが、筆者はテストの実装で頻繁に使っています。そしてPick<T, K>との使い分けについても紹介します。

テストでは、モックを作成するときに一部だけ異なる値にしたいということがよくあります。例えば、通販アプリの買い物カゴに商品を入れるという処理でテストを書くとき、商品IDや商品名はダミーの値でよくて、価格と個数だけに関心があるといった状況があります。そんなとき、商品IDや商品名にダミーの値を入れた状態で先にモックを作っておくといったことをします。

まずは基本となるItem型を宣言します。

type Item = {
  id: string;
  name: string;
  path: string;
  description: string;
  price: number;
  count: number;
};

そしてテストコードを書き始める前に、モックを作成しておきます。

const item: Item = {
  id: "dummyId",
  name: "ダミー商品名",
  path: "//dummy/path/to",
  description: "ダミー商品説明文",
  price: 500,
  count: 1,
};

ではこのモックを使ってテストを書いていきましょう。買い物カゴの内容をラベルとして出力する関数makeItemLabel()を想定します。商品を引数にとり、stringを返却します。makeItemLabel()の実装の内容自体は重要ではないので省略します。

const item: Item = { /* 略 */ };

describe("makeItemLabel()", () => {
  test("商品が1つであれば商品名を金額を出力する", () => {
    expect(makeItemLabel(item)).toEqual("ダミー商品名 500");
  });

  test("商品が2つであれば商品名を金額と個数を出力する", () => {
    item.count = 2;
    expect(makeItemLabel(item)).toEqual("ダミー商品名 500 × 2");
  });

  test("商品が1000円以上であれば金額の3桁ごとにカンマを含める", () => {
    item.price = 1000;
    expect(makeItemLabel(item)).toEqual("ダミー商品名 1,000");
  });
});

パッと見、問題なさそうかもしれませんが大きな問題があります。item.count = 2;として既存のオブジェクトのプロパティを書き換えてはいけません。この例だと、3つ目のテストで{ price: 1000, count: 1 }を期待しているように書かれつつ、実際は2つ目のテストでcount2に上書きされてしまっており、"ダミー商品名 1,000 × 2"として出力されテストが失敗します。

これはテストによって実装誤りが暴かれたのではなく、テストコード自体が根本的に誤っているため非常にまずい。ということで、オブジェクトのプロパティはプロダクトコードでもテストコードでも、基本的に上書きを前提にしないのがよいです。

その点を克服すると、このような書き方が考えられます。const itemconst itemBaseに変更したことに注目してください。

const itemBase: Item = { /* 略 */ };

describe("makeItemLabel()", () => {
  test("商品が1つであれば商品名を金額を出力する", () => {
    expect(makeItemLabel(itemBase)).toEqual("ダミー商品名 500");
  });

  test("商品が2つであれば商品名を金額と個数を出力する", () => {
    const item: Item = { ...itemBase, count: 2 };
    expect(makeItemLabel(item)).toEqual("ダミー商品名 500 × 2");
  });

  test("商品が1000円以上であれば金額の3桁ごとにカンマを含める", () => {
    const item: Item = { ...itemBase, price: 1000 };
    expect(makeItemLabel(item)).toEqual("ダミー商品名 1,000");
  });
});

こうすることでオブジェクトのプロパティを書き換えずに、すべてのテストがグリーンになりました。

ですが、まだちょっと惜しい感じも残っています。1つ目のテストではitemBaseをそのまま渡しており、2つ目、3つ目でもcount: 2price: 1000を指定している一方で、それ以外のプロパティがどうなっているかはconst itemBaseになんの値が格納されているかを頭の片隅で覚えておく必要がでてきてしまいます。これではテストが通ったとしてもちょっとアンバランスなテストコードだと言えます。

優れたテストコードはいずれのテストも同じように読みすすめることができるテストです。そのため筆者はOmit<T, K>を使います。const itemBaseの変数型アノテーションに注目してください。

const itemBase: Omit<Item, "price" | "count"> = {
  id: "dummyId",
  name: "ダミー商品名",
  path: "//dummy/path/to",
  description: "ダミー商品説明文",
};

describe("makeItemLabel()", () => {
  test("商品が1つであれば商品名を金額を出力する", () => {
    const item: Item = { ...itemBase, price: 500, count: 1 };
    expect(makeItemLabel(item)).toEqual("ダミー商品名 500");
  });

  test("商品が2つであれば商品名を金額と個数を出力する", () => {
    const item: Item = { ...itemBase, price: 500, count: 2 };
    expect(makeItemLabel(item)).toEqual("ダミー商品名 500 × 2");
  });

  test("商品が1000円以上であれば金額の3桁ごとにカンマを含める", () => {
    const item: Item = { ...itemBase, price: 1000, count: 1 };
    expect(makeItemLabel(item)).toEqual("ダミー商品名 1,000");
  });
});

このようにテストで頻繁に変更したいpricecountだけを取り除いて、それ以外だけをベースとして宣言しました。こうすることで、テストでは毎回必ずpricecountに値を格納しないとconst item: Itemを満たさずにエラーとすることができます。プロパティの上書きではなくあえてプロパティの欠落を生むことで、モックの宣言とテスト内でのパラメータの格納を分離させつつ、互いに型安全であるようにしています。

price, countの格納順を揃えておくと、この例のように[500, 1], [500, 2], [1000, 1]というまとまりで視認でき、テストの仕様と結果の解釈がよりスムーズとなります。

Pick<T, K>との使い分け

ここで悩ましいのはPick<T, K>との使い分け、そしてコードレビュアーの立場になったときにどちらの使用を勧めるべきかという点です。正直Omit<T, K>Pick<T, K>の逆というそれ以上の情報がなく、使い分けには大いに「主観」が含まれてしまうという点は注意したいところです。ただ、英語の語彙としてomitおよびpickという単語から想起されるイメージというものがその主観に作用してしまうかもしれません。

いくつか判断基準はあります。

ひとつには全体のプロパティ数に対する列挙するプロパティ数で決める方法。10個から2個選ぶときに、わざわざOmit<T, K>Kに8つのプロパティを列挙するより、Pick<T, K>で2つ挙げる方が直感的です。この判断基準は比較的反対意見が少ないのではないかと予想します。

もうひとつの判断基準、これは筆者が推進している考え方なのですが、列挙数に関わらずプロダクトコードではPick<T, K>、テストコードではOmit<T, K>を使うという考え方です。

これはプロダクトコードの場合、取り除くというよりはすでに存在している大きなドメイン(モデル、概念、など)から一部だけを取り扱いたいという要望が現れやすく、先に概念全体を定義してから、部分的にかいつまんでいく方が概念的に理解しやすいという経験に基づいた判断です。選択より除去の色を出すと「除去されなかった残りの概念」がなんなのかという理解に対してワンテンポ遅れるような感覚を筆者は持っており、書かれている内容と最終的な結果が一致するPick<T, K>の方が直感的である印象を持っています。

一方でテストコードの場合は、本稿の例でも紹介したように部分的に差し替えるために落としたいといった「動的な」扱いをしばしばおこなうため、プロパティを落とすOmit<T, K>を使うようにしています。

そのため恒久的に定義として残るプロダクトコードと、動的に複数の値を試すためのテストコードによって使い分けるといったことをしています。この辺りは正解はなくbikeshedな議論であるため、開発者ごと、開発チームごとに理解が整ってさえいればよいと思います。

明日は『Extract<T, U>Exclude<T, U>

2日続けてPick<T, K>, Omit<T, K>を紹介してきました。これらはオブジェクトのプロパティを指定して操作するものです。明日はこれらのUnion型版とも言えるExtract<T, U>, Exclude<T, U>を紹介していきます。それではまた。

Discussion

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