11
10

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 5 years have passed since last update.

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

Last updated at Posted at 2019-06-04

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

気合の入った成果物: @lollipop-onl/vuex-typesafe-helper

(微妙に実装不備アリ)

Demo

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

Demo

開発環境

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: Kkeyof Actionsを継承しているのでloginjoinRoomまたは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が存在しない可能性があるメソッドのキーのみをピックアップしています。

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

11
10
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
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?