ReduxのType System:条件型・ジェネリック推論・型レベルテスト
前提知識
- ›第1〜4回の記事
- ›TypeScript中級:ジェネリクス、条件型、inferキーワード
- ›TypeScriptのユーティリティ型に対する基本的な理解
ReduxのType System:条件型・ジェネリック推論・型レベルテスト
Reduxのランタイムコードはシンプルなことで有名ですが、型システムはその真逆です。TypeScriptの型定義は4つのファイルにまたがり、高度な手法が随所に使われています。inferを使った条件型、reducerマップから状態の形を導出するmapped type、巧妙なUnknownIfNonSpecificガード、intersectionで型拡張を積み上げるコンポーザブルなStoreEnhancerジェネリクスなどがその例です。これらの型は何百万ものTypeScriptプロジェクトで実戦を経てきました。
この記事では、型の全体的な階層をたどりながら推論の仕組みを解説し、Reduxがどのように.test-d.tsファイルを使ってCIレベルで型の正しさを検証しているかを見ていきます。
型の階層:ActionからStoreEnhancerまで
Reduxの型は4つのファイルにまたがる依存チェーンを形成しています。各ファイルは前のファイルの上に成り立っています。
flowchart TD
subgraph "actions.ts"
Act["Action<T extends string>"]
UA["UnknownAction"]
AC["ActionCreator<A, P>"]
end
subgraph "reducers.ts"
Red["Reducer<S, A, PreloadedState>"]
RMO["ReducersMapObject<S, A, PreloadedState>"]
SFRMO["StateFromReducersMapObject<M>"]
end
subgraph "store.ts"
Disp["Dispatch<A>"]
Store["Store<S, A, StateExt>"]
SE["StoreEnhancer<Ext, StateExt>"]
UINS["UnknownIfNonSpecific<T>"]
end
subgraph "middleware.ts"
MW["Middleware<_DispatchExt, S, D>"]
MWAPI["MiddlewareAPI<D, S>"]
end
Act --> UA
Act --> Red
Red --> RMO
RMO --> SFRMO
Act --> Disp
Red --> Store
Disp --> Store
Store --> SE
Disp --> MWAPI
MWAPI --> MW
すべての基盤となるのがActionです。
ここでinterfaceではなくtypeが使われているのは意図的な選択です。TypeScript issue #15300 によれば、Actionがインデックスシグネチャ付きのinterfaceだった場合、それを継承したinterfaceが意図せずインデックスシグネチャを引き継いでしまいます。typeを使うことでこの問題を回避しています。
UnknownActionはActionをベースに、unknown型の値を持つインデックスシグネチャを追加したものです。anyを使わずにactionの任意のプロパティへ安全にアクセスできます。
非推奨となったAnyActionはanyを使う点だけが異なります。型安全性の面では明らかに後退しており、Redux v5ではこちらから離れていきました。
Reducerマップからの状態推論
最も高度な型の仕事が行われるのがreducers.tsです。combineReducers({ users: usersReducer, posts: postsReducer })を呼び出したとき、TypeScriptは結合後の状態の型を自動的に推論する必要があります。その役割を担うのがStateFromReducersMapObjectです。
順を追って見ていきましょう。
-
条件
M[keyof M] extends Reducer<any, any, any> | undefinedはガード処理です。マップの値がreducerでなければ、型はneverに解決されます。 -
mapped type
{ [P in keyof M]: ... }でreducerマップのすべてのキーを反復します。 -
各キーに対して
M[P] extends Reducer<infer S, any, any> ? S : neverという条件型推論で、reducerの型から状態型Sを取り出します。
flowchart TD
A["ReducersMapObject M = {<br/>users: Reducer<User[]>,<br/>posts: Reducer<Post[]><br/>}"] --> B["StateFromReducersMapObject<M>"]
B --> C["For key 'users': M['users'] extends Reducer<infer S> → S = User[]"]
B --> D["For key 'posts': M['posts'] extends Reducer<infer S> → S = Post[]"]
C --> E["Result: { users: User[], posts: Post[] }"]
D --> E
ActionFromReducersMapObjectも同様の仕組みで、すべてのreducerからactionの型をunion型として取り出します。
PreloadedStateShapeFromReducersMapObjectはさらに複雑で、各reducerの入力側の状態型を推論します(PreloadedStateがPartial<S>の場合、出力型と異なることがあります)。
src/types/reducers.ts#L103-L114
ここではReducer<infer S>ではなく、関数シグネチャ (inputState: infer InputState, action: ...) => any にマッチさせて第一引数の型を取り出す方法を採っています。Reducer型は3つのジェネリックパラメータを持ち、PreloadedStateのデフォルトがSであるため、Reducer<infer S>だけではどのパラメータを推論すべきか曖昧になってしまうからです。
UnknownIfNonSpecificとNoInferのトリック
Reduxが持つ型ユーティリティの中でも特に巧妙なのが、たった1行のこちらです。
export type UnknownIfNonSpecific<T> = {} extends T ? unknown : T
これは微妙な問題を解決するためのものです。StoreEnhancerのStateExtパラメータはデフォルトが{}です。どのenhancerも状態を拡張しない場合、StateExtは{}に解決されます。{} extends Tがtrueになるのは、Tが{}またはそれより広い型のときだけで、{ count: number }のような具体的な型には当てはまりません。
つまり、UnknownIfNonSpecific<{}> は unknown を返し、UnknownIfNonSpecific<{ count: number }> は { count: number } をそのまま返します。Store<S, A, StateExt>では getState(): S & StateExt が使われていますが、StateExtが{}のままだと S & {} は S に簡略化されるので一見問題なさそうです。しかしunknownはintersectionの単位元(S & unknown = S)であり、意味論的にもより明確で、{}が引き起こしうる構造的サブタイピングのエッジケースも回避できます。
createStore.tsにあるNoInferトリックは、TypeScriptが型パラメータの推論にどの引数を使うかを制御します。
type NoInfer<T> = [T][T extends any ? 0 : never]
Tをタプルで包み、すぐにインデックスでアクセスし直すことで、TypeScriptは条件型を見てその位置での推論を遅延させます。これにより、戻り値の使われ方ではなく、enhancerからのみExtパラメータが推論されるようになります。
補足: TypeScript 5.4以降では組み込みの
NoInfer<T>ユーティリティ型が追加されました。Reduxが独自実装を持っているのは、古いバージョンのTypeScriptとの後方互換性を保つためです。
StoreEnhancerのコンポーザビリティ
StoreEnhancer型はReduxの型システムの中でも最大の見どころです。
2つのenhancerを合成すると、型が積み上がっていきます。
| Enhancer | Ext | StateExt |
|---|---|---|
applyMiddleware(thunk) |
{ dispatch: ThunkDispatch } |
{} |
devTools() |
{ __DEVTOOLS__: true } |
{} |
compose(applyMiddleware(thunk), devTools()) |
{ dispatch: ThunkDispatch } & { __DEVTOOLS__: true } |
{} |
Middleware型には興味深い特徴があります。最初のジェネリックパラメータの名前がアンダースコアで始まる_DispatchExtとなっています。
src/types/middleware.ts#L22-L30
コメントが示す通り「TODO: 型定義でこれを使えるか確認する(削除不可。最終的なdispatch型の取得に使われているため)」という事情があります。_DispatchExtパラメータは関数シグネチャには現れません。applyMiddlewareがこれを取り出してStoreEnhancer<{ dispatch: Ext }>に渡すためだけに存在しています。
だからこそapplyMiddlewareには1〜5個のmiddlewareに対応するオーバーロードが必要です。
src/applyMiddleware.ts#L24-L52
各オーバーロードはMiddleware<Ext, S, any>からExtを取り出し、StoreEnhancer<{ dispatch: Ext1 & Ext2 & ... }>としてintersectionにまとめます。可変長ジェネリクスだけではこれを実現できません(可変長intersectionは存在しないため)。個別のオーバーロードが唯一の選択肢です。
.test-d.tsを使った型レベルテスト
ReduxはVitestのexpectTypeOfを.test-d.tsファイルで使うことで、TypeScriptの推論を検証しています。これらのテストは実行されません。型チェック時にのみ検証されます。
test/typescript/store.test-d.ts#L60-L76
型テストで使われる主なパターンを整理しておきましょう。
expectTypeOf(x).toEqualTypeOf<T>(): 型が完全に一致することを検証するexpectTypeOf(x).toMatchTypeOf<T>(): 構造的サブタイピングを検証する// @ts-expect-error: 次の行が型エラーになることを検証する
たとえば78行目では、不完全なpreloaded stateでstoreを作成するのが型エラーになることを確認しています。
// @ts-expect-error
createStore(reducer, { b: { c: 'c' }, e: brandedString })
型の退行によってこの行がエラーを出さなくなった場合、@ts-expect-errorをエラーでない行に付けること自体がエラーになるため、テストが失敗します。
test/typescript/store.test-d.tsでは、store作成・dispatch・getState・subscribe・replaceReducer・observableをカバーしており、Storeインターフェースのすべてのジェネリクスを実質的に検証しています。
補足: 複雑なジェネリクスを持つライブラリをメンテナンスしているなら、
.test-d.tsファイルは非常に有効です。ジェネリクスが静かにanyに広がったり、オーバーロードが正しい分岐にマッチしなくなったりといった型の退行を、ランタイムテストでは絶対に検知できません。
次のステップへ
Reduxの型システムの全体像を追ってきました。シンプルなAction<T>を起点に、reducerマップへの条件型推論、UnknownIfNonSpecificガード、コンポーザブルなStoreEnhancerジェネリクス、そして型レベルテストまで。最終回となる次の記事ではソースコードを離れ、ビルドパイプラインへと踏み込みます。Reactにインスパイアされたエラーマングリング、dev/prodのコード分割、成果物のテストを通じて、これら17ファイルをどのように最適化されたマルチフォーマットバンドルへと変換しているかを見ていきます。