TypeScriptって魔法ではなく「手法」さえ覚えれば自作するのも夢じゃない。
気合の入った成果物: @lollipop-onl/vuex-typesafe-helper
(微妙に実装不備アリ)
Demo
TypeScript Playgroundで動作を確認できます。
開発環境
IDE (VS Code)
デバッグ方法
IDEでの型推論
declare var dispatch: Dispatch;
dispatch('login', { id: 'simochee', password: 'abcd1234' });
VuexのActionをタイプセーフにする
やりたいこと
// 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'
}>;
と定義したものを
dispatch('chat/login', { id: 'simochee', password: 'abcd1234' }); // returns Promise<boolean>
dispatch('chat/joinRoom', { }); // payload error!
dispatch('chat/leave'); // action name type error!
みたく使えるよう正しいアクション名にマッピングするようなDefineActions
の定義を行う。
実装方針
type Dispatch= DefineAction<typeof actions, {
'login': 'chat/login',
'joinRoom': 'chat/joinRoom',
'leaveRoom': 'chat/leaveRoom'
};
ジェネリクスでアクションオブジェクトの型とマッピングするイベント名のペアを渡す。
ジェネリック
関数の型(超適当)
// 基本的には <> の中に名前を入れていく
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
とはちょっと挙動が違う。
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. ベースの型を作る
login(context, payload): Promise<void>;
↓
dispatch('login', payload): Promise<void>;
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にマッピングする
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. アクションからペイロードを取り出す
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の引数として指定する
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)
となるようにアクション名をマッピングする。
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
的な。
// ObjのすべてのプロパティをString型にする
type Obj = { hello: string, world: number };
type StrObj = {
[K in keyof Obj]: string;
};
Step X. Payloadの未指定に対応する
Payloadを省略可能にしようとするととたんに実装がめんどくさくなる。
Before
// ペイロードがあるプロパティのキーを取り出す
type DefineActions<Actions extends BaseStoreModule, P extends Record<string, string>> = {
<K extends keyof Actions>(type: K, payload: Payload<Actions[K]>): ReturnType<Actions[K]>;
};
After
// オブジェクトの値を返す型
type Values<P extends Record<any, any>> = P extends { [key: any]: infer V } ? V : never;
// ペイロードのあるプロパティのキーを取り出す
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;
// ペイロードがない 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;
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が存在しない可能性がないメソッド(逆説的ですが)がある場合のみキーと同じ値が値となってオブジェクトを返します。
// 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が存在しない可能性があるメソッドのキーのみをピックアップしています。
もっとスマートなやり方あったら教えてほしい。