🐕

【型安全な実装】型安全にvue-i18nの辞書から配列を取得して、変数に設定する【の掘り下げ方】

2024/12/13に公開1

結論

i18nの辞書ファイル

ja.json
{
  "path": {
    "to": {
      "wordList": ["a", "b", "c"]
    }
  }
}

ユーティリティ関数
(今回の主な結論。getI18nArrayを使えば達成できます。)

utils/i18n.ts
/**
 * 引数未指定にすると、普通に`const i18n = useI18n()`とすると入ってくる型になる。
 * 型引数の使い方については、そのままuseI18nの型引数の指定方法を参照のこと。
 */
export type UseI18nReturnType<Options extends UseI18nOptions = UseI18nOptions> =
  Composer<
    NonNullable<Options['messages']>,
    NonNullable<Options['datetimeFormats']>,
    NonNullable<Options['numberFormats']>,
    Options['locale'] extends unknown ? string : Options['locale']
  >

/**
 * @example
 * ```ts
 * import { useI18n } from 'vue-i18n'
 * const i18n = useI18n() // messageは { path: { to: { wordList: ['a', 'b', 'c'] } } } とする
 * const wordList = getI18nArray(i18n, 'path.to.wordList') // ['a', 'b', 'c']
 * ```
 */
export const getI18nArray = (i18n: UseI18nReturnType, key: string): string[] =>
  Object.entries<VueMessageType>(i18n.tm(key)).map(([_, term]) => i18n.rt(term))

配列を要求する側のコンポーネント

ShowWordList.vue
<template>
  <ul>
    <li v-for="word in wordList" :key="word">{{ word }}</li>
  </ul>
</template>

<script setup lang="ts">
defineProps<{
  wordList: string[] // なんらかの事情でArrayを要求している
}>()
</script>

配列を取得して、propsとして渡す側のコンポーネント
(※:wordList:wordList="wordList"の略記です。)

Using.vue
<template>
  <div>
    <!-- ... -->
    <ShowWordList :wordList />
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const i18n = useI18n()
const wordList = getI18nArray(i18n, 'path.to.wordList')
</script>

Draft PRですが以下に、弊社のNuxt QuickStarterリポジトリに、実際のPRを作成しています。

問題

サイトを作っていると、多言語対応をしたいという要望があります。
それを実現するVue向けの方法の一つが、vue-i18nです。

そして今回、多言語化をしつつ、そのi18n辞書から直接配列を取得したいというニーズが出てきました。
つまり以下のような方法では不十分ということです。


i18nの辞書ファイル

ja.json
{
  "path": {
    "to": {
      "item1": "a",
      "item2": "b",
      "item3": "c"
    }
  }
}

配列を取得して、propsとして渡す側のコンポーネント

Using.vue
<template>
  <div>
    <!-- ... -->
    <ShowWordList :wordList />
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const i18n = useI18n()

// !! ここでは個別にアイテムを取得したくない
const wordList = [
  `${i18n.t('path.to.item1')}`,
  `${i18n.t('path.to.item2')}`,
  `${i18n.t('path.to.item3')}`,
]
</script>

i18nの辞書ファイル

ja.json
{
  "path": {
    "to": {
      "wordList": ["a", "b", "c"]
    }
  }
}

配列を取得して、propsとして渡す側のコンポーネント

Using.vue
<template>
  <div>
    <!-- ... -->
    <ShowWordList :wordList />
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const i18n = useI18n()

// !! 一度に配列を取得している
const wordList = getI18nArray(i18n, 'path.to.wordList')
</script>

解決案

調べると、次のような案が見つかりました。


<template>
  <p v-for="name in tm('heroPage.names')" :key="name">
    {{ rt(name) }}
  </p>
</template>

<script>
import { useI18n } from 'vue-i18n'

export default {
  setup() {
    const { tm, rt } = useI18n();
    return { rt, tm }
  }
}
</script>

'here is a example:'より引用


しかし我々が求めているのは、v-for="name in tm('heroPage.names')"することではなく、直接配列を取得することです。
ただしi18n.tm, i18n.rt は覚えておいた方がよさそうです。

