Recoilの階層的なkey管理とMapped Types、Conditional Types、Template Literal Types
Recoil
React用の状態管理ライブラリとして、RecoilというMeta(旧Facebook)が開発中の比較的新しいライブラリがあります。
記事執筆時点での最新バージョンはまだv0.6.1で"experimental"という位置づけですが、GitHubのスター数は約16,000あり、巷でも最近よく名前を聞くようになってきたと個人的には感じます。
Recoilでは状態の起点をatom
として定義します。さらにatomの値をもとに純粋な関数によって計算可能な値をselector
として定義することもできます。同様に他のselectorの値をもとにして計算可能な値もまたselectorとして定義できます。
Recoilの基本的なAPIの使い方等は公式ドキュメントのチュートリアルなどを参照していただくとして、Recoilを使ったアプリケーションでは上記の小さなatomやselectorが複数あり、各Reactコンポーネント内では特定のatomの状態を更新したり所望のatomやselectorの値を購読したりする、というのが基本的なコンセプトになります。
key
Atom、SelectorにおけるRecoilのatomとselectorを定義する際、key
というプロパティに何かしらの文字列を人間が指定する必要があります。公式ドキュメントの"Getting Started"のコード例を以下に引用します:
const textState = atom({
key: 'textState', // unique ID (with respect to other atoms/selectors)
default: '', // default value (aka initial value)
});
const charCountState = selector({
key: 'charCountState', // unique ID (with respect to other atoms/selectors)
get: ({get}) => {
const text = get(textState);
return text.length;
},
});
コメントにも書いてありますが、これらのkey
はアプリケーション全体を通じて他のatomやselectorに対して一意でなければならないという制約があります。この制約を人間側が頑張って担保しようとするのは明らかに無理があるので、何らかの方法でTypeScriptのコンパイラに任せたいところです。
enumによる管理戦略
こちらのブログ記事ではkeyを一元管理するファイルを作成し、enumを用いて制約を保証する手法が紹介されていました。以下にコードを引用します:
export enum RecoilAtomKeys {
TODO_STATE = 'todoState',
NOTICE_STATE = 'noticeState'
}
export enum RecoilSelectorKeys {
TODO_TODOS = 'Todo_todos',
TODO_TODO_ITEM = 'Todo_todoItem',
NOTICE_HAS_UNREAD_NOTICE = 'Notice_hasUnreadNotice'
}
これは確かに良さそうな方法ですね。
key
のprefixを自動で付けたい
上記のenumを用いる管理法は良さそうですが、keyどうしを区別するためのprefixまで含めて直接記載しているので、keyの数が増えていったときに少しだけ見た目が読みにくくなってしまいそうな気もします(筆者の個人的な気持ちですが)。また、まず無いとは思いますがprefixの部分をついうっかり書き間違えてしまう可能性も無くはありません。
export enum RecoilAtomKeys {
TODO_STATE = "todoState",
NOTICE_STATE = "noticeState",
}
export enum RecoilSelectorKeys {
TODO_TODOS = "Todo_todos",
TODO_TODO_ITEM = "Todo_todoItem",
TODO_DONE_ITEM = "Todo_doneItem",
TODO_DEADLINE_OVER_ITEM = "Todo_deadlineOverItem",
// ...
NOTICE_HAS_UNREAD_NOTICE = "Notice_hasUnreadNotice",
NOTICE_FAVORITE_NOTICE = "Noticee_favoriteNotice",
// ...
}
これを例えば、階層的なオブジェクトの形式で書くようにして、プロパティ名が名前空間的な役割を持ち、葉となるプロパティの値はそれまでのprefixを自動で受け継いでくれるようにできないでしょうか……
// 仮のイメージです
export const RecoilAtomKeys = {
todo: {
state: "TodoState",
},
notice: {
state: "NoticeState",
},
};
// 仮のイメージです
export const RecoilSelectorKeys = {
todo: {
todos: "TodoTodos",
todoItem: "TodoTodoItem",
doneItem: "TodoDoneItem",
deadlineOverItem: "TodoDeadlineOverItem",
// ...
},
notice: {
hasUnreadNotice: "NoticeHasUnreadNotice",
favoriteNotice: "NoticeFavoriteNotice",
// ...
},
};
というようなことを思ったので、以下のようなaddPrefix
という簡単な関数を書いてみました:
const addPrefix = <Prefix extends string, Keys extends Record<string, unknown>>(
prefix: Prefix,
keys: Keys
): Record<string, unknown> => {
return Object.fromEntries(
Object.entries(keys).map(([key, value]) => {
const capitalKey = `${key.charAt(0).toUpperCase()}${key.slice(1)}`;
if (typeof value === "string") {
return [key, `${prefix}${capitalKey}`];
} else {
return [
key,
addPrefix(`${prefix}${capitalKey}`, value as Record<string, unknown>),
];
}
})
);
};
addPrefix
はprefix
および管理したいkeyたちが定義されたkeys
オブジェクトを受け取り、再帰的にprefix
を付けてCamelCaseにしつつ返します。
keys
の階層の深さは任意なので引数の型としてはKeys extends Record<string, unknown>
としました。そして戻り値型も素朴に考えるとRecord<string, unknown>
となります(が、これについては後述します)。
このaddPrefix
を使うとRecoilAtomKeys
やRecoilSelectorKeys
は例えば以下のように書くことができます:
export const RecoilAtomKeys = addPrefix("", {
todo: {
state: "state",
},
notice: {
state: "state",
},
});
export const RecoilSelectorKeys = addPrefix("", {
todo: {
todos: "todos",
todoItem: "todoItem",
doneItem: "doneItem",
deadlineOverItem: "deadlineOverItem",
// ...
},
notice: {
hasUnreadNotice: "hasUnreadNotice",
favoriteNotice: "favoriteNotice",
// ...
},
});
addPrefix
関数に型をつける
ところで上記の実装ではaddPrefix
の戻り値型をRecord<string, unknown>
としたので、実際にatomやselectorのkey
として使うときはキャストが必要になります。また型情報としてもunknown
なのでVS Code上で補完が効きません。
const todoState = atom({
key: (RecoilAtomKeys.todo as Record<string, string>).state,
default: {
todos: [],
},
});
これではちょっと不便ですね。なのでaddPrefix
の戻り値にRecord<string, unknown>
よりももっと詳細な型を付けたいと思います。
TypeScriptのMapped Types、Conditional Types、Template Literal Typesの知識を用いてコードをグッと睨むと、こんなふうな型を書くことができます:
type PrefixAddedKeys<
Prefix extends string,
Keys extends Record<string, unknown>
> = {
[Key in keyof Keys]: Key extends string
? Keys[Key] extends string
? `${Prefix}${Capitalize<Key>}`
: Keys[Key] extends Record<string, unknown>
? PrefixAddedKeys<`${Prefix}${Capitalize<Key>}`, Keys[Key]>
: never
: never;
};
TypeScriptのバージョンは記事執筆時点での最新版である4.6.2です。
Mapped Types、Conditional Types、およびTemplate Literal Typesについては公式ドキュメントをはじめとして巷に解説がたくさんあると思いますので、それ自体に関する詳しい説明はここでは省略しますが、上の実装においていくつか特筆すべき点についてコメントします。
Key extends string
上記の型PrefixAddedKeys
の型パラメータのうちの1つであるKeys
は
Keys extends Record<string, unknown>
という制約をつけたので、
{
[Key in keyof Keys]: Key extends string
? // ...
: never;
}
という部分ではKey
は自明にstring
ではないのか、なぜKey extends string
というconditionを書いているのか?と思った方もいらっしゃるかもしれません。
実はRecord<string, unknown>
型の変数には、string型だけでなくnumber型やsymbol型をキーとして持つオブジェクトも代入できてしまうので、何も制約がない状態では上記のKey
の型はstring | number | symbol
となっているのです(この仕様には最初ちょっと驚きました……)。そして後述のCapitalize
が受け取る型パラメータはstring型でなければならないという制約があるので、Key extends string
というconditionを通過した後の世界でないとCapitalize<Key>
というコードはエラーになってしまうのです。
Capitalize
TypeScriptにはJavaScriptのテンプレートリテラルと同様の構文によって、文字列リテラル型をもとに新たな文字列リテラル型を作る事ができるTemplate Literal Typesという強力な機能があります。この機能を使いたい場面で文字列リテラル型を大文字にしたり小文字にしたりしたいことがよくあるので、それ専用の便利な型がTypeScriptに組み込みで4つ[1]用意されており、そのうちの1つがCapitalize
です。
Capitalize
はその名の通り、受け取った文字列リテラルの最初の1文字を大文字にして返してくれます。例を公式ドキュメントから引用します:
type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>;
上記の例でGreeting
は"Hello, world"
という型になります。
addPrefix
の実装を
if (typeof value === "string") {
return [key, `${prefix}${capitalKey}`];
}
とすることにしたので、PrefixAddedKeys
の対応する定義は
Keys[Key] extends string
? `${Prefix}${Capitalize<Key>}`
: // ...
と書けます。
再帰的な型定義
keys
の階層の深さは任意にしたいのでaddPrefix
の実装では再帰的にaddPrefix
を呼んでいましたが、型を定義する場合はどうすれば良いのかというと、このように
Keys[Key] extends Record<string, unknown>
? PrefixAddedKeys<`${Prefix}${Capitalize<Key>}`, Keys[Key]>
: // ...
なんとPrefixAddedKeys
の定義中においてPrefixAddedKeys
を呼ぶことができます。TypeScriptでは(再帰の深さに制限[2]はありますが)このようにConditional Typesの中で再帰的に型を定義することが可能です。
このような定義が果たして本当に動くのか気になるという方のためにPlaygroundをご用意しました。RecoilSelectorKeys.todo.todos
の補完が効いたり、その値が"TodoTodos"
に静的に解決されている様子などを見ることができると思います。
まとめ
-
Recoilの
key
がアプリケーション全体で一意でなければならないという制約を担保するために、階層付けられたオブジェクトの形でkey
を管理すると見通しを良くできそうという考えをご紹介しました。 -
TypeScriptのMapped Types、Conditional Types、およびTemplate Literal Typesという強力な機能を活用し、
key
の生成関数に対する完全な型付けが可能であることを示しました。
Future Work
-
RecoilAtomKeys
やRecoilSelectorKeys
を定義する際、ルートとなるプロパティにはprefixを付ける必要がないので、addPrefix
関数の引数の順番を逆にしてprefix: Prefix = ""
のようにデフォルト引数を指定しておくほうが綺麗だと思います。ところが実際にやってみるとType 'string' is not assignable to type 'Prefix'. 'string' is assignable to the constraint of type 'Prefix', but 'Prefix' could be instantiated with a different subtype of constraint 'string'. ts(2322)
というエラーになってしまいました。筆者の現時点での力ではまだこのエラーを回避する術を見つけることができていません。
-
Recoilのatomとselectorの
key
が一意であるという制約は、オブジェクトに同一の名前のプロパティが複数存在しているとTypeScriptコンパイラがエラーとして検知してくれるということによって担保できているので、プロパティ名だけを用いてkey
となる文字列を生成していれば、末端の葉となるプロパティの値には実用上の意味はありません。なので極端に言えば空文字列でも良いのですが、なんとなくtodos: "todos"
のような値を書いています。悪くはないと思いますが冗長といえば冗長なので、もっと良い定義を考えたいとも思います。
Discussion