やりたいこと
ダイアログを表示する DoDialog() とそのパラメータ DialogOptionType があるとします。そして、DialogOptionType にはボタンの定義があり、name(表示する文字列)と id (押されたボタンの識別子)が定義されており、DoDialog() を実行すると id が返却される仕組みを考え、これをサンプルコードにするとこんな感じになります。
type DialogOptionType = {
title: string;
message: string;
buttons: {
id: string;
name: string;
} [];
};
function DoDialog(option: DialogOptionType) : Promise<string> {
return new Promise<string>((resolve, reject) => {
/**
* 実際には表示処理とユーザ入力処理が実行され、その結果を以て resolve するが、
* これはサンプルなので1個目のボタンが押されたのを想定して resolve する。
**/
option.buttons.length == 0 ?
reject(Error("ボタンが無い")) :
resolve(option.buttons[0].id);
});
}
DoDialog({
title: "問題です",
message: "1+1は3ですか?",
buttons: [
{ id: "yes", name: "はい" },
{ id: "no", name: "いいえ" }
]
})
.then((result) => {
if(result == "yes") console.log("不正解");
else if(result == "no") console.log("正解");
else console.log(`resultが想定外 ${result}`);
});
不正解
これはこれで良いのですが、 result が string になってしまうので、リファクタリング中に id を変更しても問題が気付きにくいです。
たとえばこんな感じに、良かれと思ったリファクタリングが逆にバグを混入する結果になる場合があります。
DoDialog({
title: "問題です",
message: "1+1は3ですか?",
buttons: [
{ id: "Yes", name: "はい" },
{ id: "No", name: "いいえ" }
]
})
.then((result) => {
if(result == "yes") console.log("不正解");
else if(result == "no") console.log("正解");
else console.log(`resultが想定外 ${result}`);
});
resultが想定外 Yes
まぁ、ちゃんと else 節を作って result が想定外であることを示しているので、ある意味エラー処理をしているとも言えます。
が、しかし、
「この問題が発覚するのは、このコードが実行された時」
というのは如何なものかと思います。1
ここで result を string ではなく "yes" | "no" (リファクタリング後は "Yes" | "No" )のようにストリングリテラル型にできれば、コンパイルエラーとなるので問題が未然に防げるので、その方法を模索します。
色々と実験してみる
では、値(配列の特定プロパティ id )から型(idを構成するストリングリテラル型)を生成する方法を模索してみようと思います。
その1
問題を単純化するために下記のように定義します。
type OptionType = {id: string}[];
const options: OptionType = [{id: "yes"}, {id: "no"}];
type IdsType = typeof options[number]["id"];
/* IdsType: string */
やりたいことは options 内の各オブジェクトの id をストリングリテラル型にして "yes" | "no" にしたいので、こんな感じで書いみましたが、結果はダメです。
options は const なので、 options に代入はできないものの、中身を変更することは可能 2 なので、コンパイル時に定数とないからです。
では、こうしてみます。
type OptionType = {id: string}[];
const options: OptionType = [{id: "yes"}, {id: "no"}] as const; /* <- error */
type IdsType = typeof options[number]["id"];
これもダメです。
この場合、options の初期化で代入しようと定義している値の型が下記のようになるためコンパイルエラーとなります。
readonly [{ readonly id: "yes"; }, { readonly id: "no"; }]
そこで、ひとまず、こうすればエラーにはならず、"yes" | "no" も抽出できます。
const options = [{id: "yes"}, {id: "no"}] as const;
type IdsType = typeof options[number]["id"];
/* IdsType: "yes" | "no" */
が、しかし、これでは options に型指定ができないので気に入りません。最終的には DoDialog() のパラメータとしたいのですからね。
その2
ここで趣向を変えて、オブジェクトの配列内の特定のキーをストリングリテラル型にする方法を考えてまみす。
type ARR = [{id: "yes"}, {id: "no"}];
type ITEM = ARR extends (infer U)[] ? U : never;
/* ITEM: {id: "yes"} | {id: "no"} */
type IDS = "id" extends keyof ITEM ? ITEM["id"] : never;
/* IDS: "yes" | "no" */
ということは、ジェネリクスでまとめるとこんな感じになります。
type ObjectArrayKeyValuesType<KEY, ARR> =
ARR extends (infer ITEM)[] ?
KEY extends keyof ITEM ?
ITEM[KEY]
: never
: never;
実際に試してみると。。。
type IDS = ObjectArrayKeyValuesType<"id", [{id: "yes"}, {id: "no"}]>;
/* IDS: "yes" | "no" */
Object~<> とは云うものの、実際は型なので命名がイマイチですが、期待する結果になります。
あ、でも ARR が可変である場合はストリングリテラル型ではなく string になります。
const VALUES = [{id: "yes"}, {id: "no"}];
type IDS = ObjectArrayKeyValuesType<"id", typeof VALUES>;
/* IDS: string */
コンパイル時に値が確定していないから当たり前ですね。
ではこうしてみたらどうでしょう?
const VALUES = [{id: "yes"}, {id: "no"}] as const;
type IDS = ObjectArrayKeyValuesType<"id", typeof VALUES>;
/* IDS: never */
ん?! なぜ never ?
ジェネリクスでは参考演算子を連鎖しているので、どこで never 節を採用するのかを確認すべく、ジェネリクスを再度分解して確認してみましょう。
const VALUES = [{id: "yes"}, {id: "no"}] as const;
type ARR = typeof VALUES;
/* ARR: readonly [{ readonly id: "yes"; }, { readonly id: "no"; }] */
type ITEM = ARR extends (infer U)[] ? U : never;
/* ITEM: never */
type IDS = "id" extends keyof ITEM ? ITEM["id"] : never;
/* IDS: never */
どうやら、 ARR extends (infer U)[] がダメみたいです。3
ARR の違いを考えると恐らく readonly が関係してそうだって事で、readonly の場合分けで試してみることにします。
/* これが元となるコード */
type ARR = [{ id: "yes"; }, { id: "no"; }];
type ITEM = ARR extends (infer U)[] ? U : never;
/* ITEM: { id: "yes"; } | { id: "no"; } */
type IDS = "id" extends keyof ITEM ? ITEM["id"] : never;
/* IDS: "yes" | "no" */
/* readonly の組み合わせを試してみる */
type ARR_1 = readonly [{ readonly id: "yes"; }, { readonly id: "no"; }];
type ITEM_1 = ARR_1 extends (infer U)[] ? U : never;
/* ITEM_1: never */
type IDS_1 = "id" extends keyof ITEM_1 ? ITEM_1["id"] : never;
/* IDS_1: never */
type ARR_2 = [{ readonly id: "yes"; }, { readonly id: "no"; }];
type ITEM_2 = ARR_2 extends (infer U)[] ? U : never;
/* ITEM_2: { readonly id: "yes"; } | { readonly id: "no"; } */
type IDS_2 = "id" extends keyof ITEM_2 ? ITEM_2["id"] : never;
/* IDS_2: "yes" | "no" */
type ARR_3 = [{ id: "yes"; }, { readonly id: "no"; }];
type ITEM_3 = ARR_3 extends (infer U)[] ? U : never;
/* ITEM_3: { id: "yes"; } | { readonly id: "no"; } */
type IDS_3 = "id" extends keyof ITEM_3 ? ITEM_3["id"] : never;
/* IDS_3: "yes" | "no" */
type ARR_4 = readonly [{ id: "yes"; }, { id: "no"; }];
type ITEM_4 = ARR_4 extends (infer U)[] ? U : never;
/* ITEM_4: never */
type IDS_4 = "id" extends keyof ITEM_4 ? ITEM_4["id"] : never;
/* IDS_4: never */
これを見ると、配列が readonly だと infer が使えないんじゃないかという結果になりました。配列の中のプロパティについては readonly は関係ないようです。3
その3
「その1」と「その2」を比較することにしましょう。
「その1」では、下記のような取り扱いとなるという結果でした。
const options = [{id: "yes"}, {id: "no"}] as const;
type IdsType = typeof options[number]["id"];
/* IdsType: "yes" | "no" */
ここで、「その2」の記述に寄せて書くと下記のようになります。
const VALUES = [{id: "yes"}, {id: "no"}] as const;
type ARR = typeof VALUES;
/* ARR: readonly [{ readonly id: "yes"; }, { readonly id: "no"; }] */
type IDS = ARR[number]["id"];
/* IDS: "yes" | "no" */
更に、変形するとこうとも書けるでしょう。
type ARR = readonly [{ readonly id: "yes"; }, { readonly id: "no"; }];
type IDS = ARR[number]["id"];
/* IDS: "yes" | "no" */
ここで「その2」の検証では下記の結果でした。
type ARR = readonly [{ readonly id: "yes"; }, { readonly id: "no"; }];
type ITEM = ARR extends (infer U)[] ? U : never;
/* ITEM : never */
type IDS = "id" extends keyof ITEM ? ITEM["id"] : never;
/* IDS: never */
これをまとめると下記の結果になります。
type ARR = readonly [{ readonly id: "yes"; }, { readonly id: "no"; }];
/* その1 */
type IDS_CASE_1 = ARR[number]["id"];
/* IDS_CASE_1: "yes" | "no" */
/* その2 */
type ITEM_CASE_2 = ARR extends (infer U)[] ? U : never;
/* ITEM_CASE_2: never */
type IDS_CASE_2 = "id" extends keyof ITEM_CASE_2 ? ITEM_CASE_2["id"] : never;
/* IDS_CASE_2: never */
ところで、 ARR の配列に修飾する readonly を除いてみるとどうなるか試してみます。
type ARR = [{ readonly id: "yes"; }, { readonly id: "no"; }];
/* その1 */
type IDS_CASE_1 = ARR[number]["id"];
/* IDS_CASE_1: "yes" | "no" */
/* その2 */
type ITEM_CASE_2 = ARR extends (infer U)[] ? U : never;
/* ITEM_CASE_2: { readonly id: "yes"; } | { readonly id: "no"; } */
type IDS_CASE_2 = "id" extends keyof ITEM_CASE_2 ? ITEM_CASE_2["id"] : never;
/* IDS_CASE_2: "yes" | "no" */
なるほど、「その2」で作った ObjectArrayKeyValuesType で typeof を使用するためには配列の readonly を外すことができれば良さそうですが。。。
実験の結果
値から型を抽出して変形するというのは手法は不可能ではありません。
しかし、対象となる値は不変であることを要求するため、気軽に DoDialog() のパラメータとして使用するには向かないでしょう。
そこで、別な方法を検討することにします。
別の方法を試す
これまで DialogOptionType.buttons[] の id をストリングリテラル型に変換しようと苦戦していましたが、逆に id の型を制限する(許容される値を制限する)方針に変更してみます。
つまり、こんな感じになります。
type DialogOptionType = {
title: string;
message: string;
buttons: {
id: string;
name: string;
} [];
};
type DialogOptionType<T> = {
title: string;
message: string;
buttons: {
id: T;
name: string;
} [];
};
ここで T はストリングリテラル型が入り、 "yes" | "no" を指定することで、DialogOptionType の値を定義する際に静的エラーチェックができるようになります。
const opt: DialogOptionType<"yes" | "no"> = {
title: "問題です",
message: "1+1は3ですか?",
buttons: [
{ id: "Yes", name: "はい" }, /* <- コンパイルエラーになります */
{ id: "no", name: "いいえ" }
]
};
このままでは DoDialog() のパラメータにはできないので、 DoDialog() をジェネリクス関数にして "yes" | "no" を DialogOptionType に引き渡します。
function DoDialog<T>(option: DialogOptionType<T>) : Promise<T> {
return new Promise<T>((resolve, reject) => {
option.buttons.length == 0 ?
reject(Error("ボタンが無い")) :
resolve(option.buttons[0].id);
});
}
こうすることで、下記のような呼び出しで型安全になります。
DoDialog<"yes" | "no">({
title: "問題です",
message: "1+1は3ですか?",
buttons: [
{ id: "yes", name: "はい" },
{ id: "no", name: "いいえ" }
]
})
.then((result) => { /* result: "yes" | "no" */
if(result == "yes") console.log("不正解");
else if(result == "no") console.log("正解");
/* else は不要 */
});
タイプ量は若干増えます4 が、型チェックによる恩恵は計り知れないものがあります。
type DialogOptionType<T> = {
title: string;
message: string;
buttons: {
id: T;
name: string;
} [];
};
function DoDialog<T>(option: DialogOptionType<T>) : Promise<T> {
return new Promise<T>((resolve, reject) => {
option.buttons.length == 0 ?
reject(Error("ボタンが無い")) :
resolve(option.buttons[0].id);
});
}
DoDialog<"yes" | "no">({
title: "問題です",
message: "1+1は3ですか?",
buttons: [
{ id: "yes", name: "はい" },
{ id: "no", name: "いいえ" }
]
})
.then((result) => {
if(result == "yes") console.log("不正解");
else if(result == "no") console.log("正解");
});
まとめ
コンパイル時に値が確定している場合、その値を使用するコードに制限を設けることで、値とコードの不整合をコンパイル時に見つけられるようにしましょう。