LoginSignup
4
1

More than 1 year has passed since last update.

TypeScriptで「最低一つは必須なオプションオブジェクトの型」を読みやすくしてみる

Last updated at Posted at 2021-06-06

「弱い TypeScripter」は、「強い型付け」に惹かれつつもそれを自分で作ることはできない。

それは能力的な制約だったり時間的な制約だったりするのだが、「集合知」というものは偉大なため、問題を自分で型付けられなかったとしても Qiita などの技術記事から「強い TypeScripter」の叡智に与ることができる。

例えば「オブジェクトのいくつかあるプロパティのうち、最低一つは必須であるような型」を付けたいときは、@uhyo 氏の『TypeScriptで最低一つは必須なオプションオブジェクトの型を作る』からコピペして1使えばいい。

最低一つは必須なオプションオブジェクトの型
type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T ? PartialRequire<T, K> : never;
type PartialRequire<O, K extends keyof O> = {
    [P in K]-?: O[P]
} & O;

もちろん「コピペして使えばいい」とは言っても、コードの中身を理解せずに貼り付けるのはなかなか怖いものがある。

そんなときはちゃんと解説を読んで理解する......ことが出来れば問題ないわけだが、TypeScripter として弱いとどうしても理解が曖昧になったりしてしまう。

そうなると実際に型関数に対してテストを書いてみて挙動を見てみるしかない。

generics.test.ts
import type { RequireOne } from 'lib/types/generics';

type BaseOptions = {
    foo?: number,
    bar?: number,
    baz?: string,
};

test('RequireOne<T>', () => {
    // One of 'foo', 'bar', 'baz' is required at least.
    type Options = RequireOne<BaseOptions>;

    // @ts-expect-error
    const opt0 : Options = {};
    const opt11: Options = { foo: 3 };
    const opt12: Options = { bar: 3 };
    const opt13: Options = { baz: '' };
    const opt21: Options = {         bar: 5, baz: 'foo' };
    const opt22: Options = { foo: 5,         baz: 'foo' };
    const opt23: Options = { foo: 5, bar: 2,            };
    const opt3 : Options = { foo: 1, bar: 3, baz: 'foo' };
});

test('RequireOne<T, K>', () => {
    // One of 'foo', 'bar' is required at least
    type NumOptions = RequiredOne<BaseOptions, 'foo' | 'bar'>;

    // @ts-expect-error
    const opt0 : NumOptions = {};
    const opt11: NumOptions = { foo: 3 };
    const opt12: NumOptions = { bar: 3 };
    // @ts-expect-error
    const opt13: NumOptions = { baz: '' };
    const opt21: NumOptions = {         bar: 5, baz: 'foo' };
    const opt22: NumOptions = { foo: 5,         baz: 'foo' };
    const opt23: NumOptions = { foo: 5, bar: 2,            };
    const opt3 : NumOptions = { foo: 1, bar: 1, baz: 'foo' };
});

なんとなくではあるものの、ある程度網羅的に使用例に触れて挙動が理解できた気がする。テストもできたし、StoryBook 的な使用例もついてくる。

完璧だ。これで安心してコピペできる!

おっと、foobarstring 型を代入することは可能だろうか?

requireOne.test.ts
import type { RequireOne } from 'lib/types/generics';

type BaseOptions = {
    foo?: number,
    bar?: number,
    baz?: string,
};

test('RequireOne<T>', () => {
    // One of 'foo', 'bar', 'baz' is required at least.
    type Options = RequireOne<BaseOptions>;

    // @ts-expect-error
    const opt0 : Options = {};
    const opt11: Options = { foo: 3 };
    const opt12: Options = { bar: 3 };
    const opt13: Options = { baz: '' };
    const opt21: Options = {         bar: 5, baz: 'foo' };
    const opt22: Options = { foo: 5,         baz: 'foo' };
    const opt23: Options = { foo: 5, bar: 2,            };
    const opt3 : Options = { foo: 1, bar: 3, baz: 'foo' };

    // @ts-expect-error
    const omg11: Options = { foo: 'str' };
    // @ts-expect-error
    const omg12: Options = { bar: 'str' };
    // @ts-expect-error
    const omg13: Options = { baz: 3 };
    // @ts-expect-error
    const omg3 : Options = { who: 1, bar: 3, baz: 'who?' };
});

test('RequireOne<T, K>', () => {
    // One of 'foo', 'bar' is required at least
    type NumOptions = RequireOne<BaseOptions, 'foo' | 'bar'>;

    // @ts-expect-error
    const opt0 : NumOptions = {};
    const opt11: NumOptions = { foo: 3 };
    const opt12: NumOptions = { bar: 3 };
    // @ts-expect-error
    const opt13: NumOptions = { baz: '' };
    const opt21: NumOptions = {         bar: 5, baz: 'foo' };
    const opt22: NumOptions = { foo: 5,         baz: 'foo' };
    const opt23: NumOptions = { foo: 5, bar: 2,            };
    const opt3 : NumOptions = { foo: 1, bar: 1, baz: 'foo' };

    // @ts-expect-error
    const omg11: NumOptions = { foo: 'str' };
    // @ts-expect-error
    const omg12: NumOptions = { bar: 'str' };
    // @ts-expect-error
    const omg21: NumOptions = { foo: 3,         baz: 3 };
    // @ts-expect-error
    const omg22: NumOptions = {         bar: 3, baz: 0 };
    // @ts-expect-error
    const omg3 : NumOptions = { who: 1, bar: 3, baz: 'who?' };
});

