TypeScriptでVuexの型ユーティリティを作ろう

TypeScriptって魔法ではなく「手法」さえ覚えれば自作するのも夢じゃない。

> 気合の入った成果物： [@lollipop-onl/vuex-typesafe-helper](https://www.npmjs.com/package/@lollipop-onl/vuex-typesafe-helper)

（微妙に実装不備アリ）

# Demo

TypeScript Playgroundで動作を確認できます。

[Demo](http://www.typescriptlang.org/play/#src=const%20actions%20%3D%20%7B%0D%0A%20%20%20%20async%20login(%7B%20commit%20%7D%2C%20payload%3A%20%7B%20id%3A%20string%2C%20password%3A%20string%20%7D)%3A%20Promise%3Cvoid%3E%20%7B%20%7D%2C%0D%0A%20%20%20%20async%20joinRoom(%7B%20commit%20%7D%2C%20payload%3A%20%7B%20roomId%3A%20string%20%7D)%3A%20Promise%3Cvoid%3E%20%7B%20%7D%2C%0D%0A%20%20%20%20async%20leaveRoom(%7B%20commit%20%7D%2C%20payload%3A%20%7B%7D)%3A%20Promise%3Cvoid%3E%20%7B%20%7D%0D%0A%7D%3B%0D%0A%0D%0Atype%20BaseStoreModule%20%3D%20Record%3Cstring%2C%20(...args%3A%20any%5B%5D)%20%3D%3E%20any%3E%3B%0D%0Atype%20Payload%3CF%20extends%20(...args%3A%20any)%20%3D%3E%20any%3E%20%3D%20F%20extends%20(ctx%2C%20payload%3A%20infer%20P)%20%3D%3E%20any%0D%0A%20%20%3F%20P%0D%0A%20%20%3A%20never%3B%0D%0Atype%20DefineActions%3CActions%20extends%20BaseStoreModule%3E%20%3D%20%7B%0D%0A%20%20%3CK%20extends%20keyof%20Actions%3E(type%3A%20K%2C%20payload%3A%20Payload%3CActions%5BK%5D%3E)%3A%20ReturnType%3CActions%5BK%5D%3E%3B%0D%0A%7D%3B%0D%0A%0D%0Atype%20Dispatch%20%3D%20DefineActions%3Ctypeof%20actions%3E%3B%0D%0A%0D%0Adeclare%20var%20dispatch%3A%20Dispatch%3B%0D%0A%0D%0Adispatch('login'%2C%20%7B%20id%3A%20'simochee'%2C%20password%3A%20'abcd1234'%20%7D)%3B%0D%0Adispatch('logout'%2C%20%7B%7D)%3B%0D%0Adispatch('joinRoom'%2C%20%7B%20roomId%3A%20100%20%7D)%3B%0D%0A)

# 開発環境

IDE (VS Code)

# デバッグ方法

IDEでの型推論

```ts
declare var dispatch: Dispatch;

dispatch('login', { id: 'simochee', password: 'abcd1234' });
```

# VuexのActionをタイプセーフにする

## やりたいこと

```ts
// store/chat.ts
const actions = {
  login({ commit }, payload: { id: string, password: string }): Promise<boolean> {},
  joinRoom({ commit }, payload: { roomId: string }): Promise<void> {},
  leaveRoom({ commit }, payload: {}): Promise<void>
};

type Dispatch = DefineAction<typeof actions, {
  'chat/login': 'login',
  'chat/joinRoom': 'joinRoom',
  'chat/leaveRoom': 'leaveRoom'
}>;
```

と定義したものを

```ts
dispatch('chat/login', { id: 'simochee', password: 'abcd1234' }); // returns Promise<boolean>
dispatch('chat/joinRoom', { }); // payload error!
dispatch('chat/leave'); // action name type error!
```

みたく使えるよう正しいアクション名にマッピングするような`DefineActions`の定義を行う。

## 実装方針

```ts
type Dispatch= DefineAction<typeof actions, {
  'login': 'chat/login',
  'joinRoom': 'chat/joinRoom',
  'leaveRoom': 'chat/leaveRoom'
};
```

ジェネリクスでアクションオブジェクトの型とマッピングするイベント名のペアを渡す。

##### ジェネリック

関数の型（超適当）

```ts
// 基本的には <> の中に名前を入れていく
type PickValue<Obj, Key> = Obj[Key];
// extends を使うとジェネリックの型を指定できる
type PickValue<Obj extends object, Key extends string> = Obj[Key];
// = を使うと初期値を設定できる。ただし、型は確定しない
// 初期値が設定されている場合のみ、ジェネリックの指定を省略できる
type PickValue<Obj extends object, Key = 'default'> = Obj[Key];
```

##### typeof

JavaScriptの世界の値を型の世界の値に変換する。
ただし、JavaScriptでの`typeof`とはちょっと挙動が違う。

```ts
const obj1 = { hello: 'world' };
const obj2: object = { hello: 'world' };

const objType2 = typeof obj1; // Object
const objType2 = typeof obj2; // Object

type ObjType1 = typeof obj; // { 'hello': 'world' };
 type ObjType2 = typeof obj; // object
```

## Step 1. ベースの型を作る

```ts
login(context, payload): Promise<void>;
↓
dispatch('login', payload): Promise<void>;
```

```ts
type BaseStoreModule = Record<string, (...args: any[]) => any)>;
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {};
```

##### Record

オブジェクトのキーと値の型を定義する。`{ [key: string]: string }`と`Record<string, string>`は同義。

## Step 2. アクション名をdispatchにマッピングする

```ts
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {
  <K extends keyof Actions>(type: K): ReturnType<Actions[K]>;
};
```

##### keyof A

オブジェクトAのキーを取り出す。
上記の場合、`type: K`は`keyof Actions`を継承しているので`login`、`joinRoom`または`leaveRoom`のいずれかとなる。

##### ReturnType

関数の返り値を返す。

## Step 3. アクションからペイロードを取り出す

```ts
export type Payload<F extends (...args: any) => any> = F extends (ctx, payload: infer P) => any
  ? P
  : never;
```

##### A extends B ? C : D

型の三項演算子。

##### infer P

暫定的な型を使い回せるようにする。ただし、extendsの条件部に仕込む必要があり、型は二項目でのみ参照できる。

## Step 4. PayloadをDispatchの引数として指定する

```ts
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {
  <K extends keyof Actions>(type: K, payload: Payload<Actions[K]>): ReturnType<Actions[K]>;
};
```

## Step 5. Namespace付きのアクション名にマッピングする

いまのところ、`dispatch('login', payload)`となっているので、`dispatch('chat/login', payload)`となるようにアクション名をマッピングする。

```ts
type MapActions<A extends Record<string, any>, P extends Record<string, string>> = {
  [K in keyof A]: A[P[K]];
};
```

##### [K in keyof P]: ...

`P`のキーでマッピングを行う。`Array.map`的な。

```ts
// ObjのすべてのプロパティをString型にする
type Obj = { hello: string, world: number };
type StrObj = {
  [K in keyof Obj]: string;
};
```

## Step X. Payloadの未指定に対応する

Payloadを省略可能にしようとするととたんに実装がめんどくさくなる。

### Before

```ts
// ペイロードがあるプロパティのキーを取り出す
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {
  <K extends keyof Actions>(type: K, payload: Payload<Actions[K]>): ReturnType<Actions[K]>;
};
```

### After

```ts
// オブジェクトの値を返す型
type Values<P extends Record<any, any>> = P extends { [key: any]: infer V } ? V : never;
```

```ts
// ペイロードのあるプロパティのキーを取り出す
type PickKeyWithPayload<P extends Record<string, (...args: any)>> = P extends Record<string, (...args: any) => any>
  ? Values<{
      [K in keyof P]: P[K] extends (context: any) => any ? undefined : K
    }>
  : never;
```

```ts
// ペイロードがない or オプショナルなプロパティのキーを取り出す
type PickKeyWithoutPayload<P> = P extends Record<string, (...args: any) => any>
  ? Values<{
      [K in keyof P]: P[K] extends (context: any) => any ? K : undefined
    }>
  : never;
```

```ts
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {
  <K extends PickKeyWithPayload<Actions>>(type: K, payload: Payload<Actions[K]>): ReturnType<Actions[K]>;
  <K extends PickKeyWithoutPayload<Actions>>(type: K, payload?: Payload<Actions[K]>): ReturnType<Actions[K]>;
};
```

`PickKeyWithPayload`の`{ [K in keyof P]: P[K] extends (context: any) => any ? undefined : K }`の部分では、Payloadが存在しない可能性が**ない**メソッド（逆説的ですが）がある場合のみキーと同じ値が値となってオブジェクトを返します。

```ts
// Valuesしない版
type PickKeyWithPayload<P extends Record<string, (...args: any)>> = P extends Record<string, (...args: any) => any>
  ? {
      [K in keyof P]: P[K] extends (context: any) => any ? undefined : K
    }
  : never;

type KeyObj = PickKeyWithPayload<typeof actions>;
// { login: "login", joinRoom: undefined, leaveRoom: undefined }

type Keys = Values<KeyObj>; // "login"
```

`PickKeyWithoutPayload`も挙動としては同様で、Payloadが存在しない可能性があるメソッドのキーのみをピックアップしています。

もっとスマートなやり方あったら教えてほしい。