SwiftのAssociated Valuesを持てたりメソッドを持てたりするenumが便利すぎるので、Typescriptでも使いたいと思って試行錯誤した結果です。
Typescriptの技術要素とともに解説します。
type CaseWithAssociatedValues = Record<string, object>;
type Empty = Record<string, never>;
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type SwiftEnumCase<T extends CaseWithAssociatedValues, K extends keyof T, U> = U &
DeepReadonly<T[K] extends Empty
? Record<"case", K>
: Record<"case", K> & T[K]>;
type SwiftEnumCases<T extends CaseWithAssociatedValues, U = Empty> = {
readonly [K in keyof T]: SwiftEnumCase<T, K, U>;
}[keyof T];
type SwiftEnum<T extends CaseWithAssociatedValues, U> = {
[K in keyof T]: T[K] extends Empty
? () => SwiftEnumCase<T, K, U>
: (associatedValues: T[K]) => SwiftEnumCase<T, K, U>
};
const SwiftEnum = class SwiftEnum<U extends object> {
constructor(f?: (new () => U)) {
return new Proxy(this, {
get(target, prop, receiver) {
return ((typeof prop === "string") && !(prop in target))
? (associatedValues: any) => (f !== undefined)
? Object.freeze(Object.assign(new f(), { "case": prop, ...associatedValues }))
: Object.freeze({ "case": prop, ...associatedValues })
: Reflect.get(target, prop, receiver);
}
});
}
} as new <T extends CaseWithAssociatedValues, U = Empty>(f?: (new () => U)) => SwiftEnum<T, U>;
type ResponseStatus = {
success: Empty;
failure: { error: Error };
inProgress: { progress: number };
};
const ResponseStatus = new SwiftEnum<ResponseStatus>();
const status1 = ResponseStatus.success();
const status2 = ResponseStatus.failure({ error: Error() });
const status3 = ResponseStatus.inProgress({ progress: 10 });
const func = (status: SwiftEnumCases<ResponseStatus>) => {
switch (status.case) {
case "success" : {
console.log("this is success!");
break;
}
case "failure" : {
console.log("this is failure!", status.error);
break;
}
case "inProgress" : {
console.log("this is inProgress!", status.progress);
break;
}
}
};
func(status3); // "this is inProgress!", 10
解説
そもそも Associated Valuesとは?
Swiftのenumは、caseに値を持たせられるのです。分かりやすい使い方の例としてはAPIのレスポンスのEitherにエラーコードを付加するなどでしょうか。ここではcode
がAssociated Valueです。
enum Response {
case success
case failure(code: String)
}
// 実践的には色々制約をつけたりしてもっと便利に使いますがここでは割愛
パターンマッチすると、Associated Valuesを取り出して使えます。
// 作るとき
let response = Response.failure(code: "e1000")
// 使うとき
switch response {
case .success:
// 成功時の処理
case let .failure(code):
// 失敗時の処理
print(code)
}
これをTypescriptで再現します。
case用の型の用意
caseはDiscriminated Union
あるいはTagged Union
と呼ばれるお作法で作ります。
tagとなるkey(下記ではcaseですが、統一されていれば何でもOK)を必ず持ったオブジェクトをユニオンにします。
type Success = { case: "success" };
type Failure = { case: "failure"; error: Error };
type InProgress = { case: "inProgress"; progress: number };
type ResponseStatus = Success | Failure | InProgress;
実際に使う際には、目指すcaseの実体の型を用意する前段として、 case : Associated Values の形の型を定義します。
type ResponseStatus = {
success: {};
failure: { error: Error };
inProgress: { progress: number };
};
これを、Mapped Types
というお作法でkey毎にcaseの実体の型にします。
// 各case用の型
type SwiftEnumCase<T extends Record<string, object>, K extends keyof T> = Record<"case", K> & T[K];
// すべてのcaseのUnionした型(を Mapped Types で動的に作っている)
type SwiftEnumCases<T extends Record<string, object>> = {
[K in keyof T]: SwiftEnumCase<T, K>; // key毎に { case: key, ...values } の形の型に
}[keyof T];
/* 実際には明示的には作りません。enumを作るときに内部で使うだけです。 */
type ResponseStatusCases = SwiftEnumCases<ReponseStatus>;
// => Record<"case", "success"> & {} | (Record<"case", "failure"> & { error: Error; }) | (Record<"case", "inProgress"> & { progress: number; })
enum用クラスの用意
まずenum用クラスの型として、Generics引数のkeyを関数名、valueをその引数としてcaseの実体を返す関数(の型)をもつ型を用意します。
type SwiftEnum<T extends Record<string, object>> = {
[K in keyof T]: (associatedValues: T[K]) => SwiftEnumCase<T, K>
};
次にenum用クラスに、Generics引数のkeyでアクセスがあった場合の処理をProxy
で差し込みます。具体的にはcaseの実体を返す関数の処理を記述します。
const SwiftEnum = class SwiftEnum<T extends Record<string, object>> {
constructor() {
return new Proxy(this, {
get(target, prop, receiver) {
return ((typeof prop === "string") && !(prop in target))
? (associatedValues: T[string]) => ({ "case": prop, ...associatedValues })
: Reflect.get(target, prop, receiver);
}
});
}
} as new <T extends Record<string, object>>() => SwiftEnum<T>;
使い方
// keyがcaseで、valueがAssociated Valuesのオブジェクトの形の型を定義
type ResponseStatus = {
success: {};
failure: { error: Error };
inProgress: { progress: number };
};
// enumクラスにGenerics引数で渡してインスタンを生成
const ResponseStatus = new SwiftEnum<ResponseStatus>();
// case名のメソッド(Associated Valuesのオブジェクトがその引数)でcaseのインスタンスが作れる
const status1 = ResponseStatus.success({});
const status2 = ResponseStatus.failure({ error: Error() });
const status3 = ResponseStatus.inProgress({ progress: 10 });
const func = (status: SwiftEnumCases<ResponseStatus>) => {
switch (status.case) { // caseでパターンマッチ
case "success" : {
console.log("this is success!");
break;
}
case "failure" : {
console.log("this is failure!", status.error);
break;
}
case "inProgress" : {
console.log("this is inProgress!", status.progress); // マッチした後は Associated Values が呼べる
break;
}
}
};
func(status3); // "this is inProgress!", 10
終わりに
冒頭のコードでは、これらを元に Conditional Types
のお作法も駆使してAssociated Valueがない場合には引数を不要にしたり、readonly
にしたりしています(Deep Readonlyについては ここの解答を見るとお勉強になります)。
またSwiftのenumのクラスのようにメソッドを持てる機能を実現するために、case用の型に交差型(Intersection Types)のお作法でメソッドを持つクラスとcase用の型を合成し、Proxyによる差し込みメソッドにてcaseの実体を作る際にはObject.assignでそのインスタンスとcaseの実体をコピーしています。
さらなる改良
Swiftのenumはswitch文でexhaustiveか(漏れているcaseがないか)をコンパイラがチェックしてくれます。
これと同じようなことを実現したい場合は以下のeagleさんの記事をご参照ください。