Zeals TECH BLOG

チャットボットでネットにおもてなし革命を起こす、チャットコマース『Zeals』を開発する株式会社Zealsの技術やエンジニア文化について発信します。

TypeScriptでconditionベースのサブセットタイプを作成する

f:id:zeals-engineer:20191229220455p:plain Photo by Alvaro Reyes on Unsplash

究極の謎を解くためのタイピングシステムの詳細について

こちらの記事は翻訳記事となります。 原著者の許諾を得て翻訳・公開しております。

medium.com

TL; DR;

ソースコードを見てください。

この記事では、TypeScript 2.8の条件付き型およびマッピング型を実験します。 目標は、インターフェースから条件に一致しないキーを除外するタイプを作成することです。

マッピングタイプの詳細を知る必要はありません。

TypeScriptを使用すると、既存の型を取得し、それをわずかに変更して新しい型を作成できることを知っているだけで十分です。 これはTypeScriptのチューリング完全性の一部です。

型は関数と考えることができます。 入力として別の型を取り、いくつかの計算を行い、出力として新しい型を生成します。

Partial <Type> または Pick <Type、Keys> を聞いた場合、これがまさにその仕組みです。

📐問題を定義する

設定オブジェクトがあるとします。 ID、日付、関数など、さまざまなキーのグループが含まれています。

APIに由来するか、巨大になるまで何年もの間、さまざまな人々によって維持されます。 (これはよく起こりますよねww)

Promiseを返す関数のみ、または型番号のキーのようなより単純なものなど、特定の型のキーのみを抽出します。 たとえば、SubType <Base、Condition> のように、名前と定義が必要です。

SubTypeを構成する2つのジェネリックを定義しました。

  • Base:変更するインターフェイス
  • Condition:別のタイプ。これは、新しいオブジェクトに保持するプロパティを指定します

Input

テストのために、Personがあります。

これは、StringNumberFunctionなどのさまざまなタイプで構成されています。 これは、キーを除外する「巨大なオブジェクト」です。

interface Person {
    id: number;
    name: string;    
    lastName: string;
    load: () => Promise<Person>;
}

期待される結果

たとえば、文字列型に基づくPersonSubTypeは、文字列型のキーのみを返します。

// SubType<Person, string> 

type SubType = {
    name: string;
    lastName: string;
}

📈ステップバイステップのソリューション

Step 1 - ベースライン

最大の問題は、条件に一致しないキーを見つけて削除することです。

幸いなことに、TypeScript 2.8には条件付きの型が付属しています! ちょっとしたトリックとして、将来の計算のためにサポートタイプを作成してみましょう。

type FilterFlags<Base, Condition> = {
    [Key in keyof Base]: 
        Base[Key] extends Condition ? Key : never
};

全てのキーに、条件を適用します。 結果に応じて、名前をタイプとして設定するか、neverを設定します。

これは、新しいタイプでは表示したくないキーのフラグです。

ちなみに、 neverは特別なタイプで、anyタイプの反対です。 neverは他のneverにのみ割り当てられるので、他のneverを除いて、neverに割り当てることはできません。

このコードがどのように評価されるか見てください:

FilterFlags<Person, string>; // Step 1

FilterFlags<Person, string> = { // Step 2
    id: number extends string ? 'id' : never;
    name: string extends string ? 'name' : never;
    lastName: string extends string ? 'lastName' : never;
    load: () => Promise<Person> extends string ? 'load' : never;
}

FilterFlags<Person, string> = { // Step 3
    id: never;
    name: 'name';
    lastName: 'lastName';
    load: never;
}

注:idは値ではなく、stringのより正確なバージョンです。 これは後で使用します。

stringidタイプの違い:

const text: string = 'name' // OK
const text: 'id' = 'name' // ERR

step2 -タイプ条件に一致するキーのリスト

この時点で、重要な作業を完了しました!

これで、新しい目的があります。 検証に合格したキーの名前を収集します。

SubType <Person、string> の場合、'name' | 'lastName'ようになります。

type AllowedNames<Base, Condition> = 
        FilterFlags<Base, Condition>[keyof Base]

前のステップのコードを使用して、もう1つの部分だけを追加しています:[keyof Base]

これが行うことは、与えられたプロパティの最も一般的なタイプを収集し、決して無視しないことです(いずれにしても使用できないため)。

