🔑

Recoilの階層的なkey管理とMapped Types、Conditional Types、Template Literal Types

2022/03/05に公開

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の値を購読したりする、というのが基本的なコンセプトになります。

Atom、Selectorにおけるkey

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を用いて制約を保証する手法が紹介されていました。以下にコードを引用します:

RecoilKeys.ts
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の部分をついうっかり書き間違えてしまう可能性も無くはありません。

RecoilKeys.ts
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を自動で受け継いでくれるようにできないでしょうか……

RecoilKeys.ts
// 仮のイメージです
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>),
        ];
      }
    })
  );
};

addPrefixprefixおよび管理したいkeyたちが定義されたkeysオブジェクトを受け取り、再帰的にprefixを付けてCamelCaseにしつつ返します。

keysの階層の深さは任意なので引数の型としてはKeys extends Record<string, unknown>としました。そして戻り値型も素朴に考えるとRecord<string, unknown>となります(が、これについては後述します)。

このaddPrefixを使うとRecoilAtomKeysRecoilSelectorKeysは例えば以下のように書くことができます:

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 TypesConditional TypesTemplate 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

  • RecoilAtomKeysRecoilSelectorKeysを定義する際、ルートとなるプロパティには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"のような値を書いています。悪くはないと思いますが冗長といえば冗長なので、もっと良い定義を考えたいとも思います。

脚注
  1. https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#intrinsic-string-manipulation-types ↩︎

  2. https://github.com/microsoft/TypeScript/pull/45711 ↩︎

GitHubで編集を提案

Discussion