SmartHR Tech Blog

SmartHR 開発者ブログ

多言語化対応における TypeScript の型定義を通して開発のしやすさについて考えた

こんにちは、SmartHR でプロダクトエンジニアをしている @nabeliwo です。 今年の9月に SmartHR のログイン後のホーム画面がリニューアルされました。

【9/21更新】新しいホーム画面を公開しました | SmartHR|シェアNo.1のクラウド人事労務ソフト

この記事では、新しいホーム画面の実装の中で、開発者体験を損なうことなく多言語化対応を進められるよう、TypeScript の型定義を工夫した話をします。 まだまだ改善の余地がある状態ではあるのですが、私達のチームでの試行錯誤が読んでくれた方の参考になれば幸いです。

SmartHR の多言語化対応

SmartHR の既存のページではすでに WOVN.io というツールを使った多言語化対応が行われていました。 ただ諸々の理由があり1、新しいプロダクトでは自前で翻訳の仕組みを用意していこうとしています。

実際に、SmartHR のヘルプセンターでは外部ツールを使わずに翻訳の仕組みを実装しています。

今回の新しいホーム画面でもヘルプセンターでの翻訳の仕組みを参考にして実装しました。

多言語化対応の実装

まずこの記事の前提として、どのような仕組みで多言語化対応をしているのか、というのを説明します。

使用しているライブラリは react-intl です。 react-intl は Format.JS が提供している、React 向けの多言語化ライブラリです。

簡単な使い方を説明します。

言語ごとの翻訳ファイルを用意する

// ja.ts
export const ja = {
  'Shared/Close': '閉じる',
}
// ko.ts
export const ko = {
  'Shared/Close': '닫기',
}

アプリケーションのルートで Provider を用意する

// App.tsx
const App = () => {
  return (
    <IntlProvider messages={messages} locale={locale} defaultLocale="ja">
      <Component />
    </IntlProvider>
  )
}

locale props には現在選択している言語を渡します。messages props には現在選択されている言語の翻訳ファイルを渡します。 現在どの言語が選択されているかというのは、cookie や localStorage やその他なんらかの方法を使って自前で制御する必要があります。

翻訳対象の文言を FormattedMessage に置き換える

// Component.tsx
const Component = () => {
  return (
    <FormattedMessage id="Shared/Close" defaultMessage="閉じる" />
  )
}

IntlProvider の messages props に渡した翻訳ファイルに応じて、翻訳された文言が表示されるようになります。 FormattedMessage の id props には、翻訳ファイルのオブジェクトの key を渡します。

例えば、IntlProvider の messages props に ko を渡している場合は、FormattedMessage の id props に Shared/Close を渡すことで、「닫기」が出力されます。

messages props に渡した翻訳ファイルに、id props で渡した文字列が key として存在しない場合や、存在していても value が空文字の場合などは、フォールバックとして defaultMessage に渡した文字列が出力されます。

以上が基本的な翻訳の仕組みです。

まず最初に工夫したこと

ここまで解説した基本的な実装のまま進めた場合、運用していく上でいくつか不安要素があります。

  • 文言の追加時に翻訳が漏れる可能性がある
  • 存在しない id を FormattedMessage に渡してしまう可能性がある
  • 日本語の翻訳ファイルと defaultMessage で日本語の二重管理になる

一つ一つ詳しく解説しつつ、実際にやった対応を紹介します。

文言が追加された場合に翻訳が漏れる可能性がある

各言語ごとに翻訳ファイルを用意する必要がありますが、新たに文言を追加する際に翻訳漏れが起きる可能性があります。 言語ごとの翻訳ファイルが独立しているので、現状だとそれぞれを目で見て漏れていないかを確認する必要があります。

なのでまずは翻訳ファイルを型で縛ることにしました。

// type.ts
export type Messages = {
  'Shared/Close': string
}
// ja.ts
export const ja: Messages = {
  'Shared/Close': '閉じる',
}

これで、文言を追加する場合はまず Messages に型定義を追加して、その後各言語の翻訳ファイルに key value を追加するという流れを踏まなければ型エラーが出るようになりました。

