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
Type
all 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
の各プロパティ(この例ではname
1つのみ)をチェックして、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に書かせればよかったと後悔しています。とはいえ改めて勉強するとやっぱりおもしろ~~