feature image

2023年5月28日 | ブログ記事

@型パズル強者 type Start = {key: "one"; value: "1";} | {key: "two"; value: "2";} から type Goal = {one: "1"; two: "2"} を作ってください

type Start = {
    key: "one";
    value: "1";
} | {
    key: "two";
    value: "2";
} | {
    key: "three";
    value: "3";
}

// type Goal = {
//     one: "1";
//     two: "2";
//     three: "3";
// }
// が欲しい

type Goal = ???

答え

type Goal = {
    [K in Start['key']]: Extract<Start, {key: K}>['value']
}

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

動機

こんにちは、@d_etteiu8383です。traPではグラフィック班でデザインや3DCGをやっていたり、ゲーム班でゲームを作る素振りを見せていたりしています。

最近SysAd班にも所属しました。対戦よろしくお願いします。そのSysAd班で、現在NeoShowcaseというプロジェクトのフロントエンドに参加したのですが、タイトルにある型パズルをする必要が出てきました。

具体的には、Protocol Buffersにおいてoneofフィールドを持つmessageから、@bufbuild/protoc-gen-esを用いて自動生成された型が以下のような形になっていました。

message Auth {
  oneof auth {
    google.protobuf.Empty none = 1;
    AuthBasic basic = 2;
    AuthSSH ssh = 3;
  }
}
/**
 * @generated from message hoge.protobuf.Auth
 */
