JavaScriptのタグ付きテンプレートで、JavaのString#formatのような関数を作る

Jestでパラメーター化テストする時の it.each のような、バッククォートで囲った中で ${value} を使う書き方について調べてみたところ、タグ付きテンプレートというらしい。

また、それを使ってJavaString#format のような関数を作れたのでメモ。

タグ付きテンプレートとは

developer.mozilla.org

テンプレートリテラルの拡張形式。タグ付きテンプレートリテラル、ではない模様。

${~} がタグとなり、タグを含んだテンプレートリテラルを関数(タグ関数と呼ばれる)に渡すと、関数内で渡されたリテラルとタグを解析・加工できる。

この、タグ関数へテンプレートリテラルを渡す際の記法が、 関数名`タグ付きテンプレート` となる。

例えば、Jestの (test|it).each によるパラメーター化テストでは、以下のような記述になる。

    it.each`
      value      | expected
      ${''}      | ${'hello world'}
      ${'hello'} | ${'world'}
    `(
      '$value の場合 $expected が返る',
      ({ value, expected }) => {
        expect(doSomething(valule)).toEqual(expected)
      },
    )

やりたいこと

JavaString#format のように、リテラル内に置換用の文字列を埋め込んで、それを渡したパラメーターで置換するような関数を用意したい。

(param1, param2) => `${param1},${param2}` のように関数を用意してやればできるが、毎回関数を宣言するのは面倒。

タグ付きテンプレートでの実現方法

タグ関数から、文字列を生成する関数を、以下のように作る。

タグ関数の第1引数には、タグでSplitされた文字列の配列が渡される。 prefix${0}suffix を渡した場合、 ['prefix', 'suffix'] となる。

第2引数以降には、タグの ${~} で囲まれた数値または文字列が渡される。 ${0},${value},${1} を渡した場合、 0, value, 1 となる。

今回はTypeScriptで記述しているが、その場合、タグ関数の第1引数の型は TemplateStringsArray となる。

/**
 * 生成される関数の引数の型
 */
type MessageTemplateArgments = (string | number | boolean)[]

/**
 * 生成される関数の型
 */
interface MessageTemplate {
  (...argments: MessageTemplateArgments): string
}

/**
 * タグ関数
 */
const tagFunction =
  (templateStrings: TemplateStringsArray, ...tags: number[]): MessageTemplate =>
  (...argments) =>
    templateStrings[0] + // テンプレートの先頭に `${0}` がある場合、空文字になる
    tags
      .map((tag, index) => argments[tag] + templateStrings[index + 1])
      .join('')

タグ関数の第2引数をnumberにすることで、汎用的に使えるようにした。また、TypeScriptだと、 ${0} is ${value} のように、タグに文字列が含まれる場合はコンパイルエラーにしてくれる。

生成された関数には、実行時の引数のインデックス番号を元に、テンプレートリテラルと結合して文字列を生成している。

使い方は以下のようになる。

const template = tagFunction`私の名前は${0}${1}歳です。`
console.log(template('太郎', 30)) // 私の名前は太郎、30歳です。

引数の数や、型の指定などはできないが、毎回タグ関数を書かなくていいので楽。

(厳密にやろうと思えば、数についてはタグの数と渡された引数の数を比較したりはできそう)

振り返り

なんだこの書き方、と思って調べたのが2年ほど前。

トリビア的に「これはタグ付きテンプレートと言ってね...」みたいな話をしていたが、メッセージの国際化で、言語によって埋め込み位置を変えたいときに使えるんじゃないかと思い、試してみたらうまくハマってくれた。

タグ関数内での条件分岐などもできるので、複雑な文字列生成にも使えそうだが、シンプルな使い方でも十分メリットがあった。

追記

記事を公開した直後に、2018年にほぼ同じようなことをしている記事を見つけてしまった。

www.zu-min.com

やってることとかほぼ同じで、こっちのパクリ感がすごくて申し訳ないが、いちおうTypeScript対応しているのが違いということで。