この記事は、Fusic Advent Calendar 2025 7日目の記事です。
昨日は @ma2yama さんの Omniverse Kit App Template を動かしてみた でした。
背景
React のコンポーネントで文字列を受け取り、dangerouslySetInnerHTML を使う必要が出てきた際に、 「渡された文字列が静的であることを型レベルで保証できないか?」 と思い試してみました。
しかし当時は TypeScript の型システムにあまり詳しくなかったため、生成 AI に助けてもらいながら実装を進めました。
実装
Gemini 3 Pro に何度か修正を依頼した結果、最終的に以下のコードが得られました。
type IsStringPattern<T extends string> = string extends T
? true // T が string 型そのものなら動的文字列(true)
: T extends ""
? false // 空文字まで分解できれば静的文字列(false)
: T extends `${infer Head}${infer Tail}`
? string extends Head // 先頭の1文字が string 型かどうか
? true // 先頭が string 型なら動的とみなす
: IsStringPattern<Tail> // 具体的文字なら残りを再帰的に検査
: false;
// IsStringPattern を利用して静的文字列のみ許可する
type OnlyStaticString<T extends string> = IsStringPattern<T> extends true ? never : T;
IsStringPattern が true を返す場合、その文字列は「動的」であると判定しています。
最初の実装では、動的文字列でもテンプレートリテラルであれば OnlyStaticString をすり抜けてしまう不具合がありました。しかし、IsStringPattern を再帰的に適用するようにしたことで正しく検出できるようになりました。
T extends ${infer Head}${infer Tail} と書くことで、Head に 1 文字または string 型、Tail に残りの部分が推論される仕組みです。
以下が実際の使用例です。
function printStaticString<T extends string>(text: OnlyStaticString<T>) {
console.log(text);
}
const constString = 'const';
const inputs = fs.readFileSync("/dev/stdin", "utf8"); // 標準入力から受け取ってるので動的文字列
let dynamicString = 'dynamic'; // let で定義されているので、動的文字列と見なされる
const dynamicTemplate = `${constString} ${dynamicString}` as const; // dynamicString を含むので動的文字列とみなされる
printStaticString('static'); // ok
printStaticString(constString); // ok
printStaticString(inputs); // コンパイルエラー
printStaticString(dynamicString) // コンパイルエラー
printStaticString(dynamicTemplate); // コンパイルエラー
ビルド時点で値が確定している文字列のみを受け付けるようにできており、意図どおりの動作になっています。
課題
いろんなパターンで試した限り正しく動作しており、ひとまず目的は達成できています。
ただし、TypeScript の型システムに精通しているわけではないため、「これで完全に網羅できているのか?」と聞かれると少し不安は残ります。
また、再帰的に判定を行なっているので、非常に長いテンプレートリテラルを扱った場合にはコンパイル時間が伸びるかもしれないと思っています。
とはいえ、実用的な型を自作できたので満足しています。