正規表現の名前付きキャプチャに型を導入する試みで考えたこと

（[toggle holdings Engineer Blog](https://engineer-blog.toggle.co.jp/) にも[同じ内容の記事を転載しています](https://engineer-blog.toggle.co.jp/entry/2024/12/14/201512)）

@xtetsuji です。2003年から20年ほど、サーバで Perl を書いてログ処理や簡単なWebアプリケーションを書いたりする仕事をしていたのですが[^perl-at-2003]、2023年夏に[トグルホールディング](https://toggle.co.jp/)へジョインして TypeScript を書いています[^other-programming-language]。

[^perl-at-2003]: 2003年当時、動作実績が豊富という条件において、文字列を扱うことに長けたプログラミング言語の選択肢は Perl 1択だったのですが、何年もお世話になったことの感謝でずっと使い続けています。自分で言うのもなんですが、鳥の雛っぽい。

[^other-programming-language]: キャリアの中では、Ajax が出始めた時代に新たなアプリケーション設計を jQuery でやったり、Google Apps Script をひたすら書いて業務効率化をしていたので、JavaScript の基礎文法に不安はありませんでした。TypeScript の基本的な型文法はすぐ覚えられるものですが、多数の手法があるトランスパイル周辺の環境構築が鬼門。先人が構築した環境の上で開発できるのはありがたい。

この記事では、正規表現の基礎は既知のものとして進めます[^base-knowledge-regexp]。

[^base-knowledge-regexp]: 正規表現の基礎、具体的には [第58回　正規表現の勘所―わかりづらい記法の覚え方、先読みや後読みの実践（1） | gihyo.jp](https://gihyo.jp/dev/serial/01/perl-hackers-hub/005801) の「Perl正規表現の基本」で列挙されているものです。

## 正規表現「Perl から来ました」

Perl から来た人がつい書いてしまうのが正規表現。私も正規表現にたくさん助けられました。

JavaScript も当初から言語コアに導入された正規表現ですが、同世代の Perl の正規表現を多く参考にして生まれました。[MDN Web Docs](https://ja.wikipedia.org/wiki/MDN_Web_Docs) の [正規表現 - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Regular_expressions) の冒頭でも

> 正規表現（略して regex）は、開発者が文字列をパターンと照合したり、部分一致情報を抽出したり、単に文字列がそのパターンに適合するかどうかを検査したりすることができます。正規表現は多くのプログラミング言語で使われており、 **JavaScript の構文は Perl から影響を受けています** 。

と書かれています。

私 (@xtetsuji) も正規表現も、Perl から JavaScript にやって来た！？

## 正規表現の立ち位置

正規表現は Perl や JavaScript をはじめとしたプログラミング言語に標準搭載され役立つ一方で、2010年代の一時期「正規表現を書くと、問題を一つ解決する代わりに、正規表現を保守運用するという問題が一つ生まれる」といったネガティブな言説が定期的に流れ、実際に正規表現を使わないよう忌避する空気もありました。

ただ、2020年代に入ってそういう言説はあまり聞かなくなったと同時に、経験の多寡に関わらずコードレビューで（先後読み等を駆使した）初歩的なレベルを超えた正規表現が提出され、かつ問題なく採用される風景も私の周囲でよく見られるようになりました[^regexp-atmosphere-2020]。

[^regexp-atmosphere-2020]: プログラマー全体のレベルが底上げされたことも要因かもしれませんが、やはり **生成AIの登場による要因が決定的** でしょう。名前をつけた関数に正規表現を閉じ込めてテストを手厚く書いておけば意味もわかるし、保守運用の必要性に迫られた場合も生成AIに問いかけやすい小粒な粒度なのが正規表現なのだと思います。長い桁数の数値に3桁カンマを打つ（正規表現界隈で有名な）置換正規表現 `str.replace(/(?<=\d)(?=(?:\d\d\d)+(?!\d))/g, ',')` をソラで書ける必要などありませんし、読めなくてもいい時代になったのです。そういう意味で生成AIの時代は正規表現の復興につながっていると感じてしまいます。チームでプログラミング開発をしている場合、正規表現に限らず属人化解消を考慮した保守運用を考慮して難読なものは避けて平易にするべきなのは今も昔も変わりませんが、生成AIの登場でその避けるべきボーダーラインが動いているように見えるのは興味深いです。

正規表現の「復権」というと大げさですが、せっかく言語コアで使えるのですから、短く所望の処理が書ける正規表現を場面に応じて適切に活用されていって欲しいと感じます。

## キャプチャグループと名前付きキャプチャグループ

ようやく本題。

正規表現には、マッチした一部分を取り出す **キャプチャグループ** という文法があります。

- [キャプチャグループ: (...) - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Regular_expressions/Capturing_group)

正規表現が文字列にマッチすると、その正規表現の中にあるキャプチャグループは、その登場順序[^capture-group-order]にしたがって、マッチ配列の index 1 から割り当てられます[^match-array-index-0]。

```javascript
const datestr = '今は 2024-12-14 11:22:33 です';
const match = datestr.match(/(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)/);
console.log(match[0]); // => 2024-12-14 11:22:33
// index 1 から index 6（7 === match.length の直前）まで
console.log(match.slice(1, match.length)); // => [ '2024', '12', '14', '11', '22', '33' ]
```

[^capture-group-order]: 具体的には、正規表現を左から右に読む過程で出会う開き丸括弧の登場順序です。

[^match-array-index-0]: マッチ配列の index 0 は、キャプチャグループの有無にかかわらず、マッチした文字列全体が入っています。

しかし、（まさに上記の例のように）一つの正規表現の中に多数のキャプチャグループを定義した場合、その順番を数字で指定することは難しくなります。また、新たなキャプチャグループを既存のキャプチャグループ群の前に入れると、その後に置かれる既存のキャプチャグループの index の番号がずれる問題に悩まされます。さながら、コマンドライン引数や関数の仮引数にある問題と同様です。

そこで登場するのが **名前付きキャプチャグループ** です。

- [名前付きキャプチャグループ: (?<name>...) - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group)

この名前付きキャプチャグループを入れた正規表現が文字列にマッチすると、名前付きキャプチャグループの名前をキーとしてキャプチャ結果を値としたオブジェクトをマッチオブジェクト `match` から `match.groups` として参照することができます。


```javascript
const datestr = '今は 2024-12-14 11:22:33 です';
const match = datestr.match(/(?<year>\d\d\d\d)-(?<month>\d\d)-(?<day>\d\d) (?<hour>\d\d):(?<minute>\d\d):(?<second>\d\d)/);
console.log(match[0]); // => 2024-12-14 11:22:33
console.log(match.groups);
// => {
//   year: '2024',
//   month: '12',
//   day: '14',
//   hour: '11',
//   minute: '22',
//   second: '33'
// }
```

ただ、現状の TypeScript（2024年現在 5.7）において、この `match.groups` の TypeScript 型は `{[key in string]: string} | undefined` 型であり、名前付きキャプチャグループの名前を参照はしてくれません。

将来の TypeScript バージョンアップでの対応に期待しつつ、低コストな方法で名前付きキャプチャグループから `match.groups` の型推論してくれないものでしょうか。

しかし私自身 TypeScript で知っていることは、平凡なデータ構造に平凡な型を付けることくらいです。一方で、プログラマーの記載を読み取って高度な型推論をする TypeScript 製アプリケーションも多数存在します。頑張ればできるのでは……？

TypeScript の型推論を探求してみましょう。

## TypeScript の表現力と文字列処理

TypeScript には **リテラル型** という、ソースコード中に書かれた固定の基本型の値を表す型があります。

- [リテラル型 (literal type) | TypeScript入門『サバイバルTypeScript』](https://typescriptbook.jp/reference/values-types-variables/literal-types)

文字列である string 型も基本型であるため、たとえば `'Hello'` という文字列を固定で表す `'Hello'` 文字列リテラル型が定義できます。

```typescript
const person = {
    name: 'xtetsuji',
    greeting: 'こんにちは',
};
const greeting = 'Hello';
```

型が明記されていない上の例において、 `person.greeting` は string 型として推論されるのですが、`greeting` は `string` 型より制約が強い `'Hello'` （文字列）リテラル型として推論されます。前者はプロパティ指定の再代入の可能性があるけれど、後者は再代入の心配が（それこそ JavaScript レベルで）無くイミュータブルな基本型[^immutable-basic-type]であるためでしょう。

[^immutable-basic-type]: **イミュータブル** とは不変であるという意味であり、プログラミング言語においては構造を変更・破壊できないことを意味します。Perl や Ruby の場合、文字列はイミュータブルではなく、greeting 相当の文字列変数がある場合、 `$greeting =~ s/e/a/` または `greeting.gsub(/e/, 'a')` といった方法で変数に代入された文字列を変更することができます。このようなイミュータブルの対となる概念を **ミュータブル** といいます。JavaScript の文字列はメソッド呼び出しを伴いますが、[メソッド一覧](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String)を見ても破壊を伴うメソッドがないことがわかるでしょう。[Primitive (プリミティブ) - MDN Web Docs 用語集: ウェブ関連用語の定義 | MDN](https://developer.mozilla.org/ja/docs/Glossary/Primitive) や [ボックス化 (boxing) | TypeScript入門『サバイバルTypeScript』](https://typescriptbook.jp/reference/values-types-variables/boxing) も参照下さい。

<details><summary>型推論を省略せずに書く場合</summary>

```typescript
type Person = {
    name: string
    greeting: string
}
const person: Person = {
    name: 'xtetsuji',
    greeting: 'こんにちは',
};
const greeting: 'Hello' = 'Hello';
```

`const greeting` に関しては TypeScript 初歩の型推論に期待できる部分であり、同じものの二度書きになる上記のような自明な型指定は書かず、型推論に任せるべきでしょう。

</details>


そして、TypeScript 世界での文字列リテラル型を使って文字列処理を行うこともできます。

TypeScript には、プログラムの3要素とも言える以下の処理を表現することができます。

- 変数代入
    - `type Foo = ...`
- 条件分岐
    - `T extends U ? TrueCaseT : FalseCaseT`
- 繰り返し → 再帰
    - `type Deep<T> = (T | Deep<T>)[]`

繰り返しに関しては、多くのプログラミング言語にある for や foreach といった機構は無いのですが、ジェネリクスを使った一定レベルの深さの再帰型定義は可能なので、これで繰り返しを表現するのが TypeScript の型プログラミングの定石のようです。

これらの機構が備わっていることで通常のプログラミング言語相当の様々な処理が可能[^typescript-turing-complete]なようです。TypeScript も再帰型定義ができることで様々な処理が可能となりますが、一方で TypeScript の再帰型の再帰評価回数は1000程度に制限されているため[^typescript-recursive-limit]、一定の制限は課せられると思います。

[^typescript-turing-complete]: これを指して TypeScript は [**チューリング完全**](https://ja.wikipedia.org/wiki/%E3%83%81%E3%83%A5%E3%83%BC%E3%83%AA%E3%83%B3%E3%82%B0%E5%AE%8C%E5%85%A8) であるとも言われます。一方で、再帰回数の上限が厳しかったり、用意された限られた文法によって、必ずしも通常のプログラミングのような表現力にはならないでしょうが、JavaScript に型を導入するといった領域特化言語 [DSL](https://ja.wikipedia.org/wiki/DSL) である TypeScript にとって、ほとんどの場合問題とはならないでしょう。手に取りやすいところ十徳ナイフのような何でもできるものを置かないのも戦略です。

[^typescript-recursive-limit]: TypeScript 5.5 までは 50程度だった再帰回数の制限が、TypeScript 5.6 になった際に 1000程度となったようです。例えば [TypeScriptの型で遊ぶ時、再帰制限を（合法的に）突破する #TypeScript - Qiita](https://qiita.com/kazatsuyu/items/44c1b012d66aae1dc2c0) や [TypeScriptにおける型の再帰回数の限界](https://zenn.dev/todesking/articles/402ebd8bc2b3fd) 参照。

このあたり、[TSKaigi Kansai 2024](https://kansai.tskaigi.org/) で発表された [TypeScriptの型システムは万能機械の夢を見るか？](https://kansai.tskaigi.org/talks/canalun)（[スライド](https://docs.google.com/presentation/d/1hnvPiSCI2UBIZ0x5OTl3ZFHvIGMIl3g37It1_Qd7hNA/edit#slide=id.g31369b7cd88_1_473)、[動画](https://www.youtube.com/watch?v=kEs6LHdHTI0)） などが詳しいです。

## Template Literal Type の infer で文字列を取り出す

文字列処理も、書字方向から文字を繰り返し走査、文字を取り出して変数代入する処理に帰着されることから、TypeScript でもできそうだと推測できます。

こちらも、TSKaigi Kansai 2024 で発表された [アプリ文言のパースで学ぶ文字列Literal型パズル入門](https://kansai.tskaigi.org/talks/sajikix)（[スライド](https://speakerdeck.com/sajikix/apuriwen-yan-nopasudexue-bu-wen-zi-lie-literalxing-pazururu-men)、[動画](https://www.youtube.com/live/-1qNnAPEK8E?si=OJ1FaF0nazWw9Apn)）や各種オンライン記事での言及があります。

例えば、与えられた文字列を分割して文字列リテラル型のユニオン型を定義する SplitToUnion 型を実装してみましょう。

```typescript
type SplitToUnion<S extends string, Sep extends string> =
    S extends `${infer L}${Sep}${infer R}`
    ? L | SplitToUnion<R, Sep>
    : S
type ABCUnion = SplitToUnion<'a,b,c', ','> // => 'a' | 'b' | 'c'
```

上記のように string の部分型として得られる方が使い勝手が良さそうですが、配列にタプル的に入っていると使いやすい場面もありそうです。書いてみましょう。

```typescript
type SplitToTupple<S extends string, Sep extends string> =
    S extends `${infer L}${Sep}${infer R}`
    ? [L, ...SplitToTupple<R, Sep>]
    : [S]
type ABCTupple = SplitToTupple<'a,b,c', ','> // => ['a', 'b', 'c']
```

詳細は上記スライドや各種オンライン記事に譲るとして、以下のポイントを抑えると良さそうです。

- `<>` で表されるものは **ジェネリクス** と呼ばれるもの。型定義の可変部分を抜き出して関数のようにしたもの。これは TypeScript の利用側としてもよく出てくる文法です
- JavaScript の 3項演算子と類似の ` ? : ` で条件分岐をしている
- バッククォートで囲まれている部分は **テンプレートリテラル型 (Template Literal Type)** と呼ばれるもの。JavaScript のテンプレートリテラルのように、string 型の部分型を作ることができる。`${...}` に入れることができるのは TypeScript が文字列リテラル型に変換可能な `string | number | bigint | boolean | null | undefined` 型の部分型です
- extends は、ジェネリクス内で使われる場合と `? :` の真偽値部分で使われる場合、それぞれ若干意味の違いがある
    - ジェネリクス内で使われた場合は、その可変型は `extends` の右側の部分型に制限される。制約を満たさない場合は型エラーになる
    - `? :` の真偽値部分で使われる場合、この制約が正しく守られている場合は真
- **`infer X`** は、「ここに型が置かれるとしたら何になりますか？」と TypeScript の型推論を促し、型推論が成功した場合 `X` にその型が代入される。主に `? :` の真偽値部分で使われ、真の場合のところで参照される
    - テンプレートリテラル型以外でも使われる
    - テンプレートリテラル型の中に埋め込まれた場合、string の部分型つまり様々な文字列リテラル型のパターンを取ってくれる


テンプレートリテラル型に、複数の `${infer X}` が埋め込まれた場合、どちらの infer が多くの文字を取るのかは、 [Template Literal Types と infer の挙動について #TypeScript - Qiita](https://qiita.com/SiZK/items/eb1fd96e28ddbb72cdfd) がとても参考になりました。私がこの記事を読んで理解したのは以下です。

- テンプレートリテラル型の `${infer X}` は左側から評価され、マッチパターンが複数ある場合、1文字以上のなるべく短い文字列リテラル型にマッチする
    - 正規表現マッチなどでは、このような挙動のことを **最短マッチ** とも呼ばれます[^regexp-match-length-strategy]
    - 0文字、つまり空文字の文字列リテラル型になることは可能な限り避ける。なのでマッチパターンの文字列の長さの優先順序は、高い順番に `1,2,3,4, ..., 最大長, 0` の順番[^infer-string-length-priority-order]
- 空文字の文字列リテラル型は許される場合と許されない場合がある
    - 1個は許可される
    - 走査する対象文字列（リテラル型）において、走査カーソルが同じ位置で2回 `infer` の型推論が空文字の文字列リテラル型になると never 型になる
        - 元記事では Test3 型で文字列の末尾について考察していましたが、末尾でなくても走査カーソルが同じ位置であれば末尾でなくとも2回以上の空文字の文字列リテラルの推論を NG とするようです

[^regexp-match-length-strategy]: 正規表現の場合、量指定子 `?` `*` `+` はなるべく長い文字列を取ろうとして、このことを**最長マッチ**と呼びます。正規表現の場合、`.+` の代わりに `[a-zA-Z0-9]+` といった、文字や文字列を適切に制限できるのですが、テンプレートリテラル型の `${infer X}` はこのような手法が取れないので、最初から最短マッチの戦略を取っているのだと思われます。一方で、文字列を少しずつ読んでいって判断していく場合、最短マッチの方が都合良いのは当然で、正規表現でも、前述の最長マッチに対応する最短マッチ版量指定子 `??` `*?` `+?` がそれぞれ用意されています。こちらは自分の箇所で複数のパターンによる全体マッチが成立する場合なるべく短い文字列を採用しますし、 `??` と `*?` においては 0文字すなわち空文字で良いのであれば最優先で空文字を採用します。 node の対話コンソールで `"aaa".match(/(a*?)(a*)/)` などと打ってみるとその事がわかるでしょう。

[^infer-string-length-priority-order]: **1文字以上のなるべく短い文字列にマッチする、しかし全体のマッチが阻害されるなら 0 文字を取ることもあるというマッチを正規表現で書くとすると `(?:.+?)?` でしょう** 。 `+?` は左側がなるべく1個以上の短い文字列を取ろうとしますが、0個は想定されていないので、左側が0個か1個のどちらかであることを示す意味で最後に `?` を置いています。0個か1個かの `?` は、両方に置いてマッチが整理するなら1個の方を選択するためです。この点に置いて `.*?` は最初から0個を優先としてしまうため不適格です。

<details><summary>走査カーソルが同じ位置であれば末尾でなくとも2回以上の空文字の文字列リテラルの推論を NG とする説明：</summary>

```typescript
type ParseDate<Date extends string> =
    Date extends `${infer Y}-${infer M}-${infer D}`
        ? [Y, M, D]
        : never

```

において

```typescript
type Foo1 = ParseDate<'--'>
```

は `['', '', '']` 型になりますが、これは推論場所の走査カーソルの場所が違うからだと理解しました。

例えば

```typescript
type ParseMiddleThreeString<Date extends string> =
    Date extends `${infer Y}-${infer M1}${infer M2}${infer M3}-${infer D}`
        ? [Y, [M1, M2, M3], D]
        : never
```

を定義したところ、

```typescript
type Foo2 = ParseMiddleThreeString<'-01-'>
```

は `['', ['0', '1', ''], '']` 型になりますが、

```typescript
type Foo3 = ParseMiddleThreeString<'-0-'>
```

は `never` となります。

</details>

## 正規表現が文字列リテラルで定義された場合、名前付きキャプチャを取り出す型を書いてみよう

前節のことを踏まえると、**正規表現が文字列リテラルで定義されている場合** 、雑な解析であれば以下のように書けます。

```typescript
type NamedCaptureKeysUnion<S extends string> =
    S extends `${infer _Before}(?<${infer K}>${infer _After}`
    ? K | NamedCaptureKeysUnion<_After>
    : never
type NamedCaptureKeysTupple<S extends string> =
    S extends `${infer _Before}(?<${infer K}>${infer _After}`
    ? [K, ...NamedCaptureKeysTupple<_After>]
    : []
```

実際に `match.groups` のようなオブジェクトの形にするのであれば、上記の `NamedCaptureKeysUnion<S>` 型を使い

```typescript
type NamedCaptureGroups<S extends string> =
    Partial<Record<NamedCaptureKeysUnion<S>, string>>
```

となるでしょう。

```typescript
type YMDNamedCaptureGroups =
    NamedCaptureGroups<"(?<year>\d\d\d\d)-(?<month>\d\d)-(?<day>\d\d)">
    // => { year?: string, month?: string, day?: string }

type Y2YMDNamedCaptureGroups =
    NamedCaptureGroups<"(?<year>\d\d(?<year2>\d\d>)-(?<month>\d\d)-(?<day>\d\d)">
    // => { year?: string, year2?: string, month?: string, day?: string }

```

上記の文字列処理は、名前付きキャプチャグループの `(?<` と `>` に挟まれている文字列を抜き出すもので、例えば、丸括弧開きが実際はバッククォートで `\` でエスケープされていてメタ文字の意味を失っていた…といった場合に対応していません。対応が困難と思われる、そのようなケースは稀なものとして無視しても、大多数の実用上の場面において問題は起こらないでしょう[^ignore-illegal-case]。

[^ignore-illegal-case]: これ以上の対応となると、定義にかかる記法が一気に増えると思います。その割にほとんどの場合において、ここに書いたもので用が済んでしまう事実もあります。

## 正規表現オブジェクトから正規表現文字列を取り出せない件

では次に、正規表現リテラル表記 `/RE/` を書いて RegExp オブジェクトを生成、そこから正規表現文字列を取り出してみましょう。

JavaScript では RegExp オブジェクトの内容は source プロパティから取り出すことができます

- [RegExp.prototype.source - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source)

```
$ node
Welcome to Node.js v23.2.0.
Type ".help" for more information.
> const dateRe = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
undefined
> dateRe.source
'(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})'
```

しかし、この RegExp オブジェクトの実際の正規表現文字列を TypeScript から読み出すことは残念ながらできないようです。

:::note alert
TypeScript のリテラル型のサポートが、現状 JavaScript の基本型とされる `string | number | boolean | undefined |null` あたり（あと `bigint` ？）にとどまっているため、正規表現リテラルであっても基本型をこえる RegExp オブジェクトの有り様を TypeScript から知ることができないということです。 `dateRe: RegExp` の存在をもとに JavaScript で評価して初めて分かる RegExp オブジェクトのプロパティを TypeScript の文字列リテラル型のように渡す `NamedCaptureGroups<dateRe.source>` といった書き方は当然できません。TypeScript のリテラル型のサポートが基本型に限定されているからです。
:::

結局「二度書き」をするか、 `new RegExp()` コンストラクターを使うしか無いです。

```typescript
// パターン1: 二度書きする
const dateStr = '2024-12-14';
const dateReStr = "(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})";
const dateRe    = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = dateStr.match(dateRe);
if ( match ) {
    const groups = match as NamedCaptureGroups<dateReStr>;
    ...
}
```

```typescript
// パターン2: new RegExp コンストラクターを使う
const dateStr = '2024-12-14';
const dateReStr = "(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})";
const dateRe    = new RegExp(dateReStr);
const match = dateStr.match(dateRe);
if ( match ) {
    const groups = match as NamedCaptureGroups<dateReStr>;
    ...
}
```

どちらもあまり洗練されていない…。

あと、文字列で正規表現を書いているところのバックスラッシュの数が違います。こちらは後述。

そもそも今回やりたいことと同じことを考えている人はいないのでしょうか。

## 実は試みられていた先行事例

それっぽいキーワードで NPM を検索してみると

- [typed-regex - npm](https://www.npmjs.com/package/typed-regex)
- [named-regexp - npm](https://www.npmjs.com/package/named-regexp)

といったものが出てきます。named-regexp の方は2012年頃作の、まだ名前付きキャプチャグループ文法が JavaScript の正規表現に導入されていなかった頃、擬似的に導入することを試みたモジュールです。なので今回の試みとは若干違うもの。

typed-regex の方が今回この記事で取ったアプローチに近いです。下記 Example より。

```typescript
import { TypedRegEx } from 'typed-regex';

const regex = TypedRegEx('^(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})$', 'g');
const result = regex.captures('2020-12-02');

result // : undefined | { year: string, month: string, day: string }
```

正規表現リテラル記法 `/RE/` ではなく、正規表現を文字列で渡しています。

また、後続の処理を全部ラップしているため、キャプチャグループの値へのアクセスも、ネイティブのマッチオブジェクトではなく、typed-regex が導入したオブジェクトの caputres メソッドに変わっています。

[中の実装](https://github.com/phenax/typed-regex/blob/main/src/index.ts)を見ても、今回この記事で考えた内容を進化させた形となっています。しかし、前述のような「丸括弧開きがメタ文字の意味を失っていないか」といった部分のチェックは typed-regex でもされていないようです。極端なレアケースには対応せず実装のシンプルさを保っているのも同様のようです。

とはいえ、できれば JavaScript 言語コアの正規表現の取り扱いを新たなオブジェクトでラップせずそのまま見せたいんだよなぁ…というのもあります。あくまで TypeScript の範疇で解決できるのでしょうか。

## 正規表現リテラルと「文字列リテラルの正規表現」の違い

JavaScript 言語コアの正規表現の取り扱いをそのまま見せたい理由は、 **単純に覚えることが増えてほしくないから**。

正規表現を文字列リテラルとして書くことで回避できますが、「二度書き」もスマートではないし、文字列リテラルで正規表現を書くことで、バックスラッシュが正規表現エンジンだけでなく文字列リテラル評価でも取り込まれるのは悩ましいです。

「正規表現リテラル」「正規表現の文字列（リテラル）」ですが、 **大きな違いはバックスラッシュが解釈される箇所** です。 **正規表現リテラル `/RE/` であれば正規表現エンジンのみになりますが、文字列リテラル `"RE"` だとそれに加えて文字列リテラルの評価も対象となります** 。

typed-regex の例でも、 `\\d{2}` といった表記になっていましたが、

```typescript
// 正規表現リテラルでのバックスラッシュ1個は、文字列リテラルでの正規表現ではバックスラッシュ2個必要
const dateStr = '2024-12-14';
const dateRe    = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const dateReStr = "(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})";
```

これでも正規表現や文字列リテラルの内部的な解釈に不慣れな人は困惑するのに、さらにバックスラッシュがあると混迷を極めます。

```typescript
// 文字列 body 中に \n という文字列があったら <br /> に変換する
// 正規表現リテラル版
console.log(body.replace(/\\n/g, '<br />');
// 正規表現文字列版
console.log(body.replace(new RegExp('\\\\n', 'g'), '<br />');
// この例においては replaceAll で正規表現を使わない解決方法もある
```

JavaScript の文字列リテラルがどんな特殊表記を評価しようとするかは [文法とデータ型 - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Grammar_and_types#%E6%96%87%E5%AD%97%E5%88%97%E3%83%AA%E3%83%86%E3%83%A9%E3%83%AB) を見ていただくとして、文字列リテラルと正規表現リテラルで意味が違う表記で潜在的なバグを生む可能性もあります。

```typescript
// \b は 正規表現だと単語境界、文字列リテラルだとバックスペースの制御コード
// 正規表現リテラルだと簡素な表記となる
console.log('This is cat.'.match(/\bcat\b/)); // => match
console.log('This string is concatinated.'.match(/\bcat\b/));  // => null

// 文字列リテラルでバックスラッシュのエスケープを忘れると意図しない挙動になる（下記は Node.js v23 の場合）
console.log('This is cat.'.match(new RegExp('\bcat\b'))); // => null

// 正しくはこう
console.log('This is cat.'.match(new RegExp('\\bcat\\b'))); // => match
```

正規表現に余計なややこしさを生まないためにも、文字列リテラルで正規表現を書くことは極力避けたいです[^avoid-string-regexp]。

[^avoid-string-regexp]: 文字列処理を志向する方にプログラミング言語のオススメを聞かれた場合、「できれば言語のコアで正規表現リテラルが搭載されているものがいい」というアドバイスをすることがあります。今後、正規表現を活用することを念頭に置くと、バックスラッシュでのややこしさを回避できればという想いからです。これには、 JavaScript の他、Perl や Ruby が該当します。また Python の raw string `r"..."` はバックスラッシュの意味を失わせる目的で正規表現と組み合わされる仕組みのようですが、raw の `r` が regular expression の r に見えなくもないのが面白いです。結果的にこの `r"..."` も正規表現リテラルに準ずるものになっている印象です。

## TypeScript 正規表現の型付けの今後に期待

試行錯誤を随筆のように書いてしまいましたが、「正規表現の文字列リテラル」にある難しさを避けたい場合、型付けの試みに置いてシンプルな妥協点がどこにあるか、2024年12月14日時点の思索ではうまい回答を見出すことができませんでした。

JavaScript 上では `/RE/` は書いた時点で内容が決まるリテラルである[^javascript-slashslash-is-literal]のですが、評価結果が基本型ではないため TypeScript (v5.6)としてリテラル型として取り扱うことができない…しかし正規表現の文字列を使うのも避けたい…といったところで手詰まりとなりました。

[^javascript-slashslash-is-literal]: JavaScript においてスラッシュで囲まれたパターンがリテラルであるとは [正規表現 - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_expressions) にも記載されています。

一方で TypeScript では、オブジェクト

```typescript
const person1 = {
    name: 'xtetsuji',
}
```

は `{name: string}` 型ですが、 `as const` 型アサーションをつけたオブジェクト

```typescript
const person2 = {
    name: 'xtetsuji',
} as const
```

は `{name: 'xtetsuji'}` 型となります。

正規表現リテラル `/RE/` によって生成された正規表現 RegExp オブジェクトに対して、それがあらわす正規表現自体を変える方法は無さそうですし、意味的にもイミュータブルに思えます[^is-regexp-object-immutable]。もしかしたら将来の TypeScript において正規表現リテラルに対してさらに踏み込んだ解釈をしてくれるかもしれません。

[^is-regexp-object-immutable]: Node.js v23 では、RegExp オブジェクトの source プロパティに値を代入してももとの正規表現を変えることはできません。具体的には代入で例外は起こらないけれど、代入後も source プロパティの値は変わらない。しかし [RegExp.prototype.source - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/RegExp/source) を見ても、source プロパティが上書きされたときの挙動が書かれていないので、仕様の上では正規表現オブジェクトのイミュータブル性は明記されていない、そのことで現状の TypeScript では正規表現リテラルの踏み込んだ解釈をしないのかもしれません。

＊＊＊

少し話が変わりまして、業務で [Prisma](https://www.prisma.io/) を使っているのですが、その Prisma で直接書いた SQL の SELECT 文を事前に評価しておいた結果を元に実行結果の行オブジェクトに型を付ける **Typed SQL** という機能が最近導入されました[^prisma-typed-sql]。「優れた設計者であれば O/R マッパーで SQL を書くことはない」といった論説も見受けられますが、社内で基幹技術として取り組んでいる GIS や地図技術で重要な RDBMS の geometry 型に Prisma が対応しきれていないといった事情もあって、嬉しい機能として注目しています。

[^prisma-typed-sql]: 例えば [生SQLに型を手書きする時代は終わり？Prismaの新機能「TypedSQL」](https://zenn.dev/tockn/articles/0e6eac6220e072) 参照。

昨今ではデータエンジニアや DRE（Data Reliability Engineering、SRE のデータ版）といった文脈で SQL が再評価されているようです。SQL 同様、文字列を中心としたデータから所望のものを取り出す古典的な手法である正規表現にも型による支援ができないかといった想いもあり、今後も補助的に使われる DSL 的位置付けの記法に型による支援を入れていくことが注目されていくかもしれません。

所属するトグルホールディングでは、これからも技術記事の投稿やカンファレンスのスポンサード等を通じて、TypeScript 技術へのコミットを続けていきます。今回の考察を深めた後日談もどこかで公開できればと思います。