みどりのさるのエンジニア

TypeScriptのテンプレートリテラル型の利用例

2021年02月20日

先日、TypeScriptのテンプレートリテラル型を使う機会がありました。

モチベーション

フロントエンドで i18n を導入するために i18next/i18next を利用して文字列の辞書対応を進めていました。

辞書ファイルを入れ子のオブジェクトとして定義ができますが、キーを指定する場合は common.badge.required と指定する必要があります。TypeScripeで実装をするときの問題として、キーの文字列に対して型チェックが有効にならないため、タイポをしても気づかない問題があります。

import { t } from 'i18next';

const translation = {
    common: {
        badge: {
            required: '必須',
            optional: '任意'
        }
    }
}

export const resources = {
    ja: {
        translation: translation,
    },
} as const;

i18n.init({
    lng: 'ja',
    resources: resources,
})

// badge => bage とタイポしているが実行時まで気づけない
console.log(t('common.bage.required'));

テンプレートリテラル型で型定義

この問題はテンプレートリテラル型を活用してオブジェクトからキーを . で繋いだリテラル型を生成することで解消することができます。

FlattenKeyOf のユーティリティ型は型引数としてオブジェクトの型を受け取り、Mapped Types を利用してキーの一覧を取得します。取得したキーに対応する値の型を Conditional Types で判定しオブジェクト型である場合は、キーを . で先頭に結合して、対応する値のオブジェクトを FlattenKeyOf の型引数に渡して再帰的に呼び出すことで残りのキーを文字列として結合します。値がオブジェクトでない場合はキーをリテラル型として返します。

string & K と書いている部分は Kstring | number | symbol 型として扱われますが、テンプレートリテラル型が受け付ける型はプリミティブ型となっているため、型のミスマッチが発生してエラーが発生するので、Intersection Types を利用して string 型であれば K を返し違う場合は never を返すようにすることで型のエラーを回避しています。

// const translation = { link: { top: 'トップページ' }}
// type keys = FlattenKeyOf<typeof translation> = 'link.top'
// FlattenKeyOf<{link: { top: 'トップページへ' }}> = {
//     ['link']: `link.top` <= `link.${FlattenKeyOf<{top: 'トップページへ'}}`
// }['link']                               ^
//                                         |__ 'top' = FlattenKeyOf<{top: 'トップページへ'}> = { ['top']: 'top' }['top']
type FlattenKeyOf<T extends Record<string, unknown>> = {
    [K in keyof T]: T[K] extends Record<string, unknown> ? `${string & K}.${FlattenKeyOf<T[K]>}` : string & K
}[keyof T]

// common.badge.required | common.badge.optional
type ResourceKeys = FlattenKeyOf<typeof messages>;

// 引数のキー文字列が辞書ファイルのキー文字列のユニオン型に限定されるため、型チェックでエラーとなる。
console.log(t<string, ResouceKeys>('common.bage.required'))