次に'vue-i18nでまるっとオブジェクトを取得する - Blogメモφ(..)'を発見しました。
ここでは次のようなコードが例示されています。


for (const [, item] of Object.entries($tm('items.space') as any) as any) {
  console.log(item.text)
}

or

for (const item of Object.values($tm('items.space') as any) as any) {
  console.log(item.text)
}

'vue-i18nでまるっとオブジェクトを取得する - Blogメモφ(..)'より引用)


これは完成に近そうです!
次のようにできれば、解決できそうです。
ついでに、私達の環境向けに書き替えておきます。

const wordList =
  Object.entries(i18n.tm('path.to.wordList') as any)
  .map(([_, item]) => i18n.rt(item))

しかしas anyを使うのは、あまりよくないです。
anyを使用すると、そのコードが正しいのか静的に検証できなくなるからです。
いわゆる「型安全でない」というものです。

(プロジェクト規模が小さい場合は、それでよいかもしれませんが、プロジェクト単位が中規模以上であればanyは使わない方がよいでしょう。)

解決策

i18n.rti18n.tmの存在、およびanyを使った解決策は発見できました。
あとはこれらを使って、anyの代わりに適切な型を指定するだけです。

では、適切な型とは何でしょうか。
ここまできたら、lspをガンガン使って、実地調査をしていきましょう。

Object.entries

まずは「anyを使った解決策」から、Object.entriesが使えることがわかるので、型を見てみます。

entries<T>(o: { [s: string]: T } | ArrayLike<T>): [string, T][];

これをもっと簡単に考えると、次のような感じです。

entries<T>(o: Record<string, T>): [string, T][];

ここのTanyの変わりを指定すれば、よさそうですね。

i18n.tm

明らかにTになるのは、i18n.tmの要素の型、言い換えればi18n.rtの引数の型です。
ではi18n.tmの型を見てみましょう。

tm<Key extends string, ResourceKeys extends PickupKeys<Messages> = PickupKeys<Messages>, Locale extends PickupLocales<NonNullable<Messages>> = PickupLocales<NonNullable<Messages>>, Target = IsEmptyObject<Messages> extends false ? NonNullable<Messages>[Locale] : RemoveIndexSignature<{
  [K in keyof DefineLocaleMessage]: DefineLocaleMessage[K];
}>, Return = ResourceKeys extends ResourcePath<Target> ? ResourceValue<Target, ResourceKeys> : Record<string, any>>(key: Key | ResourceKeys): Return;

なんのこっちゃ?
手動簡約するのはめっちゃがんばればいけそうですが、今回はi18n.rtの型定義がもっと簡単であることに望みをかけて、そちらを見てみましょう。

rt(message: MessageFunction<VueMessageType> | VueMessageType): string;

ビンゴです!
Object.entriesの型指定は、Object.entries<MessageFunction<VueMessageType>>もしくはObject.entries<VueMessageType>になりますね。

ではMessageFunction<VueMessageType>VueMessageTypeのどちらになるのでしょうか?
これはi18n.tmの型定義を調べるのが、前述の通り静的には難しかったので、簡単にChrome DevToolsで調べました。

どうやら関数ではなさそうだったので、VueMessageTypeのようです。

材料は全てあつまりました。
答えは次になります。

const wordList =
  Object.entries<VueMessageType>(i18n.tm('path.to.wordList'))
  .map(([_, item]) => i18n.rt(item))

やった!

応用: 関数化する

本稿の答えは出ましたが、関数化しておくと、より使いやすくなります。

単純に考えて、次のようになりそうです。

utils/i18n.ts
export const getI18nArray = (i18n: ???, key: string): string[] =>
  Object.entries<VueMessageType>(i18n.tm(key)).map(([_, term]) => i18n.rt(term))

???の部分は、useI18nの返り値の型を指定すればよさそうです。
useI18nの型定義を見てみましょう。

export declare function useI18n<
  Options extends UseI18nOptions = UseI18nOptions
>(options?: Options):
  Composer<
    NonNullable<Options['messages']>,
    NonNullable<Options['datetimeFormats']>,
    NonNullable<Options['numberFormats']>,
    Options['locale'] extends unknown ? string : Options['locale']
  >;

