2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`replace` の第 2 引数を関数で渡して、利用者の名前の `$&` で文言が化けるのを防ぐ

2
Posted at

この記事で分かること

  • 「{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 章で引用
  • 文字クラスエスケープ(MDN 日本語版) / 英語版
    • 「解説」(英語版 Description)の節。\w が英字・数字・_ に一致すること。4-2 と 7 章で参照
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?