・・・・・・ふぅ。

このくらいでいいだろうか。

しかし、こうなってくると他に考慮漏れはないか不安になってくる。ブラックボックステストでは限界がある。

そもそも元の型定義さえサラッと読めればこんなことに悩まなくてもいいのに(嘘

そう、元の型定義さえサラッと読めれば――

そうか、リファクタリングだ!

本論

さて、もとのコードを思い出そう。

最低一つは必須なオプションオブジェクトの型
type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T ? PartialRequire<T, K> : never;
type PartialRequire<O, K extends keyof O> = {
    [P in K]-?: O[P]
} & O;

簡単なことからしていこう。

まずは説明変数的に見える PartialRequire の中身を RequireOne に直接書いてみる。

type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T
    ? { [P in K]-?: T[P] } & T
    : never;

これで動くだろうか。

テストはすでに書いていた2ので、結果を見てみよう。

・・・・・・うむ、大丈夫なようだ。

次は K extends keyof T が2回出てきているので、片方を削れないか試してみよう。

(私は Union Distribution3 を知っているので、型引数から削る)

type RequireOne<T, K = keyof T> =
    K extends keyof T
    ? { [P in K]-?: T[P] } & T
    : never;

テストも大丈夫なようだ。

だいぶスッキリしてきた。

簡単な整理は終わったので、次は難しいところに移ろう。

{ [P in K]-?: T[P] }

T のうち key が K に含まれているものを抜きだして4さらに required (必須) にしている5

ここで Utility Types6 を思い出すと、「T のうち key が K に含まれているものを抜きだ」すのは Pick<T,K> と同じ動作であるし、「required (必須) に」するのは Required と同じことである。

もしかしてこう書けるのではないだろうか。

type RequireOne<T, K = keyof T> =
    K extends keyof T
    ? Required<Pick<T, K>> & T
    : never;

テストの結果を見ると大丈夫なようだ。

かなり分かりやすくなった。

個人的には

Required<Pick<T, K>> & T

よりも

Required<Pick<T, K>> & Omit<T, K>

のほうが理解しやすいがどうだろうか。

型引数の制限はあったほうがいいだろうから戻しておこう。

type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T ? Required<Pick<T, K>> & Omit<T, K> : never;

最低一つは必須なオプションオブジェクトの型」について、テストを書き、リファクタリングをして、以下のように簡潔にした。

before
type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T ? PartialRequire<T, K> : never;
type PartialRequire<O, K extends keyof O> = {
    [P in K]-?: O[P]
} & O;
after
type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T
    ? Required<Pick<T, K>> & Omit<T, K> // Omit<T, K> は単に T でもよい
    : never;

蛇足だが、T[K] が optional な型でないときはこうすれば良い

type RequireOne<T, K extends keyof T = keyof T> =
    K extends keyof T
    ? Required<Pick<T, K>> & Partial<Omit<T, K>>
    : never;
  1. 権利的に心配なコードをコピペするときはライセンスをよく読もう。私は私のあとにコードを触る者が「極北のTypeScripter」の影響を受けてほしい(一度は理想論⊆極論に触れてほしい)ので引用元を示すためにコード内のコメントで記事へのリンクを貼っている。

  2. リファクタリングをするときはまずテストを書けということが Kent Beck 著, 和田 卓人 訳『テスト駆動開発』オーム社 や、 David Scott Bernstein 著, 吉羽 龍太郎, 永瀬 美穂, 田 騎郎, 有野 雅士 訳『レガシーコードからの脱却 ―ソフトウェアの寿命を延ばし価値を高める9つのプラクティス』O'Reilly Japan、 Martin Fowler 著, 児玉 公信, 友野 晶夫, 平澤 章, 梅澤 真史 訳『リファクタリング 既存のコードを安全に改善する(第2版)』オーム社 などに書かれている。鉄則である。

  3. Union Distribution については『TypeScriptの型初級#conditional-typeにおけるunion-distribution』に詳しく書いてある。公式ドキュメントはこちら「TypeScript: Documentation - TypeScript 2.8 #Distributive Conditional Types

  4. [P in K]: T[P] のような in の使い方に関しては『TypeScript の"is"と"in"を理解する#in』を参照。TypeScript 公式はこちら「TypeScript: Documentaion - Mapped Types

  5. key?: valuekey を optional にできるように、mapped types についても {[P in keyof T]?: T[P]}Partial<T> と同じ型が得られる。また {[P in keyof T]-?: T[P]}Required<T> と同じ型となる。これは mapping modifier と呼ばれる機能で ?readonly に対して使える。「TypeScript: Documentaion - Mapped Types

  6. Utility Types なんて覚えてないよという読者は『【TypeScript】Utility Typesをまとめて理解する』が簡潔にまとまっているのでブックマークしておくといい。公式ドキュメントは「TypeScript: Documentation - Utility Types」にある。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1