存在しない id を FormattedMessage に渡してしまう可能性がある

react-intl が提供する FormattedMessage の id props の型は string であるため、どのような文字列でも渡せるようになっています。 しかし実際には翻訳ファイルに定義されている key を渡したいはずで、それ以外の文字列は渡せないようにしたいです。

存在しない文字列を id に渡してしまった場合は翻訳ができずに、フォールバックとして defaultMessage に渡した文字列が表示されることになるので、日本語のみで動作確認をした場合は、翻訳できないことに気づかずにリリースされてしまう可能性があります。

それを防ぐために FormattedMessage をラップして id の型を変更しました。

// FormattedMessage.tsx
import { FormattedMessage as ReactIntlFormattedMessage } from 'react-intl'
import { Messages } from './type.ts'

type Props = Omit<ComponentProps<typeof ReactIntlFormattedMessage>, 'id'> & {
  id: keyof Messages
}

export const FormattedMessage: FC<Props> = ({ id, ...props }) => (
  <ReactIntlFormattedMessage {...props} id={id} />
)

これで id props には Messages の key に存在する文字列しか渡せないようになり、翻訳できないミスを防ぐことができました。

日本語の翻訳ファイルと defaultMessage で日本語の二重管理になる

FormattedMessage には defaultMessage を渡す必要があります。 これまでの工夫により、defaultMessage が使用される可能性をある程度減らすことはできましたが、完全に0にはできないため defaultMessage は常に渡しておきたいです。

その際に defaultMessage にそのまま日本語を渡してしまうと、ja.ts で管理している日本語と、defaultMessage に渡している日本語とで、二重管理になってしまいます。

単純に文言の変更があった場合に修正箇所が増えてしまうという問題と、翻訳ファイルのみを修正して defaultMessage の修正を忘れてしまった場合に正しく表示された日本語とフォールバックで表示される日本語とで乖離が起きてしまうという問題があります。

これを防ぐために defaultMessage は自動で渡されるようにしました。

// FormattedMessage.tsx
import { FormattedMessage as ReactIntlFormattedMessage } from 'react-intl'
import { Messages } from './type.ts'
import { ja } from './locale/ja.ts'

type Props = Omit<ComponentProps<typeof ReactIntlFormattedMessage>, 'id'> & {
  id: keyof Messages
}

export const FormattedMessage: FC<Props> = ({ id, ...props }) => (
  <ReactIntlFormattedMessage {...props} id={id} defaultMessage={ja[id]} />
)

props として渡された id を使って日本語の翻訳ファイルのオブジェクトの value を取得して defaultMessage に渡しています。 これでフォールバックとして表示される日本語は翻訳ファイルで定義されているものを使えるようになりました。

以上が、多言語化対応を導入する際に、まず最初にやった工夫です。 ここまでの内容を Pull Request にまとめてチームメンバーに共有しました。

チームメンバーからの要望

ここまで長々と書いてきましたが、実はこの記事はここからが本題です。 上記の内容に関して、チームメンバーからこんな意見をもらいました。

「コンポーネントの記述から日本語がなくなるとコードが見づらくなるので defaultMessage は残してほしい」

最初はその意見がよく理解できなかったのですが、会話を重ねるうちに、なるほど確かにという気持ちになりました。

まず、基本的な react-intl の実装方法であれば UI の実装時に常にそこに日本語ではどんな文言が来るかが見えています。

// Component.tsx
import { FormattedMessage } from 'react-intl'

const Component = () => {
  return (
    <FormattedMessage id="Shared/Close" defaultMessage="閉じる" />
  )
}

しかし今回私が工夫したやり方だとその日本語はコンポーネント上からは見えなくなります。

// Component.tsx
import { FormattedMessage } from './CustomFormattedMessage'

const Component = () => {
  return (
    <FormattedMessage id="Shared/Close" />
  )
}