export class Auth extends Message<Auth> {
  /**
   * @generated from oneof hoge.protobuf.Auth.auth
   */
  auth: {
    /**
     * @generated from field: google.protobuf.Empty none = 1;
     */
    value: Empty;
    case: "none";
  } | {
    /**
     * @generated from field: hoge.protobuf.AuthBasic basic = 2;
     */
    value: AuthBasic;
    case: "basic";
  } | {
    /**
     * @generated from field: hoge.protobuf.AuthSSH ssh = 3;
     */
    value: AuthSSH;
    case: "ssh";
  } | { case: undefined; value?: undefined } = { case: undefined };

上記のように、authプロパティはユニオン型になっていましたが、caseをkeyとしたオブジェクトを作りたい場面が出てきました。

// これが欲しい
type Auth = {
  none: Empty;
  basic: CreateRepositoryAuthBasic;
  ssh: CreateRepositoryAuthSSH;
}

どのように宣言すれば達成できるでしょうか?

解答と原理

(実は僕もすぐ思いつかず、サークル内チャットで質問を投げたら「GPT-4に教えてもらった」と言って解答がもらえました。AIちゃんたすかるぜ!!!)

type Start = {
  key: "one";
  value: "1";
} | {
  key: "two";
  value: "2";
} | {
  key: "three";
  value: "3";
}

// type Goal = {
//     one: "1";
//     two: "2";
//     three: "3";
// }

type Goal = {
  [K in Start['key']]: Extract<Start, { key: K }>['value']
}

1つずつ順を追って理解しましょう。なお、デモとしてTypeScript Playgroundを用意したので、こちらで実際にエラー等を確かめながらお読みください。

Mapped Types

参考:

{
  [Hoge]: Fuga
}

の形はMapped Typesと呼ばれるものです。

type Test1 = {
  [k: string]: number;
}

であれば、keyに文字列、valueに数値を入れられるオブジェクトを表します。

type Test1 = {
  [k: string]: number;
}

const test1: Test1 = {
  "hoge": 301,
  "fuga": "string not allowed"  // 値がnumber型ではないのでエラー
};
type Test2 = {
  [K in "hoge" | "fuga"]: number;
}

であれば、keyとして使用できるのは"hoge""fuga"の二種類になります。inを使って、ユニオン型の反復処理をするイメージ。"hoge""fuga"以外のkeyは使えませんし、どちらかがかけてもエラーになります。

type Test2 = {
  [K in "hoge" | "fuga"]: number;
}

const test2_1: Test2 = {
  hoge: 301,
  fuga: 55301,
  puri: 123,  // keyに使用できるのは"hoge" | "fuga"だけなのでエラー
};

// keyが網羅されていない("fuga"が足りない)のでエラー
const test2_2: Test2 = {
  hoge: 301,
};

ここでK型変数となっています。

型変数

参考:

型変数はその名の通り型の変数です。Test2の例では、K"hoge""fuga"の二値をとりますが、このKを(いわゆる通常の変数と同様に)別の個所でも使うことができます。例えば以下のように、オブジェクトのvalueにKを指定すると、

といった意味になります。

type Test3 = {
  [K in "hoge" | "fuga"]: K;
}

const test3: Test3 = {
  hoge: "hoge",
  fuga: "puri",  // この時valueは"fuga"でなければいけないためエラー
};

インデックスアクセス型

参考:

今回の解答をもう一度見てみると、Start['key']という、オブジェクトのプロパティを参照するような記法が、型でも用いられています。

type Goal = {
  [K in Start['key']]: Extract<Start, {key: K}>['value']
}

いわゆる通常のオブジェクトのプロパティを参照するように、型レベルでも、プロパティの型を参照することができます。これがインデックスアクセス型です。通常のオブジェクトではドット表記(object.property)が使用できますが、インデックスアクセス型ではブラケット表記(object[property])のみが利用できます。

type Test4_1 = {
  hoge: number;
}
//   type Test4_2 = number
//   v
type Test4_2 = Test4_1["hoge"]

ここまでの整理

ここまでの知識で、改めて解答の型を整理してみましょう。

type Start = {
  key: "one";
  value: "1";
} | {
  key: "two";
  value: "2";
} | {
  key: "three";
  value: "3";
}

type Goal = {
  [K in Start['key']]: Extract<Start, { key: K }>['value']
}
// インデックスアクセス型の展開
 = {
  [K in "one" | "two" | "three"]: Extract<Start, { key: K }>['value']
}
// Mapped Typesの展開と型変数の適用
 = {
  one: Extract<Start, { key: "one" }>['value']
  two: Extract<Start, { key: "two" }>['value']
  three: Extract<Start, { key: "three" }>['value']
}

ユーティリティ型 Extract<Type, Union>

参考:

TypeScript には、型の変換を容易にするためのいくつかのユーティリティ型が用意されています。今回の解答にあるExtract<Type, Union>もその一つです。

括弧(< >)の中身は型引数であり、ここでは2つの型変数を指定することで、"いいかんじに"型の変換をしてくれます。Extract<Type, Union>は、Typeに指定したユニオン型から、Unionで指定した型に割り当てできる型だけを抽出した型を返すユーティリティ型です。

//   type Test5 = "a"
//   v
type Test5 = Extract<"a" | "b" | "c", "a" | "f">;

参考資料として載せた英語ドキュメント(TypeScript: Documentation - Utility Types)では正確に記述されているのですが、

Constructs a type by extracting from Type all union members that are assignable to Union.
https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers

の、assignableがかなり大事です。これについてもう少し深堀します。

Conditional Types(条件付き型)とType Compatibility(型の互換性)

参考:

Extract<T, U>はあくまでも

// https://github.com/microsoft/TypeScript/blob/ca0fafd694f13775f7491982a83620b51c2b785d/src/lib/es5.d.ts#L1590-L1593
type Extract<T, U> = T extends U ? T : never;

というConditional Typesの型エイリアスに過ぎません。

この構文は条件 (三項) 演算子に似ており、

type Hoge = 条件 ? 条件が真の時の型 : 条件が偽の時の型

といった書き方をします。上記の通り、条件が真の時は:の前方の型が、偽の時は後方の型が採用されます。これを踏まえた上で改めてExtract<T, U>を見てみましょう。条件はT extends Uです。extends は「左側の型が右側の型に割り当て可能な場合、真」といった意味です。割り当てが可能かどうかを決定するのはType Compatibility (型の互換性)です。

詳しくは上述した参考リンク先を読んでほしいのですが、「yが少なくともxと同じプロパティを持つ場合、xyと互換性がある = xyに割り当て可能」という重要なルールがあります。

type Pet = {
  name: string;
}
let pet: Pet;
let dog = { name: "inu", owner: "Kurosu Aroma" };
pet = dog;

dogがpetに割り当て可能かどうかをチェックするために、コンパイラはpetの各プロパティ(この例ではname1つのみ)をチェックして、dogの対応するプロパティを探します。この例ではdogにはnameというstring型のプロパティが存在するため、「dogが少なくともpetと同じプロパティを持つ」という条件を満たし、dogpetに割り当て可能と判断されます。

逆に以下の例では、「dogWithoutOwnerが少なくともpetWithOwnerと同じプロパティを持つ」という条件を満たさないため(ownerプロパティを持っていない)、dogWithoutOwnerpetWithOwnerに割り当て不可能と判断されます。

type PetWithOwner = {
  name: string;
  owner: string;
}
let petWithOwner: PetWithOwner;
let dogWithoutOwner = { name: "inu" };
petWithOwner = dogWithoutOwner;

もう一度整理

さて、少し遠回りをしてしまいましたが、最後にもう一度解答の型を整理してみましょう。

type Start = {
  key: "one";
  value: "1";
} | {
  key: "two";
  value: "2";
} | {
  key: "three";
  value: "3";
}

type Goal = {
  [K in Start['key']]: Extract<Start, { key: K }>['value']
}
// インデックスアクセス型の展開
 = {
  [K in "one" | "two" | "three"]: Extract<Start, { key: K }>['value']
}
// Mapped Typesの展開と型変数の適用
 = {
  one: Extract<Start, { key: "one" }>['value']
  two: Extract<Start, { key: "two" }>['value']
  three: Extract<Start, { key: "three" }>['value']
}

この時、Extract<Start, { key: "one" }>は、Startから、{ key: "one" }に割り当てできる型だけを抽出した型を返すので、Startのうち、{ key: "one"; value: "1"; }のみが抽出されます({ key: "one"; value: "1"; }{ key: "one" }のプロパティを全て持っており割り当て可能だから)。したがって、

// Extract<T, U>の適用
 = {
  one: { key: "one"; value: "1" }['value']
  two: { key: "two"; value: "2" }['value']
  three: { key: "three"; value: "3" }['value']
}
// インデックスアクセス型の展開
 = {
  one: "1";
  two: "2";
  three: "3";
}

ようやくたどり着きました...

いかがでしたか?

GPT-4ってすごいですね(小並感)

解説もGPT-4に書かせればよかったと後悔しています。とはいえ改めて勉強するとやっぱりおもしろ~~

d_etteiu8383 icon
この記事を書いた人
d_etteiu8383

グラフィック班とゲーム班とSysAd班所属 いろいろ活動しています

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】 feature image
2018年11月3日
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】
Azon icon Azon
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2023年4月27日
Vulkanのデバイスドライバを自作してみた
kegra icon kegra
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
2024年3月15日
個人開発として2週間でWebサービスを作ってみた話 〜「LABEL」の紹介〜
Natsuki icon Natsuki
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記