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と同じプロパティを持つ場合、xはyと互換性がある = xはyに割り当て可能」という重要なルールがあります。

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と同じプロパティを持つ」という条件を満たし、dogはpetに割り当て可能と判断されます。

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

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班所属 いろいろ活動してました (24年度卒業)

この記事をシェア

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

関連する記事

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
2025年11月18日
1-Monthon 8班 Marten Clicker
Suima icon Suima 他
2024年4月14日
Spotifyのクライアントを自作しよう
d_etteiu8383 icon d_etteiu8383
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記