type family = {
    type: string;
    sad: never;
    members: number;
    friend: 'Lucy';
}

family['type' | 'members'] // string | number
family['sad' | 'members'] // number(無視されない)
family['sad' | 'friend'] // 'Lucy'

上記では、string | numberを返す例があります。

では、どのようにして名前を取得できますか? 最初のステップでは、キーのタイプをその名前に置き換えました!

type FilterFlags = {
    name: 'name';
    lastName: 'lastName';
    id: never;
}

AllowedNames<FilterFlags, string>; // 'name' | 'lastName'

ゴールに徐々に近づいています。


これで、最終オブジェクトを作成する準備が整いました。 Pickを使用するだけです。

Pickは、指定されたキー名を繰り返し処理し、新しいオブジェクトに関連付けられたタイプを抽出します。

type SubType<Base, Condition> = 
        Pick<Base, AllowedNames<Base, Condition>>

Pickは組み込みのマッピングされたタイプであり、2.1以降のTypeScriptで提供されています。

Pick<Person, 'id' | 'name'>; 
// equals to:
{
   id: number;
   name: string;
}

🎉完全なソリューション

すべてのステップを要約して、SubType実装をサポートする2つのタイプを作成しました。

type FilterFlags<Base, Condition> = {
    [Key in keyof Base]: 
        Base[Key] extends Condition ? Key : never
};

type AllowedNames<Base, Condition> = 
        FilterFlags<Base, Condition>[keyof Base];

type SubType<Base, Condition> = 
        Pick<Base, AllowedNames<Base, Condition>>;

注:これはシステムコードを入力するだけです。ループを作成し、ifステートメントを適用できる可能性があることを想像できますか?

一部の人々は、1つの式内に型を持つことを好みます。

type SubType<Base, Condition> = Pick<Base, {
    [Key in keyof Base]: Base[Key] extends Condition ? Key : never
}[keyof Base]>;

🔥 使い方

  1. JSONからプリミティブキータイプのみを抽出します
type JsonPrimitive = SubType<Person, number | string>;

// equals to:
type JsonPrimitive = {
    id: number;
    name: string;
    lastName: string;
}

// Let's assume Person has additional address key
type JsonComplex = SubType<Person, object>;

// equals to:
type JsonComplex = {
    address: {
        street: string;
        nr: number;
    };
}
  1. 関数を除くすべてを除外します
interface PersonLoader {
    loadAmountOfPeople: () => number;
    loadPeople: (city: string) => Person[];
    url: string;
}

type Callable = SubType<PersonLoader, (_: any) => any>

// equals to:
type Callable = {
    loadAmountOfPeople: () => number;
    loadPeople: (city: string) => Person[];
}

他の素晴らしいユースケースを見つけたら、ぜひコメントで教えてください!


🤔 この解決策で解決できないこと

  1. Nullableサブタイプを作成することです。

文字列| nullはnullに割り当てることができず、機能しません。 あなたがそれを解決するアイデアをお持ちの場合は、コメントでお知らせください!

// expected: Nullable = { city, street }
// actual: Nullable = {}

type Nullable = SubType<{
    street: string | null;
    city: string | null;
    id: string;
}, null>
  1. ランタイムフィルタリング...コンパイル時に型が消去されることに注意

実際のオブジェクトには何もしません。 同じ方法でオブジェクトを除外する場合は、JavaScriptコードを記述する必要があります。

また、実行時の結果が特定のタイプと異なる場合があるため、このような構造でObject.keys()を使用することはお勧めしません。


サマリ

おめでとう! 今日は、実際に条件とマップされたタイプがどのように機能するかを学びました。

しかし、さらに重要なことは、謎を解決することに焦点を合わせていることです。

複数のタイプを1つに組み合わせるのは簡単ですが、不要なキーからタイプを除外するべきでしょうか? 今ならわかるはずです 💪

TypeScriptの習得は簡単ですが、習得は難しい方が良いでしょう。

私は日々の業務で出てきた問題を解決する新しい方法を常に発見しています。 フォローアップとして、ドキュメントの高度なタイピングページを読むことを強くお勧めします。

この投稿は、StackOverflowに触発されました。 あなたが問題解決が好きなら、Dynatraceが何をしているかにも興味があるかもしれません。