背景
自分のツールサイトに、文字列のケース(大文字・小文字と区切りの書き方)を変換するページを置いています。
https://hashitosystem.com/tools/caseconvert/
getUserName を入れると、get_user_name(スネークケース)や get-user-name(ケバブケース)、GetUserName(パスカルケース)などを一度に出す、というものです。
API のレスポンスは snake_case、JavaScript 側は camelCase、みたいな行き来で私はよく使っています。
先日、getUserID をスネークケースにしてからキャメルケースに戻したら getUserId になっていて、元の名前と一致しませんでした。
変換を2回かけただけなのに名前が変わるのは地味に困るので、どこで情報が落ちているのか調べました。
単語の分け方
ケース変換は、いったん文字列を単語の配列に分けて、それを好きな形でつなぎ直す、という2段階でやっている。
難しいのは前半の「分ける」ほうだ。
スネークケースなら _ で切ればいいのですが、キャメルケースには区切り文字が無いので、大文字の位置から境界を推測するしかない。
ツールの分割関数を、読みやすいように書き直したものが下記です(正規表現と処理の順番はページの JavaScript と同じです)。
入力は変換したい文字列1つで、戻り値は単語の配列です。
function splitWords(str) {
let s = str.trim();
if (s === '') return [];
s = s.replace(/[_\-.\s]+/g, ' '); // 区切り文字を空白にそろえる
s = s.replace(/([a-z0-9])([A-Z])/g, '$1 $2'); // 小文字/数字 → 大文字(getUser → get User)
s = s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); // 大文字の連続の終わり(HTMLParser → HTML Parser)
s = s.replace(/([A-Za-z])([0-9])/g, '$1 $2'); // 英字 → 数字(version2 → version 2)
return s.split(/\s+/).filter((w) => w.length > 0);
}
3つめの置換がポイントで、HTMLParser のように大文字が続いたあとに「大文字+小文字」が来たら、その手前で切る。
大文字が続く部分は略語だろう、という推測です。
つなぎ直す側は単純で、キャメルケースなら先頭の単語を全部小文字に、2つめ以降を「先頭だけ大文字、残りは小文字」にして連結する。
スネークケースなら全部小文字にして _ でつなぐだけです。
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
const toCamel = (words) => words.map((w, i) => (i === 0 ? w.toLowerCase() : cap(w))).join('');
const toSnake = (words) => words.map((w) => w.toLowerCase()).join('_');
const snake = toSnake(splitWords('getUserID'));
console.log(snake, toCamel(splitWords(snake)));
これを実行すると get_user_id getUserId と出ます。
往復させてみた
いくつかの名前で、キャメルケース → スネークケース → キャメルケース と往復させました。
下記は Node.js 24 で、上の関数で実行した結果です。
左から、元の名前 / 分割結果 / スネークケース / 戻したキャメルケース です。
XMLHttpRequest | ["XML","Http","Request"] | xml_http_request | xmlHttpRequest
getUserID | ["get","User","ID"] | get_user_id | getUserId
userIDs | ["user","I","Ds"] | user_i_ds | userIDs
iOSVersion | ["i","OS","Version"] | i_os_version | iOsVersion
IPv6Address | ["I","Pv","6","Address"] | i_pv_6_address | iPv6Address
parseHTML5 | ["parse","HTML","5"] | parse_html_5 | parseHtml5
元に戻ったのは、6つのうち userIDs だけでした。
何が起きているのか
getUserID は、分割までは正しく get / User / ID になっている。
ところがスネークケースにした時点で get_user_id と全部小文字になり、「ID は2文字とも大文字だった」という情報が消える。
戻すときは id を普通の単語として Id にするので、getUserId になります。
スネークケースが大文字小文字を持てない以上、これはどの実装でも避けられない気がします。
userIDs はもっと変で、分割の段階で user / I / Ds になっている。
IDs の D と s が「大文字+小文字」なので、3つめの置換が I と Ds の間で切ってしまう。
スネークケースは user_i_ds という読めない名前になります。
キャメルケースに戻すと user + I + Ds で userIDs になり、往復だけは一致してしまうのが、かえって気づきにくいところです(私も最初は「戻るじゃん」と思いました…)。
iOSVersion と IPv6Address も同じ系統で、大文字小文字が混ざった固有名詞は正規表現では分けられない。
iOS が1語だということは、文字の並びだけからは分からないからです。
略語も普通の単語として書く
この問題への答えとして分かりやすいのが、Google Java Style Guide の「5.3 Camel case: defined」だ。
https://google.github.io/styleguide/javaguide.html#s5.3-camel-case
略語も含めて一度全部小文字にし、各単語の先頭だけを大文字にする、と決めている。
例として XMLHTTPRequest ではなく XmlHttpRequest、newCustomerID ではなく newCustomerId、supportsIPv6OnIOS ではなく supportsIpv6OnIos が挙がっています。
この書き方の名前を、同じ関数で往復させました。
xmlHttpRequest | ["xml","Http","Request"] | xml_http_request | xmlHttpRequest
getUserId | ["get","User","Id"] | get_user_id | getUserId
userIds | ["user","Ids"] | user_ids | userIds
supportsIpv6OnIos | ["supports","Ipv","6","On","Ios"] | supports_ipv_6_on_ios | supportsIpv6OnIos
今度は4つとも元の名前に戻りました。
userIds のスネークケースも user_ids と素直な形です。
Ipv6 だけは数字の前で切られて ipv_6 になるので、スネークケース側の見た目にこだわるなら、ここは手で直す必要があります。
なぜ略語を大文字のまま残す書き方をやめるのか
理由は、キャメルケースに区切り文字が無い以上、「大文字=単語の先頭」という約束を崩すと機械が境界を決められなくなるからです。
ID と書くと、2文字目の D も単語の先頭に見えてしまう。
人間には読みやすい書き方でも、ケース変換や、JSON のキーを snake_case と camelCase で自動変換する仕組みを通すと、名前が変わったり分割を誤ったりします。
一方で Id と書いても読みにくさはほとんど増えない、というのが私の感想です。
結び
camelCase と snake_case を行き来させる名前は、略語も普通の単語として getUserId、xmlHttpRequest のように書いてください。
getUserID や userIDs のように略語を大文字のまま残すと、スネークケースを経由した時点で大文字小文字の情報が消えて別の名前に戻るか、分割を誤ります。
すでに大文字の略語を含む名前があるなら、変換の結果を往復させて、元の名前に戻るかを一度確かめておくのが確実です。
本記事はAI補助で執筆した、個人開発の紹介記事です。