この記事で分かること
- 「{name} さん、ようこそ」のような文言に利用者の名前を
replaceで差し込むと、名前に$&などが入っているだけで文言が化ける・消える・二重になる理由 -
replaceの第 2 引数を関数で渡すと化けなくなる理由(MDN の該当の節つき) - 差し込みを 2 回に分けると、関数で渡しても残る「順番の副作用」と、正規表現 1 回でまとめる直し方
- そのまま使える差し込み関数(TypeScript、数行)
- 弱い点。翻訳ライブラリ経由の差し込みはこの記事の対象外で、文言に
{name}という文字そのものは書けない
想定読者
- アプリの文言に、利用者が入力した名前や検索語を
replaceで差し込んでいる人 - 「名前に記号を入れたら表示がおかしくなった」と言われて、原因を探している人
- React Native や Web のフロントエンドで、翻訳ファイルの文言に値を差し込む関数を自作している人
前提知識は「JavaScript の文字列と関数が書ける」だけで大丈夫です。正規表現(文字の並びのパターンを表す書き方)は、使う部分だけ本文で説明します。
検証環境
この記事のコードと出力は、次の環境で実際に動かしたものです。
| 項目 | 値 |
|---|---|
| Node.js | 22.22.2(記事の .ts ファイルを node でそのまま実行) |
| TypeScript | 7.0.2(tsc --strict で型の検査だけ実施) |
| OS | WSL2 上の Ubuntu 24.04 |
Node や TypeScript は版によって動きが変わることがあります。この記事の出力は上の版で確かめたものです。
1. 困っていたこと — 名前に $& を入れると文言が化けた
利用者の名前を文言に差し込む、ごく普通の 1 行が、名前に $& という 2 文字が入っているだけで別の文言に化けていました。
アプリでは「{name} さん、ようこそ」のような文言を翻訳ファイルに書いておき、表示するときに {name} の所へ利用者が入れた名前を差し込みます。差し込みには replace(文字列の一部を別の文字列に置き換えるメソッド)を使っていました。
次のコードは、その差し込みを最小の形にしたものです。名前として A$&B を入れています。
const template = '{name} さん、ようこそ';
const name = 'A$&B';
console.log(template.replace('{name}', name));
実行すると、次のように出ます。
A{name}B さん、ようこそ
A$&B さん、ようこそ と出てほしいのに、名前の真ん中に {name} が現れました。名前を差し込んだはずが、差し込む前の目印が戻ってきたのです。
$& のような名前を付ける人は少ないかもしれません。しかし、ニックネーム欄や検索欄は自由入力なので、$ を含む文字はいつ入ってもおかしくありません。しかも「田中」のような普通の名前では何も起きないので、手で試しても気付きにくいのが厄介な点です。
2. 解決の考え方 — 置換文字列は「記号入りの型」として読まれる
replace は第 2 引数が文字列だと、その中の $ で始まる組み合わせを「置換記号」として読みます。関数で渡せば、その読み取りが起きません。
2-1. 第 2 引数が文字列だと、$ で始まる記号を読む
MDN(Mozilla が運営する Web 技術の公式リファレンス)の String.prototype.replace() のページには、第 2 引数の文字列に書ける特殊な記号が表で並んでいます。日本語版の「置換文字列としての文字列の指定」の節(英語版は「Specifying a string as the replacement」)です。
その表を要約すると、次のとおりです(2026-10-05 に原文を確認)。
| 記号 | 差し込まれるもの |
|---|---|
$$ |
$ 1 文字 |
$& |
探して見つけた部分そのもの |
$` |
見つけた部分より前の文字列 |
$' |
見つけた部分より後ろの文字列 |
$n |
n 番目のグループ(正規表現で ( ) で囲んだ部分)に一致した文字列 |
$<Name> |
名前付きグループに一致した文字列 |
1 章の例に当てはめると、探しているのは {name} なので、$& は「{name}」に置き換わります。だから A$&B が A{name}B になりました。
同じ節には、$n と $<Name> は第 1 引数が正規表現のときだけ使える、とも書かれています。第 1 引数が '{name}' のような文字列なら、効くのは $$ $& $` $' の 4 つです。5 章ではこの 4 つを名前に入れて確かめます。
2-2. 関数で渡すと、戻り値は記号として読まれない
第 2 引数には、文字列の代わりに関数も渡せます。replace は見つけた所ごとにその関数を呼び、戻り値を差し込みます。
MDN の同じページの「置換文字列としての関数の指定」の節(英語版は「Specifying a function as the replacement」)には、次の注記があります。
上記の特殊な置き換えパターンは、置き換え関数から返される文字列には適用されません。
英語版の原文は "The above-mentioned special replacement patterns do not apply for strings returned from the replacer function." です。つまり、名前をそのまま返す関数 () => name を渡せば、名前に $& が入っていても記号としては読まれません。
文字列で渡したときと関数で渡したときの違いを、順に図にします。まず、文字列のまま渡した場合です。replace が名前の中の記号を読んでしまい、最後の箱(点線)の文言に化けます。
次に、関数にして渡した場合です。戻り値は記号として読まれないので、名前がそのまま入ります。
2-3. replaceAll も同じ
見つけた所をすべて置き換える replaceAll でも、第 2 引数の文字列は同じように記号として読まれます。
次のコードは、{name} が 2 か所ある文言に replaceAll で名前を差し込み、文字列で渡した場合と関数で渡した場合を並べたものです。
const name = 'A$&B';
console.log('{name} さん、{name} さん'.replaceAll('{name}', name));
console.log('{name} さん、{name} さん'.replaceAll('{name}', () => name));
実行結果です。1 行目(文字列で渡す)は 2 か所とも化け、2 行目(関数で渡す)は化けていません。
A{name}B さん、A{name}B さん
A$&B さん、A$&B さん
replace を replaceAll に変えるだけでは直らないので、どちらを使う場合も第 2 引数を関数にします。
3. 設計で決めたこと — 関数で渡し、1 回の置換でまとめる
差し込み関数を直すにあたって、決めたことは 3 つです。関数で渡す、差し込みを 1 回の置換でまとめる、知らない目印はそのまま残す、の順に理由を書きます。
3-1. 第 2 引数は必ず関数で渡す
利用者の文字を差し込む箇所では、第 2 引数を例外なく関数にしました。2 章のとおり、関数の戻り値は記号として読まれないので、名前に何が入っていても名前のまま表示されます。差し込む値が数字のような安全な値でも関数で渡す、と統一しておけば、「この値は安全か」を毎回考える必要がなくなります。
3-2. 差し込みは正規表現 1 回でまとめる
文言に差し込みが 2 か所以上あるときは、replace を 2 回続けるのではなく、正規表現 1 回の置換でまとめました。関数で渡していても、2 回に分けると「1 回目に入れた名前の中を、2 回目がまた探してしまう」という順番の副作用が残るからです。
次のコードは、関数で渡しつつ、差し込みを 2 回に分けた例です。名前として {count}好き を入れています。
const template = '{name} さんの記録は {count} 件';
const name = '{count}好き';
const out = template
.replace('{name}', () => name)
.replace('{count}', () => '3');
console.log(out);
実行結果です。名前の中の {count} が件数の 3 に変わり、本来の件数の位置には {count} が残りました。
3好き さんの記録は {count} 件
2 回に分けた場合と、1 回でまとめた場合の流れを比べます。上の枠は 2 回に分けた場合で、最後の箱(点線)が化けた結果です。下の枠は 1 回でまとめた場合です。
1 回でまとめる場合は、{ と } に挟まれた目印を正規表現でまとめて探し、見つけた所ごとに関数で値を返します。入れた名前の中の {count} はもう探されないので、名前は名前のまま残ります。この結果は 5 章の実行例で確かめます。
3-3. 知らない目印はそのまま残す
文言に {unknown} のような、値を渡していない目印があった場合は、空文字にせず {unknown} のまま残すことにしました。空文字にすると、値の渡し忘れが画面上で見えなくなります。目印が残っていれば、画面を見た時点で「渡し忘れている」と気付けます。
4. コード — 直すのは差し込む関数 1 つ
今回の直しに必要なのは、文言に値を差し込む関数 1 つの書き換えだけです。呼び出し側は変えません。
直す前と直した後のファイルの役割は次のとおりです。
| ファイル | 役割 |
|---|---|
fill-before.ts |
直す前の差し込み関数。値を 1 つずつ、文字列で replace に渡す |
fill.ts |
直した後の差し込み関数。正規表現 1 回で探し、関数で値を返す |
4-1. 直す前。fill-before.ts
このファイルは、文言と「目印の名前 → 値」の組を受け取り、目印ごとに replace を 1 回ずつ呼んで値を差し込みます。値は String(value) で文字列にしてから、そのまま第 2 引数に渡しています。2 章の「記号として読まれる」と、3-2 の「2 回に分けた副作用」の両方を抱えた形です。
export const fillBefore = (
template: string,
vars: Record<string, string | number>,
): string => {
let out = template;
for (const [key, value] of Object.entries(vars)) {
out = out.replace(`{${key}}`, String(value));
}
return out;
};
4-2. 直した後。fill.ts
このファイルは、文言の中の {目印} を正規表現 1 回ですべて探し、見つけた所ごとに関数で値を返します。
export const fill = (
template: string,
vars: Record<string, string | number>,
): string =>
template.replace(/\{(\w+)\}/g, (whole, key) =>
Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : whole,
);
中身を部品ごとに説明します。
-
/\{(\w+)\}/gは「{、英数字か_が 1 文字以上、}」の並びを探す正規表現です。\{と\}は、{と}をただの文字として探すための書き方です。末尾のgは「見つかった所をすべて置き換える」という指定です -
(\w+)の( )はグループで、ここに一致した目印の名前(nameやcount)が、関数の 2 番目の引数keyに入ります。1 番目の引数wholeには、見つけた部分全体({name})が入ります - 関数は、
keyに対応する値がvarsにあればString(vars[key])を返し、無ければwholeを返します。3-1 の「関数で渡す」と、3-3 の「知らない目印は残す」がこの 1 行です - 「
varsにあるか」の判定にObject.prototype.hasOwnProperty.call(vars, key)を使うのは、varsに自分で入れた値だけを見るためです。短くkey in varsと書くと、すべてのオブジェクトが最初から持っているtoStringなども「ある」と判定されます。私の環境では、文言に{toString}があるとfunction toString() { [native code] }という文字が差し込まれました。hasOwnProperty.callなら{toString}のまま残ります
呼び出し側は fillBefore(template, vars) を fill(template, vars) に変えるだけです。
5. 動かしてみる — 5 種類の名前で、文字列渡しは 4 つ化けた
4 章の 2 つの関数に同じ名前を渡して並べると、文字列で渡す方は 5 種類の名前のうち 4 つで化け、関数で渡す方はすべて名前のまま表示されました。
このファイルは、5 種類の名前と、差し込みが 2 か所ある文言を、直す前と直した後の関数にそれぞれ渡して表示します。名前には、普通の名前 1 つと、2 章の表の記号 $& $' $` $$ を含む名前 4 つを選びました。
import { fillBefore } from './fill-before.ts';
import { fill } from './fill.ts';
const greeting = '{name} さん、ようこそ';
for (const name of ['田中', 'A$&B', "x$'y", 'x$`y', '$$']) {
console.log(`名前: ${name}`);
console.log(` 文字列で渡す: ${fillBefore(greeting, { name })}`);
console.log(` 関数で渡す : ${fill(greeting, { name })}`);
}
const record = '{name} さんの記録は {count} 件';
const vars = { name: '{count}好き', count: 3 };
console.log(`名前: ${vars.name}(差し込み 2 か所)`);
console.log(` 文字列で渡す: ${fillBefore(record, vars)}`);
console.log(` 関数で渡す : ${fill(record, vars)}`);
このファイルを demo.ts として保存し、次のコマンドで実行しました。
node demo.ts
出力です。
名前: 田中
文字列で渡す: 田中 さん、ようこそ
関数で渡す : 田中 さん、ようこそ
名前: A$&B
文字列で渡す: A{name}B さん、ようこそ
関数で渡す : A$&B さん、ようこそ
名前: x$'y
文字列で渡す: x さん、ようこそy さん、ようこそ
関数で渡す : x$'y さん、ようこそ
名前: x$`y
文字列で渡す: xy さん、ようこそ
関数で渡す : x$`y さん、ようこそ
名前: $$
文字列で渡す: $ さん、ようこそ
関数で渡す : $$ さん、ようこそ
名前: {count}好き(差し込み 2 か所)
文字列で渡す: 3好き さんの記録は {count} 件
関数で渡す : {count}好き さんの記録は 3 件
文字列で渡した側で何が起きたかを、2 章の表と突き合わせて名前ごとにまとめると、次のとおりです。
| 名前 | 文字列で渡した結果 | 起きたこと |
|---|---|---|
田中 |
正しい | 記号が無いので何も起きない |
A$&B |
化ける |
$& が見つけた部分 {name} に置き換わった |
x$'y |
二重になる |
$' が {name} より後ろの「 さん、ようこそ」に置き換わった |
x$`y |
消える |
$` が {name} より前の部分(空)に置き換わった |
$$ |
1 文字減る |
$$ が $ 1 文字に置き換わった |
{count}好き |
化ける | 名前の中の {count} に件数が入った(3-2 の副作用) |
関数で渡した fill は、6 通りすべてで入れた名前がそのまま表示されています。
6. 起きたこと・学び — 自分のアプリ 4 本すべてに同じ穴があった
この穴は 1 か所だけの話ではなく、私が個人で作っている React Native / TypeScript のアプリ 4 本すべてにありました。
4 本とも、利用者が入れた名前や検索語を文言に差し込む箇所が、replace に文字列で渡す形で書かれていました。2026-10-04 に、4 本ともその箇所を関数で渡す形に直しました。
学びは 2 つあります。
1 つ目は、利用者の文字は名前欄だけではないことです。検索語のように、入力した文字を「〇〇 の検索結果」として画面に出す箇所も同じ形になります。差し込む値がどこから来たかをたどり、利用者の入力が混ざる所はすべて対象にします。
2 つ目は、1 か所見つけたら、同じ書き方を全部探すことです。差し込みの書き方はアプリをまたいで写しやすいので、1 本にある穴は他の本にもある、と考えて探す方が早く片付きます。
同じ形を探すには、replace と replaceAll の呼び出しのうち、同じ行に =>(アロー関数の矢印)が無いものを並べる方法があります。次のコマンドは、src の下の .ts と .tsx からそれを一覧にします。
grep -rnE "\.replace(All)?\(" src --include='*.ts' --include='*.tsx' | grep -v "=>"
試しに、次の見本のファイル src/sample.tsx を用意しました。文字列で渡す書き方 2 行と、関数で渡す書き方 2 行を並べたものです。
const a = t('greeting').replace('{name}', userName);
const b = t('search').replaceAll('{query}', () => query);
const c = title.replace(/\s+/g, ' ');
const d = t('hit').replace('{q}', (m) => q);
このファイルに対して上のコマンドを実行した結果です。関数で渡している 2 行は除かれ、文字列で渡している 2 行が残りました。
src/sample.tsx:1:const a = t('greeting').replace('{name}', userName);
src/sample.tsx:3:const c = title.replace(/\s+/g, ' ');
残った行がすべて危ないわけではありません。3 行目のように、第 2 引数が ' ' のような固定の文字なら記号は入らないので問題ありません。残った行のうち、第 2 引数に利用者の文字が入るものだけを関数で渡す形に直します。
7. 弱点と限界 — 翻訳ライブラリを使うなら、そちらの仕様を確かめる
この記事の fill は自作の差し込み関数のための直し方で、翻訳ライブラリの差し込みには当てはまるか分かりません。ほかにも、正規表現 1 回の方式には書けない文言があります。
次のコードは、fill が苦手な文言を 3 通り渡して、出力を確かめるものです。
import { fill } from './fill.ts';
console.log(fill('{name} さん {unknown}', { name: '田中' }));
console.log(fill('書き方は {name} です', { name: '田中' }));
console.log(fill('{名前} さん', { 名前: '田中' }));
実行結果です。1 行目から順に、以下の小見出しで説明します。
田中 さん {unknown}
書き方は 田中 です
{名前} さん
翻訳ライブラリの差し込みは今回確かめていない
i18next のような翻訳ライブラリにも、文言に値を差し込む機能があります。そうしたライブラリが内部でどう置換しているか、名前に $& を入れたときにどうなるかは、今回確かめていません。ライブラリ経由で差し込んでいる場合は、そのライブラリの文書で差し込みの仕様を読み、名前に $& を入れて画面で確かめてください。
値を渡していない目印は残る
1 行目のように、値を渡していない {unknown} はそのまま表示されます。3-3 で決めたとおりの動きですが、利用者の画面にも {unknown} が出ます。リリース前に画面を一通り見て、目印が残っていないか確かめる必要があります。
文言に {name} という文字そのものは書けない
2 行目のように、「書き方は {name} です」と目印そのものを説明したくても、{name} は必ず値に置き換わります。fill には「この { はただの文字」と伝える書き方(エスケープ)がありません。そうした文言は fill を通さずに表示してください。
目印の名前は英数字と _ だけ
3 行目のように、{名前} は置き換わりません。正規表現の \w は英字・数字・_ にだけ一致し、日本語には一致しないためです(MDN「文字クラスエスケープ」の「解説」の節)。目印の名前は英数字で付けてください。
8. まとめ
- 利用者の名前を
replaceの第 2 引数に文字列で渡すと、名前の中の$&$'$`$$が置換記号として読まれ、文言が化ける・二重になる・消える。普通の名前では起きないので気付きにくい - 第 2 引数を関数で渡せば、戻り値は記号として読まれない(MDN「置換文字列としての関数の指定」の注記)。
replaceAllも同じ - 差し込みが 2 か所以上あるときは、
replaceを続けて呼ばず、正規表現 1 回でまとめる。分けると、入れた名前の中の目印がまた置き換わる - 直すのは差し込み関数 1 つ。呼び出し側は変えなくてよい
- 1 か所見つけたら、
grepで同じ書き方を全部探す。利用者の文字は名前欄だけでなく検索語にも入る
参考
-
String.prototype.replace()(MDN 日本語版) / 英語版
- 「置換文字列としての文字列の指定」(英語版 Specifying a string as the replacement)の節。
$$$&$`$'$n$<Name>の表と、$n$<Name>は第 1 引数が正規表現のときだけ使えるという説明。2-1 と 5 章で参照 - 「置換文字列としての関数の指定」(英語版 Specifying a function as the replacement)の節。関数の戻り値には特殊な記号が適用されないという注記。2-2 と 8 章で引用
- 「置換文字列としての文字列の指定」(英語版 Specifying a string as the replacement)の節。
-
文字クラスエスケープ(MDN 日本語版) / 英語版
- 「解説」(英語版 Description)の節。
\wが英字・数字・_に一致すること。4-2 と 7 章で参照
- 「解説」(英語版 Description)の節。