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を指定すると、
- keyが
"hoge"の時はK = "hoge"となり、valueには"hoge"を代入できる - keyが
"fuga"の時はK = "fuga"となり、valueには"fuga"を代入できる
といった意味になります。
type Test3 = {
[K in "hoge" | "fuga"]: K;
}
const test3: Test3 = {
hoge: "hoge",
fuga: "puri", // この時valueは"fuga"でなければいけないためエラー
};
インデックスアクセス型
参考:
- TypeScript: Documentation - Indexed Access Types
- インデックスアクセス型 (indexed access types) | TypeScript入門『サバイバルTypeScript』
今回の解答をもう一度見てみると、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: Documentation - Utility Types
- ユーティリティ型 (utility type) | TypeScript入門『サバイバルTypeScript』
- Exclude<T, U> | TypeScript入門『サバイバルTypeScript』
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
Typeall union members that are assignable toUnion.
https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers
の、assignableがかなり大事です。これについてもう少し深堀します。
Conditional Types(条件付き型)とType Compatibility(型の互換性)
参考:
- TypeScript: Documentation - Conditional Types
- TypeScript: Documentation - Type Compatibility
- 型の互換性 - TypeScript Deep Dive 日本語版
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に書かせればよかったと後悔しています。とはいえ改めて勉強するとやっぱりおもしろ~~