2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TypescriptでSwiftのenumを実現する

Posted at

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

Playground

解説

そもそも 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)を必ず持ったオブジェクトをユニオンにします。

目指すcaseの実体の型定義
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の実体を返す関数(の型)をもつ型を用意します。

enum用クラスの型
type SwiftEnum<T extends Record<string, object>> = { 
  [K in keyof T]: (associatedValues: T[K]) => SwiftEnumCase<T, K>
};

次にenum用クラスに、Generics引数のkeyでアクセスがあった場合の処理をProxyで差し込みます。具体的にはcaseの実体を返す関数の処理を記述します。

enum用クラス
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さんの記事をご参照ください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?