実のところ簡単に考えれば、???にはReturnType<typeof useI18n<UseI18nOptions>>を指定すればよさそうですが、実はuseI18nは次のように型引数を取ることがあります。

import ja from '@/locales/ja.json'
const i18n = useI18n<{ message: typeof ja }>()

この場合、???ReturnType<typeof useI18n<UseI18nOptions>>を指定していると型がミスマッチするので、一応柔軟に定義しておきましょう。
次のようになります。

utils/i18n.ts
export type UseI18nReturnType<Options extends UseI18nOptions = UseI18nOptions> =
  Composer<
    NonNullable<Options['messages']>,
    NonNullable<Options['datetimeFormats']>,
    NonNullable<Options['numberFormats']>,
    Options['locale'] extends unknown ? string : Options['locale']
  >

export const getI18nArray = (i18n: UseI18nReturnType, key: string): string[] =>
  Object.entries<VueMessageType>(i18n.tm(key)).map(([_, term]) => i18n.rt(term))

ついにこれで完成です!

const i18n = useI18n()
const wordList = getI18nArray(i18n, 'path.to.wordList')

まとめ

今回はvue-i18nの辞書から配列を取得して、変数に設定する方法を解説しました。
型安全も仮定しています。

もう一度見てみます。

i18nの辞書ファイルは次のようになっています。

ja.json
{
  "path": {
    "to": {
      "wordList": ["a", "b", "c"]
    }
  }
}

今回の解決策は、結局のところ次のようになります。

utils/i18n.ts
/**
 * 引数未指定にすると、普通に`const i18n = useI18n()`とすると入ってくる型になる。
 * 型引数の使い方については、そのままuseI18nの型引数の指定方法を参照のこと。
 */
export type UseI18nReturnType<Options extends UseI18nOptions = UseI18nOptions> =
  Composer<
    NonNullable<Options['messages']>,
    NonNullable<Options['datetimeFormats']>,
    NonNullable<Options['numberFormats']>,
    Options['locale'] extends unknown ? string : Options['locale']
  >

/**
 * @example
 * ```ts
 * import { useI18n } from 'vue-i18n'
 * const i18n = useI18n() // messageは { path: { to: { wordList: ['a', 'b', 'c'] } } } とする
 * const wordList = getI18nArray(i18n, 'path.to.wordList') // ['a', 'b', 'c']
 * ```
 */
export const getI18nArray = (i18n: UseI18nReturnType, key: string): string[] =>
  Object.entries<VueMessageType>(i18n.tm(key)).map(([_, term]) => i18n.rt(term))

上述のgetI18nArrayを使った例を見てみましょう。

これは配列を要求する側のコンポーネントです。

ShowWordList.vue
<template>
  <ul>
    <li v-for="word in wordList" :key="word">{{ word }}</li>
  </ul>
</template>

<script setup lang="ts">
defineProps<{
  wordList: string[] // なんらかの事情でArrayを要求している
}>()
</script>

次がgetI18nArrayを使い、配列を取得して、propsとして渡す側のコンポーネントです。

Using.vue
<template>
  <div>
    <!-- ... -->
    <ShowWordList :wordList />
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const i18n = useI18n()
const wordList = getI18nArray(i18n, 'path.to.wordList')
</script>

このようにして、vue-i18nの辞書から配列を取得して、変数に設定することができます。

おつかれさまでした!


Draft PRですが以下で、弊社のNuxt QuickStarterリポジトリの、実際のPRを見ることができます。


以下、引用したコードのライセンス条項です。

vue-i18n(MIT)

vue-i18n

The MIT License (MIT)

Copyright (c) 2016 kazuya kawaguchi

Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Discussion

あいや - aiya000あいや - aiya000

TODO:

この場合、???にReturnType<typeof useI18n<UseI18nOptions>>を指定していると型がミスマッチするので、一応柔軟に定義しておきましょう。

と前置きしたにも関わらず、柔軟に定義できていない(型引数を受け取っていない)ので、修正する。