その上でメンバーの意見をまとめると、

  • 前提としてチームメンバーにはデザイナーがいて、デザイナーは実装しながらデザインや文言を検討している
  • コンポーネントから日本語が消えることで直感的に検討ができなくなる
    • 例えば、見出し・ボタンのラベル・説明文など、主な表示言語である日本語の文字量を考慮してコンポーネントを選定したり、レイアウトを調整しており、これらが近接する場合は、その組み合わせ全体で文字量が多くなりすぎないようにバランスを調整することもあるため、これらがコード上から読みづらくなってしまう
  • id で検索すればそこに入る日本語はわかるが、その1ステップの影響は少なくない

運用の安全性や効率のみを考えていた自分からすると、この意見はとても新鮮で、自分に欠けていた視点であると気づかされました。 チームで開発していく以上、「そこは慣れてください」で終わらせたくないなと思い、安全性・効率を担保しつつ要望を叶えられる方法はないだろうかと議論を重ねました。

現状の解

議論を重ねた結果として行った対応を解説します。 結論としては、FormattedMessage の型定義をさらに工夫することでコンポーネント上に日本語を残しつつも、その日本語が ja.ts と一致することが型システムによって保証されるようにしました。

まず翻訳ファイルの型定義を as const satisfies に変更しました。 この変更による影響は後述しますが、as const satisfies を簡単に説明すると、Messages による ja オブジェクトの型チェックは問題なく行え、かつ値の widening を防ぐことができるようになります。

// ja.ts
export const ja = {
  'Shared/Close': '閉じる',
} as const satisfies Messages

そして FormattedMessage コンポーネントでは、id props をジェネリック型にし、id を元に defaultMessage の型を絞れるようにしています。

// FormattedMessage.tsx
import { FormattedMessage as ReactIntlFormattedMessage } from 'react-intl'
import { Messages } from './type.ts'
import { ja } from './locale/ja.ts'

type Props<T extends keyof Messages> = Omit<ComponentProps<typeof ReactIntlFormattedMessage>, 'id' | 'defaultMessage'> & {
  id: T
  defaultMessage: (typeof ja)[T]
}

export const FormattedMessage = <T extends keyof Messages>(props: Props<T>) => (
  return <ReactIntlFormattedMessage {...props} />
)

ここで ja の型を as const satisfies Messages にしていることで、(typeof ja)[T] で導き出される型が string ではなく、文字列リテラル型になります。

FormattedMessage の型チェックの挙動を見てみると、まず id props の値の絞り込みが行われるようになりました。

Visual Studio Code のキャプチャ。id props が空の状態だとエラーになっており、id に渡すことができる値の候補が一覧表示されている

そして id props の値が決まると、自動的に defaultMessage に入る値が決まり、エディタの自動補完が効くようになります。

Visual Studio Code のキャプチャ。id props に値が入っており、defaultMessage props に「適当な日本語」と入力されており、エラーになっている。エラーの内容は「型 &quot;適当な日本語&quot; を 型 &quot;閉じる&quot; に割り当てることができません。」

これでコンポーネントに defaultMessage として日本語を出しつつ、その日本語は型で縛られているので別の値を入れることができない状態を作ることができました。 安全性・効率を担保しつつ、コンポーネント上で日本語を見えるようにしたいという要望を叶えることができました。

まとめ

この記事では、新しいホーム画面の実装の中で、開発者体験を損なうことなく多言語化対応を進められるよう、TypeScript の型定義を工夫した話をしました。

正直なところ、今回書いた解決策が完璧だとは思ってはいません。 ラップした FormattedMessage は型情報を変更しただけで、与えられた props をそのまま react-intl の FormattedMessage に渡しているだけなので、あまり筋が良いとは言えないかもしれません。

他にも、翻訳ファイルのオブジェクトの key 自体を日本語にするという方法もあり、もしかしたらそちらの方が筋が良いかもしれません。 ただ今回は様々な要素を検討した結果、そのやり方は見送りました。

ただ、今回の解決策が完璧だとは思っていませんが、私達のチームではそれがハマっていて運用もうまくいっているので、今後もしばらくこのやり方で運用を進めていこうと考えています。

私達のチームでの試行錯誤が、これから TypeScript 環境で多言語化対応をする方の参考になれば幸いです。


  1. 長くなってしまうので詳しい理由は省略します。もし興味があれば Twitter の DM やカジュアル面談等で聞いてください。