[TypeScript] タグ付きテンプレートの書き方 1. 基礎編

[タグ付きテンプレートリテラルで例外発射](https://qiita.com/sugoroku_y/items/e7462cb3ad48f68993a2)ではしれっと流して書きましたが、タグ付きテンプレートの実装方法について書こうと思います。

JavaScriptやTypeScriptのタグ付きテンプレートとは、 

```ts
tag`
ここに書かれた文字列がテンプレート文字列となる。
${value}のように書くと`value`の内容が挿入される。
改行があっても大丈夫。
`;
```

のように記述すると、文字列リテラルの配列と値をタグで指定された関数に渡してくれるという仕組みです。

まず今回は通常のテンプレートリテラルと同じく文字列を構築するだけのタグ付きテンプレートを実装してみます。

## 仕様

タグ付きテンプレートは以下のような仕様の関数になっています。

```ts
/**
 * @param {TemplateStringsArray} template 文字列リテラルの配列
 * @param {unknown[]} values 挿入される値の配列
 */
function basic(
    template: TemplateStringsArray,
    ...values: unknown[],
): string {
  // ...
}
```

`basic`がタグ付きテンプレートの名前になります。

実装によっては第2引数以降の`values`や返値の型が変化することがあります。

この`basic`という名前のタグ付きテンプレートは以下のように使います。

```ts
console.log(basic`abcdefg${'value1'}hijklmn${12345}opqrstuvwxyz`);
```

このとき`basic`関数は以下の引数で呼び出されます。

```ts
basic(['abcdefg', 'hijklmn', 'opqrstuvwxyz'], 'value1', 12345);
```

正確にはちょっと違いますが大体こんな感じです。

この通り、templateの文字列リテラルの間にそれぞれ挿入される値が挟まっているので

```js
template.length === values.length + 1
```

となります。

また、たとえ文字列リテラルが空文字列だったとしても、少なくとも一つは文字列リテラルが指定されるので、

```js
template.length >= 1
```

となります。

通常の関数と同じような呼び出し方をするとか、仕様外の使われ方をされなければ、これらは前提にしておいていいでしょう。

## 実装:基本

まずは通常のテンプレートリテラルのように文字列を構築するタグ付きテンプレートを実装してみましょう

templateで指定されている文字列の配列の間に、引数で指定された値を文字列に変換して挿入していくことになります。

単純に実装するなら、こんな感じ。

```ts
function basic(
  template: TemplateStringsArray,
  ...values: unknown[],
): string {
  let result = '';
  for (let i = 0; i < template.length; i++) {
    result += template[i];
    if (i < values.length) {
      result += String(values[i]);
    }
  }
  return result;
}
```
 
ただ前述の通り、`template.length`は必ず1以上で、values.lengthはそれより一つ少ない、という前提条件持つので、reduceメソッドの第2引数を省略したパターンが使えます。

```ts
function basic(
    template: TemplateStringsArray,
    ...values: unknown[],
): string {
  return template.reduce((r, e, i) => r.concat(String(values[i - 1]), e));
}
```

このパターンのreduceではiは0からではなく1から始まるので1を引いてやる必要があります。

もうひと工夫して1引かなくてもいいようにしてみましょう。

```ts
function basic(...args: [TemplateStringsArray, ...unknown[]]): string {
  return args[0].reduce((r, e, i) => r.concat(String(args[i]), e));
}
```

引数をまとめて`args`にすることで`values[i + 1]`が`args[i]`になりました。

## 実装:生

JavaScriptに標準で用意されているタグ付きテンプレートに`String.raw`があります。これも実装してみます。

```ts
function basicRaw(...args: [TemplateStringsArray, ...unknown[]]): string {
  return args[0].raw.reduce((r, e, i) => r.concat(String(args[i]), e));
}
```

なんと、`basic`の実装に`.raw`を追加しただけです。

タグ付きテンプレートの仕様として第1引数は単なる文字列の配列ではなく`raw`という名前の文字列の配列型のプロパティがあり、こちらにはエスケープされたままの文字列が格納されているので、これと差し替えるだけで`String.raw`と同じ実装になります。

### 考察

…ということはエスケープシーケンスとして受け付けられない文字がきたらどうなるんでしょうか。実際に試してみましょう。

テンプレートリテラルには8進数の文字コードエスケープシーケンスが利用できないという仕様があります。これを利用して試してみます。

まずは普通の文字列。タグ付きテンプレートは単なる関数呼び出しなので、何でも引数として受け付ける`console.log`もタグ付きテンプレートとして使ってみます。

```cmd
> node -e "console.log`abcdefg${123}hijklmn${'QWERT'}opqrstu`"
[ 'abcdefg', 'hijklmn', 'opqrstu' ] 123 QWERT
```

想定通りの仕様で呼び出されています。

次に8進数エスケープシーケンスを使った文字列で呼び出してみます。

```cmd
> node -e "console.log`abc\000defg${123}hijklmn${'QWERT'}opqrstu`"
[ undefined, 'hijklmn', 'opqrstu' ] 123 QWERT
```

8進数エスケープシーケンスを使ったところが`undefined`になってしまいました。

つまりエスケープシーケンスとして正しくないものは`undefined`になってしまい、エスケープされたままの文字列が格納されている`raw`プロパティを見ないと、どこで間違っているかの確認もできない、ということになります。

また`basic.raw`の方は`raw`プロパティを使うので問題ないですが、`basic`の方だとテンプレート文字列の中で8進数エスケープシーケンスを使われると、ぬるぽになってしまいます。

## 実装:安全版

そこで`raw`プロパティを自前でエスケープの解除を行うことで、安全にテンプレート文字列を構築できる**安全版**を用意しておきたいと思います。

まずはエスケープシーケンスの解除を行う関数を用意します。

```ts
/**
 * エスケープシーケンスのうち、固定変換マップ
 */
const UNESCAPE_MAP = {
  '\\b': '\b',
  '\\f': '\f',
  '\\n': '\n',
  '\\r': '\r',
  '\\t': '\t',
  '\\v': '\v',
  // 改行前に`\`があれば`\`ごと削除
  '\\\r': '',
  '\\\n': '',
  // 8進数エスケープシーケンスの中でも`\0`だけは例外的に許可
  '\\0': '\0',
} as const;

const ESCAPE_SEQUENCE = /\\[\s\S](?:(?<=0)[0-9]|(?<=x)([0-9A-Fa-f]{2})|(?<=u)(?:\{([0-9A-Fa-f]{1,6})\}|([0-9A-Fa-f]{4})))?/g;

function unescapeSafe(s: string): string {
  return s.replace(
    ESCAPE_SEQUENCE,
    (
      /** マッチした文字列全体 */
      match,
      /** 1つ目のキャプチャ。`\xXX`のXX部分 */
      $1: string | undefined,
      /** 2つ目のキャプチャ。`\u{XXXXX}`のXXXXX部分 */
      $2: string | undefined,
      /** 3つ目のキャプチャ。`\uXXXX`のXXXX部分 */
      $3: string | undefined,
      /** マッチした箇所のインデックス */
      index: number,
    ) => {
      if (match in UNESCAPE_MAP) {
        // 固定変換
        return UNESCAPE_MAP[match as keyof typeof UNESCAPE_MAP];
      }
      const ch = match.charAt(1);
      switch (ch) {
        case '0':
        case '1':
        case '2':
        case '3':
        case '4':
        case '5':
        case '6':
        case '7':
          // 8進数文字コードのエスケープはテンプレートリテラルで禁止されているのでここでも禁止
          // ただし`\0`(その後に数字の続かないもの)だけはUNESCAPE_MAPで対応済みなのでここには来ない
          return processError(
            `Octal escape sequences are not allowed in indented tagged templates.`,
          );
        case '8':
        case '9':
          // \8と\9も同様に禁止
          return processError(
            `\\8 and \\9 are not allowed in indented tagged templates.`,
          );
        case 'u':
        case 'x': {
          // 16進数部分
          const hex = $1 ?? $2 ?? $3;
          if (hex === undefined) {
            // 16進数部分が存在しない => 正しい形式ではなかった
            return processError(
              ch === 'u'
                ? `Invalid Unicode escape sequence`
                : `Invalid hexadecimal escape sequence`,
            );
          }
          // 文字コード
          const code = parseInt(hex, 16);
          if (code > 0x10ffff) {
            // Unicodeの範囲外だった(-がパターンにないので負数は考えなくて良い)
            return processError(`Undefined Unicode code-point`);
          }
          // 文字コードから文字へ
          return String.fromCodePoint(code);
        }
        default:
          // 単なるエスケープはそのまま`\`だけ削除
          return match.slice(1);
      }
      function processError(message: string) {
        // エラーがあった該当行を抽出してエラー箇所を表示
        const bol = s.lastIndexOf('\n', index) + 1;
        const eol = (i => (i < 0 ? s.length : i))(s.indexOf('\n', bol));
        // エラーのあった該当行
        const line = s.slice(bol, eol);
        // エラーのあった位置までインデント
        const colPadding = ' '.repeat(index - bol);
        // エラー箇所の文字数にあわせる
        const mark = '^'.repeat(match.length);
        // ログ出力
        console.warn(`${message}\n${line}\n${colPadding}${mark}`);
        // エラー発生時も`\`だけ削除
        return match.slice(1);
      }
    },
  );
}
```

長々と書きましたが要はエスケープシーケンスが不正なとき、ログに出力した後は`\`を除去するだけで続行する、という実装になっています。

これを使って`raw`プロパティを変換してやります。

```ts
function basicSafe(...args: [TemplateStringsArray, ...unknown[]]): string {
  return args[0].raw
    .map(unescapeSafe)
    .reduce((r, e, i) => r.concat(String(args[i]), e));
}
```

どうせなので不正なエスケープシーケンスがあれば例外を投げる`unescape`関数を用意して元の`basic`も書き換えてしまいましょう。

```ts
function basic(...args: [TemplateStringsArray, ...unknown[]]): string {
  return args[0].raw
    .map(unescape)
    .reduce((r, e, i) => r.concat(String(args[i]), e));
}
```

こうすることで不正なエスケープシーケンスを書いてしまったときにどの部分が問題なのかを特定しやすくなりました。

## まとめ

今回実装したコード+αは以下のリポジトリにまとめてあります。

https://github.com/sugoroku-y/tagged-templates

今回書いたbasic以外のタグ付きテンプレートの実装についても、いずれそのうち解説したいと思います。