toggle Engineer Blog

トグルホールディングス株式会社のエンジニアブログでは、私たちの技術的な挑戦やプロジェクトの裏側、チームの取り組みをシェアします。

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

こんにちは。トグルホールディングス、プロダクトエンジニアのid:xtetsuji です。 トグルホールディングスエンジニアアドベントカレンダーの14日目の記事です!

2003年から20年ほど、サーバで Perl を書いてログ処理や簡単なWebアプリケーションを書いたりする仕事をしていたのですが、2023年夏にトグルホールディングへジョインして TypeScript を書いています。

この記事では、正規表現の基礎は既知のものとして進めます。

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

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

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

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

と書かれています。

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

正規表現の立ち位置

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

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

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

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

ようやく本題。

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

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

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); // => 2024-12-14 11:22:33
// index 1 から index 6(7 === match.length の直前)まで
console.log(match.slice(1, match.length)); // => 

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

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

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

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); // => 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 型は {: string} | undefined 型であり、名前付きキャプチャグループの名前を参照はしてくれません。

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

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

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

TypeScript の表現力と文字列処理

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

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

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

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

型推論を省略せずに書く場合

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

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

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

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

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

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

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

このあたり、(https://kansai.tskaigi.org/) で発表された (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 で発表された (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 型を実装してみましょう。

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 の部分型として得られる方が使い勝手が良さそうですが、配列にタプル的に入っていると使いやすい場面もありそうです。書いてみましょう。

type SplitToTupple<S extends string, Sep extends string> =
    S extends `${infer L}${Sep}${infer R}`
    ? 
    : 
type ABCTupple = SplitToTupple<'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 が多くの文字を取るのかは、 (https://qiita.com/SiZK/items/eb1fd96e28ddbb72cdfd) がとても参考になりました。私がこの記事を読んで理解したのは以下です。

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

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

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

において

type Foo1 = ParseDate<'--'>

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

例えば

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

を定義したところ、

type Foo2 = ParseMiddleThreeString<'-01-'>

, ''] 型になりますが、

type Foo3 = ParseMiddleThreeString<'-0-'>

never となります。

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

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

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}`
    ? 
    : 

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

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

となるでしょう。

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 }

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

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

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

JavaScript では 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 つまり dateRe: RegExp の存在をもとに NamedCaptureGroups<dateRe.source> といった書き方はできないということです。これは TypeScript のリテラル型のサポートが基本型に限定されているからです。 :::

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

// パターン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>;
    ...
}
// パターン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 を検索してみると

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

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

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} といった表記になっていましたが、

// 正規表現リテラルでのバックスラッシュ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})";

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

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

JavaScript の文字列リテラルがどんな特殊表記を評価しようとするかは (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) を見ていただくとして、文字列リテラル正規表現リテラルで意味が違う表記で潜在的なバグを生む可能性もあります。

// \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

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

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

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

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

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

const person1 = {
    name: 'xtetsuji',
}

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

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

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

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

***

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

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

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