TS4.1の新機能でReduxToolkitの課題点を克服する

12 min読了の目安(約7500字TECH技術記事

createSlice の課題点

Redux を導入する場合、ReduxToolkit を利用するシーンは多いと思います。そのなかで議論にあがるのが「createSliceを利用するか」という点です。以前Twitterでとったアンケートからも、意見が別れていることがみてとれます。

createSlice は ActionType・ActionCreator・Reducer を一度に宣言できるため大変便利で、簡略化のために利用されている方は多いと思います。

しかし筆者はこれまで、型観点で懸念があり、使用を控えていました。createSliceで作成されたactionsからは、Action型を推論できないためです。この点については、公式ドキュメントでも言及されているため、以下引用します。

As TS cannot combine two string literals (slice.name and the key of actionMap) into a new literal, all actionCreators created by createSlice are of type 'string'. This is usually not a problem, as these types are only rarely used as literals.

TS は2つの文字列リテラル(slice.nameおよびactionMapのキー)を新しいリテラルに結合できないため、作成されるすべてのactionCreator createSlice は'string'型です。これらの型がリテラルとして使用されることはほとんどないため、これは通常問題にはなりません。

引用:https://redux-toolkit.js.org/usage/usage-with-typescript#generated-action-types-for-slices

「通常問題にならない」とされていますが「これは ReduxToolkit に閉じた世界の話」だと考えています。Middleware、既存コードを Toolkit へ移行するなどのフェーズでは、これは問題になりえる話です。

望ましいAction型

Actionは以下の様に表すことができます。「いずれかのActionが発生し得る」という表現になるため、UnionTypeです。

type Action = {
  type: 'counter/add'
  payload: { amount: number }
} | {
  type: 'todo/add'
  payload: { item: TodoItem }
}

しかしながら、createSlice で作成したactionsからは、上記の型定義を導出することはできませんでした。最大限できても、次の様なものどまり。型観点だけでなくランタイム上でも、type文字列はプロダクトにおいて一意の識別子であるべきです。

type Action = {
  type: string
  payload: { amount: number }
} | {
  type: string
  payload: { item: TodoItem }
}

type文字列で「payloadを識別できる」 という観点が何よりも重要です。これは 「判別可能なUnion型」(Discriminated Union) と呼ばれ、TypeScriptの恩恵を正しく受けることができる、覚えておきたいひとつの型定義テクニックです。

参考:https://typescript-jp.gitbook.io/deep-dive/type-system/discriminated-unions#suitchiswitch

"新しいリテラルに結合できない"が覆った

11月にリリースを予定している、TypeScript4.1 に強力な推論機能が導入されます。Template literal typesと呼ばれるもので、今TypeScript界隈はこの機能で大変賑やかになっています。

この新機能の恩恵により、これまで述べたReduxToolkit の憂慮も過去のものとなりそうです。次の自作サンプルコードがその解方で、TypeScript Playgroundでも確認できるとおり、95行目・99行目が自動で推論されていることに注目です。

この型推論により「Vanilla Redux と Toolkit は同居することが可能になった」ため「createSliceは利用して良い」という判断をしています。

// ______________________________________________________
//
// @ 1. Overload Type Definition for 'createSlice'
// 
type InferPayload<T> = T extends (a1: any, a2: { type: string; payload: infer I }, ...arg: any[]) => any ? I : never
type ActionCreatorT<K> = (() => { type: K }) & { type: K }
type ActionCreatorTP<K, P> = ((payload: P) => { type: K; payload: P }) & { type: K }
type _Actions<N extends string, R, D extends string = '/'> = {
  [K in keyof R]:  InferPayload<R[K]> extends object ?
    ActionCreatorTP<`${N}${D}${K extends string ? K : ''}`, InferPayload<R[K]>> :
    ActionCreatorT<`${N}${D}${K extends string ? K : ''}`>
}
declare function createSlice<
  S,
  N extends string,
  R extends { [k: string]: ((draft: S, action: any) => void) }
>(props: {
  name: N
  initialState: S
  reducers: R
}): {
  name : N,
  reducer : unknown, // FIXME: Reducer<S>
  actions : _Actions<N, R>,
  aseReducers: unknown // FIXME: Record<string, CaseReducer>
}
// ______________________________________________________
//
// @ 2. Create "counter" Slice
// 
const counterSlice = createSlice({
  name: 'counter',
  initialState: { amount: 0 },
  reducers: {
    increment: (draft) => {
      draft.amount++
    },
    decrement: (draft) => {
      draft.amount--
    },
    add: (draft, action: { payload: { amount: number }}) => {
      draft.amount += action.payload.amount
    }
  }
})

// you can get concatted action type. 🎉
const addAction = counterSlice.actions.add({ amount: 1 })
// const addAction: {
//     type: "counter/add";
//     payload: {
//         amount: number;
//     };
// }

// you can get concatted action type. 🎉
const addActionType = counterSlice.actions.add.type
// const addActionType: "counter/add"

// ______________________________________________________
//
// @ 3. Create "todos" Slice
// 
type Todo = { done: boolean; task: string }
const todosSlice = createSlice({
  name: 'todos',
  initialState: [] as Todo[],
  reducers: {
    add: (draft, action: { payload: { todo: Todo } }) => {
      draft.push(action.payload.todo)
    }
  }
})
// ______________________________________________________
//
// @ 4. Infer "Action" type
// 
type R<T> = T extends (...arg: any) => infer I ? I : never;
type ReturnTypes<T> = { [K in keyof T]: R<T[K]> };
type Unbox<T> = T extends { [K in keyof T]: infer I } ? I : never;
type InferSliceActions<T> = Unbox<ReturnTypes<T>>;

// 📝 combine actions
type Action = InferSliceActions<
  | typeof counterSlice.actions
  | typeof todosSlice.actions
>
// ______________________________________________________
//
// @ 5. Use Action Type anywhere 🎉
// 
function anotherReducer(state = {}, action: Action ) {
  switch(action.type) {
    case 'counter/add':
      const amount = action.payload.amount
      // const amount: number 🎉
      break;
    case todosSlice.actions.add.type:
      const todo = action.payload.todo
      // const todo: Todo 🎉
      break;
  }
}

counterSlice を例に、やっていること概要をまとめ

  • name に指定された 'counter' と redusers に含まれる関数名を、文字列連結
  • reducers に含まれる payload の型を抽出
  • 連結文字列とpayload型を突合

この様に、JavaScriptランタイムで行っている様な処理を、型レベルで実現可能となっています。ReduxToolkit だけにとどまらず、メタプログラミング的な書き味で型推論が犠牲になってしまっているライブラリは、これまで数多くありました。

その様なライブラリが、AfterTS4.1の世界では蘇るなど、大きな地殻変動が訪れる予感しかしません。これからもTypeScriptの進化から目が離せませんね。